diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cfb6410..a0586bf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ + 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/Podfile.lock b/ios/Podfile.lock index 333b0a2..3236bcb 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -171,7 +171,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/vibration/ios" SPEC CHECKSUMS: - audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd + audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b73764d..e0378fc 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 = 94; + CURRENT_PROJECT_VERSION = 98; 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 = 94; + CURRENT_PROJECT_VERSION = 98; 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 = 94; + CURRENT_PROJECT_VERSION = 98; 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 = 94; + CURRENT_PROJECT_VERSION = 98; 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 = 94; + CURRENT_PROJECT_VERSION = 98; 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 = 94; + CURRENT_PROJECT_VERSION = 98; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f0915a9..e28e34f 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 94 + 98 LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 330f28d..fdb44e7 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/main.dart b/lib/main.dart index bab8edb..15d6e99 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'providers/channels_provider.dart'; import 'providers/voice_provider.dart'; import 'providers/image_provider.dart' as ip; import 'providers/app_provider.dart'; +import 'providers/sensors_provider.dart'; import 'services/voice_codec_service.dart'; import 'services/voice_player_service.dart'; import 'services/tile_cache_service.dart'; @@ -234,6 +235,7 @@ class _MeshCoreSarAppState extends State { }, ), ChangeNotifierProvider(create: (_) => ChannelsProvider()), + ChangeNotifierProvider(create: (_) => SensorsProvider()), // Voice provider (packet reassembly + playback) ChangeNotifierProvider( 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_reception_details.dart b/lib/models/message_reception_details.dart new file mode 100644 index 0000000..40cc70d --- /dev/null +++ b/lib/models/message_reception_details.dart @@ -0,0 +1,86 @@ +const int _transmitEstimateToleranceMs = 1500; + +int? sanitizeEstimatedTransmitMs({ + required int? estimatedTransmitMs, + required int? senderToReceiptMs, +}) { + if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) { + return null; + } + + if (senderToReceiptMs == null || senderToReceiptMs <= 0) { + return estimatedTransmitMs; + } + + // Sender timestamps are second-granularity, so allow a small cushion before + // treating the estimate as impossible for the observed delivery time. + if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) { + return null; + } + + return estimatedTransmitMs; +} + +class MessageReceptionDetails { + final DateTime capturedAt; + final DateTime? packetLoggedAt; + final int? rssiDbm; + final double? snrDb; + final List? pathBytes; + final int? senderToReceiptMs; + final int? estimatedTransmitMs; + final int? postTransmitDelayMs; + + const MessageReceptionDetails({ + required this.capturedAt, + this.packetLoggedAt, + this.rssiDbm, + this.snrDb, + this.pathBytes, + this.senderToReceiptMs, + this.estimatedTransmitMs, + this.postTransmitDelayMs, + }); + + String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty + ? null + : pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + + Map toJson() { + return { + 'capturedAtMillis': capturedAt.millisecondsSinceEpoch, + 'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch, + 'rssiDbm': rssiDbm, + 'snrDb': snrDb, + 'pathBytes': pathBytes, + 'senderToReceiptMs': senderToReceiptMs, + 'estimatedTransmitMs': estimatedTransmitMs, + 'postTransmitDelayMs': postTransmitDelayMs, + }; + } + + static MessageReceptionDetails? fromJson(Map json) { + final capturedAtMillis = json['capturedAtMillis']; + if (capturedAtMillis is! int) { + return null; + } + + final pathBytes = json['pathBytes']; + return MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis), + packetLoggedAt: json['packetLoggedAtMillis'] is int + ? DateTime.fromMillisecondsSinceEpoch( + json['packetLoggedAtMillis'] as int, + ) + : null, + rssiDbm: json['rssiDbm'] as int?, + snrDb: (json['snrDb'] as num?)?.toDouble(), + pathBytes: pathBytes is List + ? pathBytes.whereType().map((b) => b.toInt()).toList() + : null, + senderToReceiptMs: json['senderToReceiptMs'] as int?, + estimatedTransmitMs: json['estimatedTransmitMs'] as int?, + postTransmitDelayMs: json['postTransmitDelayMs'] as int?, + ); + } +} 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 7c38b3e..00fe3bb 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'connection_provider.dart'; @@ -16,9 +17,13 @@ import '../services/packet_capture_storage_service.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../models/ble_packet_log.dart'; +import '../models/message_reception_details.dart'; 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 class AppProvider with ChangeNotifier { @@ -46,6 +51,8 @@ class AppProvider with ChangeNotifier { bool get isMapEnabled => _isMapEnabled; bool _isContactsEnabled = true; bool get isContactsEnabled => _isContactsEnabled; + bool _isSensorsEnabled = true; + bool get isSensorsEnabled => _isSensorsEnabled; bool _isVoiceSilenceTrimmingEnabled = true; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; @@ -59,15 +66,19 @@ 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 = {}; final Map _voiceMissingRetryTimers = {}; final Map _voiceMissingRetryAttempts = {}; - final FragmentAckWaitRegistry _voiceFragmentAckWaiters = - FragmentAckWaitRegistry(); - final FragmentAckWaitRegistry _imageFragmentAckWaiters = - FragmentAckWaitRegistry(); + final Map _imageMissingRetryTimers = {}; + final Map _imageMissingRetryAttempts = {}; + final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); + final Map> _pendingRawRouteProbes = {}; + final Map> _pendingMediaSwarmFetches = {}; + final Map> + _pendingMediaSwarmResponses = {}; Timer? _packetCaptureFlushTimer; String? _lastPersistedPacketSignature; bool _isPersistingPacketCapture = false; @@ -88,6 +99,7 @@ class AppProvider with ChangeNotifier { _loadSimpleMode(); _loadMapEnabled(); _loadContactsEnabled(); + _loadSensorsEnabled(); _loadVoiceSilenceTrimmingEnabled(); _loadVoiceBandPassFilterEnabled(); _loadVoiceCompressorEnabled(); @@ -179,12 +191,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); } @@ -198,6 +210,21 @@ class AppProvider with ChangeNotifier { } } + String? _resolveContactNameForNotification(Uint8List? publicKey) { + if (publicKey == null || publicKey.isEmpty) return null; + + Contact? contact; + if (publicKey.length >= 32) { + contact = contactsProvider.findContactByKey(publicKey); + } + contact ??= publicKey.length >= 6 + ? contactsProvider.findContactByPrefix( + Uint8List.fromList(publicKey.sublist(0, 6)), + ) + : null; + return contact?.advName; + } + /// Load simple mode setting from shared preferences Future _loadSimpleMode() async { try { @@ -267,6 +294,29 @@ class AppProvider with ChangeNotifier { } } + /// Load sensors enabled setting from shared preferences + Future _loadSensorsEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isSensorsEnabled = prefs.getBool('sensors_enabled') ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading sensors enabled setting: $e'); + } + } + + /// Toggle sensors tab on/off + Future toggleSensorsEnabled(bool enabled) async { + try { + _isSensorsEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('sensors_enabled', enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving sensors enabled setting: $e'); + } + } + /// Load voice silence trimming setting from shared preferences. Future _loadVoiceSilenceTrimmingEnabled() async { try { @@ -433,6 +483,10 @@ class AppProvider with ChangeNotifier { void _setupCallbacks() { // Monitor connection state changes to start/stop location tracking connectionProvider.addListener(_handleConnectionStateChange); + messagesProvider.resolveContactNameCallback = + _resolveContactNameForNotification; + messagesProvider.resolveChannelNameCallback = + channelsProvider.getChannelDisplayName; voiceProvider.sendRawPacketCallback = ({ @@ -460,27 +514,6 @@ class AppProvider with ChangeNotifier { payload: payload, ); }; - voiceProvider.waitForFragmentAckCallback = - ({ - required sessionId, - required index, - timeout = const Duration(seconds: 8), - }) => _waitForVoiceFragmentAck( - sessionId: sessionId, - index: index, - timeout: timeout, - ); - imageProvider.waitForFragmentAckCallback = - ({ - required sessionId, - required index, - timeout = const Duration(seconds: 8), - }) => _waitForImageFragmentAck( - sessionId: sessionId, - index: index, - timeout: timeout, - ); - // When a contact is received from BLE connectionProvider.onContactReceived = (contact) { // Pass device public key to filter out our own contact @@ -631,6 +664,9 @@ class AppProvider with ChangeNotifier { capturedAt: enrichedMessage.receivedAt, ) : null; + final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot( + enrichedMessage, + ); // Check if message is a drawing broadcast if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { @@ -662,6 +698,7 @@ class AppProvider with ChangeNotifier { updatedMessage, contactLookup: (name) => '', contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, ); // Broadcast drawing message to SSE clients if server is running @@ -675,9 +712,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, @@ -698,6 +741,7 @@ class AppProvider with ChangeNotifier { } }, contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, ); connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; @@ -706,9 +750,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, @@ -726,24 +775,12 @@ class AppProvider with ChangeNotifier { } }, contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, ); connectionProvider.broadcastMessageToSseClients(enrichedMessage); 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, @@ -763,12 +800,16 @@ class AppProvider with ChangeNotifier { } }, contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, ); // Broadcast message to SSE clients if server is running connectionProvider.broadcastMessageToSseClients(enrichedMessage); }; + // Keep a compact receive-time snapshot because packet logs roll over. + // This lets the UI still show timing/link details after app restarts. + // When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B) // Used by older firmware versions for telemetry responses connectionProvider.onTelemetryReceived = (publicKey, lppData) { @@ -791,14 +832,48 @@ 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) { + debugPrint( + '📡 [AppProvider] Incoming raw route probe: nonce=${rawProbeRequest.nonce.toRadixString(16)} requester=${rawProbeRequest.requesterKey6}', + ); + _handleRawRouteProbeRequest(rawProbeRequest); + return; + } + + final rawProbeAck = RawRouteProbeAck.tryParseBinary(payload); + if (rawProbeAck != null) { + debugPrint( + '📡 [AppProvider] Incoming raw route probe ACK: nonce=${rawProbeAck.nonce.toRadixString(16)}', + ); + _completeRawRouteProbeAck(rawProbeAck.nonce); + 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) { - final requester = contactsProvider.findContactByPrefixHex( - voiceFetchRequest.requesterKey6, + debugPrint( + '🎙️ [AppProvider] Incoming voice fetch request: session=${voiceFetchRequest.sessionId} want=${voiceFetchRequest.want} requester=${voiceFetchRequest.requesterKey6}', ); + final requester = _resolveVoiceFetchRequester(voiceFetchRequest); if (requester == null) { debugPrint( '⚠️ [AppProvider] Voice fetch requester contact not found (binary)', @@ -810,26 +885,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; } @@ -849,44 +932,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, - ), - ); - return; - } - - final voiceAck = VoiceFragmentAck.tryParseBinary(payload); - if (voiceAck != null) { - _completeVoiceFragmentAck(voiceAck.sessionId, voiceAck.index); - return; - } - - final imageAck = ImageFragmentAck.tryParseBinary(payload); - if (imageAck != null) { - _completeImageFragmentAck(imageAck.sessionId, imageAck.index); + ); + if (served) { + messagesProvider.recordMediaTransfer( + sessionId: imageFetchRequest.sessionId, + mediaType: 'image', + requesterKey6: imageFetchRequest.requesterKey6, + requesterName: requester.advName, + ); + } + }()); return; } @@ -895,12 +974,30 @@ 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, ); - _sendImageFragmentAck(frag); + _scheduleImageMissingRetry( + frag.sessionId, + justComplete: imageProvider.isComplete(frag.sessionId), + ); return; } @@ -908,8 +1005,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); - _sendVoiceFragmentAck(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); @@ -1268,15 +1382,51 @@ class AppProvider with ChangeNotifier { return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); } + Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) { + final liveContact = _resolveContactByPrefixHex(request.requesterKey6); + if (liveContact != null) { + return liveContact; + } + + return _resolveRequesterFromSentMessages( + sessionId: request.sessionId, + requesterKey6: request.requesterKey6, + tryParseEnvelope: VoiceEnvelope.tryParseText, + mediaLabel: 'voice', + ); + } + Contact? _resolveImageFetchRequester(ImageFetchRequest request) { final liveContact = _resolveContactByPrefixHex(request.requesterKey6); if (liveContact != null) { return liveContact; } + return _resolveRequesterFromSentMessages( + sessionId: request.sessionId, + requesterKey6: request.requesterKey6, + tryParseEnvelope: ImageEnvelope.tryParse, + mediaLabel: 'image', + ); + } + + Contact? _resolveRequesterFromSentMessages({ + required String sessionId, + required String requesterKey6, + required T? Function(String text) tryParseEnvelope, + required String mediaLabel, + }) { for (final message in messagesProvider.messages.reversed) { - final envelope = ImageEnvelope.tryParse(message.text); - if (envelope == null || envelope.sessionId != request.sessionId) { + final envelope = tryParseEnvelope(message.text); + if (envelope == null) { + continue; + } + final envelopeSessionId = switch (envelope) { + VoiceEnvelope voiceEnvelope => voiceEnvelope.sessionId, + ImageEnvelope imageEnvelope => imageEnvelope.sessionId, + _ => null, + }; + if (envelopeSessionId != sessionId) { continue; } final recipientKey = message.recipientPublicKey; @@ -1291,12 +1441,13 @@ class AppProvider with ChangeNotifier { .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); - if (recipientKey6 != request.requesterKey6) { + if (recipientKey6 != requesterKey6) { continue; } debugPrint( - '📷 [AppProvider] Resolved image requester from sent message metadata ' - 'for session ${request.sessionId}: ${recipient.advName}', + '${mediaLabel == 'voice' ? '🎙️' : '📷'} [AppProvider] Resolved ' + '$mediaLabel requester from sent message metadata for session ' + '$sessionId: ${recipient.advName}', ); return recipient; } @@ -1304,13 +1455,301 @@ 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, }) { + if (voiceProvider.isReceiveCanceled(sessionId)) { + _clearVoiceMissingRetry(sessionId); + return; + } if (justComplete || voiceProvider.isComplete(sessionId)) { - _voiceMissingRetryTimers.remove(sessionId)?.cancel(); - _voiceMissingRetryAttempts.remove(sessionId); + _clearVoiceMissingRetry(sessionId); return; } @@ -1321,10 +1760,43 @@ class AppProvider with ChangeNotifier { }); } + void _scheduleImageMissingRetry( + String sessionId, { + required bool justComplete, + }) { + if (imageProvider.isReceiveCanceled(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + if (justComplete || imageProvider.isComplete(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + + _imageMissingRetryAttempts[sessionId] = 0; + _imageMissingRetryTimers[sessionId]?.cancel(); + _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingImageFragments(sessionId)); + }); + } + + void _clearVoiceMissingRetry(String sessionId) { + _voiceMissingRetryTimers.remove(sessionId)?.cancel(); + _voiceMissingRetryAttempts.remove(sessionId); + } + + void _clearImageMissingRetry(String sessionId) { + _imageMissingRetryTimers.remove(sessionId)?.cancel(); + _imageMissingRetryAttempts.remove(sessionId); + } + Future _requestMissingVoicePackets(String sessionId) async { + if (voiceProvider.isReceiveCanceled(sessionId)) { + _clearVoiceMissingRetry(sessionId); + return; + } if (voiceProvider.isComplete(sessionId)) { - _voiceMissingRetryTimers.remove(sessionId)?.cancel(); - _voiceMissingRetryAttempts.remove(sessionId); + _clearVoiceMissingRetry(sessionId); return; } @@ -1333,44 +1805,44 @@ class AppProvider with ChangeNotifier { debugPrint( '⚠️ [AppProvider] Voice re-request limit reached for $sessionId', ); - _voiceMissingRetryTimers.remove(sessionId)?.cancel(); + _clearVoiceMissingRetry(sessionId); return; } 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) { - _voiceMissingRetryTimers.remove(sessionId)?.cancel(); - _voiceMissingRetryAttempts.remove(sessionId); + _clearVoiceMissingRetry(sessionId); 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; } @@ -1381,6 +1853,69 @@ class AppProvider with ChangeNotifier { }); } + Future _requestMissingImageFragments(String sessionId) async { + if (imageProvider.isReceiveCanceled(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + if (imageProvider.isComplete(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + + final attempt = _imageMissingRetryAttempts[sessionId] ?? 0; + if (attempt >= _maxPacketRetryAttempts) { + debugPrint( + '⚠️ [AppProvider] Image re-request limit reached for $sessionId', + ); + _clearImageMissingRetry(sessionId); + return; + } + + final senderKey6 = _imageSessionSenderKey6[sessionId]; + if (senderKey6 == null) return; + final sender = _resolveContactByPrefixHex(senderKey6); + final requesterKey6 = _deviceKey6Hex(); + if (requesterKey6 == null) return; + + final missing = imageProvider.missingFragmentIndices(sessionId); + if (missing.isEmpty) { + _clearImageMissingRetry(sessionId); + return; + } + + 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, + ); + } + if (!sent) { + return; + } + + _imageMissingRetryAttempts[sessionId] = attempt + 1; + _imageMissingRetryTimers[sessionId]?.cancel(); + _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingImageFragments(sessionId)); + }); + } + /// Insert or update a voice placeholder message for binary raw-data packets. /// /// Binary voice packets arrive without a chat message, so we synthesise one @@ -1421,94 +1956,212 @@ class AppProvider with ChangeNotifier { messagesProvider.addMessage(placeholder, contactLookup: (_) => ''); } - String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index'; + String _rawProbeKey(int nonce) => + nonce.toRadixString(16).padLeft(8, '0').toLowerCase(); - Future _waitForVoiceFragmentAck({ - required String sessionId, - required int index, + Future verifyRawTransportRoute( + Contact target, { Duration timeout = const Duration(seconds: 8), - }) => _voiceFragmentAckWaiters.waitFor( - _fragmentAckKey(sessionId, index), - timeout: timeout, - ); + }) async { + if (!connectionProvider.deviceInfo.isConnected) { + return false; + } + if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) { + return false; + } + if (!target.routeSupportsLegacyRawTransport) { + return false; + } + if (target.outPath.isEmpty) { + return false; + } - void _completeVoiceFragmentAck(String sessionId, int index) { - final completed = _voiceFragmentAckWaiters.complete( - _fragmentAckKey(sessionId, index), - ); - if (completed == 0) { + final probeKey = _routeProbeTargetKey(target); + final pendingProbe = _pendingRawRouteProbes[probeKey]; + if (pendingProbe != null) { + return pendingProbe; + } + + final deviceKey = connectionProvider.deviceInfo.publicKey; + if (deviceKey == null || deviceKey.length < 6) { + return false; + } + + final requesterKey6 = deviceKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final future = () async { + final nonce = math.Random.secure().nextInt(0x100000000); + final ackFuture = _rawProbeWaiters.waitFor( + _rawProbeKey(nonce), + timeout: timeout, + ); + + try { + debugPrint( + '📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.routeHopCount} nonce=${nonce.toRadixString(16)}', + ); + await connectionProvider.sendRawVoicePacket( + contactPath: target.outPath, + contactPathLen: target.routeSignedPathLen, + payload: RawRouteProbeRequest( + nonce: nonce, + requesterKey6: requesterKey6, + ).encodeBinary(), + ); + return await ackFuture; + } catch (e) { + debugPrint( + '⚠️ [AppProvider] Raw route probe failed for ${target.advName}: $e', + ); + _rawProbeWaiters.complete(_rawProbeKey(nonce)); + return false; + } + }(); + + _pendingRawRouteProbes[probeKey] = future; + try { + return await future; + } finally { + _pendingRawRouteProbes.remove(probeKey); + } + } + + String _routeProbeTargetKey(Contact target) { + if (target.publicKeyHex.isNotEmpty) { + return 'pk:${target.publicKeyHex}'; + } + return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; + } + + void _handleRawRouteProbeRequest(RawRouteProbeRequest request) { + final requester = _resolveContactByPrefixHex(request.requesterKey6); + if (requester == null) { debugPrint( - 'ℹ️ [AppProvider] Voice fragment ACK had no waiter: $sessionId#$index', + '⚠️ [AppProvider] Raw route probe requester not found: ${request.requesterKey6}', ); return; } - debugPrint( - '✅ [AppProvider] Voice fragment ACK received for $sessionId#$index ($completed waiter(s))', - ); - } - - Future _waitForImageFragmentAck({ - required String sessionId, - required int index, - Duration timeout = const Duration(seconds: 8), - }) => _imageFragmentAckWaiters.waitFor( - _fragmentAckKey(sessionId, index), - timeout: timeout, - ); - - void _completeImageFragmentAck(String sessionId, int index) { - final completed = _imageFragmentAckWaiters.complete( - _fragmentAckKey(sessionId, index), - ); - if (completed == 0) { + if (!requester.routeHasPath || + requester.routeHopCount > _maxDirectPayloadHops) { debugPrint( - 'ℹ️ [AppProvider] Image fragment ACK had no waiter: $sessionId#$index', + '⚠️ [AppProvider] Raw route probe requester out of range: ${requester.routeHopCount}', ); return; } - debugPrint( - '✅ [AppProvider] Image fragment ACK received for $sessionId#$index ($completed waiter(s))', - ); - } - - void _sendVoiceFragmentAck(VoicePacket packet) { - final senderKey6 = _voiceSessionSenderKey6[packet.sessionId]; - if (senderKey6 == null) return; - final sender = _resolveContactByPrefixHex(senderKey6); - if (sender == null) return; - if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) { + if (!requester.routeSupportsLegacyRawTransport) { return; } + if (requester.outPath.isEmpty) { + return; + } + debugPrint( + '📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.routeHopCount} nonce=${request.nonce.toRadixString(16)}', + ); unawaited( connectionProvider.sendRawVoicePacket( - contactPath: sender.outPath, - contactPathLen: sender.outPathLen, - payload: VoiceFragmentAck( - sessionId: packet.sessionId, - index: packet.index, - ).encodeBinary(), + contactPath: requester.outPath, + contactPathLen: requester.routeSignedPathLen, + payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), ), ); } - void _sendImageFragmentAck(ImagePacket fragment) { - final senderKey6 = _imageSessionSenderKey6[fragment.sessionId]; - if (senderKey6 == null) return; - final sender = _resolveContactByPrefixHex(senderKey6); - if (sender == null) return; - if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) { - return; - } - unawaited( - connectionProvider.sendRawVoicePacket( - contactPath: sender.outPath, - contactPathLen: sender.outPathLen, - payload: ImageFragmentAck( - sessionId: fragment.sessionId, - index: fragment.index, - ).encodeBinary(), - ), + void _completeRawRouteProbeAck(int nonce) { + _rawProbeWaiters.complete(_rawProbeKey(nonce)); + } + + MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) { + final matchedRxLog = _findBestMatchingRxLog(message); + final estimatedTx = estimateMessageTransmitDuration( + message, + radioBw: connectionProvider.deviceInfo.radioBw, + radioSf: connectionProvider.deviceInfo.radioSf, + radioCr: connectionProvider.deviceInfo.radioCr, ); + final senderToReceiptMs = _senderToReceiptMs(message); + final estimatedTransmitMs = sanitizeEstimatedTransmitMs( + estimatedTransmitMs: estimatedTx > Duration.zero + ? estimatedTx.inMilliseconds + : null, + senderToReceiptMs: senderToReceiptMs, + ); + final postTransmitDelayMs = + senderToReceiptMs != null && estimatedTransmitMs != null + ? (senderToReceiptMs - estimatedTransmitMs).clamp(0, 86400000).toInt() + : null; + + if (matchedRxLog == null && + senderToReceiptMs == null && + estimatedTransmitMs == null) { + return null; + } + + return MessageReceptionDetails( + capturedAt: DateTime.now(), + packetLoggedAt: matchedRxLog?.timestamp, + rssiDbm: matchedRxLog?.logRxDataInfo?.rssiDbm, + snrDb: matchedRxLog?.logRxDataInfo?.snrDb, + pathBytes: _extractPathBytesFromLog(matchedRxLog), + senderToReceiptMs: senderToReceiptMs, + estimatedTransmitMs: estimatedTransmitMs, + postTransmitDelayMs: postTransmitDelayMs, + ); + } + + int? _senderToReceiptMs(Message message) { + if (message.senderTimestamp <= 0) return null; + final senderAt = DateTime.fromMillisecondsSinceEpoch( + message.senderTimestamp * 1000, + isUtc: true, + ); + final deltaMs = message.receivedAt + .toUtc() + .difference(senderAt) + .inMilliseconds; + if (deltaMs < 0 || deltaMs > 86400000) return null; + return deltaMs; + } + + BlePacketLog? _findBestMatchingRxLog(Message message) { + if (message.pathLen < 0 || message.pathLen >= 255) return null; + final expectedPayloadType = message.messageType == MessageType.channel + ? 0x05 + : 0x02; + BlePacketLog? bestLog; + var bestDeltaMs = 999999999; + + for (final log in connectionProvider.bleService.packetLogs) { + if (log.responseCode != 0x88) continue; + if (log.rawData.length < 6) continue; + + final raw = log.rawData; + final payloadType = (raw[3] >> 2) & 0x0F; + final pathLen = raw[4]; + if (payloadType != expectedPayloadType) continue; + if (pathLen != message.pathLen) continue; + if (raw.length < 5 + pathLen) continue; + + final deltaMs = + (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); + if (deltaMs < bestDeltaMs) { + bestDeltaMs = deltaMs; + bestLog = log; + } + } + + if (bestDeltaMs > 30000) return null; + return bestLog; + } + + List? _extractPathBytesFromLog(BlePacketLog? log) { + if (log == null) return null; + final raw = log.rawData; + if (raw.length < 6) return null; + final pathLen = raw[4]; + if (pathLen <= 0 || raw.length < 5 + pathLen) return null; + return raw.sublist(5, 5 + pathLen); } // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events @@ -1604,9 +2257,15 @@ class AppProvider with ChangeNotifier { for (final timer in _voiceMissingRetryTimers.values) { timer.cancel(); } + for (final timer in _imageMissingRetryTimers.values) { + timer.cancel(); + } _voiceMissingRetryTimers.clear(); _voiceMissingRetryAttempts.clear(); + _imageMissingRetryTimers.clear(); + _imageMissingRetryAttempts.clear(); _voiceSessionSenderKey6.clear(); + _imageSessionSenderKey6.clear(); notifyListeners(); } @@ -1640,6 +2299,9 @@ class AppProvider with ChangeNotifier { for (final timer in _voiceMissingRetryTimers.values) { timer.cancel(); } + for (final timer in _imageMissingRetryTimers.values) { + timer.cancel(); + } super.dispose(); } } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index b132d37..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'; @@ -146,11 +147,15 @@ class ConnectionProvider with ChangeNotifier { final MessageDeliveryTracker _messageDeliveryTracker = MessageDeliveryTracker(); final PingTracker _pingTracker = PingTracker(); + final Map> _pendingSmartPings = {}; // Expose room login states Map get roomLoginStates => _roomLoginManager.roomLoginStates; + bool isPingInProgress(Uint8List publicKey) => + _pendingSmartPings.containsKey(_publicKeyToHex(publicKey)); + // Callbacks for other providers Function(Contact)? onContactReceived; Function(List)? onContactsComplete; @@ -1117,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'); } @@ -1169,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) { @@ -1316,6 +1336,34 @@ class ConnectionProvider with ChangeNotifier { required Uint8List contactPublicKey, required bool hasPath, Function()? onRetryWithFlooding, + }) async { + final pingKey = _publicKeyToHex(contactPublicKey); + final pendingPing = _pendingSmartPings[pingKey]; + if (pendingPing != null) { + debugPrint('ℹ️ [Provider] Joining in-flight ping for $pingKey'); + return pendingPing; + } + + final future = _runSmartPing( + contactPublicKey: contactPublicKey, + hasPath: hasPath, + onRetryWithFlooding: onRetryWithFlooding, + ); + _pendingSmartPings[pingKey] = future; + notifyListeners(); + + try { + return await future; + } finally { + _pendingSmartPings.remove(pingKey); + notifyListeners(); + } + } + + Future _runSmartPing({ + required Uint8List contactPublicKey, + required bool hasPath, + Function()? onRetryWithFlooding, }) async { if (!_activeService.isConnected) { _error = 'Not connected to device'; @@ -1334,7 +1382,10 @@ class ConnectionProvider with ChangeNotifier { ); // Send the ping - await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); + await _activeService.requestTelemetry( + contactPublicKey, + zeroHop: firstAttemptDirect, + ); // Wait for response or timeout final bool gotResponse = await pingFuture; @@ -1361,8 +1412,8 @@ class ConnectionProvider with ChangeNotifier { wasDirectAttempt: false, ); - // Retry with flooding (zeroHop=true acts as broadcast to neighbors) - await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); + // Retry with flooding. + await _activeService.requestTelemetry(contactPublicKey, zeroHop: false); // Wait for response or timeout final bool gotRetryResponse = await retryFuture; @@ -1384,6 +1435,10 @@ class ConnectionProvider with ChangeNotifier { } } + String _publicKeyToHex(Uint8List publicKey) { + return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + } + /// Send binary request to contact (modern replacement for requestTelemetry) /// /// Supports multiple request types: @@ -1967,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..f657cb0 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -423,9 +423,16 @@ class ContactsProvider with ChangeNotifier { debugPrint(' Old lastAdvert: ${contact.lastAdvert}'); debugPrint(' New lastAdvert: $currentTimestamp'); + final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation); final updatedContact = contact.copyWith( telemetry: telemetry, lastAdvert: currentTimestamp, // Update last seen time + advLat: persistedGps != null + ? _coordinateToAdvertMicrodegrees(persistedGps.latitude) + : contact.advLat, + advLon: persistedGps != null + ? _coordinateToAdvertMicrodegrees(persistedGps.longitude) + : contact.advLon, ); _contacts[contact.publicKeyHex] = updatedContact; debugPrint(' ✅ Updated contact in map (with new lastAdvert)'); @@ -476,6 +483,10 @@ class ContactsProvider with ChangeNotifier { return incomingGps == null; } + int _coordinateToAdvertMicrodegrees(double coordinate) { + return (coordinate * 1e6).round(); + } + /// Find contact by public key prefix (6 bytes) Contact? _findContactByPrefix(Uint8List prefix) { if (prefix.length < 6) return null; @@ -521,7 +532,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/ping_tracker.dart b/lib/providers/helpers/ping_tracker.dart index 0bf51e5..cca4b21 100644 --- a/lib/providers/helpers/ping_tracker.dart +++ b/lib/providers/helpers/ping_tracker.dart @@ -46,8 +46,8 @@ class PingTracker { /// Mark a ping as successful (response received) /// Should be called when telemetry response arrives void markPingSuccessful(Uint8List publicKey) { - final String keyHex = _publicKeyToHex(publicKey); - final request = _pendingPings.remove(keyHex); + final requestKey = _findMatchingPendingPingKey(publicKey); + final request = requestKey != null ? _pendingPings.remove(requestKey) : null; if (request != null) { request.cancel(); @@ -81,6 +81,23 @@ class PingTracker { String _publicKeyToHex(Uint8List publicKey) { return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); } + + String? _findMatchingPendingPingKey(Uint8List responseKey) { + final responseHex = _publicKeyToHex(responseKey); + + if (_pendingPings.containsKey(responseHex)) { + return responseHex; + } + + for (final entry in _pendingPings.entries) { + final pendingHex = entry.key; + if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) { + return pendingHex; + } + } + + return null; + } } /// Internal class to track a single ping request diff --git a/lib/providers/helpers/raw_session_retransmit.dart b/lib/providers/helpers/raw_session_retransmit.dart index fb8f1c5..1d22d8c 100644 --- a/lib/providers/helpers/raw_session_retransmit.dart +++ b/lib/providers/helpers/raw_session_retransmit.dart @@ -9,13 +9,6 @@ typedef RawPacketSender = required Uint8List payload, }); -typedef FragmentAckWaiter = - Future Function({ - required String sessionId, - required int index, - Duration timeout, - }); - Future serveCachedSessionFragments({ required String providerLabel, required String sessionId, @@ -25,9 +18,7 @@ Future serveCachedSessionFragments({ required int Function(T fragment) indexOf, required Uint8List Function(T fragment) encodeBinary, required RawPacketSender? sendRawPacket, - FragmentAckWaiter? waitForFragmentAck, Set? requestedIndices, - Duration ackTimeout = const Duration(seconds: 8), }) async { if (fragments.isEmpty) { debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId'); @@ -37,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; } @@ -65,24 +62,12 @@ Future serveCachedSessionFragments({ continue; } try { - final ackFuture = waitForFragmentAck?.call( - sessionId: sessionId, - index: index, - timeout: ackTimeout, - ); await sendRawPacket( contactPath: requester.outPath, - contactPathLen: requester.outPathLen, + contactPathLen: requester.routeSignedPathLen, payload: encodeBinary(fragment), ); servedCount++; - if (ackFuture != null) { - final acked = await ackFuture; - if (!acked) { - debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index'); - return false; - } - } } catch (e, st) { debugPrint( '❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st', 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 00a02a7..62a6c4c 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -70,12 +70,6 @@ class ImageProvider with ChangeNotifier { required Uint8List payload, })? sendRawPacketCallback; - Future Function({ - required String sessionId, - required int index, - Duration timeout, - })? - waitForFragmentAckCallback; ImageProvider() { _restore(); @@ -102,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}) { @@ -116,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) { @@ -250,21 +265,26 @@ 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(), sendRawPacket: sendRawPacketCallback, - waitForFragmentAck: waitForFragmentAckCallback, requestedIndices: requestedIndices, ); } diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 6e52aeb..f4bccea 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart'; 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'; @@ -10,6 +12,7 @@ import '../services/notification_service.dart'; import '../utils/sar_message_parser.dart'; import '../utils/drawing_message_parser.dart'; import '../utils/voice_message_parser.dart'; +import '../utils/image_message_parser.dart'; import '../l10n/app_localizations.dart'; import 'helpers/message_retry_manager.dart'; @@ -22,6 +25,8 @@ class MessagesProvider with ChangeNotifier { bool _isInitialized = false; AppLocalizations? _localizations; final Map _messageContactLocations = {}; + final Map _messageReceptionDetails = {}; + final Map _messageTransferDetails = {}; // Track pending sent messages by expected ACK/TAG final Map _pendingSentMessages = {}; @@ -74,12 +79,12 @@ class MessagesProvider with ChangeNotifier { })? sendMessageCallback; - Future Function({ - required Contact contact, - required int failureStreak, - })? + Future Function({required Contact contact, required int failureStreak})? onDirectPathFailedCallback; + String? Function(Uint8List? publicKey)? resolveContactNameCallback; + String Function(int channelIdx)? resolveChannelNameCallback; + List get messages => List.unmodifiable(_messages); List get contactMessages => @@ -115,6 +120,12 @@ class MessagesProvider with ChangeNotifier { MessageContactLocation? getMessageContactLocation(String messageId) => _messageContactLocations[messageId]; + MessageReceptionDetails? getMessageReceptionDetails(String messageId) => + _messageReceptionDetails[messageId]; + + MessageTransferDetails? getMessageTransferDetails(String messageId) => + _messageTransferDetails[messageId]; + /// Set localizations for notifications void setLocalizations(AppLocalizations localizations) { _localizations = localizations; @@ -145,9 +156,19 @@ class MessagesProvider with ChangeNotifier { final storedMessages = await _storageService.loadMessages(); final storedContactLocations = await _storageService .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) { @@ -187,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, - ); - } } } @@ -311,6 +324,7 @@ class MessagesProvider with ChangeNotifier { Message message, { String Function(String name)? contactLookup, MessageContactLocation? contactLocationSnapshot, + MessageReceptionDetails? receptionDetailsSnapshot, }) { // Always enhance message with SAR parser to detect SAR markers var enhancedMessage = SarMessageParser.enhanceMessage(message); @@ -341,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, - ); - } } } @@ -397,6 +403,22 @@ class MessagesProvider with ChangeNotifier { debugPrint( ' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', ); + final existingIndex = _messages.indexWhere( + (existing) => + existing.messageType == finalMessage.messageType && + existing.senderTimestamp == finalMessage.senderTimestamp && + existing.text == finalMessage.text, + ); + if (existingIndex != -1) { + final existingId = _messages[existingIndex].id; + if (contactLocationSnapshot != null) { + _messageContactLocations[existingId] = contactLocationSnapshot; + } + if (receptionDetailsSnapshot != null) { + _messageReceptionDetails[existingId] = receptionDetailsSnapshot; + } + _persistMessages(); + } return; // Skip duplicate } @@ -404,6 +426,9 @@ class MessagesProvider with ChangeNotifier { if (contactLocationSnapshot != null) { _messageContactLocations[finalMessage.id] = contactLocationSnapshot; } + if (receptionDetailsSnapshot != null) { + _messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot; + } // If it's a SAR marker message, extract and store the marker if (finalMessage.isSarMarker) { @@ -549,33 +574,31 @@ class MessagesProvider with ChangeNotifier { /// Trigger notification for regular message Future _triggerMessageNotification(Message message) async { try { - // Get sender name from message - final senderName = - message.senderName ?? message.senderKeyShort ?? 'Unknown'; - - // Determine if it's a channel message + final senderName = _resolveParticipantName( + publicKey: message.senderPublicKeyPrefix, + fallback: message.senderName ?? message.senderKeyShort, + ); final isChannelMessage = message.isChannelMessage; - - // Get channel name if available - String? channelName; - if (isChannelMessage) { - // You could map channelIdx to channel name here if needed - // For now, use "Public" for channel 0 - channelName = message.channelIdx == 0 - ? 'Public' - : 'Channel ${message.channelIdx}'; - } + final channelName = isChannelMessage + ? _resolveChannelName(message.channelIdx) + : null; + final messageText = _buildNotificationMessageText( + message, + senderName: senderName, + isChannelMessage: isChannelMessage, + channelName: channelName, + ); debugPrint('🔔 [MessagesProvider] Triggering message notification'); debugPrint(' Sender: $senderName'); debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}'); debugPrint( - ' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...', + ' Message: ${messageText.substring(0, messageText.length > 50 ? 50 : messageText.length)}...', ); await _notificationService.showMessageNotification( senderName: senderName, - messageText: message.text, + messageText: messageText, isChannelMessage: isChannelMessage, channelName: channelName, localizations: _localizations, @@ -587,12 +610,84 @@ class MessagesProvider with ChangeNotifier { } } + String _resolveParticipantName({ + required Uint8List? publicKey, + String? fallback, + }) { + final resolved = resolveContactNameCallback?.call(publicKey)?.trim(); + if (resolved != null && resolved.isNotEmpty) { + return resolved; + } + final normalizedFallback = fallback?.trim(); + if (normalizedFallback != null && normalizedFallback.isNotEmpty) { + return normalizedFallback; + } + return 'Unknown'; + } + + String _resolveChannelName(int? channelIdx) { + final idx = channelIdx ?? 0; + final resolved = resolveChannelNameCallback?.call(idx).trim(); + if (resolved != null && resolved.isNotEmpty) { + return resolved; + } + return idx == 0 ? 'Public' : 'Channel $idx'; + } + + String _buildNotificationMessageText( + Message message, { + required String senderName, + required bool isChannelMessage, + String? channelName, + }) { + final voiceEnvelope = VoiceEnvelope.tryParseText(message.text); + if (voiceEnvelope != null) { + final seconds = (voiceEnvelope.durationMs / 1000).ceil(); + final summary = + 'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets'; + return isChannelMessage ? '$senderName\n$summary' : summary; + } + + final imageEnvelope = ImageEnvelope.tryParse(message.text); + if (imageEnvelope != null) { + final summary = + 'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}'; + return isChannelMessage ? '$senderName\n$summary' : summary; + } + + if (!isChannelMessage && message.recipientPublicKey != null) { + final recipientName = _resolveParticipantName( + publicKey: message.recipientPublicKey, + fallback: null, + ); + if (recipientName != 'Unknown') { + return 'To: $recipientName\n${message.text}'; + } + } + + if (isChannelMessage) { + return '$senderName\n${message.text}'; + } + + return message.text; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + final kib = bytes / 1024; + if (kib < 1024) return '${kib.toStringAsFixed(kib >= 10 ? 0 : 1)} KB'; + final mib = kib / 1024; + return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB'; + } + /// Persist messages to storage (async, non-blocking) Future _persistMessages() async { try { await _storageService.saveMessages( _messages, messageContactLocations: _messageContactLocations, + messageReceptionDetails: _messageReceptionDetails, + messageTransferDetails: _messageTransferDetails, ); } catch (e) { debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); @@ -709,6 +804,8 @@ class MessagesProvider with ChangeNotifier { _messageContactMap.remove(messageId); _groupedMessageMapping.remove(messageId); _messageContactLocations.remove(messageId); + _messageReceptionDetails.remove(messageId); + _messageTransferDetails.remove(messageId); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); @@ -738,6 +835,8 @@ class MessagesProvider with ChangeNotifier { _messages.clear(); _sarMarkers.clear(); _messageContactLocations.clear(); + _messageReceptionDetails.clear(); + _messageTransferDetails.clear(); _persistMessages(); notifyListeners(); } @@ -753,10 +852,70 @@ class MessagesProvider with ChangeNotifier { _messages.clear(); _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(); @@ -852,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, - ); - } } } @@ -1062,10 +1213,11 @@ class MessagesProvider with ChangeNotifier { ' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', ); + // Once the device accepts a direct message and returns an ACK tag, the + // send itself succeeded locally even if end-to-end delivery confirmation + // may still arrive later. Keep ACK tracking, but stop showing "waiting". final updatedMessage = message.copyWith( - deliveryStatus: expectedAckTag > 0 - ? MessageDeliveryStatus.sending - : MessageDeliveryStatus.sent, + deliveryStatus: MessageDeliveryStatus.sent, expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null, suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null, ); @@ -1522,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 @@ -1678,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', @@ -1698,8 +1850,69 @@ class MessagesProvider with ChangeNotifier { } } + /// Reset an existing failed message back into a sending state so a manual + /// retry can reuse the same record instead of appending a duplicate. + bool prepareMessageForRetry(String messageId) { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index == -1) { + debugPrint( + '⚠️ [MessagesProvider] prepareMessageForRetry: Message not found: $messageId', + ); + return false; + } + + final message = _messages[index]; + + _timeoutTimers[message.id]?.cancel(); + _timeoutTimers.remove(message.id); + if (message.expectedAckTag != null) { + _pendingSentMessages.remove(message.expectedAckTag); + } + _clearAckHistoryForMessage(messageId); + _retryManager.clearRetry(messageId); + + _messages[index] = Message( + id: message.id, + messageType: message.messageType, + senderPublicKeyPrefix: message.senderPublicKeyPrefix, + channelIdx: message.channelIdx, + pathLen: message.pathLen, + textType: message.textType, + senderTimestamp: message.senderTimestamp, + text: message.text, + isSarMarker: message.isSarMarker, + sarGpsCoordinates: message.sarGpsCoordinates, + sarNotes: message.sarNotes, + sarCustomEmoji: message.sarCustomEmoji, + sarColorIndex: message.sarColorIndex, + receivedAt: message.receivedAt, + senderName: message.senderName, + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: message.recipientPublicKey, + retryAttempt: 0, + lastRetryAt: DateTime.now(), + usedFloodFallback: false, + isRead: message.isRead, + echoCount: message.echoCount, + firstEchoAt: message.firstEchoAt, + lastEchoSnrRaw: message.lastEchoSnrRaw, + lastEchoRssiDbm: message.lastEchoRssiDbm, + lastEchoAt: message.lastEchoAt, + isDrawing: message.isDrawing, + drawingId: message.drawingId, + groupId: message.groupId, + recipients: message.recipients, + isVoice: message.isVoice, + voiceId: message.voiceId, + ); + + _persistMessages(); + notifyListeners(); + return true; + } + /// Resend a failed message - Future resendMessage(String messageId) async { + Future resendMessage(String messageId, {Contact? contact}) async { final index = _messages.indexWhere((m) => m.id == messageId); if (index == -1) { debugPrint( @@ -1709,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', ); @@ -1720,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/sensors_provider.dart b/lib/providers/sensors_provider.dart new file mode 100644 index 0000000..95dacfc --- /dev/null +++ b/lib/providers/sensors_provider.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/contact.dart'; +import 'connection_provider.dart'; +import 'contacts_provider.dart'; + +enum SensorRefreshState { idle, refreshing, success, timeout, unavailable } + +enum SensorMetric { + lastSeen, + voltage, + battery, + temperature, + humidity, + pressure, + gps, + updated, +} + +class SensorsProvider with ChangeNotifier { + static const String _watchedSensorsKey = 'watched_sensor_keys'; + static const String _visibleSensorMetricsKey = 'visible_sensor_metrics'; + static const Set _defaultVisibleMetrics = { + SensorMetric.lastSeen, + SensorMetric.voltage, + SensorMetric.battery, + SensorMetric.temperature, + SensorMetric.humidity, + SensorMetric.pressure, + SensorMetric.gps, + SensorMetric.updated, + }; + + final List _watchedSensorKeys = []; + final Map _refreshStates = + {}; + final Map> _visibleMetricsBySensor = + >{}; + bool _isLoaded = false; + bool _isRefreshingAll = false; + + SensorsProvider() { + unawaited(_loadWatchedSensors()); + } + + List get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys); + bool get isLoaded => _isLoaded; + bool get isRefreshingAll => _isRefreshingAll; + + SensorRefreshState stateFor(String publicKeyHex) => + _refreshStates[publicKeyHex] ?? SensorRefreshState.idle; + + Future _loadWatchedSensors() async { + try { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getStringList(_watchedSensorsKey) ?? []; + final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey); + _watchedSensorKeys + ..clear() + ..addAll(stored); + _visibleMetricsBySensor.clear(); + if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) { + final decoded = jsonDecode(storedMetricsJson) as Map; + for (final entry in decoded.entries) { + final metricNames = (entry.value as List).cast(); + _visibleMetricsBySensor[entry.key] = metricNames + .map(_metricFromName) + .whereType() + .toSet(); + } + } + for (final key in _watchedSensorKeys) { + _visibleMetricsBySensor.putIfAbsent( + key, + () => Set.from(_defaultVisibleMetrics), + ); + } + } catch (e) { + debugPrint('Error loading watched sensors: $e'); + } finally { + _isLoaded = true; + notifyListeners(); + } + } + + Future _persistWatchedSensors() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_watchedSensorsKey, _watchedSensorKeys); + } catch (e) { + debugPrint('Error saving watched sensors: $e'); + } + } + + Future _persistVisibleMetrics() async { + try { + final prefs = await SharedPreferences.getInstance(); + final encoded = >{}; + for (final entry in _visibleMetricsBySensor.entries) { + encoded[entry.key] = entry.value.map((metric) => metric.name).toList(); + } + await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded)); + } catch (e) { + debugPrint('Error saving visible sensor metrics: $e'); + } + } + + Set visibleMetricsFor(String publicKeyHex) => + Set.unmodifiable( + _visibleMetricsBySensor[publicKeyHex] ?? _defaultVisibleMetrics, + ); + + bool showsMetric(String publicKeyHex, SensorMetric metric) => + visibleMetricsFor(publicKeyHex).contains(metric); + + Future toggleMetric( + String publicKeyHex, + SensorMetric metric, + bool visible, + ) async { + final visibleMetrics = _visibleMetricsBySensor.putIfAbsent( + publicKeyHex, + () => Set.from(_defaultVisibleMetrics), + ); + if (visible) { + visibleMetrics.add(metric); + } else { + if (visibleMetrics.length == 1 && visibleMetrics.contains(metric)) { + return; + } + visibleMetrics.remove(metric); + } + await _persistVisibleMetrics(); + notifyListeners(); + } + + bool isWatched(String publicKeyHex) => + _watchedSensorKeys.contains(publicKeyHex); + + Future addSensor(Contact contact) async { + if (!contact.isChat && !contact.isRepeater) { + return; + } + if (_watchedSensorKeys.contains(contact.publicKeyHex)) { + return; + } + + _watchedSensorKeys.add(contact.publicKeyHex); + await _persistWatchedSensors(); + _visibleMetricsBySensor[contact.publicKeyHex] = Set.from( + _defaultVisibleMetrics, + ); + await _persistVisibleMetrics(); + notifyListeners(); + } + + Future removeSensor(String publicKeyHex) async { + _watchedSensorKeys.remove(publicKeyHex); + _refreshStates.remove(publicKeyHex); + _visibleMetricsBySensor.remove(publicKeyHex); + await _persistWatchedSensors(); + await _persistVisibleMetrics(); + notifyListeners(); + } + + List availableCandidates(ContactsProvider contactsProvider) { + final candidates = [ + ...contactsProvider.chatContacts, + ...contactsProvider.repeaters, + ]; + candidates.removeWhere((contact) => isWatched(contact.publicKeyHex)); + candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); + return candidates; + } + + Future refreshAll({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) async { + if (_isRefreshingAll || _watchedSensorKeys.isEmpty) { + return; + } + + _isRefreshingAll = true; + notifyListeners(); + + try { + for (final key in _watchedSensorKeys) { + Contact? contact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == key) { + contact = entry; + break; + } + } + if (contact == null) { + _refreshStates[key] = SensorRefreshState.unavailable; + notifyListeners(); + continue; + } + + _refreshStates[key] = SensorRefreshState.refreshing; + notifyListeners(); + + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: contact.hasPath, + ); + + _refreshStates[key] = result.success + ? SensorRefreshState.success + : SensorRefreshState.timeout; + notifyListeners(); + } + } finally { + _isRefreshingAll = false; + notifyListeners(); + } + } + + SensorMetric? _metricFromName(String name) { + for (final metric in SensorMetric.values) { + if (metric.name == name) { + return metric; + } + } + return null; + } +} diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 96bde9c..adcd223 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -71,12 +71,6 @@ class VoiceProvider with ChangeNotifier { required Uint8List payload, })? sendRawPacketCallback; - Future Function({ - required String sessionId, - required int index, - Duration timeout, - })? - waitForFragmentAckCallback; final Map _outgoingSessions = {}; @@ -131,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. @@ -142,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) { @@ -185,6 +200,47 @@ class VoiceProvider with ChangeNotifier { } } + void registerEnvelope(VoiceEnvelope envelope) { + if (_ignoredIncomingSessions.contains(envelope.sessionId)) { + return; + } + final existing = _sessions[envelope.sessionId]; + if (existing == null) { + _sessions[envelope.sessionId] = VoiceSession( + sessionId: envelope.sessionId, + mode: envelope.mode, + total: envelope.total, + ); + _persistVoiceData(); + notifyListeners(); + return; + } + + final needsMerge = + existing.total != envelope.total || existing.mode != envelope.mode; + if (!needsMerge) { + notifyListeners(); + return; + } + + final merged = VoiceSession( + sessionId: envelope.sessionId, + mode: envelope.mode, + total: envelope.total, + ); + merged.firstPacketAt = existing.firstPacketAt; + merged.lastPacketAt = existing.lastPacketAt; + for (final packet in existing.packets) { + if (packet == null) continue; + if (packet.index < merged.total) { + merged.packets[packet.index] = packet; + } + } + _sessions[envelope.sessionId] = merged; + _persistVoiceData(); + notifyListeners(); + } + /// Cache encoded packets for deferred voice serving. void cacheOutgoingSession(String sessionId, List packets) { if (packets.isEmpty) return; @@ -201,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; } @@ -212,12 +272,11 @@ 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(), sendRawPacket: sendRawPacketCallback, - waitForFragmentAck: waitForFragmentAckCallback, requestedIndices: requestedIndices, ); } diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index fd3ec98..3dd82b7 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; import '../l10n/app_localizations.dart'; +import '../models/contact.dart'; import '../providers/contacts_provider.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; @@ -119,6 +120,43 @@ class _ContactsTabState extends State { return l10n.daysAgo(diff.inDays); } + List _sortContactsByDistance(List contacts) { + final sorted = List.from(contacts); + + sorted.sort((a, b) { + final distanceA = _distanceFromCurrentPosition(a); + final distanceB = _distanceFromCurrentPosition(b); + + if (distanceA != null && distanceB != null) { + final distanceCompare = distanceA.compareTo(distanceB); + if (distanceCompare != 0) return distanceCompare; + } else if (distanceA != null) { + return -1; + } else if (distanceB != null) { + return 1; + } + + return b.lastSeenTime.compareTo(a.lastSeenTime); + }); + + return sorted; + } + + double? _distanceFromCurrentPosition(Contact contact) { + final currentPosition = _currentPosition; + final contactLocation = contact.displayLocation; + if (currentPosition == null || contactLocation == null) { + return null; + } + + return _calculateDistanceInMeters( + currentPosition.latitude, + currentPosition.longitude, + contactLocation.latitude, + contactLocation.longitude, + ); + } + /// Show the add channel dialog Future _showAddChannelDialog(BuildContext context) async { final l10n = AppLocalizations.of(context)!; @@ -165,10 +203,12 @@ class _ContactsTabState extends State { return Scaffold( body: Consumer( builder: (context, contactsProvider, child) { - final chatContacts = contactsProvider.chatContacts; - final repeaters = contactsProvider.repeaters; - final rooms = contactsProvider.rooms; - final channels = contactsProvider.channels; + final chatContacts = _sortContactsByDistance( + contactsProvider.chatContacts, + ); + final repeaters = _sortContactsByDistance(contactsProvider.repeaters); + final rooms = _sortContactsByDistance(contactsProvider.rooms); + final channels = _sortContactsByDistance(contactsProvider.channels); final pendingAdverts = contactsProvider.pendingAdverts; // Check if there are any displayable contacts (excluding channels) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 999e565..8b28e10 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart'; import '../theme/app_theme.dart'; import 'messages_tab.dart'; import 'contacts_tab.dart'; +import 'sensors_tab.dart'; import 'map_tab.dart'; import 'map_management_screen.dart'; import 'settings_screen.dart'; @@ -24,7 +25,7 @@ import '../widgets/permission_request_dialog.dart'; import '../widgets/connection_dialog.dart'; import '../utils/battery_display_helper.dart'; -enum _HomeTab { messages, contacts, map } +enum _HomeTab { messages, contacts, sensors, map } class HomeScreen extends StatefulWidget { final Function(AppThemeMode) onThemeChanged; @@ -54,11 +55,13 @@ class _HomeScreenState extends State with TickerProviderStateMixin { bool _showRxTxIndicators = true; bool _isMapEnabled = true; bool _isContactsEnabled = true; + bool _isSensorsEnabled = false; List<_HomeTab> get _enabledTabs { return [ _HomeTab.messages, if (_isContactsEnabled) _HomeTab.contacts, + if (_isSensorsEnabled) _HomeTab.sensors, if (_isMapEnabled) _HomeTab.map, ]; } @@ -77,6 +80,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _appProvider = context.read(); _isMapEnabled = _appProvider.isMapEnabled; _isContactsEnabled = _appProvider.isContactsEnabled; + _isSensorsEnabled = _appProvider.isSensorsEnabled; _appProvider.addListener(_handleAppProviderChanged); // Initialize synchronously so first build always has a valid controller. @@ -106,6 +110,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _updateTabController( mapEnabled: _appProvider.isMapEnabled, contactsEnabled: _appProvider.isContactsEnabled, + sensorsEnabled: _appProvider.isSensorsEnabled, ); } @@ -129,8 +134,11 @@ class _HomeScreenState extends State with TickerProviderStateMixin { void _updateTabController({ required bool mapEnabled, required bool contactsEnabled, + required bool sensorsEnabled, }) { - if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) { + if (_isMapEnabled == mapEnabled && + _isContactsEnabled == contactsEnabled && + _isSensorsEnabled == sensorsEnabled) { return; } @@ -147,6 +155,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { // Update state _isMapEnabled = mapEnabled; _isContactsEnabled = contactsEnabled; + _isSensorsEnabled = sensorsEnabled; if (!_isMapEnabled) { _isMapFullscreen = false; } @@ -183,6 +192,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { case _HomeTab.contacts: context.read().markAllAsViewed(); break; + case _HomeTab.sensors: + break; case _HomeTab.map: break; } @@ -471,6 +482,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ? () => _navigateToTab(_HomeTab.map) : null, ); + case _HomeTab.sensors: + return const SensorsTab(); case _HomeTab.map: return MapTab( onFullscreenChanged: (isFullscreen) { @@ -502,6 +515,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: @@ -525,6 +545,11 @@ class _HomeScreenState extends State with TickerProviderStateMixin { icon: const Icon(Icons.map), text: AppLocalizations.of(context)!.map, ); + case _HomeTab.sensors: + return const Tab( + icon: Icon(Icons.sensors), + text: 'Sensors', + ); } }).toList(), ), diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index edf86a0..be80c18 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -76,6 +76,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { final BackgroundLocationService _backgroundLocationService = BackgroundLocationService(); bool _isDisposing = false; // Flag to prevent updates during disposal + MapProvider? _mapProvider; // MBTiles layers List _mbtilesLayers = []; @@ -129,6 +130,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; // Check if widget is still mounted final mapProvider = context.read(); + _mapProvider = mapProvider; mapProvider.addListener(_handleMapNavigation); // Load WMS overlay state mapProvider.loadOverlayState(); @@ -438,8 +440,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Save map position before disposing _saveMapPosition(); - final mapProvider = context.read(); - mapProvider.removeListener(_handleMapNavigation); + _mapProvider?.removeListener(_handleMapNavigation); // DO NOT stop location tracking - it's managed by AppProvider // Restore the original callback instead of setting to null diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index e3b4056..2380efd 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -19,7 +19,9 @@ import '../models/message.dart'; import '../models/contact.dart'; import '../widgets/messages/sar_update_sheet.dart'; import '../widgets/messages/recipient_selector_sheet.dart'; -import '../widgets/messages/message_bubble.dart'; +import '../widgets/messages/messages_composer.dart'; +import '../widgets/messages/messages_content.dart'; +import '../widgets/common/contact_avatar.dart'; import '../services/message_destination_preferences.dart'; import '../services/voice_bitrate_preferences.dart'; import '../services/voice_recorder_service.dart'; @@ -47,6 +49,7 @@ class MessagesTab extends StatefulWidget { class _MessagesTabState extends State { static const int _maxContactMessageBytes = 156; static const int _maxChannelMessageBytes = 127; + static const double _composerOverlayHeight = 148; final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); @@ -314,20 +317,6 @@ class _MessagesTabState extends State { } } - /// Get tooltip for destination button - String _getDestinationTooltip() { - if (_destinationType == - MessageDestinationPreferences.destinationTypeChannel && - _selectedRecipient != null) { - final channelName = _selectedRecipient!.getLocalizedDisplayName(context); - return '$channelName (tap to change)'; - } else if (_selectedRecipient != null) { - final recipientName = _selectedRecipient!.displayName; - return '$recipientName (tap to change)'; - } - return 'Select recipient'; - } - String _getDestinationLabel() { if (_destinationType == MessageDestinationPreferences.destinationTypeChannel && @@ -466,10 +455,7 @@ class _MessagesTabState extends State { ); // Add to messages list with "sending" status - messagesProvider.addSentMessage( - sentMessage, - contact: _selectedRecipient, - ); + messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient); // Send message to selected recipient final sentSuccessfully = await connectionProvider.sendTextMessage( @@ -589,9 +575,9 @@ class _MessagesTabState extends State { if (_destinationType == MessageDestinationPreferences.destinationTypeContact && _selectedRecipient != null && - _selectedRecipient!.outPathLen >= 0) { + _selectedRecipient!.routeHasPath) { imageDataBytesPerFragment = safeImageDataBytesForPath( - _selectedRecipient!.outPathLen, + _selectedRecipient!.routeHopCount, ); } @@ -615,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, @@ -627,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; @@ -929,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, @@ -941,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(); @@ -1115,6 +1089,16 @@ class _MessagesTabState extends State { ); } + Future _runAfterSheetDismissal( + BuildContext sheetContext, + Future Function() action, + ) async { + Navigator.pop(sheetContext); + await Future.delayed(const Duration(milliseconds: 180)); + if (!mounted) return; + await action(); + } + void _showComposerActions() { showModalBottomSheet( context: context, @@ -1126,9 +1110,10 @@ class _MessagesTabState extends State { ListTile( leading: const Icon(Icons.add_location_alt), title: Text(AppLocalizations.of(context)!.sendSarMarker), - onTap: () { - Navigator.pop(sheetContext); - _showSarDialog(); + onTap: () async { + await _runAfterSheetDismissal(sheetContext, () async { + _showSarDialog(); + }); }, ), if (_voiceSupported) @@ -1138,13 +1123,14 @@ class _MessagesTabState extends State { title: Text(_isRecording ? 'Stop recording' : 'Record voice'), onTap: _isSendingVoice ? null - : () { - Navigator.pop(sheetContext); - if (_isRecording) { - _stopAndSendVoice(); - } else { - _startVoiceRecording(); - } + : () async { + await _runAfterSheetDismissal(sheetContext, () async { + if (_isRecording) { + await _stopAndSendVoice(); + } else { + await _startVoiceRecording(); + } + }); }, ), ListTile( @@ -1153,9 +1139,10 @@ class _MessagesTabState extends State { title: const Text('Send image from gallery'), onTap: _isSendingImage ? null - : () { - Navigator.pop(sheetContext); - _pickAndSendImage(source: ImageSource.gallery); + : () async { + await _runAfterSheetDismissal(sheetContext, () async { + await _pickAndSendImage(source: ImageSource.gallery); + }); }, ), ListTile( @@ -1164,18 +1151,20 @@ class _MessagesTabState extends State { title: const Text('Take photo'), onTap: _isSendingImage ? null - : () { - Navigator.pop(sheetContext); - _pickAndSendImage(source: ImageSource.camera); + : () async { + await _runAfterSheetDismissal(sheetContext, () async { + await _pickAndSendImage(source: ImageSource.camera); + }); }, ), ListTile( leading: const Icon(Icons.grid_3x3), title: const Text('Start Tic-Tac-Toe'), subtitle: const Text('DM only'), - onTap: () { - Navigator.pop(sheetContext); - _startTicTacToeGame(); + onTap: () async { + await _runAfterSheetDismissal(sheetContext, () async { + await _startTicTacToeGame(); + }); }, ), ], @@ -1185,6 +1174,23 @@ class _MessagesTabState extends State { ); } + Widget _buildDestinationAvatar(BuildContext context) { + final recipient = _selectedRecipient; + if (recipient != null) { + return ContactAvatar( + contact: recipient, + radius: 14, + displayName: _getDestinationLabel(), + ); + } + + return Icon( + _getDestinationIcon(), + size: 17, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + } + Future _sendSarMessage( String emoji, String name, @@ -1496,6 +1502,28 @@ class _MessagesTabState extends State { return filteredMessages; } + void _handleMessageTap(Message message) { + if (widget.onNavigateToMap == null) return; + + if (message.isSarMarker && message.sarGpsCoordinates != null) { + final mapProvider = context.read(); + mapProvider.navigateToLocation( + location: message.sarGpsCoordinates!, + zoom: 15.0, + ); + widget.onNavigateToMap?.call(); + return; + } + + if (message.isDrawing && message.drawingId != null) { + debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}'); + final mapProvider = context.read(); + final drawingProvider = context.read(); + mapProvider.navigateToDrawing(message.drawingId!, drawingProvider); + widget.onNavigateToMap?.call(); + } + } + @override Widget build(BuildContext context) { return Consumer( @@ -1507,552 +1535,41 @@ class _MessagesTabState extends State { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => FocusScope.of(context).unfocus(), - child: Column( + child: Stack( children: [ - // Messages list with pull-to-refresh - Expanded( - child: RefreshIndicator( + Positioned.fill( + child: MessagesContent( + messages: messages, + scrollController: _scrollController, + highlightedMessageId: _highlightedMessageId, + bottomContentPadding: + _composerOverlayHeight + composerBottomPadding, onRefresh: _handleRefresh, - child: messages.isEmpty - ? LayoutBuilder( - builder: (context, constraints) => - SingleChildScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - physics: - const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: Center( - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon( - Icons.message_outlined, - size: 64, - color: Theme.of( - context, - ).disabledColor, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of( - context, - )!.noMessagesYet, - style: Theme.of( - context, - ).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of( - context, - )!.pullDownToSync, - style: Theme.of( - context, - ).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ) - : ListView.builder( - controller: _scrollController, - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - reverse: true, - padding: const EdgeInsets.all(8), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - final isHighlighted = - message.id == _highlightedMessageId; - - return MessageBubble( - key: ValueKey(message.id), - message: message, - isHighlighted: isHighlighted, - onNavigateToMap: widget.onNavigateToMap, - onTap: - widget.onNavigateToMap != null && - message.isSarMarker && - message.sarGpsCoordinates != null - ? () { - final mapProvider = context - .read(); - mapProvider.navigateToLocation( - location: message.sarGpsCoordinates!, - zoom: 15.0, - ); - widget.onNavigateToMap?.call(); - } - : widget.onNavigateToMap != null && - message.isDrawing && - message.drawingId != null - ? () { - debugPrint( - '🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}', - ); - final mapProvider = context - .read(); - final drawingProvider = context - .read(); - mapProvider.navigateToDrawing( - message.drawingId!, - drawingProvider, - ); - widget.onNavigateToMap?.call(); - } - : null, - ); - }, - ), + onNavigateToMap: widget.onNavigateToMap, + onMessageTap: _handleMessageTap, ), ), - - // Message input area - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB( - 10, - 10, - 10, - composerBottomPadding, - ), - child: Container( - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(28), - border: Border.all( - color: Theme.of( - context, - ).dividerColor.withValues(alpha: 0.35), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 18, - offset: const Offset(0, 6), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface, - shape: BoxShape.circle, - border: Border.all( - color: Theme.of( - context, - ).dividerColor.withValues(alpha: 0.35), - ), - ), - child: IconButton( - icon: Icon( - _isRecording ? Icons.stop : Icons.add, - size: 22, - ), - tooltip: _isRecording - ? 'Stop recording' - : 'More actions', - onPressed: _isRecording - ? _stopAndSendVoice - : _showComposerActions, - color: _isRecording - ? Colors.red - : Theme.of( - context, - ).colorScheme.primary, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: _showRecipientSelector, - child: Ink( - height: 42, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface, - borderRadius: BorderRadius.circular( - 20, - ), - border: Border.all( - color: Theme.of(context) - .dividerColor - .withValues(alpha: 0.35), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - ), - child: Row( - children: [ - Icon( - _getDestinationIcon(), - size: 17, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - _getDestinationLabel(), - overflow: - TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15, - fontWeight: - FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ), - ), - Icon( - Icons.expand_more_rounded, - size: 20, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ], - ), - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: AnimatedContainer( - duration: const Duration( - milliseconds: 180, - ), - constraints: const BoxConstraints( - minHeight: 46, - maxHeight: 132, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface, - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: _focusNode.hasFocus - ? Theme.of( - context, - ).colorScheme.primary - : Theme.of(context).dividerColor - .withValues(alpha: 0.35), - width: _focusNode.hasFocus ? 1.4 : 1, - ), - boxShadow: _focusNode.hasFocus - ? [ - BoxShadow( - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.10), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ] - : null, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: TextField( - controller: _textController, - focusNode: _focusNode, - minLines: 1, - maxLines: 4, - keyboardType: TextInputType.multiline, - inputFormatters: [ - _messageByteLimiter, - ], - style: const TextStyle(fontSize: 15), - textAlignVertical: - TextAlignVertical.center, - decoration: InputDecoration( - hintText: AppLocalizations.of( - context, - )!.typeYourMessage, - hintStyle: TextStyle( - fontSize: 15, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.9), - ), - filled: false, - fillColor: Colors.transparent, - border: InputBorder.none, - isCollapsed: true, - ), - textInputAction: - TextInputAction.newline, - ), - ), - ), - ), - const SizedBox(width: 8), - Builder( - builder: (context) { - final canSendText = - !_isRecording && - !_isSendingVoice && - _textController.text - .trim() - .isNotEmpty; - final semanticsLabel = _isRecording - ? 'Recording... release to send voice' - : (_isSendingVoice - ? 'Sending voice...' - : _voiceSupported - ? 'Send (long press to record voice)' - : 'Send'); - - return Semantics( - button: true, - enabled: - canSendText || - (_voiceSupported && - !_isSendingVoice), - label: semanticsLabel, - onTap: canSendText - ? _sendMessage - : null, - onLongPress: - (_voiceSupported && - !_isSendingVoice) - ? () { - if (_isRecording) { - _stopAndSendVoice(); - return; - } - _startVoiceRecording(); - } - : null, - child: Tooltip( - message: semanticsLabel, - excludeFromSemantics: true, - child: GestureDetector( - excludeFromSemantics: true, - onTap: canSendText - ? () { - debugPrint( - '👆 [MessagesTab] Send button tapped ' - '(canSendText=$canSendText, ' - 'textLength=${_textController.text.trim().length}, ' - 'recording=$_isRecording, ' - 'sendingVoice=$_isSendingVoice)', - ); - _sendMessage(); - } - : null, - onLongPressStart: - (_voiceSupported && - !_isSendingVoice) - ? (_) { - debugPrint( - '🎙️ [MessagesTab] Send button long-press start ' - '(voiceSupported=$_voiceSupported, ' - 'sendingVoice=$_isSendingVoice, ' - 'recording=$_isRecording)', - ); - _startVoiceRecording(); - } - : null, - onLongPressEnd: - (_voiceSupported && - _isRecording) - ? (_) { - debugPrint( - '🎙️ [MessagesTab] Send button long-press end ' - '(recording=$_isRecording)', - ); - _stopAndSendVoice(); - } - : null, - onLongPressCancel: - (_voiceSupported && - _isRecording) - ? () { - debugPrint( - '🎙️ [MessagesTab] Send button long-press cancel ' - '(recording=$_isRecording)', - ); - _stopAndSendVoice(); - } - : null, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedContainer( - duration: const Duration( - milliseconds: 180, - ), - width: 46, - height: 46, - decoration: BoxDecoration( - color: - canSendText || - _isRecording - ? Theme.of( - context, - ).colorScheme.primary - : Theme.of( - context, - ).colorScheme.surface, - shape: BoxShape.circle, - border: Border.all( - color: - canSendText || - _isRecording - ? Colors.transparent - : Theme.of(context) - .dividerColor - .withValues( - alpha: 0.35, - ), - ), - boxShadow: - canSendText || - _isRecording - ? [ - BoxShadow( - color: - Theme.of( - context, - ) - .colorScheme - .primary - .withValues( - alpha: - 0.22, - ), - blurRadius: 14, - offset: - const Offset( - 0, - 6, - ), - ), - ] - : null, - ), - child: _isSendingVoice - ? Center( - child: CircularProgressIndicator( - strokeWidth: 2, - color: - Theme.of( - context, - ) - .colorScheme - .onPrimary, - ), - ) - : Icon( - _isRecording - ? Icons - .mic_rounded - : Icons - .send_rounded, - size: 22, - color: - canSendText || - _isRecording - ? Theme.of( - context, - ) - .colorScheme - .onPrimary - : Theme.of( - context, - ) - .colorScheme - .onSurfaceVariant, - ), - ), - const SizedBox(height: 4), - Text( - '$_messageByteCount/$_maxMessageBytes', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: - _messageByteCount > - _maxMessageBytes * - 0.9 - ? Colors.orange.shade800 - : Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues( - alpha: 0.9, - ), - ), - ), - ], - ), - ), - ), - ); - }, - ), - ], - ), - ], - ), - ), - ), - ), - ), - ], + Positioned( + left: 0, + right: 0, + bottom: 0, + child: MessagesComposer( + textController: _textController, + focusNode: _focusNode, + messageByteLimiter: _messageByteLimiter, + messageByteCount: _messageByteCount, + maxMessageBytes: _maxMessageBytes, + isRecording: _isRecording, + isSendingVoice: _isSendingVoice, + voiceSupported: _voiceSupported, + bottomPadding: composerBottomPadding, + destinationLabel: _getDestinationLabel(), + destinationAvatar: _buildDestinationAvatar(context), + onShowComposerActions: _showComposerActions, + onShowRecipientSelector: _showRecipientSelector, + onStartVoiceRecording: _startVoiceRecording, + onStopAndSendVoice: _stopAndSendVoice, + onSendMessage: _sendMessage, ), ), ], diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart index ec05782..25e607c 100644 --- a/lib/screens/packet_log_screen.dart +++ b/lib/screens/packet_log_screen.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:meshcore_client/meshcore_client.dart'; import '../l10n/app_localizations.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../utils/log_rx_route_decoder.dart'; class PacketLogScreen extends StatefulWidget { final MeshCoreBleService bleService; @@ -456,6 +460,26 @@ class _PacketLogCard extends StatelessWidget { final isRx = log.direction == PacketDirection.rx; final directionColor = isRx ? Colors.green : Colors.blue; final rxInfo = log.logRxDataInfo; + final contacts = context.watch().contacts; + final connectionProvider = context.watch(); + final decodedRoute = LogRxRouteDecoder.decode(log.rawData); + final ownPublicKey = connectionProvider.deviceInfo.publicKey; + final ownName = + connectionProvider.deviceInfo.selfName ?? + connectionProvider.deviceInfo.displayName; + final resolvedPath = decodedRoute?.pathHashes + .map( + (hash) => LogRxRouteDecoder.resolveHash( + hash, + contacts: contacts, + ownPublicKey: ownPublicKey, + ownName: ownName, + ), + ) + .toList(); + final originalSender = resolvedPath != null && resolvedPath.isNotEmpty + ? resolvedPath.first + : null; return Card( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -580,6 +604,14 @@ class _PacketLogCard extends StatelessWidget { ), ), ], + if (isRx && decodedRoute != null) ...[ + const SizedBox(height: 12), + _RouteSection( + route: decodedRoute, + path: resolvedPath ?? const [], + originalSender: originalSender, + ), + ], const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), @@ -723,6 +755,162 @@ class _PacketLogCard extends StatelessWidget { } } +class _RouteSection extends StatelessWidget { + final DecodedLogRxRoute route; + final List path; + final ResolvedNodeHash? originalSender; + + const _RouteSection({ + required this.route, + required this.path, + required this.originalSender, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.alt_route, size: 16), + SizedBox(width: 6), + Text( + 'Mesh Route', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _FactCard( + icon: Icons.route, + label: 'Payload', + value: _payloadTypeLabel(route.payloadType), + ), + _FactCard( + icon: Icons.hub, + label: 'Hops', + value: '${route.pathHashes.length}', + ), + if (originalSender != null) + _FactCard( + icon: Icons.person_pin_circle, + label: 'Original sender', + value: _nodeLabel(originalSender!), + ), + ], + ), + const SizedBox(height: 12), + if (path.isEmpty) + Text( + 'Direct packet, no hop path attached.', + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + ) + else + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (var i = 0; i < path.length; i++) ...[ + _RouteHopChip(index: i + 1, node: path[i]), + if (i < path.length - 1) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: Icon(Icons.arrow_right_alt, size: 16), + ), + ], + ], + ), + ], + ), + ); + } + + static String _payloadTypeLabel(int payloadType) { + switch (payloadType) { + case 0x00: + return 'REQ'; + case 0x01: + return 'RESP'; + case 0x02: + return 'TXT'; + case 0x03: + return 'ACK'; + case 0x04: + return 'ADVERT'; + case 0x05: + return 'GRP_TXT'; + case 0x06: + return 'GRP_DATA'; + case 0x07: + return 'ANON_REQ'; + case 0x08: + return 'PATH'; + case 0x09: + return 'TRACE'; + case 0x0A: + return 'MULTIPART'; + case 0x0B: + return 'CONTROL'; + default: + return '0x${payloadType.toRadixString(16).padLeft(2, '0')}'; + } + } + + static String _nodeLabel(ResolvedNodeHash node) { + if (node.isOwnNode) { + return '${node.label} (${node.hexLabel})'; + } + if (node.matchCount == 0) { + return node.hexLabel; + } + if (node.isUniqueMatch) { + return '${node.label} (${node.hexLabel})'; + } + return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)'; + } +} + +class _RouteHopChip extends StatelessWidget { + final int index; + final ResolvedNodeHash node; + + const _RouteHopChip({required this.index, required this.node}); + + @override + Widget build(BuildContext context) { + final color = node.isOwnNode + ? Colors.blue + : node.isUniqueMatch + ? Colors.green + : Colors.orange; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Text( + '$index. ${_RouteSection._nodeLabel(node)}', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ); + } +} + class _FactCard extends StatelessWidget { final IconData icon; final String label; diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart new file mode 100644 index 0000000..dd8ed3e --- /dev/null +++ b/lib/screens/sensors_tab.dart @@ -0,0 +1,735 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/app_localizations.dart'; +import '../models/contact.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../providers/sensors_provider.dart'; + +class SensorsTab extends StatelessWidget { + const SensorsTab({super.key}); + + Future _showAddSensorSheet(BuildContext context) async { + final sensorsProvider = context.read(); + final contactsProvider = context.read(); + final candidates = sensorsProvider.availableCandidates(contactsProvider); + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + if (candidates.isEmpty) { + return const SafeArea( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'No eligible nodes available. Discover a relay or node first.', + ), + ), + ); + } + + return SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 20), + children: [ + const ListTile( + title: Text( + 'Add sensor node', + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text('Pick a relay or node to watch in Sensors.'), + ), + ...candidates.map( + (contact) => ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + leading: CircleAvatar( + radius: 24, + backgroundColor: const Color(0xFFDDEAF8), + child: Icon( + _typeIcon(contact), + color: const Color(0xFF1E4F7A), + ), + ), + title: Text(contact.displayName), + subtitle: _SensorCandidatePreview(contact: contact), + isThreeLine: true, + onTap: () async { + await sensorsProvider.addSensor(contact); + if (!sheetContext.mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${contact.displayName} added to Sensors', + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Future _showMetricSelector( + BuildContext context, + String publicKeyHex, + ) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => Consumer( + builder: (context, sensorsProvider, child) { + final visibleMetrics = sensorsProvider.visibleMetricsFor( + publicKeyHex, + ); + return SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + children: [ + Text( + 'Visible fields', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Choose which values appear on sensor cards.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 20), + Wrap( + spacing: 10, + runSpacing: 10, + children: SensorMetric.values.map((metric) { + final visible = visibleMetrics.contains(metric); + return FilterChip( + selected: visible, + label: Text(_metricLabel(metric)), + onSelected: (value) { + sensorsProvider.toggleMetric( + publicKeyHex, + metric, + value, + ); + }, + ); + }).toList(), + ), + ], + ), + ); + }, + ), + ); + } + + Future _refreshAll(BuildContext context) async { + await context.read().refreshAll( + contactsProvider: context.read(), + connectionProvider: context.read(), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddSensorSheet(context), + child: const Icon(Icons.add), + ), + body: Consumer2( + builder: (context, sensorsProvider, contactsProvider, child) { + final watchedKeys = sensorsProvider.watchedSensorKeys; + + return RefreshIndicator( + onRefresh: () => _refreshAll(context), + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + if (watchedKeys.isEmpty) + const _EmptySensorsState() + else + ...watchedKeys.map((key) { + Contact? contact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == key) { + contact = entry; + break; + } + } + return _SensorCard( + contact: contact, + state: sensorsProvider.stateFor(key), + visibleMetrics: sensorsProvider.visibleMetricsFor(key), + onRemove: () async { + await sensorsProvider.removeSensor(key); + }, + onCustomize: () => _showMetricSelector(context, key), + ); + }), + ], + ), + ); + }, + ), + ); + } +} + +class _SensorCandidatePreview extends StatelessWidget { + final Contact contact; + + const _SensorCandidatePreview({required this.contact}); + + @override + Widget build(BuildContext context) { + final telemetry = contact.telemetry; + final previewLines = [ + '${contact.type.displayName} • ${contact.publicKeyShort}', + if (telemetry?.batteryPercentage != null) + 'Battery ${telemetry!.batteryPercentage!.toStringAsFixed(0)}% • ' + 'Temp ${telemetry.temperature?.toStringAsFixed(1) ?? '--'}°C', + if (telemetry?.gpsLocation != null) + 'GPS ${telemetry!.gpsLocation!.latitude.toStringAsFixed(3)}, ' + '${telemetry.gpsLocation!.longitude.toStringAsFixed(3)}', + ]; + + if (previewLines.length == 1) { + previewLines.add('No telemetry preview available yet'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: previewLines + .take(3) + .map( + (line) => Text(line, maxLines: 1, overflow: TextOverflow.ellipsis), + ) + .toList(), + ); + } +} + +class _EmptySensorsState extends StatelessWidget { + const _EmptySensorsState(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Column( + children: [ + Container( + width: 84, + height: 84, + decoration: const BoxDecoration( + color: Color(0xFFDDEAF8), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.sensors_outlined, + size: 40, + color: Color(0xFF1E4F7A), + ), + ), + const SizedBox(height: 20), + Text( + 'No sensor nodes added', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 10), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + 'Use + to add discovered relays or nodes. Pull down to refresh telemetry after adding them.', + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} + +class _SensorCard extends StatelessWidget { + final Contact? contact; + final SensorRefreshState state; + final Set visibleMetrics; + final Future Function() onRemove; + final VoidCallback onCustomize; + + const _SensorCard({ + required this.contact, + required this.state, + required this.visibleMetrics, + required this.onRemove, + required this.onCustomize, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final telemetry = contact?.telemetry; + final theme = Theme.of(context); + final metrics = contact == null || telemetry == null + ? const <_MetricCardData>[] + : _buildMetricCards(l10n, telemetry, contact!); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.14), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + contact?.displayName ?? 'Unavailable node', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (state == SensorRefreshState.timeout) + const _InlineAlertBadge(label: 'No response'), + ], + ), + if (telemetry != null) ...[ + const SizedBox(height: 2), + Text( + '${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + PopupMenuButton( + onSelected: (value) async { + if (value == 'remove') { + await onRemove(); + } else if (value == 'customize') { + onCustomize(); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'customize', + child: Text('Customize fields'), + ), + PopupMenuItem( + value: 'remove', + child: Text('Remove'), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + if (((state != SensorRefreshState.idle && + state != SensorRefreshState.timeout) || + state == SensorRefreshState.unavailable) || + (contact != null && + visibleMetrics.contains(SensorMetric.lastSeen))) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + if (state != SensorRefreshState.idle && + state != SensorRefreshState.timeout) + _StatusPill(state: state), + if (contact != null && + visibleMetrics.contains(SensorMetric.lastSeen)) + _InfoPill( + icon: Icons.schedule, + label: + '${l10n.lastSeen}: ${contact!.timeSinceLastSeen}', + ), + ], + ), + ), + if (contact == null) + const Text( + 'This node is no longer available in the contact list.', + ) + else if (telemetry == null) + const Text('No telemetry received yet. Pull down to fetch it.') + else if (metrics.isEmpty) + const Text( + 'All fields are hidden. Use Visible fields to choose what to show.', + ) + else + Wrap( + spacing: 12, + runSpacing: 12, + children: metrics + .map((metric) => _MetricTile(data: metric)) + .toList(), + ), + ], + ), + ), + ); + } + + List<_MetricCardData> _buildMetricCards( + AppLocalizations l10n, + dynamic telemetry, + Contact contact, + ) { + final items = <_MetricCardData>[]; + + if (visibleMetrics.contains(SensorMetric.voltage) && + telemetry.batteryMilliVolts != null) { + items.add( + _MetricCardData( + icon: Icons.bolt, + label: l10n.voltage, + value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V', + accent: const Color(0xFF0A7D61), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.battery) && + telemetry.batteryPercentage != null) { + items.add( + _MetricCardData( + icon: Icons.battery_5_bar, + label: l10n.battery, + value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%', + accent: const Color(0xFF4B8E2F), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.temperature) && + telemetry.temperature != null) { + items.add( + _MetricCardData( + icon: Icons.thermostat, + label: l10n.temperature, + value: '${telemetry.temperature!.toStringAsFixed(1)}°C', + accent: const Color(0xFFC76821), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.humidity) && + telemetry.humidity != null) { + items.add( + _MetricCardData( + icon: Icons.water_drop, + label: l10n.humidity, + value: '${telemetry.humidity!.toStringAsFixed(1)}%', + accent: const Color(0xFF246BB2), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.pressure) && + telemetry.pressure != null) { + items.add( + _MetricCardData( + icon: Icons.compress, + label: l10n.pressure, + value: '${telemetry.pressure!.toStringAsFixed(1)} hPa', + accent: const Color(0xFF6B4BAE), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.gps) && + telemetry.gpsLocation != null) { + items.add( + _MetricCardData( + icon: Icons.place, + label: l10n.gpsTelemetry, + value: + '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', + accent: const Color(0xFFAA3F57), + wide: true, + ), + ); + } + if (visibleMetrics.contains(SensorMetric.updated)) { + items.add( + _MetricCardData( + icon: Icons.update, + label: l10n.updated, + value: _formatTelemetryTime(telemetry.timestamp), + accent: const Color(0xFF6C727F), + ), + ); + } + if (telemetry.extraSensorData != null) { + for (final entry in telemetry.extraSensorData!.entries) { + items.add( + _MetricCardData( + icon: Icons.sensors, + label: entry.key, + value: '${entry.value}', + accent: const Color(0xFF3E657C), + ), + ); + } + } + + return items; + } + + String _formatTelemetryTime(DateTime timestamp) { + final diff = DateTime.now().difference(timestamp); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + String _formatTelemetryDateTime(DateTime timestamp) { + final local = timestamp.toLocal(); + final year = local.year.toString().padLeft(4, '0'); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '$year-$month-$day $hour:$minute'; + } +} + +class _StatusPill extends StatelessWidget { + final SensorRefreshState state; + + const _StatusPill({required this.state}); + + @override + Widget build(BuildContext context) { + final (label, color, icon) = switch (state) { + SensorRefreshState.idle => ( + 'Idle', + const Color(0xFF5D7185), + Icons.sensors, + ), + SensorRefreshState.refreshing => ( + 'Refreshing', + const Color(0xFF266AC2), + Icons.sync, + ), + SensorRefreshState.success => ( + 'Updated', + const Color(0xFF218B63), + Icons.check_circle, + ), + SensorRefreshState.timeout => ( + 'No response', + const Color(0xFFC17B1D), + Icons.schedule, + ), + SensorRefreshState.unavailable => ( + 'Unavailable', + const Color(0xFFB13B55), + Icons.error_outline, + ), + }; + + return _InfoPill( + icon: icon, + label: label, + foreground: color, + background: color.withValues(alpha: 0.10), + ); + } +} + +class _InfoPill extends StatelessWidget { + final IconData icon; + final String label; + final Color? foreground; + final Color? background; + + const _InfoPill({ + required this.icon, + required this.label, + this.foreground, + this.background, + }); + + @override + Widget build(BuildContext context) { + final color = foreground ?? Theme.of(context).colorScheme.onSurfaceVariant; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: + background ?? Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 8), + Text( + label, + style: TextStyle(color: color, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} + +class _InlineAlertBadge extends StatelessWidget { + final String label; + + const _InlineAlertBadge({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFC17B1D).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: const Color(0xFFC17B1D), + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _MetricTile extends StatelessWidget { + final _MetricCardData data; + + const _MetricTile({required this.data}); + + @override + Widget build(BuildContext context) { + final width = data.wide ? 320.0 : 168.0; + + return Container( + width: width, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: data.accent.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: data.accent.withValues(alpha: 0.14)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: data.accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(data.icon, color: data.accent, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: data.accent, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + data.value, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MetricCardData { + final IconData icon; + final String label; + final String value; + final Color accent; + final bool wide; + + const _MetricCardData({ + required this.icon, + required this.label, + required this.value, + required this.accent, + this.wide = false, + }); +} + +String _metricLabel(SensorMetric metric) { + return switch (metric) { + SensorMetric.lastSeen => 'Last seen', + SensorMetric.voltage => 'Voltage', + SensorMetric.battery => 'Battery', + SensorMetric.temperature => 'Temperature', + SensorMetric.humidity => 'Humidity', + SensorMetric.pressure => 'Pressure', + SensorMetric.gps => 'GPS', + SensorMetric.updated => 'Updated', + }; +} + +IconData _typeIcon(Contact contact) { + if (contact.isRepeater) { + return Icons.router; + } + if (contact.isChat) { + return Icons.sensors; + } + return Icons.device_hub; +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 15d282c..f43c0cf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -737,6 +737,19 @@ class _SettingsScreenState extends State { }, ), ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.sensors), + title: const Text('Enable Sensors tab'), + subtitle: const Text( + 'Show a dedicated tab for watched relay and node telemetry', + ), + value: appProvider.isSensorsEnabled, + onChanged: (value) async { + await appProvider.toggleSensorsEnabled(value); + }, + ), + ), ListTile( leading: const Icon(Icons.language), title: Text(AppLocalizations.of(context)!.language), diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart index 88839b6..5ea3f0c 100644 --- a/lib/services/map_marker_service.dart +++ b/lib/services/map_marker_service.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import '../models/contact.dart'; import '../models/sar_marker.dart'; +import '../widgets/common/contact_avatar.dart'; import '../widgets/map/location_pointer.dart'; /// Centralized service for map marker management. @@ -77,9 +78,15 @@ class MapMarkerService { // Marker icon Container( decoration: BoxDecoration( - color: getContactMarkerColor(contact, context), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + color: Colors.white, + shape: contact.type == ContactType.channel || + contact.type == ContactType.room + ? BoxShape.rectangle + : BoxShape.circle, + borderRadius: contact.type == ContactType.channel || + contact.type == ContactType.room + ? BorderRadius.circular(14) + : null, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), @@ -88,17 +95,8 @@ class MapMarkerService { ), ], ), - padding: const EdgeInsets.all(6), - child: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 18), - ) - : Icon( - getContactMarkerIcon(contact), - color: Colors.white, - size: 18, - ), + padding: const EdgeInsets.all(2), + child: ContactAvatar(contact: contact, radius: 16), ), const SizedBox(height: 2), // Name label (without emoji) 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 04a2728..32858ed 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart'; 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 @@ -10,12 +12,18 @@ class MessageStorageService { static const String _messagesKey = 'stored_messages'; static const String _messageContactLocationsKey = '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 Future saveMessages( List messages, { Map messageContactLocations = const {}, + Map messageReceptionDetails = const {}, + Map messageTransferDetails = const {}, }) async { try { final prefs = await SharedPreferences.getInstance(); @@ -34,15 +42,35 @@ class MessageStorageService { .map((entry) => entry['id'] as String) .toSet(); final locationJson = {}; + final receptionJson = {}; + final transferJson = {}; for (final entry in messageContactLocations.entries) { if (retainedMessageIds.contains(entry.key)) { locationJson[entry.key] = entry.value.toJson(); } } + for (final entry in messageReceptionDetails.entries) { + if (retainedMessageIds.contains(entry.key)) { + receptionJson[entry.key] = entry.value.toJson(); + } + } + for (final entry in messageTransferDetails.entries) { + if (retainedMessageIds.contains(entry.key)) { + transferJson[entry.key] = entry.value.toJson(); + } + } await prefs.setString( _messageContactLocationsKey, jsonEncode(locationJson), ); + await prefs.setString( + _messageReceptionDetailsKey, + jsonEncode(receptionJson), + ); + await prefs.setString( + _messageTransferDetailsKey, + jsonEncode(transferJson), + ); debugPrint( '✅ [MessageStorage] Saved ${limitedList.length} messages to storage', @@ -52,8 +80,8 @@ class MessageStorageService { } } - Future> loadMessageContactLocations() - async { + Future> + loadMessageContactLocations() async { try { final prefs = await SharedPreferences.getInstance(); final jsonString = prefs.getString(_messageContactLocationsKey); @@ -82,6 +110,66 @@ class MessageStorageService { } } + Future> + loadMessageReceptionDetails() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_messageReceptionDetailsKey); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + return const {}; + } + + final result = {}; + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + final snapshot = MessageReceptionDetails.fromJson(value); + if (snapshot != null) { + result[entry.key] = snapshot; + } + } + return result; + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading reception details: $e'); + return const {}; + } + } + + Future> + 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 { @@ -116,6 +204,8 @@ class MessageStorageService { final prefs = await SharedPreferences.getInstance(); 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/services/voice_recorder_service.dart b/lib/services/voice_recorder_service.dart index f2ad576..fe378b7 100644 --- a/lib/services/voice_recorder_service.dart +++ b/lib/services/voice_recorder_service.dart @@ -18,7 +18,7 @@ class VoiceRecorderService { /// Request microphone permission. Returns true if granted. Future requestPermission() async { - return _recorder.hasPermission(); + return _recorder.hasPermission(request: true); } /// Start capturing PCM audio. @@ -38,9 +38,7 @@ class VoiceRecorderService { throw StateError('VoiceRecorderService: already recording'); } - _controller = StreamController( - onCancel: () => _stopInternal(), - ); + _controller = StreamController(onCancel: () => _stopInternal()); _isRecording = true; _startRecording( @@ -167,17 +165,17 @@ class _VoiceDynamicsProcessor { required int sampleRate, required bool enableCompressor, required bool enableLimiter, - }) : _enableCompressor = enableCompressor, - _enableLimiter = enableLimiter, - _compressor = _SimpleCompressor( - sampleRate: sampleRate.toDouble(), - thresholdDb: -18.0, - ratio: 2.5, - attackMs: 8.0, - releaseMs: 120.0, - makeupGainDb: 4.0, - ), - _limiter = _PeakLimiter(ceilingDb: -1.0); + }) : _enableCompressor = enableCompressor, + _enableLimiter = enableLimiter, + _compressor = _SimpleCompressor( + sampleRate: sampleRate.toDouble(), + thresholdDb: -18.0, + ratio: 2.5, + attackMs: 8.0, + releaseMs: 120.0, + makeupGainDb: 4.0, + ), + _limiter = _PeakLimiter(ceilingDb: -1.0); Int16List process(Int16List input) { final output = Int16List(input.length); @@ -214,11 +212,11 @@ class _SimpleCompressor { required double attackMs, required double releaseMs, required double makeupGainDb, - }) : _thresholdDb = thresholdDb, - _ratio = ratio, - _makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(), - _attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))), - _releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0))); + }) : _thresholdDb = thresholdDb, + _ratio = ratio, + _makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(), + _attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))), + _releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0))); double process(double x) { final absX = x.abs(); @@ -266,14 +264,14 @@ class _VoiceBandPassFilter { required int sampleRate, required double lowCutHz, required double highCutHz, - }) : _highPass = _BiquadFilter.highPass( - sampleRate: sampleRate.toDouble(), - cutoffHz: lowCutHz, - ), - _lowPass = _BiquadFilter.lowPass( - sampleRate: sampleRate.toDouble(), - cutoffHz: highCutHz, - ); + }) : _highPass = _BiquadFilter.highPass( + sampleRate: sampleRate.toDouble(), + cutoffHz: lowCutHz, + ), + _lowPass = _BiquadFilter.lowPass( + sampleRate: sampleRate.toDouble(), + cutoffHz: highCutHz, + ); Int16List process(Int16List input) { final output = Int16List(input.length); @@ -306,11 +304,11 @@ class _BiquadFilter { required double b2, required double a1, required double a2, - }) : _b0 = b0, - _b1 = b1, - _b2 = b2, - _a1 = a1, - _a2 = a2; + }) : _b0 = b0, + _b1 = b1, + _b2 = b2, + _a1 = a1, + _a2 = a2; factory _BiquadFilter.lowPass({ required double sampleRate, 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/location_formats.dart b/lib/utils/location_formats.dart new file mode 100644 index 0000000..8956cf4 --- /dev/null +++ b/lib/utils/location_formats.dart @@ -0,0 +1,25 @@ +String formatPlusCode(double lat, double lon) { + const base = '23456789CFGHJMPQRVWX'; + + var normalizedLat = (lat + 90) / 180; + var normalizedLon = (lon + 180) / 360; + + final buffer = StringBuffer(); + for (var i = 0; i < 8; i++) { + if (i == 4) { + buffer.write('+'); + } + + final latDigit = (normalizedLat * 20).floor() % 20; + final lonDigit = (normalizedLon * 20).floor() % 20; + + buffer + ..write(base[latDigit]) + ..write(base[lonDigit]); + + normalizedLat = (normalizedLat * 20) % 1; + normalizedLon = (normalizedLon * 20) % 1; + } + + return buffer.toString(); +} diff --git a/lib/utils/log_rx_route_decoder.dart b/lib/utils/log_rx_route_decoder.dart new file mode 100644 index 0000000..006829c --- /dev/null +++ b/lib/utils/log_rx_route_decoder.dart @@ -0,0 +1,129 @@ +import 'dart:typed_data'; + +import '../models/contact.dart'; + +class DecodedLogRxRoute { + final int payloadType; + final List pathHashes; + + const DecodedLogRxRoute({ + required this.payloadType, + required this.pathHashes, + }); + + int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first; +} + +class ResolvedNodeHash { + final int hash; + final String label; + final bool isOwnNode; + final bool isUniqueMatch; + final int matchCount; + + const ResolvedNodeHash({ + required this.hash, + required this.label, + required this.isOwnNode, + required this.isUniqueMatch, + required this.matchCount, + }); + + String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}'; +} + +class LogRxRouteDecoder { + const LogRxRouteDecoder._(); + + static DecodedLogRxRoute? decode(Uint8List rawData) { + if (rawData.length < 5 || rawData[0] != 0x88) return null; + + final rawPacketData = rawData.sublist(3); + if (rawPacketData.length < 2) return null; + + final header = rawPacketData[0]; + final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + + var index = 1; + if (routeType == 0x00 || routeType == 0x03) { + if (rawPacketData.length < index + 5) return null; + index += 4; + } + + if (rawPacketData.length <= index) return null; + final pathLen = rawPacketData[index++]; + if (rawPacketData.length < index + pathLen) return null; + + return DecodedLogRxRoute( + payloadType: payloadType, + pathHashes: rawPacketData.sublist(index, index + pathLen), + ); + } + + static ResolvedNodeHash resolveHash( + int hash, { + required Iterable contacts, + Uint8List? ownPublicKey, + String? ownName, + }) { + final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty + ? ownPublicKey.first + : null; + if (ownHash == hash) { + final ownLabel = (ownName != null && ownName.trim().isNotEmpty) + ? '$ownName (you)' + : 'You'; + return ResolvedNodeHash( + hash: hash, + label: ownLabel, + isOwnNode: true, + isUniqueMatch: true, + matchCount: 1, + ); + } + + final matches = contacts.where((contact) { + return contact.publicKey.isNotEmpty && contact.publicKey.first == hash; + }).toList(); + + if (matches.isEmpty) { + return ResolvedNodeHash( + hash: hash, + label: 'Unknown', + isOwnNode: false, + isUniqueMatch: false, + matchCount: 0, + ); + } + + if (matches.length == 1) { + return ResolvedNodeHash( + hash: hash, + label: matches.first.displayName, + isOwnNode: false, + isUniqueMatch: true, + matchCount: 1, + ); + } + + final candidateNames = matches + .map((contact) => contact.displayName) + .where((name) => name.trim().isNotEmpty) + .take(2) + .join(', '); + final extraCount = matches.length - 2; + final label = candidateNames.isEmpty + ? '${matches.length} contacts' + : extraCount > 0 + ? '$candidateNames +$extraCount' + : candidateNames; + return ResolvedNodeHash( + hash: hash, + label: label, + isOwnNode: false, + isUniqueMatch: false, + matchCount: matches.length, + ); + } +} 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/message_airtime_estimator.dart b/lib/utils/message_airtime_estimator.dart new file mode 100644 index 0000000..01c9f78 --- /dev/null +++ b/lib/utils/message_airtime_estimator.dart @@ -0,0 +1,149 @@ +import '../models/message.dart'; +import 'image_message_parser.dart'; +import 'voice_message_parser.dart'; + +const int _defaultLoRaSf = 10; +const int _defaultLoRaCr = 5; +const int _defaultLoRaBwHz = 250000; +const int _defaultLoRaPreambleSymbols = 8; +const int _defaultLoRaCrcEnabled = 1; +const int _defaultLoRaExplicitHeader = 1; +const double _defaultAirtimeBudgetFactor = 1.0; +const int _meshPacketHeaderBytes = 2; +const int _textFrameBaseBytes = 10; + +Duration estimateMessageTransmitDuration( + Message message, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final imageEnvelope = ImageEnvelope.tryParse(message.text); + if (imageEnvelope != null) { + return estimateImageTransmitDuration( + fragmentCount: imageEnvelope.total, + sizeBytes: imageEnvelope.sizeBytes, + pathLen: message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final voiceEnvelope = VoiceEnvelope.tryParseText(message.text); + if (voiceEnvelope != null) { + return estimateVoiceTransmitDuration( + mode: voiceEnvelope.mode, + packetCount: voiceEnvelope.total, + durationMs: voiceEnvelope.durationMs, + pathLen: message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final voicePacket = VoicePacket.tryParseText(message.text); + if (voicePacket != null) { + return estimateVoiceTransmitDuration( + mode: voicePacket.mode, + packetCount: voicePacket.total, + durationMs: voicePacket.durationMs * voicePacket.total, + pathLen: message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final normalizedPathLen = _normalizedPathLen(message.pathLen); + final payloadBytes = _textFrameBaseBytes + message.text.length; + final hops = normalizedPathLen + 1; + final airtimeMs = _estimateLoRaAirtimeMs( + _meshPacketHeaderBytes + normalizedPathLen + payloadBytes, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + + return Duration( + milliseconds: (airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops) + .round(), + ); +} + +int _normalizedPathLen(int pathLen) { + if (pathLen < 0 || pathLen >= 255) return 0; + return pathLen.clamp(0, 64).toInt(); +} + +double _estimateLoRaAirtimeMs( + int payloadLenBytes, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final sf = _normalizeSf(radioSf); + final bw = _resolveBandwidthHz(radioBw).toDouble(); + final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4); + final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; + final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; + + final symbolMs = ((1 << sf) / bw) * 1000.0; + final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; + + final num = + (8 * payloadLenBytes) - + (4 * sf) + + 28 + + (16 * _defaultLoRaCrcEnabled) - + (20 * ih); + final den = 4 * (sf - (2 * de)); + final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); + final payloadSymbols = + 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); + final payloadMs = payloadSymbols * symbolMs; + + return preambleMs + payloadMs; +} + +int _normalizeSf(int? value) { + if (value == null) return _defaultLoRaSf; + if (value >= 5 && value <= 12) return value; + return _defaultLoRaSf; +} + +int _normalizeCr(int? value) { + if (value == null) return _defaultLoRaCr; + if (value >= 5 && value <= 8) return value; + return _defaultLoRaCr; +} + +int _resolveBandwidthHz(int? rawBw) { + if (rawBw == null) return _defaultLoRaBwHz; + if (rawBw > 1000) return rawBw; + switch (rawBw) { + case 0: + return 7800; + case 1: + return 10400; + case 2: + return 15600; + case 3: + return 20800; + case 4: + return 31250; + case 5: + return 41700; + case 6: + return 62500; + case 7: + return 125000; + case 8: + return 250000; + case 9: + return 500000; + default: + return _defaultLoRaBwHz; + } +} diff --git a/lib/utils/raw_route_probe.dart b/lib/utils/raw_route_probe.dart new file mode 100644 index 0000000..4782a06 --- /dev/null +++ b/lib/utils/raw_route_probe.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +class RawRouteProbeRequest { + static const int _binaryMagic = 0x70; // 'p' + + final int nonce; + final String requesterKey6; + + const RawRouteProbeRequest({ + required this.nonce, + required this.requesterKey6, + }); + + static RawRouteProbeRequest? tryParseBinary(Uint8List payload) { + if (payload.length != 11 || payload[0] != _binaryMagic) return null; + try { + final nonce = + (payload[1] << 24) | + (payload[2] << 16) | + (payload[3] << 8) | + payload[4]; + final requesterKey6 = payload + .sublist(5, 11) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + return RawRouteProbeRequest(nonce: nonce, requesterKey6: requesterKey6); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { + throw ArgumentError.value( + requesterKey6, + 'requesterKey6', + 'Expected 12 hex chars', + ); + } + final out = Uint8List(11); + out[0] = _binaryMagic; + out[1] = (nonce >> 24) & 0xFF; + out[2] = (nonce >> 16) & 0xFF; + out[3] = (nonce >> 8) & 0xFF; + out[4] = nonce & 0xFF; + for (var i = 0; i < 6; i++) { + out[5 + i] = int.parse( + requesterKey6.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } + return out; + } +} + +class RawRouteProbeAck { + static const int _binaryMagic = 0x71; // 'q' + + final int nonce; + + const RawRouteProbeAck({required this.nonce}); + + static RawRouteProbeAck? tryParseBinary(Uint8List payload) { + if (payload.length != 5 || payload[0] != _binaryMagic) return null; + try { + final nonce = + (payload[1] << 24) | + (payload[2] << 16) | + (payload[3] << 8) | + payload[4]; + return RawRouteProbeAck(nonce: nonce); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + final out = Uint8List(5); + out[0] = _binaryMagic; + out[1] = (nonce >> 24) & 0xFF; + out[2] = (nonce >> 16) & 0xFF; + out[3] = (nonce >> 8) & 0xFF; + out[4] = nonce & 0xFF; + return out; + } +} diff --git a/lib/utils/transmission_target_resolver.dart b/lib/utils/transmission_target_resolver.dart index 61354b1..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 } +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/common/location_display.dart b/lib/widgets/common/location_display.dart index 0488209..95af749 100644 --- a/lib/widgets/common/location_display.dart +++ b/lib/widgets/common/location_display.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import '../../l10n/app_localizations.dart'; +import '../../utils/location_formats.dart'; /// Reusable location display widget with tap-to-show modal /// Shows coordinates in a compact format with ability to view all formats @@ -35,9 +36,9 @@ class LocationDisplay extends StatelessWidget { Text( '${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w600, - ), + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), ), const SizedBox(width: 6), Icon( @@ -54,9 +55,9 @@ class LocationDisplay extends StatelessWidget { // Non-compact version (just text) return Text( '${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), ); } @@ -79,10 +80,7 @@ class LocationDisplay extends StatelessWidget { children: [ const Text( 'Location Formats', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), IconButton( icon: const Icon(Icons.close), @@ -121,7 +119,7 @@ class LocationDisplay extends StatelessWidget { _buildFormatRow( context, 'Plus Code', - _convertToPlusCode(location.latitude, location.longitude), + formatPlusCode(location.latitude, location.longitude), ), const SizedBox(height: 8), ], @@ -140,9 +138,9 @@ class LocationDisplay extends StatelessWidget { Text( label, style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Colors.grey, - fontWeight: FontWeight.w500, - ), + color: Colors.grey, + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 4), InkWell( @@ -150,7 +148,9 @@ class LocationDisplay extends StatelessWidget { Clipboard.setData(ClipboardData(text: value)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)), + content: Text( + AppLocalizations.of(context)!.copiedToClipboard(label), + ), duration: const Duration(seconds: 2), ), ); @@ -168,9 +168,9 @@ class LocationDisplay extends StatelessWidget { child: Text( value, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - ), + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), ), ), Icon( @@ -242,31 +242,4 @@ class LocationDisplay extends StatelessWidget { // Full MGRS would require UTM conversion library return '$zone$letter (approximate)'; } - - /// Convert to Google Plus Code format - /// Simplified implementation - returns approximate code - String _convertToPlusCode(double lat, double lon) { - // This is a simplified version - full Plus Code requires the open_location_code package - const base = '23456789CFGHJMPQRVWX'; - - // Normalize coordinates - lat = (lat + 90) / 180; // 0 to 1 - lon = (lon + 180) / 360; // 0 to 1 - - String code = ''; - for (int i = 0; i < 8; i++) { - if (i == 4) code += '+'; - - int latDigit = (lat * 20).floor() % 20; - int lonDigit = (lon * 20).floor() % 20; - - code += base[latDigit]; - code += base[lonDigit]; - - lat = (lat * 20) % 1; - lon = (lon * 20) % 1; - } - - return code; - } } diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index 56442a9..c5a25c5 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -21,10 +21,19 @@ class _ConnectionDialogState extends State final List _discoveredServers = []; int _scannedCount = 0; int _totalToScan = 0; - String? _connectingToServerKey; // Track which server is being connected to (ip:port) + int _lastTabIndex = 0; + String? + _connectingToServerKey; // Track which server is being connected to (ip:port) // Named listener method for proper cleanup void _onTabChanged() { + if (_tabController.index == _lastTabIndex) return; + _lastTabIndex = _tabController.index; + + if (_tabController.index == 0) { + _refreshBleDevices(); + } + if (_tabController.index == 1) { // Switched to network tab if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { @@ -47,13 +56,16 @@ class _ConnectionDialogState extends State void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - _connectionProvider = Provider.of(context, listen: false); + _connectionProvider = Provider.of( + context, + listen: false, + ); // Defer scan startup until after the first frame so Provider listeners // are not notified while this dialog is still being built. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _connectionProvider.startScan(); + _refreshBleDevices(); }); // Set up network scanner callbacks @@ -101,6 +113,12 @@ class _ConnectionDialogState extends State _networkScanner.scan(); } + Future _refreshBleDevices() async { + await _connectionProvider.stopScan(); + if (!mounted) return; + await _connectionProvider.startScan(); + } + Color _getSignalColor(int rssi) { if (rssi >= -60) return Colors.green; if (rssi >= -75) return Colors.orange; @@ -218,10 +236,7 @@ class _ConnectionDialogState extends State Icons.refresh, color: Theme.of(context).colorScheme.onPrimaryContainer, ), - onPressed: () { - connectionProvider.stopScan(); - connectionProvider.startScan(); - }, + onPressed: _refreshBleDevices, ), ], ), @@ -255,10 +270,7 @@ class _ConnectionDialogState extends State ), const SizedBox(height: 8), TextButton.icon( - onPressed: () { - connectionProvider.stopScan(); - connectionProvider.startScan(); - }, + onPressed: _refreshBleDevices, icon: const Icon(Icons.refresh), label: Text(AppLocalizations.of(context)!.scanAgain), ), 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 6994581..884729c 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -9,8 +9,9 @@ import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/map_provider.dart'; import '../../providers/app_provider.dart'; -import 'direct_message_sheet.dart'; +import 'contact_route_dialog.dart'; import 'room_login_sheet.dart'; +import '../../utils/location_formats.dart'; import '../../utils/toast_logger.dart'; import '../../utils/battery_display_helper.dart'; import '../../l10n/app_localizations.dart'; @@ -51,6 +52,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; @@ -69,6 +71,9 @@ class ContactTile extends StatelessWidget { // Get room login state if this is a room final connectionProvider = context.watch(); + final isPingInProgress = connectionProvider.isPingInProgress( + contact.publicKey, + ); final roomLoginState = contact.type == ContactType.room ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) : null; @@ -155,12 +160,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, ), ), @@ -168,25 +173,36 @@ 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, ), ), ], ), ), ], + if (isPingInProgress) ...[ + const SizedBox(width: 6), + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], ], ), subtitle: isSimpleMode @@ -443,9 +459,9 @@ class ContactTile extends StatelessWidget { ) : null, onTap: () { - // In simple mode, tap directly opens message sheet for chat contacts + // In simple mode, tap directly opens the route editor for chat contacts if (isSimpleMode && contact.type == ContactType.chat) { - _showDirectMessageDialog(context, contact); + _showSetRouteDialog(context, contact); } else if (isSimpleMode && contact.type == ContactType.repeater) { // In simple mode, tapping a repeater jumps to the map _jumpToMapForRepeater(context, contact); @@ -457,52 +473,47 @@ class ContactTile extends StatelessWidget { _showContactDetails(context, contact); } }, - onLongPress: () async { - final connectionProvider = context.read(); + onLongPress: isPingInProgress + ? null + : () async { + final connectionProvider = context.read(); - // Determine if we should use flooding (no path) or direct (has path) - final hasPath = contact.hasPath; + // Determine if we should use flooding (no path) or direct (has path) + final hasPath = contact.routeHasPath; - // Use smart ping with automatic fallback - final result = await connectionProvider.smartPing( - contactPublicKey: contact.publicKey, - hasPath: hasPath, - onRetryWithFlooding: () { - // Called when retrying with flooding after direct timeout - if (context.mounted) { - ToastLogger.warning( - context, - AppLocalizations.of( - context, - )!.directPingTimeout(contact.displayName), + // Use smart ping with automatic fallback + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: hasPath, + onRetryWithFlooding: () { + // Called when retrying with flooding after direct timeout + if (context.mounted) { + ToastLogger.warning( + context, + AppLocalizations.of( + context, + )!.directPingTimeout(contact.displayName), + ); + } + }, ); - } - }, - ); - // Show final result - if (context.mounted) { - if (!result.success) { - ToastLogger.error( - context, - AppLocalizations.of(context)!.pingFailed(contact.displayName), - ); - } - } - }, + // Show final result + if (context.mounted) { + if (!result.success) { + ToastLogger.error( + context, + AppLocalizations.of( + context, + )!.pingFailed(contact.displayName), + ); + } + } + }, ), ); } - void _showDirectMessageDialog(BuildContext context, Contact contact) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => DirectMessageSheet(contact: contact), - ); - } - void _showRoomLoginDialog(BuildContext context, Contact contact) { showModalBottomSheet( context: context, @@ -602,400 +613,529 @@ class ContactTile extends StatelessWidget { minChildSize: 0.4, maxChildSize: 0.9, expand: false, - builder: (context, scrollController) => 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), + 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), + ), ), - ), - // 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, - ), - ); - Navigator.pop(context); + TextButton.icon( + onPressed: isPingInProgress + ? null + : () async { + final connectionProvider = context + .read(); + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: contact.routeHasPath, + ); - // Switch to map tab using callback - onNavigateToMap?.call(); - }, - icon: const Icon(Icons.map, size: 18), - label: Text(AppLocalizations.of(context)!.viewOnMap), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, + if (!context.mounted || result.success) { + return; + } + + ToastLogger.error( + context, + AppLocalizations.of( + context, + )!.pingFailed(contact.displayName), + ); + }, + 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, + ), ), ), + ], + ), + 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)}%', ), - ], - ), - 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, + 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)})', ), - ), - // Degrees Decimal Minutes (DDM) - _detailRowWithCopy( - context, - 'DDM', - _convertToDDM( - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, + ], + if (!currentContact.isChannel) ...[ + const SizedBox(height: 16), + Text( + 'Route', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), - ), - // MGRS (Military Grid Reference System) - _detailRowWithCopy( - context, - 'MGRS', - _convertToMGRS( - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ), - ), - // Google Plus Code - _detailRowWithCopy( - context, - 'Plus Code', - _convertToPlusCode( - 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, + 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'), + ), ), - ), - TextButton.icon( + 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, + ), + ), + ), + ], + ), + ], + // 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: () { - final connectionProvider = context - .read(); - connectionProvider.requestTelemetry( - contact.publicKey, - zeroHop: true, - ); + Navigator.pop(context); // Close details first + _showRoomLoginDialog(context, contact); }, - icon: const Icon(Icons.refresh, size: 18), - label: Text(AppLocalizations.of(context)!.refresh), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, + 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, ), ), - ], - ), - 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, - ), - ), - ), - ], - ], + ), ), - ), - ], - ), + ], + ); + }, ), ); } + 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), @@ -1113,34 +1253,6 @@ class ContactTile extends StatelessWidget { return '$zone$letter (approximate)'; } - /// Convert to Google Plus Code format - /// Simplified implementation - returns approximate code - String _convertToPlusCode(double lat, double lon) { - // This is a simplified version - full Plus Code requires the open_location_code package - // For now, return a placeholder that shows it's not fully implemented - const base = '23456789CFGHJMPQRVWX'; - - // Normalize coordinates - lat = (lat + 90) / 180; // 0 to 1 - lon = (lon + 180) / 360; // 0 to 1 - - String code = ''; - for (int i = 0; i < 8; i++) { - if (i == 4) code += '+'; - - int latDigit = (lat * 20).floor() % 20; - int lonDigit = (lon * 20).floor() % 20; - - code += base[latDigit]; - code += base[lonDigit]; - - lat = (lat * 20) % 1; - lon = (lon * 20) % 1; - } - - return code; - } - IconData _getTypeIcon(ContactType type) { switch (type) { case ContactType.chat: diff --git a/lib/widgets/contacts/direct_message_sheet.dart b/lib/widgets/contacts/direct_message_sheet.dart deleted file mode 100644 index f0c12d6..0000000 --- a/lib/widgets/contacts/direct_message_sheet.dart +++ /dev/null @@ -1,452 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; -import '../../models/contact.dart'; -import '../../models/message.dart'; -import '../../providers/connection_provider.dart'; -import '../../providers/messages_provider.dart'; -import '../../providers/app_provider.dart'; -import '../../utils/toast_logger.dart'; -import '../../l10n/app_localizations.dart'; - -class DirectMessageSheet extends StatefulWidget { - final Contact contact; - - const DirectMessageSheet({super.key, required this.contact}); - - @override - State createState() => _DirectMessageSheetState(); -} - -class _DirectMessageSheetState extends State { - final TextEditingController _textController = TextEditingController(); - final FocusNode _focusNode = FocusNode(); - int _characterCount = 0; - static const int _maxCharacters = 160; - - @override - void initState() { - super.initState(); - _textController.addListener(_updateCharacterCount); - } - - @override - void dispose() { - _textController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void _updateCharacterCount() { - if (!mounted) return; - setState(() { - _characterCount = _textController.text.length; - }); - } - - /// Insert current GPS location at cursor position - Future _insertCurrentLocation() async { - try { - // Check location permission - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) { - if (!mounted) return; - ToastLogger.error(context, 'Location permission denied'); - return; - } - } - - if (permission == LocationPermission.deniedForever) { - if (!mounted) return; - ToastLogger.error(context, 'Location permission permanently denied'); - return; - } - - // Get current position - final position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.best, - ), - ); - - // Format location text - final locationText = - '📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}'; - - // Check if adding location would exceed limit - final currentText = _textController.text; - if (currentText.length + locationText.length > _maxCharacters) { - if (!mounted) return; - ToastLogger.error( - context, - 'Adding location would exceed 160 character limit', - ); - return; - } - - // Insert at cursor position or append - final selection = _textController.selection; - final newText = currentText.replaceRange( - selection.start >= 0 ? selection.start : currentText.length, - selection.end >= 0 ? selection.end : currentText.length, - locationText, - ); - - _textController.text = newText; - - // Move cursor to end of inserted text - final newCursorPosition = - (selection.start >= 0 ? selection.start : currentText.length) + - locationText.length; - _textController.selection = TextSelection.fromPosition( - TextPosition(offset: newCursorPosition), - ); - - if (!mounted) return; - } catch (e) { - if (!mounted) return; - ToastLogger.error(context, 'Failed to get location: $e'); - } - } - - Future _sendDirectMessage() async { - final text = _textController.text.trim(); - if (text.isEmpty) return; - - final connectionProvider = context.read(); - final messagesProvider = context.read(); - - if (!connectionProvider.deviceInfo.isConnected) { - if (!mounted) return; - ToastLogger.error( - context, - AppLocalizations.of(context)!.notConnectedToDevice, - ); - return; - } - - try { - // Create message ID - final messageId = '${DateTime.now().millisecondsSinceEpoch}_dm_sent'; - final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - // Get current device's public key (first 6 bytes) - final devicePublicKey = connectionProvider.deviceInfo.publicKey; - final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); - - // Create sent message object with recipient public key for retry support - final sentMessage = Message( - id: messageId, - messageType: MessageType.contact, - senderPublicKeyPrefix: senderPublicKeyPrefix, - pathLen: 0, - textType: MessageTextType.plain, - senderTimestamp: timestamp, - text: text, - receivedAt: DateTime.now(), - deliveryStatus: MessageDeliveryStatus.sending, - recipientPublicKey: - widget.contact.publicKey, // Store recipient for retry - ); - - // Add to messages list with "sending" status - // Pass contact for retry logic - messagesProvider.addSentMessage(sentMessage, contact: widget.contact); - - // Send direct message to contact (include contact for path logging) - final sentSuccessfully = await connectionProvider.sendTextMessage( - contactPublicKey: widget.contact.publicKey, - text: text, - messageId: messageId, // Pass message ID for tracking - contact: widget.contact, - ); - - if (!sentSuccessfully) { - // Mark message as failed if sending failed - messagesProvider.markMessageFailed(messageId); - } - - _textController.clear(); - _focusNode.unfocus(); - - if (!mounted) return; - Navigator.pop(context); // Close the dialog - } catch (e) { - if (!mounted) return; - ToastLogger.error( - context, - AppLocalizations.of(context)!.failedToSend(e.toString()), - ); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final appProvider = context.watch(); - final isSimpleMode = appProvider.isSimpleMode; - final contactLocation = widget.contact.displayLocation; - - return Container( - height: MediaQuery.of(context).size.height * 0.9, - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), - ), - ), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), - onPressed: () => Navigator.pop(context), - ), - Expanded( - child: Column( - children: [ - Text( - AppLocalizations.of(context)!.directMessage, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - widget.contact.displayName, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 14, - ), - ), - ], - ), - ), - const SizedBox(width: 48), // Spacer to keep title centered - ], - ), - ), - - // Mini map in simple mode (scrollable content) - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 16), - if (isSimpleMode && contactLocation != null) ...[ - GestureDetector( - onTap: () { - // Hide keyboard when tapping on map - _focusNode.unfocus(); - }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - height: 200, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.outline), - ), - clipBehavior: Clip.antiAlias, - child: FlutterMap( - options: MapOptions( - initialCenter: LatLng( - contactLocation.latitude, - contactLocation.longitude, - ), - initialZoom: 13.0, - interactionOptions: const InteractionOptions( - flags: - InteractiveFlag.pinchZoom | - InteractiveFlag.drag, - ), - ), - children: [ - TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.meshcore.sar', - ), - MarkerLayer( - markers: [ - Marker( - point: LatLng( - contactLocation.latitude, - contactLocation.longitude, - ), - width: 40, - height: 40, - child: Icon( - Icons.location_on, - color: colorScheme.primary, - size: 40, - ), - ), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 8), - // Location coordinates - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.gps_fixed, - size: 14, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - '${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 12, - fontFamily: 'monospace', - ), - ), - ], - ), - ), - const SizedBox(height: 16), - ], - ], - ), - ), - ), - - // Message input - Container( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewInsets.bottom, - ), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - ), - child: Column( - children: [ - TextField( - controller: _textController, - focusNode: _focusNode, - maxLength: _maxCharacters, - maxLines: 3, - autofocus: true, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: TextStyle(color: colorScheme.onSurface), - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.typeYourMessage, - hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colorScheme.primary, - width: 2, - ), - ), - contentPadding: const EdgeInsets.all(16), - counterText: '', // Hide default counter - ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendDirectMessage(), - ), - // Always-visible character counter - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 4, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - '$_characterCount / $_maxCharacters', - style: TextStyle( - fontSize: 12, - color: _characterCount > 155 - ? Colors.red - : (_characterCount > 140 - ? Colors.orange - : colorScheme.onSurfaceVariant), - fontWeight: _characterCount > 140 - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ], - ), - ), - const SizedBox(height: 8), - // Location and Send buttons - Row( - children: [ - OutlinedButton.icon( - onPressed: _insertCurrentLocation, - icon: const Icon(Icons.my_location, size: 18), - label: Text(AppLocalizations.of(context)!.myLocation), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - side: BorderSide(color: colorScheme.outline), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: _textController.text.trim().isEmpty - ? null - : _sendDirectMessage, - icon: const Icon(Icons.send), - label: Text( - AppLocalizations.of(context)!.sendDirectMessage, - ), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - disabledBackgroundColor: - colorScheme.surfaceContainerHighest, - disabledForegroundColor: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index 1f8d946..a5003ac 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -3,10 +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'; @@ -32,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; @@ -58,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( @@ -65,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( @@ -81,6 +90,7 @@ class _ImageMessageBubbleState extends State { if (mounted) { setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = null; }); } @@ -90,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 @@ -109,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, @@ -124,6 +147,8 @@ class _ImageMessageBubbleState extends State { _statusText( isComplete: isComplete, isRequesting: _isRequesting, + isReceivingData: isReceivingData, + isPartialRequest: _isPartialRequest, received: received, total: total, envelope: envelope, @@ -134,6 +159,7 @@ class _ImageMessageBubbleState extends State { isSentByMe: widget.isSentByMe, eta: eta, pathLen: effectivePathLen, + transferCount: transferCount, ), style: TextStyle( fontSize: 11, @@ -155,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, @@ -179,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, @@ -228,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', ), ], ], @@ -254,23 +300,25 @@ class _ImageMessageBubbleState extends State { int pathLen = 0, }) async { if (_isRequesting) return; + final conn = context.read(); final imageProvider = context.read(); imageProvider.resumeIncomingSession(envelope.sessionId); final contactsProvider = context.read(); - final resolution = await TransmissionTargetResolver.resolveFetchTarget( + final appProvider = context.read(); + var resolution = await TransmissionTargetResolver.resolveFetchTarget( contactsProvider: contactsProvider, refreshContacts: conn.getContacts, isSentByMe: widget.isSentByMe, recipientPublicKey: widget.message.recipientPublicKey, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, - senderKey6FromEnvelope: envelope.senderKey6, senderName: widget.message.senderName, maxFetchHops: _maxFetchHops, ); if (!mounted) return; if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch image', 'Sender contact is unknown. Sync contacts first.', @@ -278,6 +326,7 @@ class _ImageMessageBubbleState extends State { return; } if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch image', 'Sender route is unknown. Sync contacts/path first.', @@ -285,23 +334,94 @@ class _ImageMessageBubbleState extends State { return; } if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch image', 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', ); return; } + if (resolution.failure == TransmissionTargetFailure.unreachable) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender route did not respond to a path check. Sync contacts/path and try again.', + ); + return; + } - final sender = resolution.target!; - if (sender.outPathLen >= 2) { + var sender = resolution.target!; + var routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + await conn.getContacts(); + if (!mounted) return; + resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: conn.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender contact is unknown. Sync contacts first.', + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender route is unknown. Sync contacts/path first.', + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', + ); + return; + } + sender = resolution.target!; + routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender route did not respond on the raw transport path.', + ); + return; + } + } + + if (!sender.routeSupportsLegacyRawTransport) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.', + ); + return; + } + + if (sender.routeHopCount >= 2) { _showToast( - 'Image fetch over ${sender.outPathLen} hops may take a while.', + 'Image fetch over ${sender.routeHopCount} hops may take a while.', ); } setState(() => _errorText = null); final deviceKey = conn.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch image', 'Device key is unavailable.', @@ -318,30 +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, ); - setState(() { - _isRequesting = true; - _errorText = null; - }); - final payload = request.encodeBinary(); try { + debugPrint( + '📷 [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 (_) { @@ -349,6 +470,7 @@ class _ImageMessageBubbleState extends State { _showToast('Image fetch failed to send request'); setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = 'Image unavailable right now'; }); } @@ -357,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, @@ -380,6 +502,7 @@ class _ImageMessageBubbleState extends State { _showToast('Image fetch timed out'); setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = 'Image fetch timed out'; }); } @@ -401,10 +524,19 @@ class _ImageMessageBubbleState extends State { _showToast('Image receive canceled'); setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = 'Image receive canceled'; }); } + void _clearRequestState() { + if (!mounted) return; + setState(() { + _isRequesting = false; + _isPartialRequest = false; + }); + } + Future _showBlockingAlert(String title, String message) async { if (!mounted) return; _showToast('$title: $message'); @@ -426,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, @@ -436,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, @@ -450,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) { @@ -477,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, @@ -523,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 0a37116..c0210f1 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -13,7 +13,6 @@ import '../../providers/connection_provider.dart'; import '../../providers/drawing_provider.dart'; import '../../providers/voice_provider.dart'; import '../../providers/image_provider.dart' as ip; -import '../contacts/direct_message_sheet.dart'; import '../drawing_minimap_preview.dart'; import '../../models/ble_packet_log.dart'; import '../../services/sar_template_service.dart'; @@ -22,15 +21,19 @@ import '../../utils/sar_message_parser.dart'; import '../../utils/key_comparison.dart'; import '../../utils/voice_message_parser.dart'; import '../../utils/image_message_parser.dart'; +import '../../utils/message_airtime_estimator.dart'; import '../../utils/tictactoe_message_parser.dart'; -import '../../utils/avatar_label_helper.dart'; +import '../../utils/location_formats.dart'; import '../../l10n/app_localizations.dart'; import '../../utils/message_extensions.dart'; -import '../common/contact_avatar.dart'; +import '../../models/message_transfer_details.dart'; import 'voice_message_bubble.dart'; import 'image_message_bubble.dart'; import 'tictactoe_message_bubble.dart'; import 'message_trace_sheet.dart'; +import 'message_bubble_header.dart'; +import 'message_bubble_signal.dart'; +import 'system_message_bubble.dart'; /// Reusable message bubble widget that displays messages with various types: /// - Regular text messages (channel or direct) @@ -64,103 +67,6 @@ class _MessageBubbleState extends State { bool _isExpanded = false; bool _showReceivedStats = false; - Widget _buildHeaderAvatar( - BuildContext context, { - required bool isOwnMessage, - required bool isChannelMessage, - required dynamic senderContact, - required String displayName, - }) { - if (isOwnMessage) { - return CircleAvatar( - radius: 10.5, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - child: Icon( - Icons.account_circle, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - ); - } - - if (senderContact is Contact) { - return ContactAvatar( - contact: senderContact, - radius: 10.5, - displayName: displayName, - ); - } - - final background = isChannelMessage - ? Colors.teal.withValues(alpha: 0.16) - : Theme.of(context).colorScheme.surfaceContainerHighest; - final foreground = isChannelMessage - ? Colors.teal.shade800 - : Theme.of(context).colorScheme.onSurfaceVariant; - - return CircleAvatar( - radius: 10.5, - backgroundColor: background, - child: Text( - AvatarLabelHelper.buildLabel(displayName), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - color: foreground, - letterSpacing: -0.2, - ), - ), - ); - } - - Widget _buildBubbleMetaFooter( - BuildContext context, { - required Message message, - required bool isOwnMessage, - required bool isSarMarker, - }) { - final metaColor = Theme.of( - context, - ).textTheme.labelSmall?.color?.withValues(alpha: 0.68); - - final items = [ - Text( - message.getLocalizedTimeAgo(context), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: metaColor, - fontWeight: FontWeight.w500, - ), - ), - ]; - - if (!isOwnMessage && !isSarMarker && message.pathLen < 255) { - items.addAll([ - Text( - ' • ', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: metaColor), - ), - Icon(Icons.alt_route, size: 11, color: metaColor), - const SizedBox(width: 3), - Text( - message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: metaColor), - ), - ]); - } - - return Padding( - padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18), - child: Align( - alignment: Alignment.centerRight, - child: Row(mainAxisSize: MainAxisSize.min, children: items), - ), - ); - } - @override void didUpdateWidget(MessageBubble oldWidget) { super.didUpdateWidget(oldWidget); @@ -186,6 +92,12 @@ class _MessageBubbleState extends State { } void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) { + if (!widget.message.isRead && + !widget.message.isSentMessage && + !widget.message.isSystemMessage) { + context.read().markAsRead(widget.message.id); + } + if (!widget.isCompact && !isSarMarker && !isDrawing) { setState(() { _showReceivedStats = !_showReceivedStats; @@ -207,20 +119,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, @@ -236,13 +137,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, @@ -250,26 +148,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; @@ -281,19 +192,12 @@ class _MessageBubbleState extends State { } void _showMessageOptions(BuildContext context) { - // Determine if this is own message final connectionProvider = context.read(); final selfPublicKey = connectionProvider.deviceInfo.publicKey; final isOwnMessage = widget.message.isSentMessage || widget.message.isFromSelf(selfPublicKey); - // Check if we can reply to this message (must be contact message from someone else) - final canReply = - widget.message.isContactMessage && - !isOwnMessage && - widget.message.senderPublicKeyPrefix != null; - showModalBottomSheet( context: context, backgroundColor: Theme.of(context).colorScheme.surface, @@ -305,16 +209,6 @@ class _MessageBubbleState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Reply option (only for contact messages from others) - if (canReply) - ListTile( - leading: const Icon(Icons.reply), - title: Text(AppLocalizations.of(context)!.reply), - onTap: () { - Navigator.pop(context); - _showReplySheet(context); - }, - ), // Copy text option ListTile( leading: const Icon(Icons.copy), @@ -500,9 +394,14 @@ class _MessageBubbleState extends State { final senderLocationSnapshot = messagesProvider.getMessageContactLocation( widget.message.id, ); + 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; @@ -539,16 +438,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 @@ -564,16 +453,19 @@ class _MessageBubbleState extends State { widget.message, ); final packetPathBytes = _extractPathBytesFromLog(matchedRxLog); - final packetPathHex = packetPathBytes + final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes) ?.map((b) => b.toRadixString(16).padLeft(2, '0')) .join(':'); final snrDb = + receptionDetails?.snrDb ?? matchedRxLog?.logRxDataInfo?.snrDb ?? (widget.message.lastEchoSnrRaw != null ? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0) : null); final rssiDbm = - matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm; + receptionDetails?.rssiDbm ?? + matchedRxLog?.logRxDataInfo?.rssiDbm ?? + widget.message.lastEchoRssiDbm; final retryCause = _retryCauseLabel(widget.message); final retryResult = _retryResultLabel(widget.message); final retryMode = _retryModeLabel(widget.message); @@ -596,6 +488,9 @@ class _MessageBubbleState extends State { 'Matched RX RSSI: ${rssiDbm ?? '-'}', 'Matched RX SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}', 'Matched path bytes: ${packetPathHex ?? '-'}', + 'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}', + 'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}', + 'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}', 'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}', 'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}', 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', @@ -619,10 +514,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})', ); @@ -630,17 +532,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'); } @@ -683,8 +575,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) { @@ -759,7 +649,7 @@ class _MessageBubbleState extends State { _techBadge( context, icon: Icons.route, - label: _hopDisplayLabel(widget.message), + label: hopDisplayLabel(widget.message), ), _techBadge( context, @@ -847,6 +737,30 @@ class _MessageBubbleState extends State { value: widget.message.expectedAckTag! .toString(), ), + if (receptionDetails?.senderToReceiptMs != null) + _detailRow( + context, + label: 'Sender to receipt', + value: _formatDurationMs( + receptionDetails!.senderToReceiptMs!, + ), + ), + if (receptionDetails?.estimatedTransmitMs != null) + _detailRow( + context, + label: 'Estimated tx', + value: _formatDurationMs( + receptionDetails!.estimatedTransmitMs!, + ), + ), + if (receptionDetails?.postTransmitDelayMs != null) + _detailRow( + context, + label: 'Post-tx delay', + value: _formatDurationMs( + receptionDetails!.postTransmitDelayMs!, + ), + ), if (widget.message.suggestedTimeoutMs != null) _detailRow( context, @@ -971,11 +885,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( @@ -992,6 +902,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, @@ -1043,6 +968,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, @@ -1202,6 +1142,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, @@ -1255,6 +1209,18 @@ class _MessageBubbleState extends State { '${fraction}Z'; } + String _formatDurationMs(int durationMs) { + if (durationMs >= 60000) { + final minutes = durationMs ~/ 60000; + final seconds = (durationMs % 60000) ~/ 1000; + return '${minutes}m ${seconds}s'; + } + if (durationMs >= 1000) { + return '${(durationMs / 1000).toStringAsFixed(durationMs >= 10000 ? 0 : 1)} s'; + } + return '$durationMs ms'; + } + BlePacketLog? _findBestMatchingRxLog( List logs, Message message, @@ -1300,44 +1266,6 @@ class _MessageBubbleState extends State { return raw.sublist(5, 5 + pathLen); } - void _showReplySheet(BuildContext context) { - // Find the sender contact by public key prefix - final contactsProvider = context.read(); - - if (widget.message.senderPublicKeyPrefix == null) { - ToastLogger.error(context, 'Cannot reply: sender information missing'); - return; - } - - // Find contact by public key prefix (first 6 bytes) - final senderKeyHex = widget.message.senderPublicKeyPrefix! - .sublist( - 0, - widget.message.senderPublicKeyPrefix!.length < 6 - ? widget.message.senderPublicKeyPrefix!.length - : 6, - ) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(''); - - final senderContact = contactsProvider.contacts.where((c) { - return c.publicKeyHex.startsWith(senderKeyHex); - }).firstOrNull; - - if (senderContact == null) { - ToastLogger.error(context, 'Cannot reply: contact not found'); - return; - } - - // Show direct message sheet for the sender - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => DirectMessageSheet(contact: senderContact), - ); - } - void _showDeleteConfirmation(BuildContext context) { final l10n = AppLocalizations.of(context)!; showDialog( @@ -1583,163 +1511,12 @@ class _MessageBubbleState extends State { } } - IconData _getDeliveryStatusIcon(MessageDeliveryStatus status) { - switch (status) { - case MessageDeliveryStatus.sending: - return Icons.schedule; - case MessageDeliveryStatus.sent: - return Icons.check; - case MessageDeliveryStatus.delivered: - return Icons.done_all; - case MessageDeliveryStatus.failed: - return Icons.error_outline; - case MessageDeliveryStatus.received: - return Icons.inbox; - } - } - - Color _getDeliveryStatusColor(MessageDeliveryStatus status) { - switch (status) { - case MessageDeliveryStatus.sending: - return Colors.orange; - case MessageDeliveryStatus.sent: - return Colors.blue; - case MessageDeliveryStatus.delivered: - return Colors.green; - case MessageDeliveryStatus.failed: - return Colors.red; - case MessageDeliveryStatus.received: - return Colors.grey; - } - } - - Widget _buildChannelEchoStatus(BuildContext context, Message message) { - final statusColor = _getDeliveryStatusColor(message.deliveryStatus); - final hasEcho = message.echoCount > 0; - - if (!hasEcho) { - return Text( - message.getLocalizedDeliveryStatus(context), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: statusColor, - fontStyle: FontStyle.italic, - ), - ); - } - - final rssi = message.lastEchoRssiDbm; - final snr = message.lastEchoSnrRaw != null - ? message.lastEchoSnrRaw!.toSigned(8) / 4.0 - : null; - final quality = _linkQualityLabel(rssi, snr); - final qualityColor = _linkQualityColor(quality); - - return Wrap( - spacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _techChip( - context, - icon: Icons.hub_outlined, - label: 'x${message.echoCount}', - color: statusColor, - ), - if (message.expectedAckTag != null) - _techChip( - context, - icon: Icons.tag, - label: - 'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}', - color: Colors.indigo, - ), - _techChip( - context, - icon: Icons.bolt, - label: quality, - color: qualityColor, - ), - if (message.lastEchoRssiDbm != null) - _signalCapsule( - context, - icon: Icons.network_cell, - label: message.lastEchoRssiDbm!.toString(), - filled: _rssiScore(message.lastEchoRssiDbm!), - color: Colors.blueGrey, - ), - if (message.lastEchoSnrRaw != null) - _signalCapsule( - context, - icon: Icons.graphic_eq, - label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed( - 1, - ), - filled: _snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0), - color: Colors.teal, - ), - ], - ); - } - - Widget _buildReceivedSignalStatus( - BuildContext context, - Message message, { - required int? rssiDbm, - required double? snrDb, - }) { - final hopLabel = _hopDisplayLabel(message); - - return Wrap( - spacing: 4, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _techChip( - context, - icon: Icons.alt_route, - label: hopLabel, - color: Colors.indigo, - ), - if (rssiDbm != null || snrDb != null) ...[ - _techChip( - context, - icon: Icons.bolt, - label: _linkQualityLabel(rssiDbm, snrDb), - color: _linkQualityColor(_linkQualityLabel(rssiDbm, snrDb)), - ), - if (rssiDbm != null) - _signalCapsule( - context, - icon: Icons.network_cell, - label: '$rssiDbm', - filled: _rssiScore(rssiDbm), - color: Colors.blueGrey, - ), - if (snrDb != null) - _signalCapsule( - context, - icon: Icons.graphic_eq, - label: snrDb.toStringAsFixed(1), - filled: _snrScore(snrDb), - color: Colors.teal, - ), - ], - ], - ); - } - - String _hopDisplayLabel(Message message) { - if (message.pathLen == 0) return 'Direct'; - if (message.pathLen >= 255 && message.isContactMessage) return 'Direct'; - if (message.pathLen >= 255) return 'Unknown'; - return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; - } - String _hopDebugLabel(Message message) { if (message.pathLen >= 255 && message.isContactMessage) { return 'Direct (raw: ${message.pathLen})'; } if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})'; - return _hopDisplayLabel(message); + return hopDisplayLabel(message); } String? _retryCauseLabel(Message message) { @@ -1821,110 +1598,6 @@ class _MessageBubbleState extends State { return null; } - Widget _techChip( - BuildContext context, { - required IconData icon, - required String label, - required Color color, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 10, color: color), - const SizedBox(width: 2), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ], - ), - ); - } - - Widget _signalCapsule( - BuildContext context, { - required IconData icon, - required String label, - required int filled, - required Color color, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 10, color: color), - const SizedBox(width: 2), - Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(5, (i) { - final active = i < filled; - return Container( - width: 3, - height: (4 + i).toDouble(), - margin: const EdgeInsets.symmetric(horizontal: 0.5), - decoration: BoxDecoration( - color: active ? color : color.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(1), - ), - ); - }), - ), - const SizedBox(width: 2), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ], - ), - ); - } - - int _rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5); - - int _snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5); - - String _linkQualityLabel(int? rssiDbm, double? snrDb) { - var score = 0; - if (rssiDbm != null) score += _rssiScore(rssiDbm); - if (snrDb != null) score += _snrScore(snrDb); - if (score >= 8) return 'Excellent'; - if (score >= 6) return 'Good'; - if (score >= 4) return 'Fair'; - return 'Weak'; - } - - Color _linkQualityColor(String quality) { - switch (quality) { - case 'Excellent': - return Colors.green; - case 'Good': - return Colors.lightGreen; - case 'Fair': - return Colors.orange; - default: - return Colors.redAccent; - } - } - @override Widget build(BuildContext context) { // Display system messages with minimal styling @@ -1945,9 +1618,13 @@ class _MessageBubbleState extends State { // Determine if this is own message final connectionProvider = context.read(); + final messagesProvider = context.read(); final selfPublicKey = connectionProvider.deviceInfo.publicKey; final isOwnMessage = message.isSentMessage || message.isFromSelf(selfPublicKey); + final receptionDetails = !isOwnMessage + ? messagesProvider.getMessageReceptionDetails(message.id) + : null; final matchedRxLog = !isOwnMessage ? _findBestMatchingRxLog( connectionProvider.bleService.packetLogs, @@ -1955,12 +1632,15 @@ class _MessageBubbleState extends State { ) : null; final snrDb = + receptionDetails?.snrDb ?? matchedRxLog?.logRxDataInfo?.snrDb ?? (message.lastEchoSnrRaw != null ? (message.lastEchoSnrRaw!.toSigned(8) / 4.0) : null); final rssiDbm = - matchedRxLog?.logRxDataInfo?.rssiDbm ?? message.lastEchoRssiDbm; + receptionDetails?.rssiDbm ?? + matchedRxLog?.logRxDataInfo?.rssiDbm ?? + message.lastEchoRssiDbm; // Look up contact information for rich display name final contactsProvider = context.read(); @@ -2039,6 +1719,9 @@ class _MessageBubbleState extends State { isOwnMessage && message.isChannelMessage && recipientDisplayName != null ? '${l10n.channel}: $recipientDisplayName' : recipientDisplayName; + final directCounterpartLabel = !message.isChannelMessage + ? (isOwnMessage ? recipientSubtitle : l10n.you) + : null; final receivedChannelSubtitle = !isOwnMessage && message.isChannelMessage && channelDisplayName != null ? '${l10n.channel}: $channelDisplayName' @@ -2137,78 +1820,78 @@ class _MessageBubbleState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header: Badge (if SAR or drawing) and time - if (isSarMarker || message.isDrawing) + // Header badge for drawing messages + if (message.isDrawing) Row( children: [ - if (isSarMarker) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: _getSarMarkerBorderColor(context, isDarkMode), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.warning_amber_rounded, - size: 16, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - AppLocalizations.of(context)!.sarAlert, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), - ), - ], - ), - ) - else if (message.isDrawing) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.draw, - size: 16, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - AppLocalizations.of(context)!.mapDrawing, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), - ), - ], - ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.draw, size: 16, color: Colors.white), + const SizedBox(width: 4), + Text( + AppLocalizations.of(context)!.mapDrawing, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ], + ), + ), ], ), // Sender info row (shown for all messages) Row( children: [ + if (isSarMarker) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: _getSarMarkerBorderColor(context, isDarkMode), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.warning_amber_rounded, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + AppLocalizations.of(context)!.sarAlert, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ], + ), + ), + const SizedBox(width: 8), + ], // Unread indicator badge (only for regular messages, not SAR/drawing) if (!message.isRead && !message.isSentMessage && @@ -2224,7 +1907,7 @@ class _MessageBubbleState extends State { shape: BoxShape.circle, ), ), - _buildHeaderAvatar( + buildMessageHeaderAvatar( context, isOwnMessage: isOwnMessage, isChannelMessage: message.isChannelMessage, @@ -2235,7 +1918,7 @@ class _MessageBubbleState extends State { Expanded( child: Row( children: [ - Flexible( + Expanded( child: Text( displayName, style: Theme.of(context).textTheme.labelMedium @@ -2251,98 +1934,25 @@ class _MessageBubbleState extends State { ), if (!widget.isCompact && (recipientSubtitle != null || + directCounterpartLabel != null || receivedChannelSubtitle != null)) ...[ const SizedBox(width: 8), if (message.isChannelMessage) - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.65), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isOwnMessage - ? Icons.campaign_outlined - : Icons.tag, - size: 11, - color: Theme.of(context) - .textTheme - .labelSmall - ?.color - ?.withValues(alpha: 0.7), - ), - const SizedBox(width: 5), - Flexible( - child: Text( - isOwnMessage - ? recipientDisplayName! - : channelDisplayName!, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: Theme.of(context) - .textTheme - .labelSmall - ?.color - ?.withValues(alpha: 0.82), - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + Align( + alignment: Alignment.centerRight, + child: buildChannelHeaderPill( + context, + label: isOwnMessage + ? recipientDisplayName! + : channelDisplayName!, ), ) else - Expanded( - child: Row( - children: [ - Icon( - isOwnMessage - ? Icons.arrow_forward - : Icons.arrow_back, - size: 12, - color: Theme.of(context) - .textTheme - .labelSmall - ?.color - ?.withValues(alpha: 0.7), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - isOwnMessage - ? recipientSubtitle! - : receivedChannelSubtitle!, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: Theme.of(context) - .textTheme - .labelSmall - ?.color - ?.withValues(alpha: 0.75), - fontStyle: FontStyle.italic, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], + Align( + alignment: Alignment.centerRight, + child: buildDirectHeaderCounterpart( + context, + label: directCounterpartLabel!, ), ), ], @@ -2407,6 +2017,9 @@ class _MessageBubbleState extends State { fontWeight: FontWeight.w800, letterSpacing: -0.2, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, ), if (message.sarNotes != null && message.sarNotes!.isNotEmpty) ...[ @@ -2455,27 +2068,64 @@ class _MessageBubbleState extends State { ), borderRadius: BorderRadius.circular(10), ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.place_outlined, - size: 15, - color: _getSarMarkerBorderColor( - context, - isDarkMode, - ), + Row( + children: [ + Icon( + Icons.place_outlined, + size: 15, + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + ), + ], ), - const SizedBox(width: 6), - Expanded( - child: Text( - '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', - style: Theme.of(context).textTheme.labelMedium - ?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w700, - letterSpacing: 0.15, + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.tag_rounded, + size: 15, + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + formatPlusCode( + message.sarGpsCoordinates!.latitude, + message.sarGpsCoordinates!.longitude, ), - ), + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + ), + ], ), ], ), @@ -2593,11 +2243,13 @@ class _MessageBubbleState extends State { if (!widget.isCompact && !isSarMarker && !message.isDrawing && + !message.isSentMessage && _showReceivedStats) ...[ const SizedBox(height: 6), - _buildReceivedSignalStatus( + buildReceivedSignalStatus( context, message, + receptionDetails: receptionDetails, rssiDbm: rssiDbm, snrDb: snrDb, ), @@ -2783,81 +2435,124 @@ class _MessageBubbleState extends State { ), ] // Show single message delivery status - else - Row( - mainAxisSize: MainAxisSize.max, - children: [ - Icon( - _getDeliveryStatusIcon(message.deliveryStatus), - size: 12, - color: _getDeliveryStatusColor(message.deliveryStatus), - ), - const SizedBox(width: 3), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: - message.isChannelMessage && - message.deliveryStatus == - MessageDeliveryStatus.sent - ? _buildChannelEchoStatus(context, message) - : Text( - message.getLocalizedDeliveryStatus(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: _getDeliveryStatusColor( - message.deliveryStatus, - ), - fontStyle: FontStyle.italic, - ), + else if (!message.isChannelMessage || + message.deliveryStatus == MessageDeliveryStatus.failed) + Builder( + builder: (context) { + final txEstimate = estimateMessageTransmitDuration( + message, + radioBw: connectionProvider.deviceInfo.radioBw, + radioSf: connectionProvider.deviceInfo.radioSf, + radioCr: connectionProvider.deviceInfo.radioCr, + ); + final showSentDirectStats = + message.isContactMessage && + message.deliveryStatus == + MessageDeliveryStatus.delivered && + _showReceivedStats && + message.roundTripTimeMs != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + children: [ + Icon( + getDeliveryStatusIcon(message.deliveryStatus), + size: 12, + color: getDeliveryStatusColor( + message.deliveryStatus, ), - ), - ), - // Show retry button for failed messages - if (message.deliveryStatus == - MessageDeliveryStatus.failed) ...[ - const SizedBox(width: 6), - GestureDetector( - onTap: () => _retryFailedMessage(context, message), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: Colors.orange, - width: 1, ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.refresh, - size: 12, - color: Colors.orange, + const SizedBox(width: 3), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + message.getLocalizedDeliveryStatus(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: getDeliveryStatusColor( + message.deliveryStatus, + ), + fontStyle: FontStyle.italic, + ), + ), ), - const SizedBox(width: 4), - Text( - 'Retry', - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: Colors.orange, - fontWeight: FontWeight.bold, + ), + // Show retry button for failed messages + if (message.deliveryStatus == + MessageDeliveryStatus.failed) ...[ + const SizedBox(width: 6), + GestureDetector( + onTap: () => + _retryFailedMessage(context, message), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.orange.withValues( + alpha: 0.2, ), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.orange, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.refresh, + size: 12, + color: Colors.orange, + ), + const SizedBox(width: 4), + Text( + 'Retry', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), ), ], - ), + ], ), - ), - ], - ], + if (showSentDirectStats) ...[ + const SizedBox(height: 6), + buildSentDirectSignalStatus( + context, + message, + roundTripTimeMs: message.roundTripTimeMs!, + txEstimate: txEstimate, + ), + ], + ], + ); + }, ), + if (shouldShowSentChannelStats( + message, + showReceivedStats: _showReceivedStats, + )) ...[ + const SizedBox(height: 6), + buildChannelEchoStatus(context, message), + ], ], ], ), @@ -2871,10 +2566,9 @@ class _MessageBubbleState extends State { : CrossAxisAlignment.start, children: [ bubble, - _buildBubbleMetaFooter( + buildBubbleMetaFooter( context, message: message, - isOwnMessage: isOwnMessage, isSarMarker: isSarMarker, ), ], @@ -2892,85 +2586,3 @@ class _MessageBubbleState extends State { ); } } - -/// System message bubble - compact log-style display -class SystemMessageBubble extends StatelessWidget { - final Message message; - - const SystemMessageBubble({super.key, required this.message}); - - Color _getLevelColor(String? level) { - switch (level?.toLowerCase()) { - case 'success': - return Colors.green; - case 'warning': - return Colors.orange; - case 'error': - return Colors.red; - case 'info': - default: - return Colors.blue.shade300; - } - } - - IconData _getLevelIcon(String? level) { - switch (level?.toLowerCase()) { - case 'success': - return Icons.check_circle_outline; - case 'warning': - return Icons.warning_amber_outlined; - case 'error': - return Icons.error_outline; - case 'info': - default: - return Icons.info_outline; - } - } - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final level = message.senderName ?? 'info'; - final levelColor = _getLevelColor(level); - - return Container( - margin: const EdgeInsets.only(bottom: 2), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: isDarkMode - ? levelColor.withValues(alpha: 0.1) - : levelColor.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - children: [ - Icon(_getLevelIcon(level), size: 14, color: levelColor), - const SizedBox(width: 6), - Text( - message.getLocalizedTimeAgo(context), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), - fontSize: 10, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - message.text, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withValues(alpha: 0.8), - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/messages/message_bubble_header.dart b/lib/widgets/messages/message_bubble_header.dart new file mode 100644 index 0000000..496d739 --- /dev/null +++ b/lib/widgets/messages/message_bubble_header.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; + +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../utils/avatar_label_helper.dart'; +import '../../utils/message_extensions.dart'; +import '../common/contact_avatar.dart'; + +Widget buildMessageHeaderAvatar( + BuildContext context, { + required bool isOwnMessage, + required bool isChannelMessage, + required dynamic senderContact, + required String displayName, +}) { + if (isOwnMessage) { + return CircleAvatar( + radius: 10.5, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + Icons.account_circle, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ); + } + + if (senderContact is Contact) { + return ContactAvatar( + contact: senderContact, + radius: 10.5, + displayName: displayName, + ); + } + + final background = isChannelMessage + ? Colors.teal.withValues(alpha: 0.16) + : Theme.of(context).colorScheme.surfaceContainerHighest; + final foreground = isChannelMessage + ? Colors.teal.shade800 + : Theme.of(context).colorScheme.onSurfaceVariant; + + return CircleAvatar( + radius: 10.5, + backgroundColor: background, + child: Text( + AvatarLabelHelper.buildLabel(displayName), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: foreground, + letterSpacing: -0.2, + ), + ), + ); +} + +Widget buildBubbleMetaFooter( + BuildContext context, { + required Message message, + required bool isSarMarker, +}) { + final metaColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.68); + + final items = []; + final sentEchoLabel = message.isSentMessage && message.echoCount > 0 + ? '${message.echoCount} echo${message.echoCount == 1 ? '' : 'es'}' + : null; + + if (!isSarMarker && sentEchoLabel != null) { + items.addAll([ + Icon(Icons.hub_outlined, size: 11, color: metaColor), + const SizedBox(width: 3), + Text( + sentEchoLabel, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + Text( + ' • ', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + ]); + } else if (!isSarMarker && message.pathLen < 255) { + items.addAll([ + Icon(Icons.alt_route, size: 11, color: metaColor), + const SizedBox(width: 3), + Text( + message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + Text( + ' • ', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + ]); + } + + items.add( + Text( + message.getLocalizedTimeAgo(context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: metaColor, + fontWeight: FontWeight.w500, + ), + ), + ); + + return Padding( + padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18), + child: Align( + alignment: Alignment.centerRight, + child: Row(mainAxisSize: MainAxisSize.min, children: items), + ), + ); +} + +Widget buildChannelHeaderPill( + BuildContext context, { + required String label, + IconData icon = Icons.campaign_outlined, +}) { + final labelColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.82); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 11, + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.7), + ), + const SizedBox(width: 5), + Flexible( + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: labelColor, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); +} + +Widget buildDirectHeaderCounterpart( + BuildContext context, { + required String label, +}) { + return buildChannelHeaderPill( + context, + label: label, + icon: Icons.alternate_email, + ); +} diff --git a/lib/widgets/messages/message_bubble_signal.dart b/lib/widgets/messages/message_bubble_signal.dart new file mode 100644 index 0000000..572742f --- /dev/null +++ b/lib/widgets/messages/message_bubble_signal.dart @@ -0,0 +1,382 @@ +import 'package:flutter/material.dart'; + +import '../../models/message.dart'; +import '../../models/message_reception_details.dart'; + +IconData getDeliveryStatusIcon(MessageDeliveryStatus status) { + switch (status) { + case MessageDeliveryStatus.sending: + return Icons.schedule; + case MessageDeliveryStatus.sent: + return Icons.done; + case MessageDeliveryStatus.delivered: + return Icons.done_all; + case MessageDeliveryStatus.failed: + return Icons.error_outline; + case MessageDeliveryStatus.received: + return Icons.inbox; + } +} + +Color getDeliveryStatusColor(MessageDeliveryStatus status) { + switch (status) { + case MessageDeliveryStatus.sending: + return Colors.orange; + case MessageDeliveryStatus.sent: + return Colors.blue; + case MessageDeliveryStatus.delivered: + return Colors.green; + case MessageDeliveryStatus.failed: + return Colors.red; + case MessageDeliveryStatus.received: + return Colors.grey; + } +} + +Widget buildChannelEchoStatus(BuildContext context, Message message) { + final hasEcho = message.echoCount > 0; + + if (!hasEcho) { + return const SizedBox.shrink(); + } + + final statusColor = getDeliveryStatusColor(message.deliveryStatus); + final rssi = message.lastEchoRssiDbm; + final snr = message.lastEchoSnrRaw != null + ? message.lastEchoSnrRaw!.toSigned(8) / 4.0 + : null; + final quality = linkQualityLabel(rssi, snr); + final qualityColor = linkQualityColor(quality); + + return Wrap( + spacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _techChip( + context, + icon: Icons.hub_outlined, + label: 'x${message.echoCount}', + color: statusColor, + ), + if (message.expectedAckTag != null) + _techChip( + context, + icon: Icons.tag, + label: + 'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}', + color: Colors.indigo, + ), + _techChip(context, icon: Icons.bolt, label: quality, color: qualityColor), + if (message.lastEchoRssiDbm != null) + _signalCapsule( + context, + icon: Icons.network_cell, + label: message.lastEchoRssiDbm!.toString(), + filled: rssiScore(message.lastEchoRssiDbm!), + color: Colors.blueGrey, + ), + if (message.lastEchoSnrRaw != null) + _signalCapsule( + context, + icon: Icons.graphic_eq, + label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1), + filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0), + color: Colors.teal, + ), + ], + ); +} + +bool shouldShowSentChannelStats( + Message message, { + required bool showReceivedStats, +}) { + if (!message.isSentMessage || !message.isChannelMessage) { + return false; + } + + final hasSignalData = + message.echoCount > 0 || + message.lastEchoRssiDbm != null || + message.lastEchoSnrRaw != null || + message.expectedAckTag != null; + return showReceivedStats && hasSignalData; +} + +Widget buildReceivedSignalStatus( + BuildContext context, + Message message, { + MessageReceptionDetails? receptionDetails, + required int? rssiDbm, + required double? snrDb, +}) { + final hopLabel = hopDisplayLabel(message); + + return Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _techChip( + context, + icon: Icons.alt_route, + label: hopLabel, + color: Colors.indigo, + ), + if (receptionDetails?.senderToReceiptMs != null) + _techChip( + context, + icon: Icons.schedule, + label: _formatMs(receptionDetails!.senderToReceiptMs!), + color: Colors.deepPurple, + ), + if (receptionDetails?.estimatedTransmitMs != null) + _techChip( + context, + icon: Icons.timelapse, + label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx', + color: Colors.blue, + ), + if (receptionDetails?.postTransmitDelayMs != null) + _techChip( + context, + icon: Icons.hourglass_bottom, + label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag', + color: Colors.orange, + ), + if (receptionDetails?.pathBytesHex != null) + _techChip( + context, + icon: Icons.route, + label: receptionDetails!.pathBytesHex!, + color: Colors.brown, + ), + if (rssiDbm != null || snrDb != null) ...[ + _techChip( + context, + icon: Icons.bolt, + label: linkQualityLabel(rssiDbm, snrDb), + color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)), + ), + if (rssiDbm != null) + _signalCapsule( + context, + icon: Icons.network_cell, + label: '$rssiDbm', + filled: rssiScore(rssiDbm), + color: Colors.blueGrey, + ), + if (snrDb != null) + _signalCapsule( + context, + icon: Icons.graphic_eq, + label: snrDb.toStringAsFixed(1), + filled: snrScore(snrDb), + color: Colors.teal, + ), + ], + ], + ); +} + +Widget buildSentDirectSignalStatus( + BuildContext context, + Message message, { + required int roundTripTimeMs, + required Duration txEstimate, +}) { + final estimatedTransmitMs = sanitizeEstimatedTransmitMs( + estimatedTransmitMs: txEstimate > Duration.zero + ? txEstimate.inMilliseconds + : null, + senderToReceiptMs: roundTripTimeMs, + ); + final postTransmitDelayMs = estimatedTransmitMs != null + ? (roundTripTimeMs - estimatedTransmitMs).clamp(0, 86400000).toInt() + : null; + + return Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _techChip( + context, + icon: Icons.alt_route, + label: hopDisplayLabel(message), + color: Colors.indigo, + ), + _techChip( + context, + icon: Icons.schedule, + label: _formatMs(roundTripTimeMs), + color: Colors.deepPurple, + ), + if (estimatedTransmitMs != null) + _techChip( + context, + icon: Icons.timelapse, + label: '~${_formatMs(estimatedTransmitMs)} tx', + color: Colors.blue, + ), + if (postTransmitDelayMs != null) + _techChip( + context, + icon: Icons.hourglass_bottom, + label: '+${_formatMs(postTransmitDelayMs)} lag', + color: Colors.orange, + ), + if (message.retryAttempt > 0) + _techChip( + context, + icon: Icons.refresh, + label: 'retry ${message.retryAttempt}/3', + color: Colors.redAccent, + ), + if (message.suggestedTimeoutMs != null) + _techChip( + context, + icon: Icons.timer_outlined, + label: 'timeout ${_formatMs(message.suggestedTimeoutMs!)}', + color: Colors.blueGrey, + ), + if (message.usedFloodFallback) + _techChip( + context, + icon: Icons.waves, + label: 'flood fallback', + color: Colors.teal, + ) + else if (message.expectedAckTag != null) + _techChip( + context, + icon: Icons.route, + label: 'direct ACK', + color: Colors.indigo, + ), + ], + ); +} + +String _formatMs(int value) { + if (value >= 60000) { + final minutes = value ~/ 60000; + final seconds = (value % 60000) ~/ 1000; + return '${minutes}m ${seconds}s'; + } + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s'; + } + return '${value}ms'; +} + +String hopDisplayLabel(Message message) { + if (message.pathLen == 0) return 'Direct'; + if (message.pathLen >= 255 && message.isContactMessage) return 'Direct'; + if (message.pathLen >= 255) return 'Unknown'; + return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; +} + +Widget _techChip( + BuildContext context, { + required IconData icon, + required String label, + required Color color, +}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 2), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ], + ), + ); +} + +Widget _signalCapsule( + BuildContext context, { + required IconData icon, + required String label, + required int filled, + required Color color, +}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (i) { + final active = i < filled; + return Container( + width: 3, + height: (4 + i).toDouble(), + margin: const EdgeInsets.symmetric(horizontal: 0.5), + decoration: BoxDecoration( + color: active ? color : color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(1), + ), + ); + }), + ), + const SizedBox(width: 2), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ], + ), + ); +} + +int rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5); + +int snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5); + +String linkQualityLabel(int? rssiDbm, double? snrDb) { + var score = 0; + if (rssiDbm != null) score += rssiScore(rssiDbm); + if (snrDb != null) score += snrScore(snrDb); + if (score >= 8) return 'Excellent'; + if (score >= 6) return 'Good'; + if (score >= 4) return 'Fair'; + return 'Weak'; +} + +Color linkQualityColor(String quality) { + switch (quality) { + case 'Excellent': + return Colors.green; + case 'Good': + return Colors.lightGreen; + case 'Fair': + return Colors.orange; + default: + return Colors.redAccent; + } +} 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/messages_composer.dart b/lib/widgets/messages/messages_composer.dart new file mode 100644 index 0000000..7a3ad16 --- /dev/null +++ b/lib/widgets/messages/messages_composer.dart @@ -0,0 +1,432 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../l10n/app_localizations.dart'; + +class MessagesComposer extends StatelessWidget { + final TextEditingController textController; + final FocusNode focusNode; + final TextInputFormatter messageByteLimiter; + final int messageByteCount; + final int maxMessageBytes; + final bool isRecording; + final bool isSendingVoice; + final bool voiceSupported; + final double bottomPadding; + final String destinationLabel; + final Widget destinationAvatar; + final VoidCallback onShowComposerActions; + final VoidCallback onShowRecipientSelector; + final Future Function() onStartVoiceRecording; + final Future Function() onStopAndSendVoice; + final Future Function() onSendMessage; + + const MessagesComposer({ + super.key, + required this.textController, + required this.focusNode, + required this.messageByteLimiter, + required this.messageByteCount, + required this.maxMessageBytes, + required this.isRecording, + required this.isSendingVoice, + required this.voiceSupported, + required this.bottomPadding, + required this.destinationLabel, + required this.destinationAvatar, + required this.onShowComposerActions, + required this.onShowRecipientSelector, + required this.onStartVoiceRecording, + required this.onStopAndSendVoice, + required this.onSendMessage, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration(color: Colors.transparent), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: Theme.of( + context, + ).dividerColor.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _ComposerActionButton( + isRecording: isRecording, + onPressed: isRecording + ? onStopAndSendVoice + : onShowComposerActions, + ), + const SizedBox(width: 8), + Expanded( + child: _DestinationSelector( + destinationLabel: destinationLabel, + destinationAvatar: destinationAvatar, + onTap: onShowRecipientSelector, + ), + ), + ], + ), + const SizedBox(height: 8), + ListenableBuilder( + listenable: Listenable.merge([ + textController, + focusNode, + ]), + builder: (context, _) { + final canSendText = + !isRecording && + !isSendingVoice && + textController.text.trim().isNotEmpty; + final semanticsLabel = isRecording + ? 'Recording... release to send voice' + : (isSendingVoice + ? 'Sending voice...' + : voiceSupported + ? 'Send (long press to record voice)' + : 'Send'); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: _MessageInput( + textController: textController, + focusNode: focusNode, + messageByteLimiter: messageByteLimiter, + ), + ), + const SizedBox(width: 8), + _SendButton( + canSendText: canSendText, + isRecording: isRecording, + isSendingVoice: isSendingVoice, + voiceSupported: voiceSupported, + semanticsLabel: semanticsLabel, + messageByteCount: messageByteCount, + maxMessageBytes: maxMessageBytes, + onSendMessage: onSendMessage, + onStartVoiceRecording: onStartVoiceRecording, + onStopAndSendVoice: onStopAndSendVoice, + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _ComposerActionButton extends StatelessWidget { + final bool isRecording; + final VoidCallback onPressed; + + const _ComposerActionButton({ + required this.isRecording, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.35), + ), + ), + child: IconButton( + icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22), + tooltip: isRecording ? 'Stop recording' : 'More actions', + onPressed: onPressed, + color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary, + ), + ); + } +} + +class _DestinationSelector extends StatelessWidget { + final String destinationLabel; + final Widget destinationAvatar; + final VoidCallback onTap; + + const _DestinationSelector({ + required this.destinationLabel, + required this.destinationAvatar, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Ink( + height: 42, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.35), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + destinationAvatar, + const SizedBox(width: 10), + Expanded( + child: Text( + destinationLabel, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + Icon( + Icons.expand_more_rounded, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _MessageInput extends StatelessWidget { + final TextEditingController textController; + final FocusNode focusNode; + final TextInputFormatter messageByteLimiter; + + const _MessageInput({ + required this.textController, + required this.focusNode, + required this.messageByteLimiter, + }); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + constraints: const BoxConstraints(minHeight: 46, maxHeight: 132), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: focusNode.hasFocus + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor.withValues(alpha: 0.35), + width: focusNode.hasFocus ? 1.4 : 1, + ), + boxShadow: focusNode.hasFocus + ? [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.10), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: TextField( + controller: textController, + focusNode: focusNode, + minLines: 1, + maxLines: 4, + keyboardType: TextInputType.multiline, + inputFormatters: [messageByteLimiter], + style: const TextStyle(fontSize: 15), + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.typeYourMessage, + hintStyle: TextStyle( + fontSize: 15, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.9), + ), + filled: false, + fillColor: Colors.transparent, + border: InputBorder.none, + isCollapsed: true, + ), + textInputAction: TextInputAction.newline, + ), + ), + ); + } +} + +class _SendButton extends StatelessWidget { + final bool canSendText; + final bool isRecording; + final bool isSendingVoice; + final bool voiceSupported; + final String semanticsLabel; + final int messageByteCount; + final int maxMessageBytes; + final Future Function() onSendMessage; + final Future Function() onStartVoiceRecording; + final Future Function() onStopAndSendVoice; + + const _SendButton({ + required this.canSendText, + required this.isRecording, + required this.isSendingVoice, + required this.voiceSupported, + required this.semanticsLabel, + required this.messageByteCount, + required this.maxMessageBytes, + required this.onSendMessage, + required this.onStartVoiceRecording, + required this.onStopAndSendVoice, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + enabled: canSendText || (voiceSupported && !isSendingVoice), + label: semanticsLabel, + onTap: canSendText ? onSendMessage : null, + onLongPress: (voiceSupported && !isSendingVoice) + ? () { + if (isRecording) { + onStopAndSendVoice(); + return; + } + onStartVoiceRecording(); + } + : null, + child: Tooltip( + message: semanticsLabel, + excludeFromSemantics: true, + child: GestureDetector( + excludeFromSemantics: true, + onTap: canSendText ? onSendMessage : null, + onLongPressStart: (voiceSupported && !isSendingVoice) + ? (_) => onStartVoiceRecording() + : null, + onLongPressEnd: (voiceSupported && isRecording) + ? (_) => onStopAndSendVoice() + : null, + onLongPressCancel: (voiceSupported && isRecording) + ? onStopAndSendVoice + : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 46, + height: 46, + decoration: BoxDecoration( + color: canSendText || isRecording + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: canSendText || isRecording + ? Colors.transparent + : Theme.of( + context, + ).dividerColor.withValues(alpha: 0.35), + ), + boxShadow: canSendText || isRecording + ? [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.22), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ] + : null, + ), + child: isSendingVoice + ? Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onPrimary, + ), + ) + : Icon( + isRecording ? Icons.mic_rounded : Icons.send_rounded, + size: 22, + color: canSendText || isRecording + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + '$messageByteCount/$maxMessageBytes', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: messageByteCount > maxMessageBytes * 0.9 + ? Colors.orange.shade800 + : Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.9), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/messages/messages_content.dart b/lib/widgets/messages/messages_content.dart new file mode 100644 index 0000000..80e1315 --- /dev/null +++ b/lib/widgets/messages/messages_content.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/message.dart'; +import '../../widgets/messages/message_bubble.dart'; + +class MessagesContent extends StatelessWidget { + static const double defaultPadding = 8; + + final List messages; + final ScrollController scrollController; + final String? highlightedMessageId; + final double bottomContentPadding; + final Future Function() onRefresh; + final VoidCallback? onNavigateToMap; + final ValueChanged? onMessageTap; + + const MessagesContent({ + super.key, + required this.messages, + required this.scrollController, + required this.highlightedMessageId, + this.bottomContentPadding = 0, + required this.onRefresh, + this.onNavigateToMap, + this.onMessageTap, + }); + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: onRefresh, + child: messages.isEmpty + ? LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.message_outlined, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.noMessagesYet, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.pullDownToSync, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ) + : ListView.builder( + controller: scrollController, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + reverse: true, + padding: EdgeInsets.fromLTRB( + defaultPadding, + defaultPadding, + defaultPadding, + defaultPadding + bottomContentPadding, + ), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + + return MessageBubble( + key: ValueKey(message.id), + message: message, + isHighlighted: message.id == highlightedMessageId, + onNavigateToMap: onNavigateToMap, + onTap: onMessageTap == null + ? null + : () => onMessageTap!(message), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart index 36232fe..56198e2 100644 --- a/lib/widgets/messages/recipient_selector_sheet.dart +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/contact.dart'; import '../../l10n/app_localizations.dart'; +import '../common/contact_avatar.dart'; /// Bottom sheet for selecting message recipient (channel, contact, or room) class RecipientSelectorSheet extends StatefulWidget { @@ -168,7 +169,7 @@ class _RecipientSelectorSheetState extends State { ...filteredChannels.map((channel) { return _buildRecipientTile( context: context, - icon: Icons.public, + contact: channel, title: channel.getLocalizedDisplayName(context), subtitle: channel.isPublicChannel ? l10n.broadcastToAllNearby @@ -215,10 +216,9 @@ class _RecipientSelectorSheetState extends State { ...filteredContacts.map((contact) { return _buildRecipientTile( context: context, - icon: Icons.person, + contact: contact, title: contact.displayName, subtitle: contact.publicKeyShort, - emoji: contact.roleEmoji, isSelected: _isSelected('contact', contact), onTap: () { widget.onSelect('contact', contact); @@ -261,10 +261,9 @@ class _RecipientSelectorSheetState extends State { ...filteredRooms.map((room) { return _buildRecipientTile( context: context, - icon: Icons.meeting_room, + contact: room, title: room.displayName, subtitle: room.publicKeyShort, - emoji: room.roleEmoji, isSelected: _isSelected('room', room), onTap: () { widget.onSelect('room', room); @@ -275,7 +274,9 @@ class _RecipientSelectorSheetState extends State { ], // Empty state - if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[ + if (widget.contacts.isEmpty && + widget.rooms.isEmpty && + widget.channels.isEmpty) ...[ Padding( padding: const EdgeInsets.all(32), child: Column( @@ -310,36 +311,16 @@ class _RecipientSelectorSheetState extends State { Widget _buildRecipientTile({ required BuildContext context, - required IconData icon, + required Contact contact, required String title, required String subtitle, - String? emoji, required bool isSelected, required VoidCallback onTap, }) { return ListTile( - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Icon( - icon, - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + leading: ContactAvatar(contact: contact, radius: 20, displayName: title), title: Row( children: [ - if (emoji != null && emoji.isNotEmpty) ...[ - Text(emoji, style: const TextStyle(fontSize: 16)), - const SizedBox(width: 8), - ], Expanded( child: Text( title, diff --git a/lib/widgets/messages/system_message_bubble.dart b/lib/widgets/messages/system_message_bubble.dart new file mode 100644 index 0000000..34ca845 --- /dev/null +++ b/lib/widgets/messages/system_message_bubble.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../../models/message.dart'; +import '../../utils/message_extensions.dart'; + +class SystemMessageBubble extends StatelessWidget { + final Message message; + + const SystemMessageBubble({super.key, required this.message}); + + Color _getLevelColor(String? level) { + switch (level?.toLowerCase()) { + case 'success': + return Colors.green; + case 'warning': + return Colors.orange; + case 'error': + return Colors.red; + case 'info': + default: + return Colors.blue.shade300; + } + } + + IconData _getLevelIcon(String? level) { + switch (level?.toLowerCase()) { + case 'success': + return Icons.check_circle_outline; + case 'warning': + return Icons.warning_amber_outlined; + case 'error': + return Icons.error_outline; + case 'info': + default: + return Icons.info_outline; + } + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final level = message.senderName ?? 'info'; + final levelColor = _getLevelColor(level); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_getLevelIcon(level), size: 16, color: levelColor), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + level.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: levelColor, + fontWeight: FontWeight.bold, + letterSpacing: 0.4, + ), + ), + ), + Text( + message.getLocalizedTimeAgo(context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + message.text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + height: 1.3, + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index dd7eef6..840c809 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -2,9 +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'; @@ -27,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; @@ -54,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); @@ -62,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); @@ -76,6 +84,7 @@ class _VoiceMessageBubbleState extends State { if (!mounted) return; setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = null; }); }); @@ -92,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); @@ -116,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, @@ -155,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), ), ), ), @@ -168,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), @@ -188,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, @@ -218,23 +257,31 @@ class _VoiceMessageBubbleState extends State { int pathLen = 0, }) async { if (_isRequesting) return; + setState(() { + _isRequesting = true; + _isPartialRequest = false; + _autoPlayWhenReady = true; + _errorText = null; + }); + final connectionProvider = context.read(); final voiceProvider = context.read(); voiceProvider.resumeIncomingSession(sessionId); final contactsProvider = context.read(); - final resolution = await TransmissionTargetResolver.resolveFetchTarget( + final appProvider = context.read(); + var resolution = await TransmissionTargetResolver.resolveFetchTarget( contactsProvider: contactsProvider, refreshContacts: connectionProvider.getContacts, isSentByMe: widget.isSentByMe, recipientPublicKey: widget.message.recipientPublicKey, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, - senderKey6FromEnvelope: envelope?.senderKey6, senderName: widget.message.senderName, maxFetchHops: _maxFetchHops, ); if (!mounted) return; if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch voice', 'Sender contact is unknown. Sync contacts first.', @@ -242,6 +289,7 @@ class _VoiceMessageBubbleState extends State { return; } if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch voice', 'Sender route is unknown. Sync contacts/path first.', @@ -249,27 +297,93 @@ class _VoiceMessageBubbleState extends State { return; } if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch voice', 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', ); return; } + if (resolution.failure == TransmissionTargetFailure.unreachable) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender route did not respond to a path check. Sync contacts/path and try again.', + ); + return; + } - final sender = resolution.target!; - if (sender.outPathLen >= 2) { + var sender = resolution.target!; + var routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + await connectionProvider.getContacts(); + if (!mounted) return; + resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: connectionProvider.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender contact is unknown. Sync contacts first.', + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender route is unknown. Sync contacts/path first.', + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', + ); + return; + } + sender = resolution.target!; + routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender route did not respond on the raw transport path.', + ); + return; + } + } + + if (!sender.routeSupportsLegacyRawTransport) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.', + ); + return; + } + + if (sender.routeHopCount >= 2) { _showToast( - 'Voice fetch over ${sender.outPathLen} hops may take a while.', + 'Voice fetch over ${sender.routeHopCount} hops may take a while.', ); } - if (!mounted) return; - setState(() { - _errorText = null; - }); - final deviceKey = connectionProvider.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch voice', 'Device key is unavailable.', @@ -281,23 +395,38 @@ class _VoiceMessageBubbleState extends State { .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); - final request = VoiceFetchRequest( + final missing = voiceProvider.missingPacketIndices(sessionId); + final totalPackets = sessionPacketCount( + voiceProvider: voiceProvider, sessionId: sessionId, - requesterKey6: requesterKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, - version: 2, + envelope: envelope, ); - - setState(() { - _isRequesting = true; - _autoPlayWhenReady = true; - _errorText = null; - }); + 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, + ) + : VoiceFetchRequest( + sessionId: sessionId, + requesterKey6: requesterKey6, + ); try { + debugPrint( + '🎙️ [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 (_) { @@ -306,14 +435,21 @@ 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 && + totalPackets > 0 && + missing.isNotEmpty && + missing.length < totalPackets + ? ((envelope.durationMs * missing.length) / totalPackets).round() + : envelope?.durationMs; final txEstimate = envelope != null ? estimateVoiceTransmitDuration( - packetCount: envelope.total, + packetCount: isPartialResume ? missing.length : envelope.total, mode: envelope.mode, - durationMs: envelope.durationMs, + durationMs: estimatedDurationMs ?? envelope.durationMs, pathLen: effectivePathLen, radioBw: radioBw, radioSf: radioSf, @@ -331,16 +467,34 @@ class _VoiceMessageBubbleState extends State { ); } + int sessionPacketCount({ + required VoiceProvider voiceProvider, + required String sessionId, + required VoiceEnvelope? envelope, + }) { + return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0; + } + void _setUnavailable() { if (!mounted) return; _showToast(AppLocalizations.of(context)!.voiceUnavailable); setState(() { _isRequesting = false; + _isPartialRequest = false; _autoPlayWhenReady = false; _errorText = AppLocalizations.of(context)!.voiceUnavailable; }); } + void _clearRequestState() { + if (!mounted) return; + setState(() { + _isRequesting = false; + _isPartialRequest = false; + _autoPlayWhenReady = false; + }); + } + void _cancelReceive(String sessionId) { if (!mounted) return; _requestTimeoutTimer?.cancel(); @@ -348,6 +502,7 @@ class _VoiceMessageBubbleState extends State { _showToast('Voice receive canceled'); setState(() { _isRequesting = false; + _isPartialRequest = false; _autoPlayWhenReady = false; _errorText = 'Voice receive canceled'; }); @@ -392,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({ @@ -487,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/pubspec.lock b/pubspec.lock index 42e7d53..3becaca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: "direct main" description: name: audioplayers - sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 url: "https://pub.dev" source: hosted - version: "6.5.1" + version: "6.6.0" audioplayers_android: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: audioplayers_darwin - sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.4.0" audioplayers_linux: dependency: transitive description: @@ -69,18 +69,18 @@ packages: dependency: transitive description: name: audioplayers_web - sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "5.2.0" audioplayers_windows: dependency: transitive description: name: audioplayers_windows - sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.3.0" bluez: dependency: transitive description: @@ -851,7 +851,7 @@ packages: description: path: "." ref: main - resolved-ref: "3f870e98ee9527a3137bfcbdd1454036912fb609" + resolved-ref: cea66b5251135c7f9b84f15c0878d4c8af6e88e9 url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" diff --git a/pubspec.yaml b/pubspec.yaml index a5df551..ff659e1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.0306.3+13 +version: 2026.0307.4+17 environment: sdk: ^3.9.2 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/models/message_reception_details_test.dart b/test/models/message_reception_details_test.dart new file mode 100644 index 0000000..2982281 --- /dev/null +++ b/test/models/message_reception_details_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/message_reception_details.dart'; + +void main() { + test('drops impossible transmit estimate for received messages', () { + expect( + sanitizeEstimatedTransmitMs( + estimatedTransmitMs: 16 * 60 * 1000 + 54 * 1000, + senderToReceiptMs: 4200, + ), + isNull, + ); + }); + + test( + 'keeps close transmit estimate despite second-level timestamp rounding', + () { + expect( + sanitizeEstimatedTransmitMs( + estimatedTransmitMs: 1800, + senderToReceiptMs: 900, + ), + 1800, + ); + }, + ); + + test('round trips reception details json', () { + final details = MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000), + packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + rssiDbm: -92, + snrDb: 7.5, + pathBytes: const [0xAA, 0xBB, 0xCC], + senderToReceiptMs: 4200, + estimatedTransmitMs: 1800, + postTransmitDelayMs: 2400, + ); + + final decoded = MessageReceptionDetails.fromJson(details.toJson()); + + expect(decoded, isNotNull); + expect(decoded!.rssiDbm, -92); + expect(decoded.snrDb, 7.5); + expect(decoded.pathBytesHex, 'aa:bb:cc'); + expect(decoded.senderToReceiptMs, 4200); + expect(decoded.estimatedTransmitMs, 1800); + expect(decoded.postTransmitDelayMs, 2400); + }); +} diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 0c5961b..48e2300 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -128,6 +128,8 @@ void main() { expect(updated.displayLocation, isNotNull); expect(updated.displayLocation!.latitude, closeTo(45.0001, 0.0001)); expect(updated.displayLocation!.longitude, closeTo(13.9999, 0.0001)); + expect(updated.advLat, equals((45.0001 * 1e6).round())); + expect(updated.advLon, equals((13.9999 * 1e6).round())); }); test( @@ -232,5 +234,70 @@ void main() { expect(snapshot.location.latitude, closeTo(46.0569, 0.000001)); expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); }); + + test('persists last valid telemetry gps on the contact across reloads', () async { + final telemetryData = CayenneLppParser.createGpsData( + latitude: 45.0001, + longitude: 13.9999, + ); + + provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData); + await Future.delayed(Duration.zero); + + final reloadedProvider = ContactsProvider(); + await reloadedProvider.initializeEarly(); + + final reloaded = reloadedProvider.findContactByKey(publicKey)!; + expect(reloaded.advLat, equals((45.0001 * 1e6).round())); + expect(reloaded.advLon, equals((13.9999 * 1e6).round())); + expect(reloaded.advertLocation, isNotNull); + expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001)); + expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001)); + }); + }); + + group('ContactsProvider route updates', () { + late ContactsProvider provider; + late Uint8List publicKey; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + publicKey = createPublicKey(64); + provider.addOrUpdateContact( + createContact(key: publicKey, type: ContactType.chat, name: 'Routey'), + ); + }); + + test('optimistically stores a multi-byte route locally', () { + final route = ContactRouteCodec.parse('AABB,CCDD'); + + provider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.routeHasPath, isTrue); + expect(updated.routeHashSize, 2); + expect(updated.routeHopCount, 2); + expect(updated.routeCanonicalText, 'AABB,CCDD'); + }); + + test('resetContactRouteLocal clears route state', () { + final route = ContactRouteCodec.parse('AA,BB,CC'); + provider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + + provider.resetContactRouteLocal(publicKey); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.routeHasPath, isFalse); + expect(updated.routeSummary, 'Flood/Unknown'); + }); }); } diff --git a/test/providers/helpers/ping_tracker_test.dart b/test/providers/helpers/ping_tracker_test.dart new file mode 100644 index 0000000..3891ae2 --- /dev/null +++ b/test/providers/helpers/ping_tracker_test.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/helpers/ping_tracker.dart'; + +void main() { + Uint8List createPublicKey() => Uint8List.fromList( + List.generate(32, (index) => index + 1), + ); + + group('PingTracker', () { + test('completes pending ping when response uses public key prefix', () async { + final tracker = PingTracker(); + final publicKey = createPublicKey(); + + final pingFuture = tracker.trackPing( + publicKey: publicKey, + wasDirectAttempt: true, + ); + + tracker.markPingSuccessful(publicKey.sublist(0, 6)); + + await expectLater(pingFuture, completion(isTrue)); + expect(tracker.hasPendingPing(publicKey), isFalse); + }); + }); +} diff --git a/test/providers/helpers/raw_session_retransmit_test.dart b/test/providers/helpers/raw_session_retransmit_test.dart index 5f83aa1..512cd76 100644 --- a/test/providers/helpers/raw_session_retransmit_test.dart +++ b/test/providers/helpers/raw_session_retransmit_test.dart @@ -47,9 +47,8 @@ void main() { expect(ok, isFalse); }); - test('sends only requested indices and waits for ack', () async { + test('sends only requested indices', () async { final sent = []; - final waited = []; final ok = await serveCachedSessionFragments<_Fragment>( providerLabel: 'TestProvider', sessionId: 'deadbeef', @@ -70,15 +69,6 @@ void main() { }) async { sent.add(payload); }, - waitForFragmentAck: - ({ - required sessionId, - required index, - timeout = const Duration(seconds: 8), - }) async { - waited.add(index); - return true; - }, requestedIndices: {1, 2}, ); @@ -86,37 +76,6 @@ void main() { expect(sent.length, equals(2)); expect(sent[0], equals(Uint8List.fromList([20]))); expect(sent[1], equals(Uint8List.fromList([30]))); - expect(waited, equals([1, 2])); - }); - - test('fails when ack does not arrive', () async { - final ok = await serveCachedSessionFragments<_Fragment>( - providerLabel: 'TestProvider', - sessionId: 'deadbeef', - requester: _buildContact(outPathLen: 1), - fragments: [ - _Fragment(0, Uint8List.fromList([1])), - ], - maxDirectPayloadHops: 3, - indexOf: (f) => f.index, - encodeBinary: (f) => f.payload, - sendRawPacket: - ({ - required contactPath, - required contactPathLen, - required payload, - }) async {}, - waitForFragmentAck: - ({ - required sessionId, - required index, - timeout = const Duration(seconds: 8), - }) async { - return false; - }, - ); - - expect(ok, isFalse); }); test('fails when no requested index matches cached fragments', () async { 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 5f0b59e..7b490c2 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -40,7 +40,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('MessagesProvider retransmission', () { - test('direct messages stay pending until delivery ACK arrives', () { + test('direct messages become sent before delivery ACK arrives', () { final provider = MessagesProvider(); provider.addSentMessage( _buildDirectMessage('m1'), @@ -51,7 +51,7 @@ void main() { expect( provider.messages.single.deliveryStatus, - MessageDeliveryStatus.sending, + MessageDeliveryStatus.sent, ); expect(provider.messages.single.expectedAckTag, 77); @@ -64,6 +64,56 @@ void main() { expect(provider.messages.single.roundTripTimeMs, 180); }); + test( + 'direct messages stay sent after device accept until confirm arrives', + () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m1b'), + contact: _buildContact(), + ); + + provider.markMessageSent('m1b', 78, 250); + + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + expect(provider.messages.single.expectedAckTag, 78); + expect(provider.messages.single.roundTripTimeMs, isNull); + expect(provider.messages.single.deliveredAt, isNull); + }, + ); + + test('fallback sent state can later upgrade to ACK-tracked delivery', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m1c'), + contact: _buildContact(), + ); + + provider.markMessageSent('m1c', 0, 0); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + expect(provider.messages.single.expectedAckTag, isNull); + + provider.markMessageSent('m1c', 79, 250); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + expect(provider.messages.single.expectedAckTag, 79); + + provider.markMessageDelivered(79, 190); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.delivered, + ); + expect(provider.messages.single.roundTripTimeMs, 190); + }); + test('channel messages are marked sent immediately', () { final provider = MessagesProvider(); provider.addSentMessage( @@ -162,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(); @@ -172,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'); @@ -204,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/services/map_marker_service_test.dart b/test/services/map_marker_service_test.dart new file mode 100644 index 0000000..344f02f --- /dev/null +++ b/test/services/map_marker_service_test.dart @@ -0,0 +1,63 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/map_marker_service.dart'; +import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart'; + +void main() { + Contact buildContact({ + required String name, + required ContactType type, + required int advLat, + required int advLon, + }) { + return Contact( + publicKey: Uint8List(32), + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch, + advLat: advLat, + advLon: advLon, + lastMod: DateTime.now().millisecondsSinceEpoch, + ); + } + + testWidgets('contact map markers render shared contact avatars', (tester) async { + final service = MapMarkerService(); + final contact = buildContact( + name: 'John Smith', + type: ContactType.chat, + advLat: (46.0569 * 1e6).round(), + advLon: (14.5058 * 1e6).round(), + ); + + late Widget markerChild; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final markers = service.generateContactMarkers( + contacts: [contact], + context: context, + ); + markerChild = markers.single.child; + return const SizedBox.shrink(); + }, + ), + ), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: Center(child: markerChild))), + ); + + expect(find.byType(ContactAvatar), findsOneWidget); + expect(find.text('JS'), findsOneWidget); + }); +} 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/log_rx_route_decoder_test.dart b/test/utils/log_rx_route_decoder_test.dart new file mode 100644 index 0000000..6e25321 --- /dev/null +++ b/test/utils/log_rx_route_decoder_test.dart @@ -0,0 +1,92 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/utils/log_rx_route_decoder.dart'; + +void main() { + group('LogRxRouteDecoder.decode', () { + test('parses route and sender from LOG_RX_DATA packet', () { + final packet = Uint8List.fromList([ + 0x88, + 0x37, + 0xae, + 0x05, + 0x04, + 0xc2, + 0xba, + 0x5f, + 0xde, + 0x5c, + ]); + + final decoded = LogRxRouteDecoder.decode(packet); + + expect(decoded, isNotNull); + expect(decoded!.payloadType, 0x01); + expect(decoded.pathHashes, [0xc2, 0xba, 0x5f, 0xde]); + expect(decoded.originalSenderHash, 0xc2); + }); + }); + + group('LogRxRouteDecoder.resolveHash', () { + test('prefers own node when hash matches device key', () { + final resolved = LogRxRouteDecoder.resolveHash( + 0xc2, + contacts: const [], + ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]), + ownName: 'Base', + ); + + expect(resolved.isOwnNode, isTrue); + expect(resolved.label, 'Base (you)'); + }); + + test('resolves unique contact by first public key byte', () { + final resolved = LogRxRouteDecoder.resolveHash( + 0xc2, + contacts: [_contact(name: 'Alpha', keyPrefix: 0xc2)], + ); + + expect(resolved.isUniqueMatch, isTrue); + expect(resolved.label, 'Alpha'); + }); + + test('marks ambiguous matches without pretending certainty', () { + final resolved = LogRxRouteDecoder.resolveHash( + 0xc2, + contacts: [ + _contact(name: 'Alpha', keyPrefix: 0xc2), + _contact(name: 'Bravo', keyPrefix: 0xc2), + ], + ); + + expect(resolved.isUniqueMatch, isFalse); + expect(resolved.matchCount, 2); + }); + }); +} + +Contact _contact({required String name, required int keyPrefix}) { + return Contact( + publicKey: Uint8List.fromList([ + keyPrefix, + 0x11, + 0x22, + 0x33, + 0x44, + 0x55, + 0x66, + 0x77, + ]), + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(0), + advName: name, + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + ); +} 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)); }); });