Compare commits

..

35 Commits

Author SHA1 Message Date
Janez T
ab0c531a4b Add toast for image fetch failures 2026-03-05 13:07:53 +01:00
Janez T
9709511e90 Fix image bubble contact lookup 2026-03-05 11:56:34 +01:00
Janez T
162d5333ce Hide tab bar when single tab 2026-03-05 11:51:23 +01:00
Janez T
0cb1d49804 Fix null check crash in TabBar 2026-03-05 11:41:15 +01:00
Janez T
234edf5bb0 Add message clear option 2026-03-05 11:04:41 +01:00
Janez T
2d9cb0ddb9 Add tic tac toe DM game 2026-03-05 09:57:14 +01:00
Janez T
76b093685e Add fragment transfer estimate 2026-03-05 09:38:39 +01:00
Janez T
55a85659d3 Add tic tac toe DM game 2026-03-05 09:05:18 +01:00
Janez T
972a9ba944 Document raw binary routing 2026-03-05 08:51:18 +01:00
Janez T
f4e3f2e834 Limit media fetch hops and warn user 2026-03-05 08:39:58 +01:00
Janez T
50a4322f44 Limit media fetch hops to three 2026-03-05 08:31:34 +01:00
Janez T
c83eaa4d98 Limit direct payload hops 2026-03-05 08:30:06 +01:00
Janez T
5beac70644 Add auto contact toggle 2026-03-05 08:16:48 +01:00
Janez T
99cd378e6a Add stats pills and RFC3339 date 2026-03-05 08:13:18 +01:00
Janez T
6a52e7cfa1 Add stats pills and RFC009 date 2026-03-05 08:12:48 +01:00
Janez T
af2e969640 Update release checker to GitHub API 2026-03-05 08:05:55 +01:00
Janez T
b12567f613 Add trace button and raw packets 2026-03-05 08:02:39 +01:00
Janez T
13b91e3cc9 Add packet trace storage flow 2026-03-05 07:50:42 +01:00
Janez T
40d786eb25 Add trace button to message bubble 2026-03-05 07:45:16 +01:00
Janez T
b6aa0bcfaa Bump iOS project version 2026-03-05 07:36:21 +01:00
Janez T
f31083694f Fix DM private media sending 2026-03-04 19:55:53 +01:00
Janez T
1460bdb51f fix: remove all auto-retry image fetch, user-initiated only
- No automatic IR1 requests; fragments arrive only when user taps load
- Bubble sends IR1 with missing indices only if partial session exists
  (partial resume: only missing fragments requested, not the whole image)
- 30s timeout resets the loading spinner so user can tap retry again
- Saves LoRa airtime by never transmitting IR1 without explicit user action
2026-03-04 19:47:01 +01:00
Janez T
537d98e2d4 fix: image transfer 10s idle timeout with partial resume
- Replace 1.2s fixed retry with 10s idle debounce timer
- Timer resets on every received fragment (debounce)
- Timer also starts on IE1 envelope receipt (handles pull-only senders)
- IR1 always requests only missing indices (partial resume)
- Attempt counter no longer resets per fragment; bounded to 5 retries
- Max attempts raised to 5 (was 4)
2026-03-04 19:42:18 +01:00
Janez T
7ffc46438f fix: disable voice recording on web via kIsWeb guard 2026-03-04 19:21:43 +01:00
Janez T
efab2cd057 fix: stub codec2 FFI for web build using conditional exports 2026-03-04 19:19:40 +01:00
Janez T
d6077d1588 ci: add web build and GitHub Pages deployment 2026-03-04 19:16:15 +01:00
Janez T
84a6f81c26 feat: replace SSE client with direct TCP/WiFi connection
- Remove SseClientService and ConnectionMode.sseClient
- Add ConnectionMode.tcp with MeshCoreTcpService (port 5000)
- NetworkScannerService: scan TCP port 5000 via raw Socket.connect
- mDNS service type changed to _meshcore._tcp
- connection_dialog: connect via connectTcp() instead of connectToSseServer()
- home_screen: show WiFi icon and label for TCP mode
- Bump meshcore_client to pick up TCP transport support
2026-03-04 19:11:16 +01:00
Janez T
c6b3b86c2f feat: add iOS TestFlight link README
ref:
2026-03-04 13:30:59 +01:00
Janez T
d1e6aaafbe feat: update README protocol media details
ref:
2026-03-04 13:27:25 +01:00
Janez T
3036ba620c Add codec2 avif meshcore details 2026-03-04 13:25:54 +01:00
Janez Troha
9854dd2034 Add Chinese translation
Add new Chinese translation
2026-03-04 12:48:37 +01:00
MGJ
e9a08fab4d Add new Chinese translation 2026-03-04 15:02:39 +08:00
Janez Troha
9814c95cee Update demo video section in README
Updated demo video link and thumbnail in README.
2026-03-02 10:46:53 +01:00
Janez T
85ebf51bcb Add MeshCore SAR demo link 2026-03-02 10:44:52 +01:00
Janez T
e85e290265 Update MeshCoreBleService calls 2026-03-02 10:33:49 +01:00
45 changed files with 11001 additions and 2593 deletions

View File

@@ -8,6 +8,8 @@ on:
permissions: permissions:
contents: write contents: write
pages: write
id-token: write
env: env:
FLUTTER_VERSION: "3.35.6" FLUTTER_VERSION: "3.35.6"
@@ -256,6 +258,49 @@ jobs:
${{ env.IOS_RUNNER_ZIP }} ${{ env.IOS_RUNNER_ZIP }}
if-no-files-found: error if-no-files-found: error
build-web:
name: Build Web App
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- name: Enable web
run: flutter config --enable-web
- name: Install dependencies
run: flutter pub get
- name: Build web release
run: flutter build web --release --base-href /meshcore_sar_app/
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: build/web
deploy-pages:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
needs: build-web
if: github.ref == 'refs/heads/main'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
upload-release-assets: upload-release-assets:
name: Upload Assets To Release name: Upload Assets To Release
if: github.event_name == 'release' if: github.event_name == 'release'

View File

@@ -34,8 +34,9 @@ DAILY_BUILD := $(shell \
# Version format: YYYY.MMDD.DAILY+BUILD # Version format: YYYY.MMDD.DAILY+BUILD
# DAILY resets each day (for readability), BUILD always increments (for Android) # DAILY resets each day (for readability), BUILD always increments (for Android)
NEW_VERSION := $(YEAR).$(MMDD).$(DAILY_BUILD)+$(NEW_BUILD_NUMBER) NEW_VERSION := $(YEAR).$(MMDD).$(DAILY_BUILD)+$(NEW_BUILD_NUMBER)
NEW_BUILD_NAME := $(shell echo $(NEW_VERSION) | cut -d'+' -f1)
.PHONY: help version bump build release release-android release-ios clean deps analyze test icon bundle bundle-no-bump .PHONY: help version bump make-bump sync-ios-version build release release-android release-ios clean deps analyze test icon bundle bundle-no-bump
help: ## Show this help help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
@@ -49,8 +50,19 @@ version: ## Show current and next version
bump: ## Bump version in pubspec.yaml bump: ## Bump version in pubspec.yaml
@echo "Bumping version to $(NEW_VERSION)..." @echo "Bumping version to $(NEW_VERSION)..."
@sed -i '' 's/^version: .*/version: $(NEW_VERSION)/' $(PUBSPEC) @sed -i '' 's/^version: .*/version: $(NEW_VERSION)/' $(PUBSPEC)
@$(MAKE) sync-ios-version BUILD_NAME=$(NEW_BUILD_NAME) BUILD_NUMBER=$(NEW_BUILD_NUMBER)
@echo "Version bumped to $(NEW_VERSION)" @echo "Version bumped to $(NEW_VERSION)"
make-bump: bump ## Alias for bump
sync-ios-version: ## Sync iOS FLUTTER_BUILD_NAME/NUMBER from pubspec or provided BUILD_NAME/BUILD_NUMBER
@$(eval IOS_VERSION := $(shell grep '^version:' $(PUBSPEC) | sed 's/version: //'))
@$(eval IOS_BUILD_NAME := $(if $(BUILD_NAME),$(BUILD_NAME),$(shell echo $(IOS_VERSION) | cut -d'+' -f1)))
@$(eval IOS_BUILD_NUMBER := $(if $(BUILD_NUMBER),$(BUILD_NUMBER),$(shell echo $(IOS_VERSION) | cut -d'+' -f2)))
@echo "Syncing iOS version to $(IOS_BUILD_NAME)+$(IOS_BUILD_NUMBER)..."
@flutter build ios --config-only --build-name "$(IOS_BUILD_NAME)" --build-number "$(IOS_BUILD_NUMBER)" > /dev/null
@echo "iOS version synced"
deps: ## Install dependencies deps: ## Install dependencies
flutter pub get flutter pub get
@@ -115,6 +127,7 @@ release-android: build ## Build APK and create GitHub release (Android only)
release-ios: ## Build iOS and upload to TestFlight release-ios: ## Build iOS and upload to TestFlight
@echo "Building iOS and uploading to TestFlight..." @echo "Building iOS and uploading to TestFlight..."
@$(MAKE) sync-ios-version
cd ios && fastlane release cd ios && fastlane release
@echo "iOS release uploaded!" @echo "iOS release uploaded!"

View File

@@ -15,13 +15,19 @@
</p> </p>
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app. MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
## Highlights ## Highlights
- Fast mesh messaging for direct and group coordination - Rapid mesh chat for both 1:1 and group coordination
- On-demand voice and image transfer optimized for constrained links - On-demand voice (Codec2) and image (AVIF) transfer tuned for low-bandwidth links
- Offline-first mapping with tactical overlays and SAR markers - Offline-first mapping with tactical overlays and SAR incident markers
- Live team tracking, trails, and shareable map drawings - Live team location, movement trails, and shareable tactical drawings
## Demo Video
[<img width="1280" height="720" alt="MeshCore SAR demo preview" src="https://github.com/user-attachments/assets/13ccacee-7306-4976-a408-f31f3336828a" />](https://youtu.be/rLsKeLJBpFg)
## Screenshots ## Screenshots
@@ -38,8 +44,8 @@ MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity envir
| Area | What you get | | Area | What you get |
|---|---| |---|---|
| Messaging | Direct and group chat over mesh, with contact/room awareness from live telemetry | | Messaging | Direct and group chat over mesh, with contact/room awareness from live telemetry |
| Voice | Push-to-talk voice clips, fetched on demand when play is pressed, auto-play on completion | | Voice | Push-to-talk voice clips (Codec2), fetched on demand when play is pressed, auto-play on completion |
| Images | Camera/gallery image sending, auto-compression, tap-to-load receiving, full-screen viewer | | Images | Camera/gallery image sending (AVIF), auto-compression, tap-to-load receiving, full-screen viewer |
| Maps | Street/topo/satellite/terrain layers, offline tile downloads, optional MBTiles import | | Maps | Street/topo/satellite/terrain layers, offline tile downloads, optional MBTiles import |
| SAR Operations | Team markers with freshness indicators, SAR markers for incidents and staging points | | SAR Operations | Team markers with freshness indicators, SAR markers for incidents and staging points |
| Tracking | Continuous GPS updates, personal trails, distance/duration trail stats | | Tracking | Continuous GPS updates, personal trails, distance/duration trail stats |

View File

@@ -5,8 +5,9 @@
Image mode mirrors the voice on-demand architecture exactly: Image mode mirrors the voice on-demand architecture exactly:
- **Control plane (text messages):** - **Control plane (text messages):**
- `IE1:` image envelope announces image availability in chat. - `IE2:` image envelope announces image availability in chat.
- `IR1:` direct fetch request asks sender to stream image fragments. - **Control plane (raw binary request):**
- Binary image fetch request (same raw route as image fragments).
- **Data plane (raw binary packets):** - **Data plane (raw binary packets):**
- `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`. - `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`.
@@ -17,8 +18,8 @@ pixels are fetched on demand when the user taps the image bubble.
- `lib/utils/image_message_parser.dart` - `lib/utils/image_message_parser.dart`
- `ImagePacket` (binary fragment format) - `ImagePacket` (binary fragment format)
- `ImageEnvelope` (`IE1`) - `ImageEnvelope` (`IE2`)
- `ImageFetchRequest` (`IR1`) - `ImageFetchRequest` (binary)
- `fragmentImage()` — split compressed bytes into packets - `fragmentImage()` — split compressed bytes into packets
- `reassembleImage()` — join received fragments into bytes - `reassembleImage()` — join received fragments into bytes
- `lib/screens/messages_tab.dart` - `lib/screens/messages_tab.dart`
@@ -27,7 +28,7 @@ pixels are fetched on demand when the user taps the image bubble.
- Reassembly sessions, outgoing cache, deferred serving - Reassembly sessions, outgoing cache, deferred serving
- Outgoing sessions also registered as complete incoming sessions for immediate local display - Outgoing sessions also registered as complete incoming sessions for immediate local display
- `lib/providers/app_provider.dart` - `lib/providers/app_provider.dart`
- Incoming routing for `IE1`, `IR1`, binary `0x49` packets - Incoming routing for `IE2`, binary image fetch requests, binary `0x49` packets
- `lib/widgets/messages/image_message_bubble.dart` - `lib/widgets/messages/image_message_bubble.dart`
- Square cover thumbnail (up to 256 px); tap-to-load for received images; - Square cover thumbnail (up to 256 px); tap-to-load for received images;
progress ring during fetch; full-screen `InteractiveViewer` on tap progress ring during fetch; full-screen `InteractiveViewer` on tap
@@ -39,52 +40,52 @@ pixels are fetched on demand when the user taps the image bubble.
## 3. Wire Formats ## 3. Wire Formats
### 3.1 Image Envelope (`IE1`) ### 3.1 Image Envelope (`IE2`)
Prefix: `IE1:` + colon-delimited payload Prefix: `IE2:` + colon-delimited compact payload (base36 numeric fields)
Fields: Fields:
| Field | Type | Description | | Field | Type | Description |
|--------------|--------|------------------------------------------------| |--------------|--------|------------------------------------------------|
| `sid` | string | 8 hex chars (4 bytes), session ID | | `sid` | string | base36 token for 32-bit session ID |
| `fmt` | int | `ImageFormat.id` (0 = AVIF, 1 = JPEG) | | `fmt` | base36 | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
| `total` | int | Fragment count (1..255) | | `total` | base36 | Fragment count (1..255) |
| `w` | int | Actual image width after compression (pixels) | | `w` | base36 | Actual image width after compression (pixels) |
| `h` | int | Actual image height after compression (pixels) | | `h` | base36 | Actual image height after compression (pixels) |
| `bytes` | int | Total compressed size in bytes | | `bytes` | base36 | Total compressed size in bytes |
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) | | `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
| `ts` | int | Unix timestamp (seconds) | | `ts` | base36 | Unix timestamp (seconds) |
| `ver` | int | Protocol version (currently `1`) |
Compact format: Compact format:
```text ```text
IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver} IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
``` ```
Example (256×171 landscape image, 14 fragments): Example (256×171 landscape image, 14 fragments):
```text ```text
IE1:deadbeef:0:14:256:171:2100:aabbccddeeff:1700000000:1 IE2:a:0:e:74:4r:1mc:aabbccddeeff:s44we8
``` ```
Note: `w` and `h` reflect the actual post-compression dimensions, which preserve 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). the source aspect ratio (contain within the configured max size).
### 3.2 Image Fetch Request (`IR1`) ### 3.2 Image Fetch Request (binary)
Same structure as `VR1`: Binary payload format:
```text ```text
IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} [magic=0x69][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
``` ```
| Field | Value | | Field | Value |
|------------------|--------------------------| |------------------|--------------------------|
| `want` | `a` (= "all fragments") | | `flags` | bit0=1 => request missing indices, else all |
| `requesterKey6` | 12 hex chars | | `requesterKey6` | 6-byte requester key prefix |
| `ver` | `1` | | `ts` | unix timestamp seconds (u32) |
### 3.3 Raw Image Packet (data plane) ### 3.3 Raw Image Packet (data plane)
@@ -151,16 +152,16 @@ only the shorter axis is padded — no cropping occurs.
7. Envelope sent via normal message path: 7. Envelope sent via normal message path:
- Channel: `sendChannelMessage` - Channel: `sendChannelMessage`
- Direct: `sendTextMessage` - Direct: `sendTextMessage`
8. Local placeholder message added (`IE1:` text, `deliveryStatus.sending`). 8. Local placeholder message added (`IE2:` text, `deliveryStatus.sending`).
## 6. Incoming Flow (Receive) ## 6. Incoming Flow (Receive)
### 6.1 `IE1` envelope received ### 6.1 `IE2` envelope received
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to `AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
chat. The bubble shows a grey square placeholder with a download icon. chat. The bubble shows a grey square placeholder with a download icon.
### 6.2 `IR1` request received ### 6.2 Binary image fetch request received
`AppProvider` treats it as control-plane only (not added to chat): `AppProvider` treats it as control-plane only (not added to chat):
@@ -194,7 +195,7 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
- **Complete session**: `AspectRatio(1.0)``AvifImage.memory(fit: cover)` - **Complete session**: `AspectRatio(1.0)``AvifImage.memory(fit: cover)`
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition. square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
- **Incomplete/missing**: grey square placeholder with download icon; - **Incomplete/missing**: grey square placeholder with download icon;
tap → sends `IR1` fetch request. tap → sends binary fetch request.
- **Loading**: circular progress indicator showing `received/total` count. - **Loading**: circular progress indicator showing `received/total` count.
- **Error**: broken-image icon. - **Error**: broken-image icon.
@@ -212,7 +213,8 @@ Image bubbles and Message Technical Details show an **estimated transmit time**
The estimate is airtime-based (LoRa packet model), not just compressed image size: The estimate is airtime-based (LoRa packet model), not just compressed image size:
- Source inputs: - Source inputs:
- `total` fragments and `bytes` from `IE1` envelope - `total` fragments and `bytes` from `IE2` envelope
- all numeric envelope values are decoded from base36
- `pathLen` from message metadata - `pathLen` from message metadata
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
- Per-fragment payload model: - Per-fragment payload model:
@@ -248,6 +250,16 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
- Raw return path requires a valid direct route to requester. - Raw return path requires a valid direct route to requester.
- Available on iOS and Android (`image_picker` + `flutter_avif`). - Available on iOS and Android (`image_picker` + `flutter_avif`).
### 11.1 Raw Binary Routing Semantics
- Image fragment payloads use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
- This flow is direct-route only, not flood/broadcast:
- it is sent to one destination path;
- only nodes on that path relay it;
- it is **not** received by everyone in the mesh.
## 12. High-Level Sequence ## 12. High-Level Sequence
```mermaid ```mermaid
@@ -260,10 +272,10 @@ sequenceDiagram
A->>A: Compress: contain resize → grayscale → PNG → AVIF A->>A: Compress: contain resize → grayscale → PNG → AVIF
A->>A: Fragment into ≤152B packets A->>A: Fragment into ≤152B packets
A->>A: Cache outgoing + populate local session (immediate display) A->>A: Cache outgoing + populate local session (immediate display)
A->>M: Send IE1 envelope (actual w×h, fragment count) A->>M: Send IE2 envelope (actual w×h, fragment count)
M->>B: Deliver IE1 M->>B: Deliver IE2
B->>B: Render grey placeholder bubble B->>B: Render grey placeholder bubble
B->>A: Tap → send IR1 fetch request B->>A: Tap → send binary fetch request
A->>B: Stream binary ImagePackets A->>B: Stream binary ImagePackets
B->>B: Reassemble fragments B->>B: Reassemble fragments
B->>B: Display AVIF image (cover thumbnail) B->>B: Display AVIF image (cover thumbnail)

View File

@@ -5,8 +5,9 @@
Voice mode uses a **two-plane architecture**: Voice mode uses a **two-plane architecture**:
- **Control plane (text messages):** - **Control plane (text messages):**
- `VE1:` voice envelope announces voice availability in chat. - `VE2:` voice envelope announces voice availability in chat.
- `VR1:` direct fetch request asks sender to stream voice payload. - **Control plane (raw binary request):**
- Binary voice fetch request (same raw route as voice packets).
- **Data plane (raw binary packets):** - **Data plane (raw binary packets):**
- `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`. - `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`.
@@ -16,73 +17,58 @@ This design avoids broadcasting full voice payloads to channels/rooms. Chat carr
- `lib/utils/voice_message_parser.dart` - `lib/utils/voice_message_parser.dart`
- `VoicePacket` (legacy text + binary packet format) - `VoicePacket` (legacy text + binary packet format)
- `VoiceEnvelope` (`VE1`) - `VoiceEnvelope` (`VE2`)
- `VoiceFetchRequest` (`VR1`) - `VoiceFetchRequest` (binary)
- `lib/screens/messages_tab.dart` - `lib/screens/messages_tab.dart`
- Capture/encode voice, cache encoded packets, send envelope only - Capture/encode voice, cache encoded packets, send envelope only
- `lib/providers/voice_provider.dart` - `lib/providers/voice_provider.dart`
- Reassembly/playback sessions - Reassembly/playback sessions
- Outgoing session cache + deferred serving - Outgoing session cache + deferred serving
- `lib/providers/app_provider.dart` - `lib/providers/app_provider.dart`
- Incoming routing for `VE1` and `VR1` - Incoming routing for `VE2` and binary voice fetch requests
- Handles raw packet ingestion - Handles raw packet ingestion
- `lib/widgets/messages/voice_message_bubble.dart` - `lib/widgets/messages/voice_message_bubble.dart`
- Play behavior (immediate play if complete, otherwise fetch + auto-play) - Play behavior (immediate play if complete, otherwise fetch + auto-play)
- `lib/providers/messages_provider.dart` - `lib/providers/messages_provider.dart`
- Message-level voice detection (`VE1` + legacy `V:`) - Message-level voice detection (`VE2` + legacy `V:`)
- `lib/services/message_storage_service.dart` - `lib/services/message_storage_service.dart`
- Persists `isVoice` and `voiceId` - Persists `isVoice` and `voiceId`
## 3. Wire Formats ## 3. Wire Formats
### 3.1 Voice Envelope (`VE1`) ### 3.1 Voice Envelope (`VE2`)
Prefix: `VE1:` + colon-delimited compact payload Prefix: `VE2:` + colon-delimited compact payload (base36 numeric fields)
Fields: Fields:
- `sid` (string, 8 hex chars): session ID - `sid` (string): base36 token for 32-bit session ID
- `mode` (int): codec mode ID (`VoicePacketMode.id`) - `mode` (base36): codec mode ID (`VoicePacketMode.id`)
- `total` (int): packet count (1..255) - `total` (base36): packet count (1..255)
- `durMs` (int): estimated duration in ms - `durS` (base36): estimated duration in seconds
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes) - `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
- `ts` (int): unix timestamp seconds - `ts` (base36): unix timestamp seconds
- `ver` (int): protocol version (currently `1`)
`sid` is base36 on wire and expands to 8-hex internally.
Compact format: Compact format:
```text ```text
VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver} VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
``` ```
Example: Example:
```text ```text
VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1 VE2:a:1:4:4:aabbccddeeff:s44we8
``` ```
### 3.2 Voice Fetch Request (`VR1`) ### 3.2 Voice Fetch Request (binary)
Prefix: `VR1:` + colon-delimited compact payload Binary payload format:
Fields:
- `sid` (string, 8 hex chars): requested session
- `want` (string): currently `a` (compact token for `all`)
- `requesterKey6` (string, 12 hex chars): requester key prefix
- `ts` (int): unix timestamp seconds
- `ver` (int): protocol version (`1`)
Compact format:
```text ```text
VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} [magic=0x72][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
```
Example:
```text
VR1:deadbeef:a:112233445566:1700000010:1
``` ```
### 3.3 Raw Voice Packet (data plane) ### 3.3 Raw Voice Packet (data plane)
@@ -102,18 +88,18 @@ Binary payload structure:
2. Each chunk is codec2-encoded into `VoicePacket` objects. 2. Each chunk is codec2-encoded into `VoicePacket` objects.
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min). 3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`). 4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
5. Sender sends one envelope (`VE1`) through normal message path: 5. Sender sends one envelope (`VE2`) through normal message path:
- channel/room: `sendChannelMessage` - channel/room: `sendChannelMessage`
- direct: `sendTextMessage` - direct: `sendTextMessage`
6. **No raw audio packets are sent during initial send.** 6. **No raw audio packets are sent during initial send.**
## 5. Incoming Routing ## 5. Incoming Routing
### 5.1 `VE1` envelope received ### 5.1 `VE2` envelope received
`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat. `AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat.
### 5.2 `VR1` request received ### 5.2 Binary voice fetch request received
`AppProvider` treats it as control-plane only: `AppProvider` treats it as control-plane only:
@@ -132,8 +118,8 @@ In `VoiceMessageBubble`:
- If session already complete: play immediately. - If session already complete: play immediately.
- If incomplete/missing: - If incomplete/missing:
1. Resolve sender contact (message sender prefix or `VE1.senderKey6` fallback) 1. Resolve sender contact (message sender prefix or `VE2.senderKey6` fallback)
2. Send direct `VR1` fetch request 2. Send direct binary fetch request
3. Show requesting state in UI 3. Show requesting state in UI
4. Auto-play when session becomes complete 4. Auto-play when session becomes complete
@@ -170,10 +156,9 @@ Parser validation enforces:
- strict hex lengths for IDs and key prefixes - strict hex lengths for IDs and key prefixes
- valid mode range - valid mode range
- valid packet counts and duration bounds - valid packet counts and duration bounds
- fixed protocol version (`ver == 1`) - compact base36 numeric fields in envelope/request
- `VR1.want` token `a` (internally normalized to `all`) - Binary request flags specify `all` or `missing` indices.
- Request payload includes `requesterKey6` to resolve return route.
`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk.
## 10. Transmit Time Estimate (UI) ## 10. Transmit Time Estimate (UI)
@@ -182,7 +167,8 @@ Voice bubbles and Message Technical Details show an **estimated transmit time**
The estimate is airtime-based (LoRa packet model), not file-duration-only: The estimate is airtime-based (LoRa packet model), not file-duration-only:
- Source inputs: - Source inputs:
- `packetCount` and `durationMs` from `VE1` envelope, or - `packetCount` and `durationMs` from `VE2` envelope, or
- numeric envelope values decoded from base36
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist - actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
- `pathLen` from message metadata - `pathLen` from message metadata
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
@@ -210,10 +196,20 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
- Raw return path needs a currently valid direct route to requester. - Raw return path needs a currently valid direct route to requester.
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`). - Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
### 11.1 Raw Binary Routing Semantics
- Voice payload packets use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
- This flow is direct-route only, not flood/broadcast:
- it is sent to one destination path;
- only nodes on that path relay it;
- it is **not** received by everyone in the mesh.
## 12. Backward Compatibility ## 12. Backward Compatibility
- Legacy `V:` text packet parsing is still supported. - Legacy `V:` text packet parsing is still supported.
- Message voice detection accepts both new `VE1` and legacy `V:` formats. - Message voice detection accepts `VE2` and legacy `V:` formats.
## 13. High-Level Sequence ## 13. High-Level Sequence
@@ -225,10 +221,10 @@ sequenceDiagram
A->>A: Record + encode voice packets A->>A: Record + encode voice packets
A->>A: Cache session packets (TTL 15m) A->>A: Cache session packets (TTL 15m)
A->>M: Send VE1 envelope A->>M: Send VE2 envelope
M->>B: Deliver VE1 M->>B: Deliver VE2
B->>B: Render voice bubble (metadata only) B->>B: Render voice bubble (metadata only)
B->>A: Send VR1 request on Play B->>A: Send binary fetch request on Play
A->>B: Stream raw VoicePacket packets A->>B: Stream raw VoicePacket packets
B->>B: Reassemble session B->>B: Reassemble session
B->>B: Auto-play when complete B->>B: Auto-play when complete

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 68; CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68; CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68; CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68; CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 68; CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 68; CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>68</string> <string>81</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
@@ -68,8 +68,12 @@
<string>MeshCore SAR needs access to the compass to show your heading direction on the map</string> <string>MeshCore SAR needs access to the compass to show your heading direction on the map</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string> <string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
<key>NSCameraUsageDescription</key>
<string>MeshCore SAR needs camera access to take photos and attach them to SAR messages</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations</string> <string>MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>MeshCore SAR needs permission to save exported screenshots and SAR documentation images to your photo library</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000238"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.34961"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.428509">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="83.968525"> <testcase classname="fastlane.lanes" name="2: build_app" time="102.185635">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="186.029813"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="3.215591">
</testcase> </testcase>

View File

@@ -13,6 +13,7 @@ import 'app_localizations_fr.dart';
import 'app_localizations_hr.dart'; import 'app_localizations_hr.dart';
import 'app_localizations_it.dart'; import 'app_localizations_it.dart';
import 'app_localizations_sl.dart'; import 'app_localizations_sl.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -108,6 +109,7 @@ abstract class AppLocalizations {
Locale('hr'), Locale('hr'),
Locale('it'), Locale('it'),
Locale('sl'), Locale('sl'),
Locale('zh'),
]; ];
/// The application title /// The application title
@@ -4225,6 +4227,7 @@ class _AppLocalizationsDelegate
'hr', 'hr',
'it', 'it',
'sl', 'sl',
'zh',
].contains(locale.languageCode); ].contains(locale.languageCode);
@override @override
@@ -4250,6 +4253,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsIt(); return AppLocalizationsIt();
case 'sl': case 'sl':
return AppLocalizationsSl(); return AppLocalizationsSl();
case 'zh':
return AppLocalizationsZh();
} }
throw FlutterError( throw FlutterError(

File diff suppressed because it is too large Load Diff

3848
lib/l10n/app_zh.arb Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -17,8 +17,8 @@ enum ConnectionMode {
/// Act as SSE server - share BLE device with multiple clients /// Act as SSE server - share BLE device with multiple clients
sseServer, sseServer,
/// Connect to remote SSE server - no direct BLE connection /// Direct TCP/WiFi connection to MeshCore device (port 5000)
sseClient, tcp,
} }
extension ConnectionModeExtension on ConnectionMode { extension ConnectionModeExtension on ConnectionMode {
@@ -28,8 +28,8 @@ extension ConnectionModeExtension on ConnectionMode {
return 'Direct (BLE)'; return 'Direct (BLE)';
case ConnectionMode.sseServer: case ConnectionMode.sseServer:
return 'Share Device (Server)'; return 'Share Device (Server)';
case ConnectionMode.sseClient: case ConnectionMode.tcp:
return 'Connect to Server'; return 'Direct (WiFi)';
} }
} }
@@ -39,8 +39,8 @@ extension ConnectionModeExtension on ConnectionMode {
return 'Direct BLE connection to MeshCore device'; return 'Direct BLE connection to MeshCore device';
case ConnectionMode.sseServer: case ConnectionMode.sseServer:
return 'Share BLE device with multiple clients over network'; return 'Share BLE device with multiple clients over network';
case ConnectionMode.sseClient: case ConnectionMode.tcp:
return 'Connect to remote server without BLE'; return 'Direct WiFi/TCP connection to MeshCore device';
} }
} }
} }

View File

@@ -10,14 +10,17 @@ import 'voice_provider.dart';
import 'image_provider.dart' as ip; import 'image_provider.dart' as ip;
import '../services/tile_cache_service.dart'; import '../services/tile_cache_service.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/packet_capture_storage_service.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/ble_packet_log.dart';
import '../utils/drawing_message_parser.dart'; import '../utils/drawing_message_parser.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3;
final ConnectionProvider connectionProvider; final ConnectionProvider connectionProvider;
final ContactsProvider contactsProvider; final ContactsProvider contactsProvider;
final MessagesProvider messagesProvider; final MessagesProvider messagesProvider;
@@ -28,6 +31,8 @@ class AppProvider with ChangeNotifier {
final TileCacheService tileCacheService; final TileCacheService tileCacheService;
final LocationTrackingService locationTrackingService = final LocationTrackingService locationTrackingService =
LocationTrackingService(); LocationTrackingService();
final PacketCaptureStorageService packetCaptureStorageService =
PacketCaptureStorageService();
bool _isInitialized = false; bool _isInitialized = false;
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
@@ -37,6 +42,8 @@ class AppProvider with ChangeNotifier {
bool _isMapEnabled = true; bool _isMapEnabled = true;
bool get isMapEnabled => _isMapEnabled; bool get isMapEnabled => _isMapEnabled;
bool _isContactsEnabled = true;
bool get isContactsEnabled => _isContactsEnabled;
bool _isVoiceSilenceTrimmingEnabled = true; bool _isVoiceSilenceTrimmingEnabled = true;
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
@@ -46,15 +53,20 @@ class AppProvider with ChangeNotifier {
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled; bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
bool _isVoiceLimiterEnabled = true; bool _isVoiceLimiterEnabled = true;
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
bool _autoAddDiscoveredContacts = false;
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
static const Duration _packetRetryDelay = Duration(milliseconds: 1200); static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const int _maxPacketRetryAttempts = 4; static const int _maxPacketRetryAttempts = 4;
final Map<String, String> _voiceSessionSenderKey6 = {}; final Map<String, String> _voiceSessionSenderKey6 = {};
final Map<String, String> _imageSessionSenderKey6 = {}; final Map<String, String> _imageSessionSenderKey6 = {};
final Map<String, Timer> _voiceMissingRetryTimers = {}; final Map<String, Timer> _voiceMissingRetryTimers = {};
final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {}; final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, int> _imageMissingRetryAttempts = {}; final Map<String, Completer<void>> _voiceFragmentAckWaiters = {};
final Map<String, Completer<void>> _imageFragmentAckWaiters = {};
Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
AppProvider({ AppProvider({
required this.connectionProvider, required this.connectionProvider,
@@ -71,14 +83,75 @@ class AppProvider with ChangeNotifier {
_initializeLocationTracking(); _initializeLocationTracking();
_loadSimpleMode(); _loadSimpleMode();
_loadMapEnabled(); _loadMapEnabled();
_loadContactsEnabled();
_loadVoiceSilenceTrimmingEnabled(); _loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled(); _loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled(); _loadVoiceCompressorEnabled();
_loadVoiceLimiterEnabled(); _loadVoiceLimiterEnabled();
_loadAutoAddDiscoveredContacts();
_startPacketCapturePersistence();
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load _syncDrawingsOnStartup(); // Sync drawings immediately after providers load
_isInitialized = true; _isInitialized = true;
} }
void _startPacketCapturePersistence() {
_packetCaptureFlushTimer?.cancel();
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
unawaited(_flushPacketCaptureLogs());
});
unawaited(_flushPacketCaptureLogs());
}
String _packetLogSignature(BlePacketLog log) {
final prefix = log.rawData.length <= 12
? log.rawData
: log.rawData.sublist(0, 12);
final prefixHex = prefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
return '${log.timestamp.microsecondsSinceEpoch}|'
'${log.direction.name}|${log.responseCode ?? -1}|'
'${log.rawData.length}|$prefixHex';
}
Future<void> _flushPacketCaptureLogs() async {
if (_isPersistingPacketCapture) return;
_isPersistingPacketCapture = true;
try {
final logs = connectionProvider.bleService.packetLogs;
if (logs.isEmpty) return;
List<BlePacketLog> toPersist = const [];
if (_lastPersistedPacketSignature == null) {
toPersist = logs;
} else {
final lastSig = _lastPersistedPacketSignature!;
var lastIndex = -1;
for (var i = logs.length - 1; i >= 0; i--) {
if (_packetLogSignature(logs[i]) == lastSig) {
lastIndex = i;
break;
}
}
if (lastIndex == -1) {
// In-memory log rotated or cleared; persist current window to avoid gaps.
toPersist = logs;
} else if (lastIndex < logs.length - 1) {
toPersist = logs.sublist(lastIndex + 1);
}
}
if (toPersist.isNotEmpty) {
await packetCaptureStorageService.appendLogs(toPersist);
}
_lastPersistedPacketSignature = _packetLogSignature(logs.last);
} catch (e) {
debugPrint('❌ [AppProvider] Packet capture flush failed: $e');
} finally {
_isPersistingPacketCapture = false;
}
}
/// Sync drawings from messages on app startup (before BLE connection) /// Sync drawings from messages on app startup (before BLE connection)
Future<void> _syncDrawingsOnStartup() async { Future<void> _syncDrawingsOnStartup() async {
// Wait for MessagesProvider to finish initializing // Wait for MessagesProvider to finish initializing
@@ -144,6 +217,29 @@ class AppProvider with ChangeNotifier {
} }
} }
/// Load contacts enabled setting from shared preferences
Future<void> _loadContactsEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isContactsEnabled = prefs.getBool('contacts_enabled') ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading contacts enabled setting: $e');
}
}
/// Toggle contacts tab on/off
Future<void> toggleContactsEnabled(bool enabled) async {
try {
_isContactsEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('contacts_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving contacts enabled setting: $e');
}
}
/// Load voice silence trimming setting from shared preferences. /// Load voice silence trimming setting from shared preferences.
Future<void> _loadVoiceSilenceTrimmingEnabled() async { Future<void> _loadVoiceSilenceTrimmingEnabled() async {
try { try {
@@ -239,6 +335,30 @@ class AppProvider with ChangeNotifier {
} }
} }
/// Load auto-add discovered contacts setting from shared preferences.
Future<void> _loadAutoAddDiscoveredContacts() async {
try {
final prefs = await SharedPreferences.getInstance();
_autoAddDiscoveredContacts =
prefs.getBool('auto_add_discovered_contacts') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading auto-add discovered contacts setting: $e');
}
}
/// Toggle auto-add discovered contacts on/off.
Future<void> toggleAutoAddDiscoveredContacts(bool enabled) async {
try {
_autoAddDiscoveredContacts = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('auto_add_discovered_contacts', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving auto-add discovered contacts setting: $e');
}
}
/// Initialize tile cache service /// Initialize tile cache service
Future<void> _initializeTileCache() async { Future<void> _initializeTileCache() async {
try { try {
@@ -313,6 +433,26 @@ class AppProvider with ChangeNotifier {
payload: payload, 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 // When a contact is received from BLE
connectionProvider.onContactReceived = (contact) { connectionProvider.onContactReceived = (contact) {
@@ -449,46 +589,6 @@ class AppProvider with ChangeNotifier {
} }
} }
// Voice control plane: request sender to stream raw voice packets.
final voiceFetchRequest = VoiceFetchRequest.tryParseText(
enrichedMessage.text,
);
if (voiceFetchRequest != null) {
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
if (senderPrefix == null) {
debugPrint(
'⚠️ [AppProvider] Voice fetch request without sender prefix',
);
return;
}
final senderPrefixHex = senderPrefix
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (senderPrefixHex.toLowerCase() !=
voiceFetchRequest.requesterKey6.toLowerCase()) {
debugPrint('⚠️ [AppProvider] Voice fetch requester key mismatch');
return;
}
final requester = contactsProvider.findContactByPrefix(senderPrefix);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester contact not found',
);
return;
}
unawaited(
voiceProvider.serveSessionTo(
sessionId: voiceFetchRequest.sessionId,
requester: requester,
requestedIndices: voiceFetchRequest.want == 'missing'
? voiceFetchRequest.missingIndices.toSet()
: null,
),
);
return;
}
// Check if message is a drawing broadcast // Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
debugPrint('🎨 [AppProvider] Drawing message received, parsing...'); debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
@@ -558,38 +658,6 @@ class AppProvider with ChangeNotifier {
return; return;
} }
// Image fetch request (IR1): requester asks us to stream image fragments.
final imageFetchRequest = ImageFetchRequest.tryParse(
enrichedMessage.text,
);
if (imageFetchRequest != null) {
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
if (senderPrefix != null) {
final senderPrefixHex = senderPrefix
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (senderPrefixHex.toLowerCase() ==
imageFetchRequest.requesterKey6.toLowerCase()) {
final requester = contactsProvider.findContactByPrefix(
senderPrefix,
);
if (requester != null) {
unawaited(
imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId,
requester: requester,
requestedIndices: imageFetchRequest.want == 'missing'
? imageFetchRequest.missingIndices.toSet()
: null,
),
);
}
}
}
return; // IR1 is control-plane only; not displayed in chat
}
// Image envelope (IE1): announce image availability. // Image envelope (IE1): announce image availability.
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text); final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
if (imageEnvelope != null) { if (imageEnvelope != null) {
@@ -676,19 +744,110 @@ class AppProvider with ChangeNotifier {
}; };
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84) // When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet. // Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
if (voiceFetchRequest != null) {
final requester = contactsProvider.findContactByPrefixHex(
voiceFetchRequest.requesterKey6,
);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester contact not found (binary)',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
return;
}
if (requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return;
}
unawaited(
voiceProvider.serveSessionTo(
sessionId: voiceFetchRequest.sessionId,
requester: requester,
requestedIndices: voiceFetchRequest.want == 'missing'
? voiceFetchRequest.missingIndices.toSet()
: null,
),
);
return;
}
final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload);
if (imageFetchRequest != null) {
final requester = contactsProvider.findContactByPrefixHex(
imageFetchRequest.requesterKey6,
);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Image fetch requester contact not found (binary)',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
return;
}
if (requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return;
}
unawaited(
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);
return;
}
if (ImagePacket.isImageBinary(payload)) { if (ImagePacket.isImageBinary(payload)) {
final frag = ImagePacket.tryParseBinary(payload); final frag = ImagePacket.tryParseBinary(payload);
if (frag == null) return; if (frag == null) return;
debugPrint('📷 [AppProvider] Binary image fragment received: $frag'); debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId); final session = imageProvider.session(frag.sessionId);
final justComplete = imageProvider.addFragment( imageProvider.addFragment(
frag, frag,
width: session?.width ?? 0, width: session?.width ?? 0,
height: session?.height ?? 0, height: session?.height ?? 0,
); );
_scheduleImageMissingRetry(frag.sessionId, justComplete: justComplete); _sendImageFragmentAck(frag);
return; return;
} }
@@ -697,6 +856,7 @@ class AppProvider with ChangeNotifier {
if (pkt == null) return; if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt'); debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final justComplete = voiceProvider.addPacket(pkt); final justComplete = voiceProvider.addPacket(pkt);
_sendVoiceFragmentAck(pkt);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete); _scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
// Insert or update the placeholder message in the chat list // Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete); _handleIncomingVoicePacket(pkt, justComplete: justComplete);
@@ -737,13 +897,22 @@ class AppProvider with ChangeNotifier {
} }
}); });
} else { } else {
contactsProvider.addPendingAdvert( if (_autoAddDiscoveredContacts) {
publicKey, debugPrint(' Unknown contact - auto-add enabled, fetching details');
devicePublicKey: connectionProvider.deviceInfo.publicKey, Future.delayed(const Duration(milliseconds: 100), () {
); if (connectionProvider.deviceInfo.isConnected) {
debugPrint( connectionProvider.getContact(publicKey);
' Unknown contact - added to pending adverts list and waiting for details', }
); });
} else {
contactsProvider.addPendingAdvert(
publicKey,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
debugPrint(
' Unknown contact - added to pending adverts list and waiting for details',
);
}
} }
}; };
@@ -1077,15 +1246,18 @@ class AppProvider with ChangeNotifier {
missingIndices: missing, missingIndices: missing,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1, version: 2,
); );
final sent = await connectionProvider.sendTextMessage( try {
contactPublicKey: sender.publicKey, await connectionProvider.sendRawVoicePacket(
text: request.encodeText(), contactPath: sender.outPath,
contact: sender, contactPathLen: sender.outPathLen,
); payload: request.encodeBinary(),
if (!sent) return; );
} catch (_) {
return;
}
_voiceMissingRetryAttempts[sessionId] = attempt + 1; _voiceMissingRetryAttempts[sessionId] = attempt + 1;
_voiceMissingRetryTimers[sessionId]?.cancel(); _voiceMissingRetryTimers[sessionId]?.cancel();
@@ -1094,79 +1266,6 @@ class AppProvider with ChangeNotifier {
}); });
} }
void _scheduleImageMissingRetry(
String sessionId, {
required bool justComplete,
}) {
if (justComplete || imageProvider.isComplete(sessionId)) {
_imageMissingRetryTimers.remove(sessionId)?.cancel();
_imageMissingRetryAttempts.remove(sessionId);
return;
}
_imageMissingRetryAttempts[sessionId] = 0;
_imageMissingRetryTimers[sessionId]?.cancel();
_imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
unawaited(_requestMissingImageFragments(sessionId));
});
}
Future<void> _requestMissingImageFragments(String sessionId) async {
if (imageProvider.isComplete(sessionId)) {
_imageMissingRetryTimers.remove(sessionId)?.cancel();
_imageMissingRetryAttempts.remove(sessionId);
return;
}
final attempt = _imageMissingRetryAttempts[sessionId] ?? 0;
if (attempt >= _maxPacketRetryAttempts) {
debugPrint(
'⚠️ [AppProvider] Image re-request limit reached for $sessionId',
);
_imageMissingRetryTimers.remove(sessionId)?.cancel();
return;
}
final senderKey6 = _imageSessionSenderKey6[sessionId];
if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6);
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (sender == null || deviceKey == null || deviceKey.length < 6) return;
final missing = imageProvider.missingFragmentIndices(sessionId);
if (missing.isEmpty) {
_imageMissingRetryTimers.remove(sessionId)?.cancel();
_imageMissingRetryAttempts.remove(sessionId);
return;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final request = ImageFetchRequest(
sessionId: sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: sender.publicKey,
text: request.encode(),
contact: sender,
);
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. /// Insert or update a voice placeholder message for binary raw-data packets.
/// ///
/// Binary voice packets arrive without a chat message, so we synthesise one /// Binary voice packets arrive without a chat message, so we synthesise one
@@ -1207,6 +1306,102 @@ class AppProvider with ChangeNotifier {
messagesProvider.addMessage(placeholder, contactLookup: (_) => ''); messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
} }
String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
Future<bool> _waitForVoiceFragmentAck({
required String sessionId,
required int index,
Duration timeout = const Duration(seconds: 8),
}) async {
final key = _fragmentAckKey(sessionId, index);
final completer = Completer<void>();
_voiceFragmentAckWaiters[key] = completer;
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
if (_voiceFragmentAckWaiters[key] == completer) {
_voiceFragmentAckWaiters.remove(key);
}
return false;
}
}
void _completeVoiceFragmentAck(String sessionId, int index) {
final key = _fragmentAckKey(sessionId, index);
final completer = _voiceFragmentAckWaiters.remove(key);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
Future<bool> _waitForImageFragmentAck({
required String sessionId,
required int index,
Duration timeout = const Duration(seconds: 8),
}) async {
final key = _fragmentAckKey(sessionId, index);
final completer = Completer<void>();
_imageFragmentAckWaiters[key] = completer;
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
if (_imageFragmentAckWaiters[key] == completer) {
_imageFragmentAckWaiters.remove(key);
}
return false;
}
}
void _completeImageFragmentAck(String sessionId, int index) {
final key = _fragmentAckKey(sessionId, index);
final completer = _imageFragmentAckWaiters.remove(key);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
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) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
payload: VoiceFragmentAck(
sessionId: packet.sessionId,
index: packet.index,
).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(),
),
);
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching // The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
@@ -1300,15 +1495,9 @@ class AppProvider with ChangeNotifier {
for (final timer in _voiceMissingRetryTimers.values) { for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel(); timer.cancel();
} }
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
_voiceMissingRetryTimers.clear(); _voiceMissingRetryTimers.clear();
_imageMissingRetryTimers.clear();
_voiceMissingRetryAttempts.clear(); _voiceMissingRetryAttempts.clear();
_imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear(); _voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear();
notifyListeners(); notifyListeners();
} }
@@ -1328,6 +1517,8 @@ class AppProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
_packetCaptureFlushTimer?.cancel();
unawaited(_flushPacketCaptureLogs());
// Remove connection state listener // Remove connection state listener
connectionProvider.removeListener(_handleConnectionStateChange); connectionProvider.removeListener(_handleConnectionStateChange);
// Clear location service callbacks // Clear location service callbacks
@@ -1340,9 +1531,6 @@ class AppProvider with ChangeNotifier {
for (final timer in _voiceMissingRetryTimers.values) { for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel(); timer.cancel();
} }
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
super.dispose(); super.dispose();
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
import 'package:flutter/foundation.dart';
import '../../models/contact.dart';
typedef RawPacketSender =
Future<void> Function({
required Uint8List contactPath,
required int contactPathLen,
required Uint8List payload,
});
typedef FragmentAckWaiter =
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
});
Future<bool> serveCachedSessionFragments<T>({
required String providerLabel,
required String sessionId,
required Contact requester,
required List<T> fragments,
required int maxDirectPayloadHops,
required int Function(T fragment) indexOf,
required Uint8List Function(T fragment) encodeBinary,
required RawPacketSender? sendRawPacket,
FragmentAckWaiter? waitForFragmentAck,
Set<int>? requestedIndices,
Duration ackTimeout = const Duration(seconds: 8),
}) async {
if (fragments.isEmpty) {
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
return false;
}
if (sendRawPacket == null) {
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
return false;
}
if (requester.outPathLen < 0) {
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
return false;
}
if (requester.outPathLen > maxDirectPayloadHops) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
);
return false;
}
if (requester.outPath.isEmpty) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
);
return false;
}
var servedCount = 0;
for (final fragment in fragments) {
final index = indexOf(fragment);
if (index < 0) {
debugPrint('⚠️ [$providerLabel] Invalid fragment index $index');
continue;
}
if (requestedIndices != null && !requestedIndices.contains(index)) {
continue;
}
try {
final ackFuture = waitForFragmentAck?.call(
sessionId: sessionId,
index: index,
timeout: ackTimeout,
);
await sendRawPacket(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
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',
);
return false;
}
}
if (servedCount == 0) {
debugPrint(
'⚠️ [$providerLabel] No fragments matched request for $sessionId',
);
return false;
}
debugPrint('✅ [$providerLabel] Served $servedCount fragments for $sessionId');
return true;
}

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
/// Reassembly state for one incoming image session. /// Reassembly state for one incoming image session.
@@ -13,6 +14,8 @@ class ImageSession {
final int width; final int width;
final int height; final int height;
final List<ImagePacket?> fragments; // indexed by fragment.index final List<ImagePacket?> fragments; // indexed by fragment.index
DateTime? firstFragmentAt;
DateTime? lastFragmentAt;
ImageSession({ ImageSession({
required this.sessionId, required this.sessionId,
@@ -25,6 +28,21 @@ class ImageSession {
int get receivedCount => fragments.where((f) => f != null).length; int get receivedCount => fragments.where((f) => f != null).length;
bool get isComplete => receivedCount == total; bool get isComplete => receivedCount == total;
Duration? estimateRemaining() {
if (isComplete) return Duration.zero;
if (firstFragmentAt == null || lastFragmentAt == null) return null;
if (receivedCount < 2) return null;
final elapsedMs = lastFragmentAt!
.difference(firstFragmentAt!)
.inMilliseconds;
if (elapsedMs <= 0) return null;
final avgMsPerFragment = elapsedMs / (receivedCount - 1);
final remaining = total - receivedCount;
if (remaining <= 0) return Duration.zero;
return Duration(milliseconds: (avgMsPerFragment * remaining).round());
}
/// Reassemble the complete image bytes, or null if any fragment is missing. /// Reassemble the complete image bytes, or null if any fragment is missing.
Uint8List? get imageBytes => reassembleImage(fragments); Uint8List? get imageBytes => reassembleImage(fragments);
} }
@@ -36,6 +54,7 @@ class ImageSession {
class ImageProvider with ChangeNotifier { class ImageProvider with ChangeNotifier {
static const String _storageKey = 'stored_image_sessions_v1'; static const String _storageKey = 'stored_image_sessions_v1';
static const Duration _outgoingTtl = Duration(minutes: 15); static const Duration _outgoingTtl = Duration(minutes: 15);
static const int maxDirectPayloadHops = 3;
/// Incoming sessions keyed by sessionId. /// Incoming sessions keyed by sessionId.
final Map<String, ImageSession> _sessions = {}; final Map<String, ImageSession> _sessions = {};
@@ -50,6 +69,12 @@ class ImageProvider with ChangeNotifier {
required Uint8List payload, required Uint8List payload,
})? })?
sendRawPacketCallback; sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
ImageProvider() { ImageProvider() {
_restore(); _restore();
@@ -61,6 +86,8 @@ class ImageProvider with ChangeNotifier {
bool isComplete(String sessionId) => bool isComplete(String sessionId) =>
_sessions[sessionId]?.isComplete ?? false; _sessions[sessionId]?.isComplete ?? false;
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId); bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining();
List<int> missingFragmentIndices(String sessionId) { List<int> missingFragmentIndices(String sessionId) {
final session = _sessions[sessionId]; final session = _sessions[sessionId];
@@ -93,7 +120,13 @@ class ImageProvider with ChangeNotifier {
final session = _sessions[fragment.sessionId]!; final session = _sessions[fragment.sessionId]!;
if (fragment.index < session.total) { if (fragment.index < session.total) {
final wasMissing = session.fragments[fragment.index] == null;
session.fragments[fragment.index] = fragment; session.fragments[fragment.index] = fragment;
if (wasMissing) {
final now = DateTime.now();
session.firstFragmentAt ??= now;
session.lastFragmentAt = now;
}
} }
final justComplete = session.isComplete; final justComplete = session.isComplete;
@@ -105,19 +138,8 @@ class ImageProvider with ChangeNotifier {
/// Register envelope metadata for a session (called when IE1 is received /// Register envelope metadata for a session (called when IE1 is received
/// before any binary fragments arrive). /// before any binary fragments arrive).
void registerEnvelope(ImageEnvelope envelope) { void registerEnvelope(ImageEnvelope envelope) {
_sessions.putIfAbsent( final existing = _sessions[envelope.sessionId];
envelope.sessionId, if (existing == null) {
() => ImageSession(
sessionId: envelope.sessionId,
format: envelope.format,
total: envelope.total,
width: envelope.width,
height: envelope.height,
),
);
// Update dimensions if we created the session from a fragment (w/h = 0).
final session = _sessions[envelope.sessionId]!;
if (session.width == 0 || session.height == 0) {
_sessions[envelope.sessionId] = ImageSession( _sessions[envelope.sessionId] = ImageSession(
sessionId: envelope.sessionId, sessionId: envelope.sessionId,
format: envelope.format, format: envelope.format,
@@ -125,12 +147,38 @@ class ImageProvider with ChangeNotifier {
width: envelope.width, width: envelope.width,
height: envelope.height, height: envelope.height,
); );
// Copy existing fragments into the new session. unawaited(_persist());
final old = _sessions[envelope.sessionId]!; notifyListeners();
for (var i = 0; i < session.fragments.length && i < old.total; i++) { return;
old.fragments[i] = session.fragments[i]; }
final needsMerge =
existing.width == 0 ||
existing.height == 0 ||
existing.total != envelope.total ||
existing.format != envelope.format;
if (!needsMerge) {
notifyListeners();
return;
}
final merged = ImageSession(
sessionId: envelope.sessionId,
format: envelope.format,
total: envelope.total,
width: envelope.width,
height: envelope.height,
);
merged.firstFragmentAt = existing.firstFragmentAt;
merged.lastFragmentAt = existing.lastFragmentAt;
for (final fragment in existing.fragments) {
if (fragment == null) continue;
if (fragment.index < merged.total) {
merged.fragments[fragment.index] = fragment;
} }
} }
_sessions[envelope.sessionId] = merged;
unawaited(_persist());
notifyListeners(); notifyListeners();
} }
@@ -182,35 +230,18 @@ class ImageProvider with ChangeNotifier {
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId'); debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
return false; return false;
} }
if (sendRawPacketCallback == null) { return serveCachedSessionFragments<ImagePacket>(
debugPrint('⚠️ [ImageProvider] sendRawPacketCallback not set'); providerLabel: 'ImageProvider',
return false; sessionId: sessionId,
} requester: requester,
if (requester.outPathLen < 0) { fragments: cached.fragments,
debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path'); maxDirectPayloadHops: maxDirectPayloadHops,
return false; indexOf: (fragment) => fragment.index,
} encodeBinary: (fragment) => fragment.encodeBinary(),
sendRawPacket: sendRawPacketCallback,
for (final fragment in cached.fragments) { waitForFragmentAck: waitForFragmentAckCallback,
if (requestedIndices != null && requestedIndices: requestedIndices,
!requestedIndices.contains(fragment.index)) {
continue;
}
try {
await sendRawPacketCallback!(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
payload: fragment.encodeBinary(),
);
} catch (e, st) {
debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st');
return false;
}
}
debugPrint(
'📷 [ImageProvider] Served ${cached.fragments.length} fragments of $sessionId',
); );
return true;
} }
// ── Persistence ────────────────────────────────────────────────────────── // ── Persistence ──────────────────────────────────────────────────────────

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../services/voice_player_service.dart'; import '../services/voice_player_service.dart';
@@ -13,6 +14,8 @@ class VoiceSession {
final VoicePacketMode mode; final VoicePacketMode mode;
final int total; final int total;
final List<VoicePacket?> packets; // indexed by packet.index final List<VoicePacket?> packets; // indexed by packet.index
DateTime? firstPacketAt;
DateTime? lastPacketAt;
VoiceSession({ VoiceSession({
required this.sessionId, required this.sessionId,
@@ -23,6 +26,19 @@ class VoiceSession {
int get receivedCount => packets.where((p) => p != null).length; int get receivedCount => packets.where((p) => p != null).length;
bool get isComplete => receivedCount == total; bool get isComplete => receivedCount == total;
Duration? estimateRemaining() {
if (isComplete) return Duration.zero;
if (firstPacketAt == null || lastPacketAt == null) return null;
if (receivedCount < 2) return null;
final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds;
if (elapsedMs <= 0) return null;
final avgMsPerPacket = elapsedMs / (receivedCount - 1);
final remaining = total - receivedCount;
if (remaining <= 0) return Duration.zero;
return Duration(milliseconds: (avgMsPerPacket * remaining).round());
}
/// Total estimated audio duration in seconds (sum of all received packets). /// Total estimated audio duration in seconds (sum of all received packets).
double get estimatedDurationSeconds { double get estimatedDurationSeconds {
var ms = 0; var ms = 0;
@@ -36,6 +52,7 @@ class VoiceSession {
/// Manages incoming voice packet sessions and coordinates playback. /// Manages incoming voice packet sessions and coordinates playback.
class VoiceProvider with ChangeNotifier { class VoiceProvider with ChangeNotifier {
static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1'; static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1';
static const int maxDirectPayloadHops = 3;
final VoiceCodecService _codec; final VoiceCodecService _codec;
final VoicePlayerService _player; final VoicePlayerService _player;
late final StreamSubscription<void> _playerEventsSub; late final StreamSubscription<void> _playerEventsSub;
@@ -53,6 +70,12 @@ class VoiceProvider with ChangeNotifier {
required Uint8List payload, required Uint8List payload,
})? })?
sendRawPacketCallback; sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {}; final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
@@ -92,6 +115,8 @@ class VoiceProvider with ChangeNotifier {
bool hasOutgoingSession(String sessionId) => bool hasOutgoingSession(String sessionId) =>
_outgoingSessions.containsKey(sessionId); _outgoingSessions.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining();
List<int> missingPacketIndices(String sessionId) { List<int> missingPacketIndices(String sessionId) {
final session = _sessions[sessionId]; final session = _sessions[sessionId];
@@ -119,7 +144,13 @@ class VoiceProvider with ChangeNotifier {
final session = _sessions[packet.sessionId]!; final session = _sessions[packet.sessionId]!;
if (packet.index < session.total) { if (packet.index < session.total) {
final wasMissing = session.packets[packet.index] == null;
session.packets[packet.index] = packet; session.packets[packet.index] = packet;
if (wasMissing) {
final now = DateTime.now();
session.firstPacketAt ??= now;
session.lastPacketAt = now;
}
} }
final justComplete = session.isComplete; final justComplete = session.isComplete;
@@ -151,36 +182,18 @@ class VoiceProvider with ChangeNotifier {
); );
return false; return false;
} }
if (sendRawPacketCallback == null) { return serveCachedSessionFragments<VoicePacket>(
debugPrint('⚠️ [VoiceProvider] sendRawPacketCallback is not set'); providerLabel: 'VoiceProvider',
return false; sessionId: sessionId,
} requester: requester,
if (requester.outPathLen < 0) { fragments: cached.packets,
debugPrint( maxDirectPayloadHops: maxDirectPayloadHops,
'⚠️ [VoiceProvider] Requester ${requester.advName} has no direct path', indexOf: (packet) => packet.index,
); encodeBinary: (packet) => packet.encodeBinary(),
return false; sendRawPacket: sendRawPacketCallback,
} waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices,
for (final packet in cached.packets) { );
if (requestedIndices != null &&
!requestedIndices.contains(packet.index)) {
continue;
}
try {
await sendRawPacketCallback!(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
payload: packet.encodeBinary(),
);
} catch (e, st) {
debugPrint(
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
);
return false;
}
}
return true;
} }
// ── Playback ───────────────────────────────────────────────────────────── // ── Playback ─────────────────────────────────────────────────────────────

View File

@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:vibration/vibration.dart'; import 'package:vibration/vibration.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/device_info.dart' show ConnectionMode;
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
@@ -22,6 +23,8 @@ import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart'; import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
enum _HomeTab { messages, contacts, map }
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged; final Function(AppThemeMode) onThemeChanged;
final Function(Locale?) onLocaleChanged; final Function(Locale?) onLocaleChanged;
@@ -49,13 +52,30 @@ class _HomeScreenState extends State<HomeScreen>
bool _isMapFullscreen = false; bool _isMapFullscreen = false;
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
bool _isMapEnabled = true; bool _isMapEnabled = true;
bool _isContactsEnabled = true;
List<_HomeTab> get _enabledTabs {
return [
_HomeTab.messages,
if (_isContactsEnabled) _HomeTab.contacts,
if (_isMapEnabled) _HomeTab.map,
];
}
_HomeTab get _currentTab {
final tabs = _enabledTabs;
final safeIndex = _currentIndex < tabs.length
? _currentIndex
: tabs.length - 1;
return tabs[safeIndex < 0 ? 0 : safeIndex];
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize synchronously so first build always has a valid controller. // Initialize synchronously so first build always has a valid controller.
_initTabController(); _initTabController();
_loadMapEnabledAndInitTabs(); _loadTabVisibilityAndInitTabs();
_loadRxTxPreference(); _loadRxTxPreference();
// Show permission dialog after the first frame if needed // Show permission dialog after the first frame if needed
@@ -66,58 +86,75 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
Future<void> _loadMapEnabledAndInitTabs() async { Future<void> _loadTabVisibilityAndInitTabs() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final mapEnabled = prefs.getBool('map_enabled') ?? true; final mapEnabled = prefs.getBool('map_enabled') ?? true;
final contactsEnabled = prefs.getBool('contacts_enabled') ?? true;
if (!mounted) return; if (!mounted) return;
if (_isMapEnabled != mapEnabled) { if (_isMapEnabled != mapEnabled || _isContactsEnabled != contactsEnabled) {
_updateTabController(mapEnabled); _updateTabController(
mapEnabled: mapEnabled,
contactsEnabled: contactsEnabled,
);
} }
} }
void _initTabController() { void _initTabController() {
final tabCount = _isMapEnabled ? 3 : 2; _tabController = TabController(length: _enabledTabs.length, vsync: this);
_tabController = TabController(length: tabCount, vsync: this);
_tabController.addListener(_onTabChanged); _tabController.addListener(_onTabChanged);
} }
void _onTabChanged() { void _onTabChanged() {
setState(() { setState(() {
_currentIndex = _tabController.index; _currentIndex = _tabController.index;
// Exit fullscreen when switching away from map tab (only if map is enabled and is tab 2) if (_currentTab != _HomeTab.map) {
if (_isMapEnabled && _currentIndex != 2) {
_isMapFullscreen = false; _isMapFullscreen = false;
} }
}); });
} }
void _updateTabController(bool mapEnabled) { void _updateTabController({
if (_isMapEnabled == mapEnabled) return; required bool mapEnabled,
required bool contactsEnabled,
}) {
if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) {
return;
}
// Save current index before rebuilding final oldTabs = _enabledTabs;
final oldIndex = _tabController.index; final oldIndex = _tabController.index;
final oldTab = oldTabs[oldIndex];
// Remove old listener and dispose final oldController = _tabController;
_tabController.removeListener(_onTabChanged); oldController.removeListener(_onTabChanged);
_tabController.dispose();
// Update state // Update state
_isMapEnabled = mapEnabled; _isMapEnabled = mapEnabled;
_isContactsEnabled = contactsEnabled;
final newTabs = _enabledTabs;
final newIndex = newTabs.indexOf(oldTab);
// Create new controller // Create new controller
final tabCount = mapEnabled ? 3 : 2; _tabController = TabController(length: newTabs.length, vsync: this);
_tabController = TabController(length: tabCount, vsync: this);
_tabController.addListener(_onTabChanged); _tabController.addListener(_onTabChanged);
// Restore index (clamp to valid range) _currentIndex = newIndex >= 0 ? newIndex : 0;
if (oldIndex < tabCount) { _tabController.index = _currentIndex;
_tabController.index = oldIndex;
_currentIndex = oldIndex;
} else {
_currentIndex = tabCount - 1;
}
setState(() {}); setState(() {});
// Dispose old controller after widgets have rebound to the new controller.
WidgetsBinding.instance.addPostFrameCallback((_) {
oldController.dispose();
});
}
void _navigateToTab(_HomeTab tab) {
final targetIndex = _enabledTabs.indexOf(tab);
if (targetIndex >= 0 && targetIndex != _tabController.index) {
_tabController.animateTo(targetIndex);
}
} }
Future<void> _loadRxTxPreference() async { Future<void> _loadRxTxPreference() async {
@@ -275,18 +312,23 @@ class _HomeScreenState extends State<HomeScreen>
messagesProvider.setLocalizations(localizations); messagesProvider.setLocalizations(localizations);
} }
// Check if map enabled setting changed and update tab controller // Check if tab visibility settings changed and update tab controller
final appProvider = context.watch<AppProvider>(); final appProvider = context.watch<AppProvider>();
if (_isMapEnabled != appProvider.isMapEnabled) { if (_isMapEnabled != appProvider.isMapEnabled ||
_isContactsEnabled != appProvider.isContactsEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_updateTabController(appProvider.isMapEnabled); _updateTabController(
mapEnabled: appProvider.isMapEnabled,
contactsEnabled: appProvider.isContactsEnabled,
);
}); });
} }
// Determine if we should hide the UI (only in fullscreen on map tab) final enabledTabs = _enabledTabs;
final shouldHideUI = final isMapTabActive = _currentTab == _HomeTab.map;
_isMapEnabled && _isMapFullscreen && _currentIndex == 2; final shouldHideUI = _isMapEnabled && _isMapFullscreen && isMapTabActive;
final shouldShowTabBar = enabledTabs.length > 1;
return Scaffold( return Scaffold(
appBar: shouldHideUI appBar: shouldHideUI
@@ -298,7 +340,7 @@ class _HomeScreenState extends State<HomeScreen>
builder: (context, provider, child) { builder: (context, provider, child) {
final isConnected = final isConnected =
provider.deviceInfo.isConnected || provider.deviceInfo.isConnected ||
provider.isSseClientConnected; provider.deviceInfo.isConnected;
if (isConnected) { if (isConnected) {
return IconButton( return IconButton(
onPressed: () async { onPressed: () async {
@@ -373,29 +415,33 @@ class _HomeScreenState extends State<HomeScreen>
), ),
body: TabBarView( body: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: enabledTabs.map((tab) {
MessagesTab( switch (tab) {
onNavigateToMap: _isMapEnabled case _HomeTab.messages:
? () => _tabController.animateTo(2) return MessagesTab(
: null, onNavigateToMap: _isMapEnabled
), ? () => _navigateToTab(_HomeTab.map)
ContactsTab( : null,
onNavigateToMap: _isMapEnabled );
? () => _tabController.animateTo(2) case _HomeTab.contacts:
: null, return ContactsTab(
), onNavigateToMap: _isMapEnabled
if (_isMapEnabled) ? () => _navigateToTab(_HomeTab.map)
MapTab( : null,
onFullscreenChanged: (isFullscreen) { );
setState(() { case _HomeTab.map:
_isMapFullscreen = isFullscreen; return MapTab(
}); onFullscreenChanged: (isFullscreen) {
}, setState(() {
onNavigateToMessages: () => _tabController.animateTo(0), _isMapFullscreen = isFullscreen;
), });
], },
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
);
}
}).toList(),
), ),
bottomNavigationBar: shouldHideUI bottomNavigationBar: shouldHideUI || !shouldShowTabBar
? null ? null
: Consumer2<MessagesProvider, ContactsProvider>( : Consumer2<MessagesProvider, ContactsProvider>(
builder: (context, messagesProvider, contactsProvider, child) { builder: (context, messagesProvider, contactsProvider, child) {
@@ -414,27 +460,31 @@ class _HomeScreenState extends State<HomeScreen>
), ),
child: TabBar( child: TabBar(
controller: _tabController, controller: _tabController,
tabs: [ tabs: enabledTabs.map((tab) {
Tab( switch (tab) {
icon: _buildTabIconWithBadge( case _HomeTab.messages:
Icons.message, return Tab(
unreadCount, icon: _buildTabIconWithBadge(
), Icons.message,
text: AppLocalizations.of(context)!.messages, unreadCount,
), ),
Tab( text: AppLocalizations.of(context)!.messages,
icon: _buildTabIconWithBadge( );
Icons.contacts, case _HomeTab.contacts:
newContactsCount, return Tab(
), icon: _buildTabIconWithBadge(
text: AppLocalizations.of(context)!.contacts, Icons.contacts,
), newContactsCount,
if (_isMapEnabled) ),
Tab( text: AppLocalizations.of(context)!.contacts,
icon: const Icon(Icons.map), );
text: AppLocalizations.of(context)!.map, case _HomeTab.map:
), return Tab(
], icon: const Icon(Icons.map),
text: AppLocalizations.of(context)!.map,
);
}
}).toList(),
), ),
); );
}, },
@@ -446,9 +496,9 @@ class _HomeScreenState extends State<HomeScreen>
return Consumer<ConnectionProvider>( return Consumer<ConnectionProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
final deviceInfo = provider.deviceInfo; final deviceInfo = provider.deviceInfo;
final isBleConnected = deviceInfo.isConnected; final isConnected = deviceInfo.isConnected;
final isSseConnected = provider.isSseClientConnected; final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
final isConnected = isBleConnected || isSseConnected; final isBleConnected = isConnected && !isTcpConnected;
if (!isConnected) { if (!isConnected) {
// Disconnected state: show connect button // Disconnected state: show connect button
@@ -536,10 +586,10 @@ class _HomeScreenState extends State<HomeScreen>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
isSseConnected isTcpConnected
? Icons.wifi ? Icons.wifi
: Icons.bluetooth_connected, : Icons.bluetooth_connected,
color: isSseConnected color: isTcpConnected
? Colors.green ? Colors.green
: (deviceInfo.signalRssi != null : (deviceInfo.signalRssi != null
? BatteryDisplayHelper.getSignalColor( ? BatteryDisplayHelper.getSignalColor(
@@ -561,10 +611,10 @@ class _HomeScreenState extends State<HomeScreen>
), ),
), ),
], ],
if (isSseConnected && !isBleConnected) ...[ if (isTcpConnected) ...[
const SizedBox(width: 3), const SizedBox(width: 3),
Text( Text(
'SSE', 'WiFi',
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.green, color: Colors.green,

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -26,6 +27,7 @@ import '../utils/toast_logger.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/tictactoe_message_parser.dart';
import '../providers/image_provider.dart' as ip; import '../providers/image_provider.dart' as ip;
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
@@ -67,7 +69,7 @@ class _MessagesTabState extends State<MessagesTab> {
static const double _silenceRmsThreshold = 500.0; static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0; static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2; static const int _maxInteriorSilentChunks = 2;
bool get _voiceSupported => Platform.isIOS || Platform.isAndroid; bool get _voiceSupported => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
StreamSubscription<Int16List>? _voiceStreamSub; StreamSubscription<Int16List>? _voiceStreamSub;
String? _currentVoiceSessionId; String? _currentVoiceSessionId;
final List<Int16List> _recordedChunks = []; final List<Int16List> _recordedChunks = [];
@@ -427,12 +429,63 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
Future<void> _startTicTacToeGame() async {
if (!mounted) return;
if (_destinationType !=
MessageDestinationPreferences.destinationTypeContact ||
_selectedRecipient == null) {
ToastLogger.warning(
context,
'Tic-Tac-Toe works only in direct messages. Choose a contact first.',
);
return;
}
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
final contactsProvider = context.read<ContactsProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
return;
}
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
if (devicePublicKey == null || devicePublicKey.length < 6) {
ToastLogger.error(context, 'Device key unavailable');
return;
}
final gameId = List.generate(
8,
(_) => math.Random.secure().nextInt(16).toRadixString(16),
).join();
final starterKey6 = devicePublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final startMessage = TicTacToeMessageParser.encodeStart(
gameId: gameId,
starterKey6: starterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
await _sendToRecipient(
startMessage,
connectionProvider,
messagesProvider,
contactsProvider,
);
}
// ── Image sending ─────────────────────────────────────────────────────────── // ── Image sending ───────────────────────────────────────────────────────────
Future<void> _pickAndSendImage({ Future<void> _pickAndSendImage({
ImageSource source = ImageSource.gallery, ImageSource source = ImageSource.gallery,
}) async { }) async {
if (_isSendingImage) return; if (_isSendingImage) return;
final shouldContinue = await _confirmPublicChannelMediaSend('image');
if (!shouldContinue) return;
if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) { if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device'); ToastLogger.error(context, 'Not connected to device');
@@ -534,10 +587,14 @@ class _MessagesTabState extends State<MessagesTab> {
final isChannel = final isChannel =
_destinationType == _destinationType ==
MessageDestinationPreferences.destinationTypeChannel; MessageDestinationPreferences.destinationTypeChannel;
final channelIdx = isChannel
? (_selectedRecipient?.publicKey[1] ?? 0)
: null;
final recipient = _selectedRecipient;
final placeholder = Message( final placeholder = Message(
id: msgId, id: msgId,
messageType: isChannel ? MessageType.channel : MessageType.contact, messageType: isChannel ? MessageType.channel : MessageType.contact,
channelIdx: isChannel ? 0 : null, channelIdx: channelIdx,
senderPublicKeyPrefix: deviceKey.sublist(0, 6), senderPublicKeyPrefix: deviceKey.sublist(0, 6),
pathLen: 0, pathLen: 0,
textType: MessageTextType.plain, textType: MessageTextType.plain,
@@ -545,30 +602,37 @@ class _MessagesTabState extends State<MessagesTab> {
text: envelope.encode(), text: envelope.encode(),
receivedAt: DateTime.now(), receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: isChannel ? null : recipient?.publicKey,
); );
messagesProvider.addSentMessage(placeholder); messagesProvider.addSentMessage(placeholder);
// Send IE1 envelope via normal message path. // Send IE1 envelope via normal message path.
final envelopeText = envelope.encode(); final envelopeText = envelope.encode();
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) { if (isChannel) {
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
channelIdx: 0, channelIdx: channelIdx ?? 0,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
); );
} else if (_selectedRecipient != null) { } else if (recipient != null) {
final sent = await connectionProvider.sendTextMessage( final sent = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey, contactPublicKey: recipient.publicKey,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
contact: _selectedRecipient!, contact: recipient,
); );
if (!sent) { if (!sent) {
messagesProvider.markMessageFailed(msgId); messagesProvider.markMessageFailed(msgId);
if (!mounted) return; if (!mounted) return;
ToastLogger.error(context, 'Failed to announce image'); ToastLogger.error(context, 'Failed to announce image');
return;
} }
} else {
messagesProvider.markMessageFailed(msgId);
if (!mounted) return;
ToastLogger.error(context, 'No recipient selected');
return;
} }
debugPrint( debugPrint(
@@ -576,6 +640,22 @@ class _MessagesTabState extends State<MessagesTab> {
'${fragments.length} fragments, ${compressed.length}B, ' '${fragments.length} fragments, ${compressed.length}B, '
'chunk=${imageDataBytesPerFragment}B', 'chunk=${imageDataBytesPerFragment}B',
); );
// Push all fragments immediately for direct contacts.
// For channels, fragments are served on demand via IR1 fetch requests.
if (!isChannel && recipient != null) {
// Small delay so the IE1 envelope can propagate before fragments arrive.
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
final served = await imageProvider.serveSessionTo(
sessionId: sessionId,
requester: recipient,
);
debugPrint(
'📷 [Image] Pushed ${served ? fragments.length : 0} '
'fragments to ${recipient.advName}',
);
}
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st'); debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
if (!mounted) return; if (!mounted) return;
@@ -712,6 +792,17 @@ class _MessagesTabState extends State<MessagesTab> {
return; return;
} }
final shouldContinue = await _confirmPublicChannelMediaSend('voice');
if (!shouldContinue) {
if (mounted) {
setState(() {
_isSendingVoice = false;
_currentVoiceSessionId = null;
});
}
return;
}
try { try {
await _encodeAndSendAllPackets( await _encodeAndSendAllPackets(
chunks: chunks, chunks: chunks,
@@ -743,8 +834,6 @@ class _MessagesTabState extends State<MessagesTab> {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final voiceProvider = context.read<VoiceProvider>(); final voiceProvider = context.read<VoiceProvider>();
// Insert the chat placeholder before sending (so it appears immediately).
final msgId = 'voice_${sessionId}_sent'; final msgId = 'voice_${sessionId}_sent';
final devicePublicKey = connectionProvider.deviceInfo.publicKey; final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = final senderPublicKeyPrefix =
@@ -754,24 +843,8 @@ class _MessagesTabState extends State<MessagesTab> {
final isChannel = final isChannel =
_destinationType == _destinationType ==
MessageDestinationPreferences.destinationTypeChannel; MessageDestinationPreferences.destinationTypeChannel;
final sentMsg = Message( final recipient = _selectedRecipient;
id: msgId, final channelIdx = isChannel ? (recipient?.publicKey[1] ?? 0) : null;
messageType: (!isChannel && _selectedRecipient != null)
? MessageType.contact
: MessageType.channel,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
text: '',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sent,
isVoice: true,
voiceId: sessionId,
channelIdx: isChannel ? (_selectedRecipient?.publicKey[1] ?? 0) : null,
recipientPublicKey: _selectedRecipient?.publicKey,
);
messagesProvider.addSentMessage(sentMsg);
final encodedPackets = <VoicePacket>[]; final encodedPackets = <VoicePacket>[];
debugPrint( debugPrint(
@@ -831,32 +904,49 @@ class _MessagesTabState extends State<MessagesTab> {
); );
final envelopeText = envelope.encodeText(); final envelopeText = envelope.encodeText();
// Insert placeholder with real VE1 envelope text so technical details are populated.
final sentMsg = Message(
id: msgId,
messageType: (!isChannel && recipient != null)
? MessageType.contact
: MessageType.channel,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
text: envelopeText,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sent,
isVoice: true,
voiceId: sessionId,
channelIdx: channelIdx,
recipientPublicKey: isChannel ? null : recipient?.publicKey,
);
messagesProvider.addSentMessage(sentMsg);
try { try {
if (isChannel) { if (isChannel) {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
channelIdx: channelIdx, channelIdx: channelIdx ?? 0,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
); );
} else if (_selectedRecipient != null) { } else if (recipient != null) {
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey, contactPublicKey: recipient.publicKey,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
contact: _selectedRecipient, contact: recipient,
); );
if (!sentSuccessfully) { if (!sentSuccessfully) {
messagesProvider.markMessageFailed(msgId); messagesProvider.markMessageFailed(msgId);
return; return;
} }
} else { } else {
// Fallback to public channel if destination cannot be resolved. messagesProvider.markMessageFailed(msgId);
await connectionProvider.sendChannelMessage( if (!mounted) return;
channelIdx: 0, ToastLogger.error(context, 'No recipient selected');
text: envelopeText, return;
messageId: msgId,
);
} }
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [Voice] envelope send error: $e\n$st'); debugPrint('❌ [Voice] envelope send error: $e\n$st');
@@ -913,6 +1003,43 @@ class _MessagesTabState extends State<MessagesTab> {
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold; return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
} }
bool _isPublicChannelSelected() {
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
return false;
}
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
return channelIdx == 0;
}
Future<bool> _confirmPublicChannelMediaSend(String mediaType) async {
if (!_isPublicChannelSelected() || !mounted) return true;
final decision = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Send to Public Channel?'),
content: Text(
'You are about to send $mediaType to the Public Channel. '
'This is not advised because everyone on the mesh may receive it. '
'Choose a private or tagged channel unless this is what you want.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Send anyway'),
),
],
),
);
return decision ?? false;
}
// ── SAR dialog ───────────────────────────────────────────────────────────── // ── SAR dialog ─────────────────────────────────────────────────────────────
void _showSarDialog() { void _showSarDialog() {
@@ -999,6 +1126,15 @@ class _MessagesTabState extends State<MessagesTab> {
_pickAndSendImage(source: ImageSource.camera); _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();
},
),
], ],
), ),
); );
@@ -1257,13 +1393,20 @@ class _MessagesTabState extends State<MessagesTab> {
List<Message> filteredMessages; List<Message> filteredMessages;
// If public channel is selected, show ALL messages // If channel destination is selected, filter by selected channel.
if (_destinationType == if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel && MessageDestinationPreferences.destinationTypeChannel) {
_selectedRecipient == null) { final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
filteredMessages = allMessages; if (selectedChannelIdx == 0) {
// Public channel view keeps showing all messages (current app behavior).
filteredMessages = allMessages;
} else {
filteredMessages = allMessages
.where((message) => message.channelIdx == selectedChannelIdx)
.toList();
}
} }
// If a contact or room is selected, filter by recipient // If a contact or room is selected, filter by recipient/sender prefixes.
else if ((_destinationType == else if ((_destinationType ==
MessageDestinationPreferences.destinationTypeContact || MessageDestinationPreferences.destinationTypeContact ||
_destinationType == _destinationType ==

View File

@@ -621,6 +621,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Future<void> _clearMessages() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Messages'),
content: const Text(
'This will permanently delete all stored messages. Are you sure?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
final messagesProvider = Provider.of<MessagesProvider>(
context,
listen: false,
);
messagesProvider.clearMessages();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('All messages cleared'),
backgroundColor: Colors.orange,
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -661,6 +701,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.map_outlined), secondary: const Icon(Icons.map_outlined),
@@ -674,6 +727,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.contacts_outlined),
title: const Text('Disable Contacts'),
subtitle: const Text(
'Hide the contacts tab to simplify navigation',
),
value: !appProvider.isContactsEnabled,
onChanged: (value) async {
await appProvider.toggleContactsEnabled(!value);
},
),
),
ListTile( ListTile(
leading: const Icon(Icons.language), leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.language), title: Text(AppLocalizations.of(context)!.language),
@@ -681,6 +747,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(), onTap: () => _showLanguageDialog(),
), ),
ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text(
'Clear Messages',
style: TextStyle(color: Colors.red),
),
subtitle: const Text('Delete all stored message history'),
onTap: _clearMessages,
),
const Divider(), const Divider(),
// Voice Settings Section // Voice Settings Section

View File

@@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class MeshMapNode {
final int type;
final String name;
final String publicKey;
final double latitude;
final double longitude;
final int updatedAtMs;
const MeshMapNode({
required this.type,
required this.name,
required this.publicKey,
required this.latitude,
required this.longitude,
required this.updatedAtMs,
});
factory MeshMapNode.fromJson(Map<String, dynamic> json) {
return MeshMapNode(
type: (json['type'] as num?)?.toInt() ?? 0,
name: (json['name'] as String?)?.trim() ?? 'Unknown',
publicKey: ((json['public_key'] as String?) ?? '').toLowerCase(),
latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
updatedAtMs: (json['updated_at'] as num?)?.toInt() ?? 0,
);
}
}
class MeshMapNodesService {
static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2);
static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt;
static Future<List<MeshMapNode>> fetchNodes({bool forceRefresh = false}) async {
final now = DateTime.now();
if (!forceRefresh &&
_cachedNodes != null &&
_cachedAt != null &&
now.difference(_cachedAt!) < _cacheTtl) {
return _cachedNodes!;
}
final response = await http
.get(Uri.parse(_nodesEndpoint))
.timeout(const Duration(seconds: 12));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Map nodes API returned ${response.statusCode}');
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final nodesRaw = decoded['nodes'] as List<dynamic>? ?? const [];
final nodes = nodesRaw
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where((n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0)
.toList();
_cachedNodes = nodes;
_cachedAt = now;
return nodes;
}
}

View File

@@ -1,213 +1,155 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:nsd/nsd.dart'; import 'package:nsd/nsd.dart';
/// Discovered SSE server on the network /// Discovered MeshCore device on the network (TCP/WiFi)
class DiscoveredServer { class DiscoveredServer {
final String ipAddress; final String ipAddress;
final int port; final int port;
final int responseTime; // in milliseconds final int responseTime; // milliseconds
final String serverUrl;
DiscoveredServer({ const DiscoveredServer({
required this.ipAddress, required this.ipAddress,
required this.port, required this.port,
required this.responseTime, required this.responseTime,
}) : serverUrl = 'http://$ipAddress:$port'; });
@override @override
String toString() { String toString() => 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
return 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) =>
if (identical(this, other)) return true; other is DiscoveredServer &&
return other is DiscoveredServer && other.ipAddress == ipAddress &&
other.ipAddress == ipAddress && other.port == port;
other.port == port;
}
@override @override
int get hashCode => Object.hash(ipAddress, port); int get hashCode => Object.hash(ipAddress, port);
} }
/// Network Scanner Service /// Discovers MeshCore devices running the TCP/WiFi server (port 5000).
/// ///
/// Discovers SSE servers on the local network using Bonjour/mDNS. /// First tries mDNS/Bonjour (_meshcore._tcp), then falls back to a parallel
/// Falls back to port scanning (12929) if no services are discovered. /// TCP-connect port scan of the local /24 subnet.
/// Uses parallel scanning (20 IPs at once) for fast discovery.
class NetworkScannerService { class NetworkScannerService {
static const int defaultPort = 12929; static const int defaultPort = 5000;
static const String serviceType = '_meshcore-sse._tcp'; static const String serviceType = '_meshcore._tcp';
static const int parallelScans = 20; static const int parallelScans = 20;
static const Duration scanTimeout = Duration(seconds: 2); static const Duration connectTimeout = Duration(seconds: 2);
static const Duration bonjourTimeout = Duration(seconds: 5); static const Duration bonjourTimeout = Duration(seconds: 5);
Discovery? _activeDiscovery; Discovery? _activeDiscovery;
/// Callback for when a server is discovered
Function(DiscoveredServer)? onServerDiscovered; Function(DiscoveredServer)? onServerDiscovered;
/// Callback for scan progress updates
Function(int scanned, int total)? onProgressUpdate; Function(int scanned, int total)? onProgressUpdate;
bool _isScanning = false; bool _isScanning = false;
bool get isScanning => _isScanning; bool get isScanning => _isScanning;
/// Cached discovered servers from the last scan
List<DiscoveredServer> _cachedServers = []; List<DiscoveredServer> _cachedServers = [];
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers); List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
/// Whether we have cached results from a previous scan
bool get hasCachedResults => _cachedServers.isNotEmpty; bool get hasCachedResults => _cachedServers.isNotEmpty;
/// Get all local IP addresses // ── Helpers ────────────────────────────────────────────────────────────────
Future<Set<String>> _getLocalIpAddresses() async {
final Set<String> localIps = {};
Future<Set<String>> _getLocalIpAddresses() async {
final ips = <String>{};
try { try {
final interfaces = await NetworkInterface.list(); for (final iface in await NetworkInterface.list()) {
for (final interface in interfaces) { for (final addr in iface.addresses) {
for (final addr in interface.addresses) { if (addr.type == InternetAddressType.IPv4) ips.add(addr.address);
if (addr.type == InternetAddressType.IPv4) {
localIps.add(addr.address);
}
} }
} }
} catch (e) { } catch (e) {
debugPrint('❌ [NetworkScanner] Error getting local IPs: $e'); debugPrint('❌ [NetworkScanner] Error getting local IPs: $e');
} }
return ips;
return localIps;
} }
/// Get local network IP range to scan
Future<List<String>> _getLocalNetworkRange() async { Future<List<String>> _getLocalNetworkRange() async {
final List<String> ips = [];
try { try {
// Get all network interfaces for (final iface in await NetworkInterface.list()) {
final interfaces = await NetworkInterface.list(); for (final addr in iface.addresses) {
for (final interface in interfaces) {
for (final addr in interface.addresses) {
// Only scan IPv4 addresses that are not loopback
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) { if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
final ip = addr.address; final parts = addr.address.split('.');
final parts = ip.split('.');
if (parts.length == 4) { if (parts.length == 4) {
// Generate range for the same subnet (e.g., 192.168.1.1-254)
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}'; final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
debugPrint('📡 [NetworkScanner] Scanning subnet $subnet.0/24');
// Scan from .1 to .254 (skip .0 and .255) return [for (int i = 1; i <= 254; i++) '$subnet.$i'];
for (int i = 1; i <= 254; i++) {
ips.add('$subnet.$i');
}
debugPrint('📡 [NetworkScanner] Will scan subnet: $subnet.0/24');
// Only scan first viable subnet
return ips;
} }
} }
} }
} }
} catch (e) { } catch (e) {
debugPrint('❌ [NetworkScanner] Error getting network interfaces: $e'); debugPrint('❌ [NetworkScanner] Error getting network range: $e');
} }
return [];
return ips;
} }
/// Check if an IP has an SSE server running /// Try a raw TCP connect to check if the MeshCore TCP server is listening.
Future<DiscoveredServer?> _checkServer(String ip, int port) async { Future<DiscoveredServer?> _checkDevice(String ip, int port) async {
final sw = Stopwatch()..start();
Socket? socket;
try { try {
final stopwatch = Stopwatch()..start(); socket = await Socket.connect(
final url = Uri.parse('http://$ip:$port/api/status'); ip,
port,
final response = await http.get(url).timeout(scanTimeout); timeout: connectTimeout,
);
stopwatch.stop(); sw.stop();
debugPrint(
if (response.statusCode == 200) { '✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)');
debugPrint('✅ [NetworkScanner] Found server at $ip:$port (${stopwatch.elapsedMilliseconds}ms)'); return DiscoveredServer(
ipAddress: ip,
return DiscoveredServer( port: port,
ipAddress: ip, responseTime: sw.elapsedMilliseconds,
port: port, );
responseTime: stopwatch.elapsedMilliseconds,
);
}
} on TimeoutException {
// Timeout - server not responding, ignore
} on SocketException { } on SocketException {
// Connection refused - no server at this IP, ignore // Connection refused or timed out — no device here
} catch (e) { } catch (e) {
// Other errors - ignore debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port$e');
debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port - $e'); } finally {
socket?.destroy();
} }
return null; return null;
} }
/// Discover servers using Bonjour/mDNS // ── mDNS discovery ─────────────────────────────────────────────────────────
Future<List<DiscoveredServer>> _discoverViaBonjourAsync({int? port}) async {
Future<List<DiscoveredServer>> _discoverViaMdns({int? port}) async {
final scanPort = port ?? defaultPort; final scanPort = port ?? defaultPort;
final List<DiscoveredServer> discoveredServers = []; final found = <DiscoveredServer>[];
try { try {
debugPrint('🔍 [NetworkScanner] Starting Bonjour discovery for $serviceType...'); debugPrint('🔍 [NetworkScanner] mDNS discovery for $serviceType...');
// Get local IP addresses to filter out
final localIps = await _getLocalIpAddresses(); final localIps = await _getLocalIpAddresses();
debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}');
// Start discovery with IP lookup
_activeDiscovery = await startDiscovery( _activeDiscovery = await startDiscovery(
serviceType, serviceType,
ipLookupType: IpLookupType.any, ipLookupType: IpLookupType.any,
); );
// Wait for discovery to find services
await Future.delayed(bonjourTimeout); await Future.delayed(bonjourTimeout);
// Process discovered services for (final service in _activeDiscovery?.services ?? []) {
final services = _activeDiscovery?.services ?? []; for (final addr in service.addresses ?? []) {
debugPrint('📡 [NetworkScanner] Bonjour found ${services.length} services'); if (localIps.contains(addr.address)) continue;
final result =
for (final service in services) { await _checkDevice(addr.address, service.port ?? scanPort);
if (service.addresses != null && service.addresses!.isNotEmpty) { if (result != null) {
for (final address in service.addresses!) { found.add(result);
// Skip if this is a local IP address onServerDiscovered?.call(result);
if (localIps.contains(address.address)) {
debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${address.address}');
continue;
}
// Verify service is actually reachable
final result = await _checkServer(
address.address,
service.port ?? scanPort,
);
if (result != null) {
discoveredServers.add(result);
onServerDiscovered?.call(result);
}
} }
} }
} }
// Stop discovery
await stopDiscovery(_activeDiscovery!); await stopDiscovery(_activeDiscovery!);
_activeDiscovery = null; _activeDiscovery = null;
debugPrint(
debugPrint('✅ [NetworkScanner] Bonjour discovery complete. Found ${discoveredServers.length} servers.'); '✅ [NetworkScanner] mDNS done. Found ${found.length} devices.');
} catch (e) { } catch (e) {
debugPrint('⚠️ [NetworkScanner] Bonjour discovery failed: $e'); debugPrint('⚠️ [NetworkScanner] mDNS failed: $e');
if (_activeDiscovery != null) { if (_activeDiscovery != null) {
try { try {
await stopDiscovery(_activeDiscovery!); await stopDiscovery(_activeDiscovery!);
@@ -216,132 +158,70 @@ class NetworkScannerService {
} }
} }
return discoveredServers; return found;
} }
/// Scan the local network for SSE servers // ── Port scan fallback ─────────────────────────────────────────────────────
/// First tries Bonjour/mDNS, then falls back to port scanning if nothing found
Future<List<DiscoveredServer>> scan({int? port}) async {
if (_isScanning) {
debugPrint('⚠️ [NetworkScanner] Scan already in progress');
return [];
}
_isScanning = true; Future<List<DiscoveredServer>> _scanByPort({int? port}) async {
final scanPort = port ?? defaultPort; final scanPort = port ?? defaultPort;
List<DiscoveredServer> discoveredServers = []; final found = <DiscoveredServer>[];
try { final localIps = await _getLocalIpAddresses();
// Try Bonjour/mDNS discovery first final ips = await _getLocalNetworkRange();
discoveredServers = await _discoverViaBonjourAsync(port: scanPort); if (ips.isEmpty) return [];
// Fall back to port scanning if Bonjour found nothing debugPrint(
if (discoveredServers.isEmpty) { '🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort');
debugPrint('🔍 [NetworkScanner] Bonjour found nothing, falling back to port scanning...');
discoveredServers = await _scanByPortAsync(port: scanPort); int scanned = 0;
for (int i = 0; i < ips.length; i += parallelScans) {
final batch = ips.skip(i).take(parallelScans).toList();
final results =
await Future.wait(batch.map((ip) => _checkDevice(ip, scanPort)));
for (final result in results) {
if (result != null && !localIps.contains(result.ipAddress)) {
found.add(result);
onServerDiscovered?.call(result);
}
} }
// Cache the results scanned += batch.length;
_cachedServers = discoveredServers; onProgressUpdate?.call(scanned, ips.length);
} catch (e) { }
debugPrint('❌ [NetworkScanner] Scan error: $e');
return found;
}
// ── Public API ─────────────────────────────────────────────────────────────
/// Scan for MeshCore WiFi devices. Tries mDNS first, falls back to port scan.
Future<List<DiscoveredServer>> scan({int? port}) async {
if (_isScanning) return [];
_isScanning = true;
try {
var found = await _discoverViaMdns(port: port);
if (found.isEmpty) {
debugPrint(
'🔍 [NetworkScanner] mDNS found nothing, falling back to port scan');
found = await _scanByPort(port: port);
}
_cachedServers = found;
return found;
} finally { } finally {
_isScanning = false; _isScanning = false;
} }
return discoveredServers;
} }
/// Fallback port scanning method /// Verify a previously discovered device is still reachable.
Future<List<DiscoveredServer>> _scanByPortAsync({int? port}) async {
final scanPort = port ?? defaultPort;
final List<DiscoveredServer> discoveredServers = [];
try {
debugPrint('🔍 [NetworkScanner] Starting port scan on port $scanPort...');
// Get local IP addresses to filter out
final localIps = await _getLocalIpAddresses();
debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}');
final ips = await _getLocalNetworkRange();
if (ips.isEmpty) {
debugPrint('⚠️ [NetworkScanner] No network interfaces found');
return [];
}
debugPrint('📊 [NetworkScanner] Scanning ${ips.length} IPs with $parallelScans parallel connections');
int scannedCount = 0;
// Scan in batches of 20 parallel connections
for (int i = 0; i < ips.length; i += parallelScans) {
final batch = ips.skip(i).take(parallelScans).toList();
// Scan batch in parallel
final futures = batch.map((ip) => _checkServer(ip, scanPort)).toList();
final results = await Future.wait(futures);
// Collect discovered servers (excluding local IPs)
for (int j = 0; j < results.length; j++) {
final result = results[j];
if (result != null) {
// Skip if this is a local IP address
if (localIps.contains(result.ipAddress)) {
debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${result.ipAddress}');
continue;
}
discoveredServers.add(result);
onServerDiscovered?.call(result);
}
}
scannedCount += batch.length;
onProgressUpdate?.call(scannedCount, ips.length);
}
debugPrint('✅ [NetworkScanner] Port scan complete. Found ${discoveredServers.length} servers.');
} catch (e) {
debugPrint('❌ [NetworkScanner] Port scan error: $e');
}
return discoveredServers;
}
/// Clear cached results (useful for forcing a fresh scan)
void clearCache() {
_cachedServers = [];
debugPrint('🗑️ [NetworkScanner] Cache cleared');
}
/// Stop ongoing scan
void stopScan() {
if (_isScanning) {
debugPrint('🛑 [NetworkScanner] Stopping scan...');
_isScanning = false;
}
}
/// Verify that a previously discovered server is still available
/// Returns true if server is reachable, false otherwise
Future<bool> verifyServer(DiscoveredServer server) async { Future<bool> verifyServer(DiscoveredServer server) async {
try { final result = await _checkDevice(server.ipAddress, server.port);
debugPrint('🔍 [NetworkScanner] Verifying server at ${server.ipAddress}:${server.port}...'); return result != null;
final result = await _checkServer(server.ipAddress, server.port);
if (result != null) {
debugPrint('✅ [NetworkScanner] Server verified at ${server.ipAddress}:${server.port}');
return true;
} else {
debugPrint('❌ [NetworkScanner] Server no longer available at ${server.ipAddress}:${server.port}');
return false;
}
} catch (e) {
debugPrint('❌ [NetworkScanner] Server verification failed: $e');
return false;
}
} }
void clearCache() => _cachedServers = [];
void stopScan() => _isScanning = false;
} }

View File

@@ -0,0 +1,146 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../models/ble_packet_log.dart';
class StoredPacketCapture {
final DateTime timestamp;
final String direction;
final int? responseCode;
final String? description;
final String rawBase64;
final int rawSize;
final double? snrDb;
final int? rssiDbm;
const StoredPacketCapture({
required this.timestamp,
required this.direction,
required this.responseCode,
required this.description,
required this.rawBase64,
required this.rawSize,
required this.snrDb,
required this.rssiDbm,
});
Map<String, dynamic> toJson() {
return {
'ts': timestamp.millisecondsSinceEpoch,
'dir': direction,
'code': responseCode,
'desc': description,
'raw': rawBase64,
'size': rawSize,
'snr': snrDb,
'rssi': rssiDbm,
};
}
static StoredPacketCapture fromJson(Map<String, dynamic> json) {
return StoredPacketCapture(
timestamp: DateTime.fromMillisecondsSinceEpoch((json['ts'] as num).toInt()),
direction: (json['dir'] as String?) ?? 'rx',
responseCode: (json['code'] as num?)?.toInt(),
description: json['desc'] as String?,
rawBase64: (json['raw'] as String?) ?? '',
rawSize: (json['size'] as num?)?.toInt() ?? 0,
snrDb: (json['snr'] as num?)?.toDouble(),
rssiDbm: (json['rssi'] as num?)?.toInt(),
);
}
}
/// Durable storage for raw BLE packets so future features can consume
/// historical packet bytes across app restarts.
class PacketCaptureStorageService {
static const String _fileName = 'packet_captures.jsonl';
static const int _maxStoredPackets = 20000;
File? _file;
Future<File> _resolveFile() async {
if (_file != null) return _file!;
final dir = await getApplicationSupportDirectory();
final file = File('${dir.path}/$_fileName');
if (!await file.exists()) {
await file.create(recursive: true);
}
_file = file;
return file;
}
Future<void> appendLogs(List<BlePacketLog> logs) async {
if (logs.isEmpty) return;
try {
final file = await _resolveFile();
final sink = file.openWrite(mode: FileMode.append);
for (final log in logs) {
final row = StoredPacketCapture(
timestamp: log.timestamp,
direction: log.direction.name,
responseCode: log.responseCode,
description: log.description,
rawBase64: base64Encode(log.rawData),
rawSize: log.rawData.length,
snrDb: log.logRxDataInfo?.snrDb,
rssiDbm: log.logRxDataInfo?.rssiDbm,
);
sink.writeln(jsonEncode(row.toJson()));
}
await sink.flush();
await sink.close();
await _pruneIfNeeded(file);
} catch (e) {
debugPrint('❌ [PacketCaptureStorage] Failed to append logs: $e');
}
}
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
try {
final file = await _resolveFile();
if (!await file.exists()) return const [];
final lines = await file.readAsLines();
if (lines.isEmpty) return const [];
final start = lines.length > limit ? lines.length - limit : 0;
return lines
.sublist(start)
.where((l) => l.trim().isNotEmpty)
.map((l) => StoredPacketCapture.fromJson(jsonDecode(l) as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('❌ [PacketCaptureStorage] Failed to load recent logs: $e');
return const [];
}
}
Future<int> count() async {
try {
final file = await _resolveFile();
if (!await file.exists()) return 0;
final lines = await file.readAsLines();
return lines.where((l) => l.trim().isNotEmpty).length;
} catch (_) {
return 0;
}
}
Future<void> clear() async {
try {
final file = await _resolveFile();
if (await file.exists()) {
await file.writeAsString('');
}
} catch (e) {
debugPrint('❌ [PacketCaptureStorage] Failed to clear logs: $e');
}
}
Future<void> _pruneIfNeeded(File file) async {
final lines = await file.readAsLines();
if (lines.length <= _maxStoredPackets) return;
final keep = lines.sublist(lines.length - _maxStoredPackets);
await file.writeAsString('${keep.join('\n')}\n');
}
}

View File

@@ -1,667 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart' as io_client;
import '../models/message.dart';
import '../models/contact.dart';
import 'package:latlong2/latlong.dart';
/// SSE Client Service
///
/// Connects to a remote SSE server to receive messages and contacts in real-time.
/// This enables multiple app instances to share a single MeshCore BLE device
/// without direct BLE connections.
class SseClientService {
String? _serverUrl;
String? _authToken;
http.Client? _httpClient;
StreamSubscription? _messageSubscription;
StreamSubscription? _contactSubscription;
bool _isConnected = false;
bool _isConnecting = false;
bool _hasConnectedBefore =
false; // Track if we've ever successfully connected
Timer? _reconnectTimer;
Timer? _heartbeatTimer;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 10;
static const Duration _reconnectDelay = Duration(seconds: 5);
/// Callback for when a message is received
Function(Message)? onMessageReceived;
/// Callback for when a contact is received
Function(Contact)? onContactReceived;
/// Callback for connection state changes
Function(bool isConnected)? onConnectionStateChanged;
/// Callback for errors
Function(String error)? onError;
/// Check if client is connected
bool get isConnected => _isConnected;
/// Check if client is currently connecting
bool get isConnecting => _isConnecting;
/// Get current reconnection attempt number
int get reconnectionAttempts => _reconnectAttempts;
/// Get maximum reconnection attempts
int get maxReconnectionAttempts => _maxReconnectAttempts;
/// Get server URL
String? get serverUrl => _serverUrl;
/// Connect to SSE server
Future<void> connect({required String serverUrl, String? authToken}) async {
if (_isConnected) {
debugPrint('⚠️ [SseClient] Already connected');
return;
}
_serverUrl = serverUrl;
_authToken = authToken;
_isConnecting = true;
debugPrint(
'🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)',
);
try {
// Create a new HTTP client with custom configuration for SSE streaming
// Using IOClient with custom HttpClient for better control over connection settings
final ioHttpClient = io.HttpClient();
ioHttpClient.connectionTimeout = const Duration(seconds: 10);
ioHttpClient.idleTimeout = const Duration(
hours: 1,
); // Keep SSE connections alive
_httpClient = io_client.IOClient(ioHttpClient);
// Test server availability
await _checkServerStatus();
// Fetch initial message history
await _fetchMessageHistory();
// Fetch initial contact list
await _fetchContacts();
// Subscribe to SSE streams
debugPrint('🔗 [SseClient] Subscribing to message stream...');
debugPrint(
'🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}',
);
await _subscribeToMessages();
debugPrint('🔗 [SseClient] Subscribing to contact stream...');
await _subscribeToContacts();
debugPrint('🔗 [SseClient] All subscriptions complete');
_isConnected = true;
_isConnecting = false;
_hasConnectedBefore = true; // Mark that we've successfully connected
_reconnectAttempts = 0;
debugPrint('🔔 [SseClient] Calling onConnectionStateChanged(true)');
onConnectionStateChanged?.call(true);
// Start heartbeat to detect connection loss
_startHeartbeat();
debugPrint('✅ [SseClient] Connected successfully');
} catch (e) {
_isConnecting = false;
_httpClient?.close();
_httpClient = null;
debugPrint('❌ [SseClient] Connection failed: $e');
onError?.call('Connection failed: $e');
// Only auto-reconnect if we've successfully connected before
// Initial connection failures should be handled by the user
if (_hasConnectedBefore) {
_scheduleReconnect();
}
}
}
/// Disconnect from SSE server
Future<void> disconnect() async {
debugPrint('🔌 [SseClient] Disconnecting...');
_isConnected = false;
_isConnecting = false;
_hasConnectedBefore = false; // Reset on manual disconnect
_reconnectTimer?.cancel();
_heartbeatTimer?.cancel();
await _messageSubscription?.cancel();
await _contactSubscription?.cancel();
_httpClient?.close();
_serverUrl = null;
_authToken = null;
_httpClient = null;
onConnectionStateChanged?.call(false);
debugPrint('✅ [SseClient] Disconnected');
}
/// Check server status
Future<void> _checkServerStatus() async {
final url = Uri.parse('$_serverUrl/api/status');
try {
final response = await http
.get(url, headers: _getHeaders())
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) {
throw Exception('Server returned ${response.statusCode}');
}
final data = jsonDecode(response.body);
debugPrint('📊 [SseClient] Server status: ${data['status']}');
debugPrint(' Connected clients: ${data['connectedClients']}');
debugPrint(' Messages: ${data['messageCount']}');
debugPrint(' Contacts: ${data['contactCount']}');
} catch (e) {
// Wrap the error with more user-friendly message
throw Exception(_formatConnectionError(e));
}
}
/// Format connection error to be more user-friendly
String _formatConnectionError(dynamic error) {
final errorStr = error.toString();
// Extract the actual server URL being connected to
final serverUri = Uri.tryParse(_serverUrl ?? '');
final host = serverUri?.host ?? 'unknown';
final port = serverUri?.port ?? 0;
if (errorStr.contains('Connection refused')) {
return 'Server not available at $host:$port. The server may be offline or not running.';
} else if (errorStr.contains('TimeoutException') ||
errorStr.contains('timed out')) {
return 'Connection to $host:$port timed out. Check your network connection.';
} else if (errorStr.contains('SocketException')) {
return 'Network error connecting to $host:$port. Check your network connection.';
} else if (errorStr.contains('Failed host lookup')) {
return 'Could not resolve hostname: $host';
}
// Return the original error if we can't make it more user-friendly
return errorStr;
}
/// Fetch message history on connect
Future<void> _fetchMessageHistory() async {
try {
final url = Uri.parse('$_serverUrl/api/messages/history');
final response = await http
.get(url, headers: _getHeaders())
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception(
'Failed to fetch message history: ${response.statusCode}',
);
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final messages = data['messages'] as List;
debugPrint(
'📥 [SseClient] Received ${messages.length} messages from history',
);
for (final msgJson in messages) {
try {
final message = _messageFromJson(msgJson);
onMessageReceived?.call(message);
} catch (e) {
debugPrint('⚠️ [SseClient] Failed to parse message: $e');
}
}
} catch (e) {
debugPrint('❌ [SseClient] Error fetching message history: $e');
// Don't throw - continue with connection even if history fetch fails
}
}
/// Fetch contacts on connect
Future<void> _fetchContacts() async {
try {
final url = Uri.parse('$_serverUrl/api/contacts');
final response = await http
.get(url, headers: _getHeaders())
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Failed to fetch contacts: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final contacts = data['contacts'] as List;
debugPrint('📥 [SseClient] Received ${contacts.length} contacts');
for (final contactJson in contacts) {
try {
final contact = _contactFromJson(contactJson);
onContactReceived?.call(contact);
} catch (e) {
debugPrint('⚠️ [SseClient] Failed to parse contact: $e');
}
}
} catch (e) {
debugPrint('❌ [SseClient] Error fetching contacts: $e');
// Don't throw - continue with connection even if contacts fetch fails
}
}
/// Subscribe to SSE message stream
Future<void> _subscribeToMessages() async {
try {
if (_httpClient == null) {
throw Exception('HTTP client not initialized');
}
debugPrint('📡 [SseClient] Creating message stream request...');
final url = Uri.parse('$_serverUrl/sse/messages');
final request = http.Request('GET', url);
request.headers.addAll(_getHeaders());
request.headers['Accept'] = 'text/event-stream';
request.headers['Cache-Control'] = 'no-cache';
debugPrint('📡 [SseClient] Sending message stream request to $url');
debugPrint('📡 [SseClient] Request headers: ${request.headers}');
final streamedResponse = await _httpClient!
.send(request)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('❌ [SseClient] Timeout waiting for response headers');
throw TimeoutException(
'Message stream connection timed out after 10 seconds',
);
},
);
debugPrint(
'📡 [SseClient] Received response with status: ${streamedResponse.statusCode}',
);
debugPrint(
'📡 [SseClient] Response headers: ${streamedResponse.headers}',
);
debugPrint(
'📡 [SseClient] Response content length: ${streamedResponse.contentLength}',
);
debugPrint(
'📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}',
);
if (streamedResponse.statusCode != 200) {
throw Exception(
'SSE messages subscription failed: ${streamedResponse.statusCode}',
);
}
debugPrint(
'📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}',
);
debugPrint('📡 [SseClient] Setting up stream listener...');
_messageSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received line: "$line"');
_handleSseLine(line, 'message');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Message stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint(
'⚠️ [SseClient] Message stream closed (onDone called)',
);
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Message stream listener set up successfully');
} catch (e) {
debugPrint('❌ [SseClient] Error subscribing to message stream: $e');
rethrow;
}
}
/// Subscribe to SSE contact stream
Future<void> _subscribeToContacts() async {
try {
if (_httpClient == null) {
throw Exception('HTTP client not initialized');
}
debugPrint('📡 [SseClient] Creating contact stream request...');
final url = Uri.parse('$_serverUrl/sse/contacts');
final request = http.Request('GET', url);
request.headers.addAll(_getHeaders());
request.headers['Accept'] = 'text/event-stream';
request.headers['Cache-Control'] = 'no-cache';
debugPrint('📡 [SseClient] Sending contact stream request to $url');
final streamedResponse = await _httpClient!
.send(request)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException(
'Contact stream connection timed out after 10 seconds',
);
},
);
if (streamedResponse.statusCode != 200) {
throw Exception(
'SSE contacts subscription failed: ${streamedResponse.statusCode}',
);
}
debugPrint(
'📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}',
);
debugPrint('📡 [SseClient] Setting up contact stream listener...');
_contactSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received contact line: "$line"');
_handleSseLine(line, 'contact');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Contact stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint(
'⚠️ [SseClient] Contact stream closed (onDone called)',
);
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Contact stream listener set up successfully');
} catch (e) {
debugPrint('❌ [SseClient] Error subscribing to contact stream: $e');
rethrow;
}
}
/// Handle SSE line
String _eventType = '';
void _handleSseLine(String line, String streamType) {
if (line.isEmpty) {
// Event complete, reset
_eventType = '';
return;
}
if (line.startsWith('event:')) {
_eventType = line.substring(6).trim();
} else if (line.startsWith('data:')) {
final jsonData = line.substring(5).trim();
try {
final data = jsonDecode(jsonData) as Map<String, dynamic>;
if (streamType == 'message' && _eventType == 'message') {
final message = _messageFromJson(data);
onMessageReceived?.call(message);
} else if (streamType == 'contact' && _eventType == 'contact') {
final contact = _contactFromJson(data);
onContactReceived?.call(contact);
}
} catch (e) {
debugPrint('⚠️ [SseClient] Failed to parse SSE data: $e');
}
}
}
/// Handle disconnect
void _handleDisconnect() {
if (!_isConnected) return;
_isConnected = false;
onConnectionStateChanged?.call(false);
_scheduleReconnect();
}
/// Schedule reconnection attempt
void _scheduleReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
debugPrint('❌ [SseClient] Max reconnection attempts reached');
onError?.call('Max reconnection attempts reached');
return;
}
_reconnectAttempts++;
final delay = _reconnectDelay * _reconnectAttempts;
debugPrint(
'🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s',
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
if (_serverUrl != null) {
connect(serverUrl: _serverUrl!, authToken: _authToken);
}
});
}
/// Start heartbeat to detect connection loss
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) async {
try {
await _checkServerStatus();
} catch (e) {
debugPrint('⚠️ [SseClient] Heartbeat failed: $e');
_handleDisconnect();
}
});
}
/// Send message to server
Future<bool> sendMessage({
required String recipientPublicKey,
required String text,
}) async {
if (!_isConnected || _serverUrl == null) {
throw Exception('Not connected to server');
}
try {
final url = Uri.parse('$_serverUrl/api/messages');
final response = await http
.post(
url,
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
body: jsonEncode({
'recipientPublicKey': recipientPublicKey,
'text': text,
}),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send message failed: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data['success'] as bool? ?? false;
} catch (e) {
debugPrint('❌ [SseClient] Error sending message: $e');
rethrow;
}
}
/// Send channel message to server
Future<void> sendChannelMessage({
required int channelIdx,
required String text,
}) async {
if (!_isConnected || _serverUrl == null) {
throw Exception('Not connected to server');
}
try {
final url = Uri.parse('$_serverUrl/api/messages/channel');
final response = await http
.post(
url,
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
body: jsonEncode({'channelIdx': channelIdx, 'text': text}),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send channel message failed: ${response.statusCode}');
}
} catch (e) {
debugPrint('❌ [SseClient] Error sending channel message: $e');
rethrow;
}
}
/// Request contact sync
Future<void> syncContacts() async {
if (!_isConnected || _serverUrl == null) {
throw Exception('Not connected to server');
}
try {
final url = Uri.parse('$_serverUrl/api/contacts/sync');
final response = await http
.post(url, headers: _getHeaders())
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Contact sync failed: ${response.statusCode}');
}
debugPrint('✅ [SseClient] Contact sync requested');
} catch (e) {
debugPrint('❌ [SseClient] Error syncing contacts: $e');
rethrow;
}
}
/// Get headers for HTTP requests
Map<String, String> _getHeaders() {
final headers = <String, String>{};
if (_authToken != null) {
headers['Authorization'] = 'Bearer $_authToken';
}
return headers;
}
/// Convert JSON to Message
Message _messageFromJson(Map<String, dynamic> json) {
return Message(
id: json['id'] as String,
messageType: MessageType.values.firstWhere(
(e) => e.name == json['messageType'],
orElse: () => MessageType.contact,
),
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
? Uint8List.fromList(
(json['senderPublicKeyPrefix'] as List).cast<int>(),
)
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String,
isSarMarker: json['isSarMarker'] as bool? ?? false,
sarGpsCoordinates: json['sarGpsCoordinates'] != null
? LatLng(
(json['sarGpsCoordinates']['latitude'] as num).toDouble(),
(json['sarGpsCoordinates']['longitude'] as num).toDouble(),
)
: null,
sarNotes: json['sarNotes'] as String?,
sarCustomEmoji: json['sarCustomEmoji'] as String?,
sarColorIndex: json['sarColorIndex'] as int?,
receivedAt: DateTime.parse(json['receivedAt'] as String),
senderName: json['senderName'] as String?,
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
(e) => e.name == json['deliveryStatus'],
orElse: () => MessageDeliveryStatus.received,
),
expectedAckTag: json['expectedAckTag'] as int?,
suggestedTimeoutMs: json['suggestedTimeoutMs'] as int?,
roundTripTimeMs: json['roundTripTimeMs'] as int?,
deliveredAt: json['deliveredAt'] != null
? DateTime.parse(json['deliveredAt'] as String)
: null,
recipientPublicKey: json['recipientPublicKey'] != null
? Uint8List.fromList((json['recipientPublicKey'] as List).cast<int>())
: null,
retryAttempt: json['retryAttempt'] as int? ?? 0,
lastRetryAt: json['lastRetryAt'] != null
? DateTime.parse(json['lastRetryAt'] as String)
: null,
usedFloodFallback: json['usedFloodFallback'] as bool? ?? false,
isRead: json['isRead'] as bool? ?? false,
echoCount: json['echoCount'] as int? ?? 0,
firstEchoAt: json['firstEchoAt'] != null
? DateTime.parse(json['firstEchoAt'] as String)
: null,
lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?,
lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?,
lastEchoAt: json['lastEchoAt'] != null
? DateTime.parse(json['lastEchoAt'] as String)
: null,
isDrawing: json['isDrawing'] as bool? ?? false,
drawingId: json['drawingId'] as String?,
);
}
/// Convert JSON to Contact
Contact _contactFromJson(Map<String, dynamic> json) {
return Contact(
publicKey: Uint8List.fromList((json['publicKey'] as List).cast<int>()),
type: ContactType.fromValue(json['type'] as int),
flags: json['flags'] as int,
outPathLen: json['outPathLen'] as int,
outPath: Uint8List.fromList((json['outPath'] as List).cast<int>()),
advName: json['advName'] as String,
lastAdvert: json['lastAdvert'] as int,
advLat: json['advLat'] as int,
advLon: json['advLon'] as int,
lastMod: json['lastMod'] as int,
);
}
/// Dispose resources
void dispose() {
disconnect();
}
}

View File

@@ -5,16 +5,19 @@ import '../models/update_info.dart';
import 'build_info_service.dart'; import 'build_info_service.dart';
/// Service for checking if a new app version is available /// Service for checking if a new app version is available
/// Compares current build's commit hash with latest manifest from server /// Compares current build's commit hash with latest GitHub release
class UpdateCheckerService { class UpdateCheckerService {
static final UpdateCheckerService _instance = UpdateCheckerService._internal(); static final UpdateCheckerService _instance =
UpdateCheckerService._internal();
factory UpdateCheckerService() => _instance; factory UpdateCheckerService() => _instance;
UpdateCheckerService._internal(); UpdateCheckerService._internal();
final BuildInfoService _buildInfoService = BuildInfoService(); final BuildInfoService _buildInfoService = BuildInfoService();
// Manifest URL for the latest unstable build static const String _repoOwner = 'dz0ny';
static const String _manifestUrl = 'https://meshcore-sar.dz0ny.dev/unstable/latest/manifest.json'; static const String _repoName = 'meshcore-sar';
static const String _latestReleaseUrl =
'https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest';
/// Check if an update is available /// Check if an update is available
/// Returns UpdateInfo with availability status and download URL if available /// Returns UpdateInfo with availability status and download URL if available
@@ -25,61 +28,78 @@ class UpdateCheckerService {
// Skip check for dev builds (local development) // Skip check for dev builds (local development)
if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') { if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') {
debugPrint('[UpdateChecker] Skipping update check for dev/unknown build'); debugPrint(
'[UpdateChecker] Skipping update check for dev/unknown build',
);
return UpdateInfo.noUpdate(currentCommitHash); return UpdateInfo.noUpdate(currentCommitHash);
} }
debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash'); debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash');
debugPrint('[UpdateChecker] Fetching latest manifest from: $_manifestUrl'); debugPrint(
'[UpdateChecker] Fetching latest release from: $_latestReleaseUrl',
// Fetch manifest from server
final response = await http.get(
Uri.parse(_manifestUrl),
headers: {'Accept': 'application/json'},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('[UpdateChecker] Manifest fetch timed out');
throw Exception('Manifest fetch timed out');
},
); );
// Fetch latest release from GitHub
final response = await http
.get(
Uri.parse(_latestReleaseUrl),
headers: {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('[UpdateChecker] Latest release fetch timed out');
throw Exception('Latest release fetch timed out');
},
);
if (response.statusCode != 200) { if (response.statusCode != 200) {
debugPrint('[UpdateChecker] Failed to fetch manifest: ${response.statusCode}'); debugPrint(
'[UpdateChecker] Failed to fetch release: ${response.statusCode}',
);
return UpdateInfo.noUpdate(currentCommitHash); return UpdateInfo.noUpdate(currentCommitHash);
} }
// Parse manifest JSON // Parse release JSON
final Map<String, dynamic> manifest = json.decode(response.body); final Map<String, dynamic> release = json.decode(response.body);
final latestCommitHash = manifest['commit'] as String?; final tagName = release['tag_name'] as String?;
final commitShort = manifest['commit_short'] as String?; final targetCommitish = release['target_commitish'] as String?;
final buildId = manifest['build_id'] as String?; final publishedAt = release['published_at'] as String?;
final timestamp = manifest['timestamp'] as String?; final assets = release['assets'] as List<dynamic>?;
final artifacts = manifest['artifacts'] as List<dynamic>?;
if (latestCommitHash == null || commitShort == null) { if (tagName == null) {
debugPrint('[UpdateChecker] Invalid manifest: missing commit information'); debugPrint('[UpdateChecker] Invalid release: missing tag_name');
return UpdateInfo.noUpdate(currentCommitHash); return UpdateInfo.noUpdate(currentCommitHash);
} }
debugPrint('[UpdateChecker] Latest commit hash: $latestCommitHash'); debugPrint('[UpdateChecker] Latest release tag: $tagName');
debugPrint('[UpdateChecker] Latest commit short: $commitShort'); if (targetCommitish != null && targetCommitish.isNotEmpty) {
debugPrint(
'[UpdateChecker] Release target commitish: $targetCommitish',
);
}
// Compare commit hashes final isUpdateAvailable = await _isUpdateAvailable(
// Current hash might be full SHA or short (7 chars) currentCommitHash: currentCommitHash,
// Latest from manifest is full SHA releaseTag: tagName,
final isUpdateAvailable = !_compareCommitHashes(currentCommitHash, latestCommitHash); targetCommitish: targetCommitish,
);
if (!isUpdateAvailable) { if (!isUpdateAvailable) {
debugPrint('[UpdateChecker] No update available (same commit)'); debugPrint('[UpdateChecker] No update available');
return UpdateInfo.noUpdate(currentCommitHash); return UpdateInfo.noUpdate(currentCommitHash);
} }
// Find Android APK in artifacts // Find Android APK in release assets
final String? apkUrl = _findAndroidApkUrl(artifacts); final String? apkUrl = _findAndroidApkUrl(assets);
if (apkUrl == null) { if (apkUrl == null) {
debugPrint('[UpdateChecker] Update available but no APK found in artifacts'); debugPrint(
'[UpdateChecker] Update available but no APK found in artifacts',
);
return UpdateInfo.noUpdate(currentCommitHash); return UpdateInfo.noUpdate(currentCommitHash);
} }
@@ -87,10 +107,10 @@ class UpdateCheckerService {
return UpdateInfo.available( return UpdateInfo.available(
currentCommitHash: currentCommitHash, currentCommitHash: currentCommitHash,
latestCommitHash: commitShort, latestCommitHash: _formatLatestVersion(targetCommitish, tagName),
downloadUrl: apkUrl, downloadUrl: apkUrl,
buildId: buildId, buildId: tagName,
timestamp: timestamp, timestamp: publishedAt,
); );
} catch (e) { } catch (e) {
debugPrint('[UpdateChecker] Error checking for update: $e'); debugPrint('[UpdateChecker] Error checking for update: $e');
@@ -118,15 +138,94 @@ class UpdateCheckerService {
return false; return false;
} }
/// Find Android APK URL in artifacts list Future<bool> _isUpdateAvailable({
String? _findAndroidApkUrl(List<dynamic>? artifacts) { required String currentCommitHash,
if (artifacts == null || artifacts.isEmpty) return null; required String releaseTag,
required String? targetCommitish,
}) async {
// Prefer direct SHA compare when release target is a hash.
if (_looksLikeSha(targetCommitish)) {
return !_compareCommitHashes(currentCommitHash, targetCommitish!);
}
// Look for .apk file in artifacts // Fallback: ask GitHub how current commit compares to the release tag.
for (final artifact in artifacts) { final compareResult = await _compareWithReleaseTag(
if (artifact is String && artifact.toLowerCase().endsWith('.apk')) { currentCommitHash,
// Construct full URL releaseTag,
return 'https://meshcore-sar.dz0ny.dev/unstable/latest/$artifact'; );
if (compareResult != null) {
return compareResult;
}
// If we cannot compare, do not force update prompts.
return false;
}
Future<bool?> _compareWithReleaseTag(
String currentCommitHash,
String releaseTag,
) async {
try {
final compareUrl =
'https://api.github.com/repos/$_repoOwner/$_repoName/compare/$currentCommitHash...$releaseTag';
final response = await http
.get(
Uri.parse(compareUrl),
headers: {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
debugPrint(
'[UpdateChecker] Compare API failed: ${response.statusCode}',
);
return null;
}
final Map<String, dynamic> comparison = json.decode(response.body);
final status = comparison['status'] as String?;
debugPrint('[UpdateChecker] Compare status: $status');
if (status == 'behind') return true;
if (status == 'identical' || status == 'ahead') return false;
// "diverged" means the release and current commit differ.
if (status == 'diverged') return true;
} catch (e) {
debugPrint('[UpdateChecker] Compare API error: $e');
}
return null;
}
bool _looksLikeSha(String? value) {
if (value == null) return false;
final v = value.trim();
if (v.length < 7 || v.length > 40) return false;
return RegExp(r'^[a-fA-F0-9]+$').hasMatch(v);
}
String _formatLatestVersion(String? targetCommitish, String tagName) {
if (_looksLikeSha(targetCommitish)) {
return targetCommitish!.substring(0, 7).toLowerCase();
}
return tagName;
}
/// Find Android APK URL in release assets list
String? _findAndroidApkUrl(List<dynamic>? assets) {
if (assets == null || assets.isEmpty) return null;
for (final asset in assets) {
if (asset is! Map<String, dynamic>) continue;
final name = (asset['name'] as String?)?.toLowerCase() ?? '';
if (!name.endsWith('.apk')) continue;
final browserDownloadUrl = asset['browser_download_url'] as String?;
if (browserDownloadUrl != null && browserDownloadUrl.isNotEmpty) {
return browserDownloadUrl;
} }
} }

View File

@@ -1,89 +1,2 @@
import 'dart:typed_data'; export 'voice_codec_service_stub.dart'
import 'package:codec2_flutter/codec2_flutter.dart'; if (dart.library.io) 'voice_codec_service_io.dart';
import 'package:flutter/foundation.dart';
import '../utils/voice_message_parser.dart';
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
}
}
/// Selects the [VoicePacketMode] best suited for a given LoRa radio bandwidth.
///
/// Call with [radioBandwidthHz] from the device's radio params
/// (e.g. 125000 for 125 kHz).
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
return VoicePacketMode.mode1300;
}
/// High-level codec service that provides async Codec2 encode/decode
/// executed in a background isolate so the UI thread is never blocked.
class VoiceCodecService {
void _ensureCodec2Supported() {
if (kIsWeb ||
(defaultTargetPlatform != TargetPlatform.iOS &&
defaultTargetPlatform != TargetPlatform.android)) {
throw UnsupportedError('Codec2 is enabled only on iOS and Android.');
}
}
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
/// Returns the raw Codec2-encoded bytes.
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
_ensureCodec2Supported();
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
}
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
_ensureCodec2Supported();
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
}
/// Decode and concatenate multiple [packets] into a single PCM Int16List.
/// Packets with null/missing entries are substituted with silence.
Future<Int16List> decodePackets(
List<VoicePacket?> packets,
VoicePacketMode mode,
) async {
_ensureCodec2Supported();
final c2Mode = codec2ModeFor(mode);
final c2 = Codec2.create(c2Mode);
final spf = c2.samplesPerFrame;
c2.destroy();
// Estimate total samples (use actual data or silence per missing packet)
final all = <Int16List>[];
for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) {
// Silence for missing packet — duration approximated by mode
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
all.add(Int16List(silenceSamples));
} else {
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
all.add(decoded);
}
}
final total = all.fold<int>(0, (sum, l) => sum + l.length);
final result = Int16List(total);
var offset = 0;
for (final chunk in all) {
result.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length;
}
return result;
}
}

View File

@@ -0,0 +1,89 @@
import 'dart:typed_data';
import 'package:codec2_flutter/codec2_flutter.dart';
import 'package:flutter/foundation.dart';
import '../utils/voice_message_parser.dart';
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
}
}
/// Selects the [VoicePacketMode] best suited for a given LoRa radio bandwidth.
///
/// Call with [radioBandwidthHz] from the device's radio params
/// (e.g. 125000 for 125 kHz).
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
return VoicePacketMode.mode1300;
}
/// High-level codec service that provides async Codec2 encode/decode
/// executed in a background isolate so the UI thread is never blocked.
class VoiceCodecService {
void _ensureCodec2Supported() {
if (kIsWeb ||
(defaultTargetPlatform != TargetPlatform.iOS &&
defaultTargetPlatform != TargetPlatform.android)) {
throw UnsupportedError('Codec2 is enabled only on iOS and Android.');
}
}
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
/// Returns the raw Codec2-encoded bytes.
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
_ensureCodec2Supported();
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
}
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
_ensureCodec2Supported();
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
}
/// Decode and concatenate multiple [packets] into a single PCM Int16List.
/// Packets with null/missing entries are substituted with silence.
Future<Int16List> decodePackets(
List<VoicePacket?> packets,
VoicePacketMode mode,
) async {
_ensureCodec2Supported();
final c2Mode = codec2ModeFor(mode);
final c2 = Codec2.create(c2Mode);
final spf = c2.samplesPerFrame;
c2.destroy();
// Estimate total samples (use actual data or silence per missing packet)
final all = <Int16List>[];
for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) {
// Silence for missing packet — duration approximated by mode
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
all.add(Int16List(silenceSamples));
} else {
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
all.add(decoded);
}
}
final total = all.fold<int>(0, (sum, l) => sum + l.length);
final result = Int16List(total);
var offset = 0;
for (final chunk in all) {
result.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length;
}
return result;
}
}

View File

@@ -0,0 +1,71 @@
import 'dart:typed_data';
import '../utils/voice_message_parser.dart';
/// Web/unsupported platform stub — Codec2 FFI is not available.
enum Codec2Mode {
mode3200(0),
mode2400(1),
mode1600(2),
mode1400(3),
mode1300(4),
mode1200(5),
mode700c(8);
const Codec2Mode(this.c2ModeId);
final int c2ModeId;
int get framesPerSecond => (this == mode3200 || this == mode2400) ? 50 : 25;
int get samplesPerFrame => 8000 ~/ framesPerSecond;
int get bytesPerSecond {
switch (this) {
case mode3200: return 400;
case mode700c: return 100;
case mode1200: return 150;
case mode1300: return 175;
case mode1400: return 175;
case mode1600: return 200;
case mode2400: return 300;
}
}
static const int _maxBytesPerPacket = 160;
int get packetDurationMs {
final bytesPerFrame = bytesPerSecond / framesPerSecond;
final framesPerPacket = (_maxBytesPerPacket / bytesPerFrame).floor();
return (framesPerPacket * 1000 ~/ framesPerSecond);
}
}
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
}
}
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
return VoicePacketMode.mode1300;
}
class VoiceCodecService {
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) =>
Future.error(UnsupportedError('Voice not supported on web'));
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) =>
Future.error(UnsupportedError('Voice not supported on web'));
Future<Int16List> decodePackets(
List<VoicePacket?> packets,
VoicePacketMode mode,
) =>
Future.error(UnsupportedError('Voice not supported on web'));
}

View File

@@ -243,11 +243,11 @@ int _resolveBandwidthHz(int? rawBw) {
/// Envelope announcing image availability (control plane). /// Envelope announcing image availability (control plane).
/// ///
/// Text format: /// Text format:
/// IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver} /// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
/// Example: /// Example:
/// IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1 /// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8
class ImageEnvelope { class ImageEnvelope {
static const String prefix = 'IE1:'; static const String _prefix = 'IE2:';
final String sessionId; // 8 hex chars final String sessionId; // 8 hex chars
final ImageFormat format; final ImageFormat format;
@@ -268,38 +268,36 @@ class ImageEnvelope {
required this.sizeBytes, required this.sizeBytes,
required this.senderKey6, required this.senderKey6,
required this.timestampSec, required this.timestampSec,
this.version = 1, this.version = 2,
}); });
static bool isEnvelope(String text) => text.startsWith(prefix); static bool isEnvelope(String text) => text.startsWith(_prefix);
static ImageEnvelope? tryParse(String text) { static ImageEnvelope? tryParse(String text) {
if (!isEnvelope(text)) return null; if (!isEnvelope(text)) return null;
final body = text.substring(prefix.length); final body = text.substring(_prefix.length);
final parts = body.split(':'); final parts = body.split(':');
if (parts.length != 9) return null; if (parts.length != 8) return null;
try { try {
final sid = parts[0]; final sid = _decodeSessionId(parts[0]);
final fmtId = int.tryParse(parts[1]); final fmtId = _parseInt(parts[1], base36: true);
final total = int.tryParse(parts[2]); final total = _parseInt(parts[2], base36: true);
final w = int.tryParse(parts[3]); final w = _parseInt(parts[3], base36: true);
final h = int.tryParse(parts[4]); final h = _parseInt(parts[4], base36: true);
final bytes = int.tryParse(parts[5]); final bytes = _parseInt(parts[5], base36: true);
final senderKey6 = parts[6]; final senderKey6 = parts[6];
final ts = int.tryParse(parts[7]); final ts = _parseInt(parts[7], base36: true);
final ver = int.tryParse(parts[8]);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null; if (sid == null) return null;
if (fmtId == null) return null; if (fmtId == null) return null;
if (total == null || total < 1 || total > 255) return null; if (total == null || total < 1 || total > 255) return null;
if (w == null || h == null || w < 1 || h < 1) return null; if (w == null || h == null || w < 1 || h < 1) return null;
if (bytes == null || bytes < 1) return null; if (bytes == null || bytes < 1) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null; if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
if (ts == null || ts <= 0) return null; if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return ImageEnvelope( return ImageEnvelope(
sessionId: sid.toLowerCase(), sessionId: sid,
format: ImageFormat.fromId(fmtId), format: ImageFormat.fromId(fmtId),
total: total, total: total,
width: w, width: w,
@@ -307,7 +305,7 @@ class ImageEnvelope {
sizeBytes: bytes, sizeBytes: bytes,
senderKey6: senderKey6.toLowerCase(), senderKey6: senderKey6.toLowerCase(),
timestampSec: ts, timestampSec: ts,
version: ver, version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -315,17 +313,21 @@ class ImageEnvelope {
} }
String encode() => String encode() =>
'$prefix${sessionId.toLowerCase()}:${format.id}:$total:$width:$height:$sizeBytes:${senderKey6.toLowerCase()}:$timestampSec:$version'; '$_prefix${_encodeSessionId(sessionId)}:'
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
'${_toBase36(height)}:${_toBase36(sizeBytes)}:'
'${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
} }
/// Direct request to fetch image fragments (control plane). /// Direct request to fetch image fragments (control plane).
/// ///
/// Text format: /// Text format:
/// IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} /// IR2:{sid}:{want}:{requesterKey6}:{ts}
/// Example: /// Example:
/// IR1:deadbeef:a:aabbccddeeff:1700000010:1 /// IR2:deadbeef:a:aabbccddeeff:s44wea
class ImageFetchRequest { class ImageFetchRequest {
static const String prefix = 'IR1:'; static const String _prefix = 'IR2:';
static const int _binaryMagic = 0x69; // 'i'
final String sessionId; final String sessionId;
final String want; // 'all' or 'missing' final String want; // 'all' or 'missing'
@@ -340,51 +342,89 @@ class ImageFetchRequest {
this.missingIndices = const [], this.missingIndices = const [],
required this.requesterKey6, required this.requesterKey6,
required this.timestampSec, required this.timestampSec,
this.version = 1, this.version = 2,
}); });
static bool isRequest(String text) => text.startsWith(prefix); static bool isRequest(String text) => text.startsWith(_prefix);
static bool isRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
static ImageFetchRequest? tryParse(String text) { static ImageFetchRequest? tryParse(String text) {
if (!isRequest(text)) return null; if (!isRequest(text)) return null;
final body = text.substring(prefix.length); final body = text.substring(_prefix.length);
final parts = body.split(':'); final parts = body.split(':');
if (parts.length != 5) return null; if (parts.length != 4) return null;
try { try {
final sid = parts[0]; final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1]; final wantToken = parts[1];
final requesterKey6 = parts[2]; final requesterKey6 = parts[2];
final ts = int.tryParse(parts[3]); final ts = _parseInt(parts[3], base36: true);
final ver = int.tryParse(parts[4]);
final normalizedWant = wantToken == 'a' final normalizedWant = wantToken == 'a'
? 'all' ? 'all'
: (wantToken.startsWith('m-') ? 'missing' : wantToken); : ((wantToken.startsWith('m'))
? 'missing'
: wantToken);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null; if (sid == null) return null;
final missingIndices = <int>[]; final missingIndices = <int>[];
if (normalizedWant == 'missing') { if (normalizedWant == 'missing') {
final encoded = wantToken.substring(2); final encoded = wantToken.substring(1);
if (encoded.isEmpty) return null; if (encoded.isEmpty) return null;
for (final raw in encoded.split(',')) { missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
final idx = int.tryParse(raw);
if (idx == null || idx < 0 || idx > 254) return null;
missingIndices.add(idx);
}
if (missingIndices.isEmpty) return null; if (missingIndices.isEmpty) return null;
} else if (normalizedWant != 'all') { } else if (normalizedWant != 'all') {
return null; return null;
} }
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null; if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
if (ts == null || ts <= 0) return null; if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return ImageFetchRequest( return ImageFetchRequest(
sessionId: sid.toLowerCase(), sessionId: sid,
want: normalizedWant, want: normalizedWant,
missingIndices: missingIndices, missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(), requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts, timestampSec: ts,
version: ver, version: 2,
);
} catch (_) {
return null;
}
}
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
if (!isRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
try {
final sid = payload
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final flags = payload[5];
final requesterKey6 = payload
.sublist(6, 12)
.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 wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[];
for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]);
}
return ImageFetchRequest(
sessionId: sid,
want: wantMissing ? 'missing' : 'all',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: ts,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -393,10 +433,168 @@ class ImageFetchRequest {
String encode() { String encode() {
final wantToken = want == 'missing' && missingIndices.isNotEmpty final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm-${missingIndices.join(',')}' ? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want); : (want == 'all' ? 'a' : want);
return '$prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version'; return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
} }
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
throw ArgumentError.value(
requesterKey6,
'requesterKey6',
'Expected 12 hex chars',
);
}
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
final missing = useMissing
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[];
final out = Uint8List(17 + 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);
}
out[5] = useMissing ? 0x01 : 0x00;
for (var i = 0; i < 6; i++) {
out[6 + i] = int.parse(
requesterKey6.substring(i * 2, i * 2 + 2),
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;
for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i];
}
return out;
}
}
/// Per-fragment ACK for raw image payload packets.
///
/// Binary format:
/// [0x6a 'j'][sessionId:4B][index:1B]
class ImageFragmentAck {
static const int _binaryMagic = 0x6a; // 'j'
final String sessionId; // 8 hex chars
final int index; // 0..254
const ImageFragmentAck({required this.sessionId, required this.index});
static bool isImageFragmentAckBinary(Uint8List payload) =>
payload.length == 6 && payload[0] == _binaryMagic;
static ImageFragmentAck? tryParseBinary(Uint8List payload) {
if (!isImageFragmentAckBinary(payload)) return null;
try {
final sid = payload
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final idx = payload[5];
return ImageFragmentAck(sessionId: sid, index: idx);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (index < 0 || index > 254) {
throw ArgumentError.value(index, 'index', 'Expected 0..254');
}
final out = Uint8List(6);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
}
out[5] = index;
return out;
}
}
int? _parseInt(String token, {required bool base36}) =>
int.tryParse(token, radix: base36 ? 36 : 10);
String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
}
final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36);
}
String? _decodeSessionId(String token) {
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
return value.toRadixString(16).padLeft(8, '0');
}
String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort();
if (sorted.isEmpty) return '';
final chunks = <String>[];
var start = sorted.first;
var prev = sorted.first;
for (var i = 1; i < sorted.length; i++) {
final curr = sorted[i];
if (curr == prev + 1) {
prev = curr;
continue;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
start = curr;
prev = curr;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
return chunks.join('.');
}
List<int> _decodeMissingIndicesCompact(String encoded) {
final out = <int>[];
for (final token in encoded.split('.')) {
if (token.isEmpty) continue;
if (!token.contains('-')) {
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 254) return const [];
out.add(value);
continue;
}
final parts = token.split('-');
if (parts.length != 2) return const [];
final start = int.tryParse(parts[0], radix: 36);
final end = int.tryParse(parts[1], radix: 36);
if (start == null || end == null || start < 0 || end > 254 || start > end) {
return const [];
}
for (var i = start; i <= end; i++) {
out.add(i);
}
}
return out;
} }
/// Fragment the compressed image bytes into [ImagePacket] list. /// Fragment the compressed image bytes into [ImagePacket] list.

View File

@@ -0,0 +1,163 @@
enum TicTacToeEventType { start, move }
class TicTacToeEvent {
final TicTacToeEventType type;
final String gameId;
final String playerKey6;
final int? cell;
final int timestampSec;
const TicTacToeEvent({
required this.type,
required this.gameId,
required this.playerKey6,
this.cell,
required this.timestampSec,
});
}
class TicTacToeMessageParser {
static const String _prefix = 'TTT1:';
static bool isTicTacToe(String text) => text.startsWith(_prefix);
static TicTacToeEvent? tryParse(String text) {
if (!isTicTacToe(text)) return null;
final body = text.substring(_prefix.length);
final parts = body.split(':');
if (parts.length < 4) return null;
final action = parts[0];
final gameId = parts[1].toLowerCase();
if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(gameId)) return null;
if (action == 'S' && parts.length == 4) {
final starterKey6 = parts[2].toLowerCase();
final ts = int.tryParse(parts[3]);
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(starterKey6)) return null;
if (ts == null || ts <= 0) return null;
return TicTacToeEvent(
type: TicTacToeEventType.start,
gameId: gameId,
playerKey6: starterKey6,
timestampSec: ts,
);
}
if (action == 'M' && parts.length == 5) {
final cell = int.tryParse(parts[2]);
final playerKey6 = parts[3].toLowerCase();
final ts = int.tryParse(parts[4]);
if (cell == null || cell < 0 || cell > 8) return null;
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(playerKey6)) return null;
if (ts == null || ts <= 0) return null;
return TicTacToeEvent(
type: TicTacToeEventType.move,
gameId: gameId,
playerKey6: playerKey6,
cell: cell,
timestampSec: ts,
);
}
return null;
}
static String encodeStart({
required String gameId,
required String starterKey6,
required int timestampSec,
}) {
return '$_prefix'
'S:${gameId.toLowerCase()}:${starterKey6.toLowerCase()}:$timestampSec';
}
static String encodeMove({
required String gameId,
required int cell,
required String playerKey6,
required int timestampSec,
}) {
return '$_prefix'
'M:${gameId.toLowerCase()}:$cell:${playerKey6.toLowerCase()}:$timestampSec';
}
}
class TicTacToeGameState {
final String gameId;
final String xPlayerKey6;
final String oPlayerKey6;
final List<String?> board; // 'X' / 'O' / null
final String nextSymbol;
final String? winnerSymbol;
const TicTacToeGameState({
required this.gameId,
required this.xPlayerKey6,
required this.oPlayerKey6,
required this.board,
required this.nextSymbol,
this.winnerSymbol,
});
bool get isDraw =>
winnerSymbol == null && board.every((cell) => cell != null);
bool get isFinished => winnerSymbol != null || isDraw;
}
TicTacToeGameState buildTicTacToeState({
required String gameId,
required String xPlayerKey6,
required String oPlayerKey6,
required List<TicTacToeEvent> events,
}) {
final board = List<String?>.filled(9, null);
var next = 'X';
String? winner;
final sorted = [...events]
..sort((a, b) => a.timestampSec.compareTo(b.timestampSec));
for (final event in sorted) {
if (event.type != TicTacToeEventType.move) continue;
if (winner != null) break;
final expectedKey = next == 'X' ? xPlayerKey6 : oPlayerKey6;
final cell = event.cell;
if (cell == null) continue;
if (event.playerKey6 != expectedKey) continue;
if (board[cell] != null) continue;
board[cell] = next;
winner = _computeWinner(board);
next = next == 'X' ? 'O' : 'X';
}
return TicTacToeGameState(
gameId: gameId,
xPlayerKey6: xPlayerKey6,
oPlayerKey6: oPlayerKey6,
board: board,
nextSymbol: next,
winnerSymbol: winner,
);
}
String? _computeWinner(List<String?> board) {
const lines = <List<int>>[
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
for (final line in lines) {
final a = board[line[0]];
if (a == null) continue;
if (a == board[line[1]] && a == board[line[2]]) return a;
}
return null;
}

View File

@@ -0,0 +1,157 @@
import 'dart:typed_data';
import '../models/contact.dart';
import '../providers/contacts_provider.dart';
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar }
class TransmissionTargetResolution {
final Contact? target;
final TransmissionTargetFailure? failure;
final int maxHops;
const TransmissionTargetResolution({
required this.target,
required this.failure,
required this.maxHops,
});
int get hops => target?.outPathLen ?? -1;
bool get isValid => target != null && failure == null;
}
class TransmissionTargetResolver {
const TransmissionTargetResolver._();
static Contact? resolveLocalTarget({
required ContactsProvider contactsProvider,
required bool isSentByMe,
Uint8List? recipientPublicKey,
Uint8List? senderPublicKeyPrefix,
String? senderKey6FromEnvelope,
String? senderName,
}) {
if (isSentByMe) {
final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey);
if (recipient != null) return recipient;
}
final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope);
if (byEnvelope != null) return byEnvelope;
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
if (byPrefix != null) return byPrefix;
return _findByName(contactsProvider, senderName);
}
static Future<TransmissionTargetResolution> resolveFetchTarget({
required ContactsProvider contactsProvider,
required Future<void> Function() refreshContacts,
required bool isSentByMe,
Uint8List? recipientPublicKey,
Uint8List? senderPublicKeyPrefix,
String? senderKey6FromEnvelope,
String? senderName,
required int maxFetchHops,
}) async {
var target = resolveLocalTarget(
contactsProvider: contactsProvider,
isSentByMe: isSentByMe,
recipientPublicKey: recipientPublicKey,
senderPublicKeyPrefix: senderPublicKeyPrefix,
senderKey6FromEnvelope: senderKey6FromEnvelope,
senderName: senderName,
);
if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) {
await refreshContacts();
target = resolveLocalTarget(
contactsProvider: contactsProvider,
isSentByMe: isSentByMe,
recipientPublicKey: recipientPublicKey,
senderPublicKeyPrefix: senderPublicKeyPrefix,
senderKey6FromEnvelope: senderKey6FromEnvelope,
senderName: senderName,
);
}
if (target == null) {
return TransmissionTargetResolution(
target: null,
failure: TransmissionTargetFailure.unknownContact,
maxHops: maxFetchHops,
);
}
if (target.outPathLen < 0) {
return TransmissionTargetResolution(
target: target,
failure: TransmissionTargetFailure.unknownRoute,
maxHops: maxFetchHops,
);
}
if (target.outPathLen > maxFetchHops) {
return TransmissionTargetResolution(
target: target,
failure: TransmissionTargetFailure.tooFar,
maxHops: maxFetchHops,
);
}
return TransmissionTargetResolution(
target: target,
failure: null,
maxHops: maxFetchHops,
);
}
static Contact? _findByRecipientKey(
ContactsProvider contactsProvider,
Uint8List? recipientKey,
) {
if (recipientKey == null || recipientKey.isEmpty) return null;
final byKey = contactsProvider.findContactByKey(recipientKey);
if (byKey != null) return byKey;
if (recipientKey.length >= 6) {
return contactsProvider.findContactByPrefix(
Uint8List.fromList(recipientKey.sublist(0, 6)),
);
}
return null;
}
static Contact? _findByEnvelopeKey6(
ContactsProvider contactsProvider,
String? senderKey6FromEnvelope,
) {
if (senderKey6FromEnvelope == null || senderKey6FromEnvelope.isEmpty) {
return null;
}
return contactsProvider.findContactByPrefixHex(senderKey6FromEnvelope);
}
static Contact? _findByPrefix(
ContactsProvider contactsProvider,
Uint8List? senderPublicKeyPrefix,
) {
if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) {
return null;
}
return contactsProvider.findContactByPrefix(
Uint8List.fromList(senderPublicKeyPrefix.sublist(0, 6)),
);
}
static Contact? _findByName(
ContactsProvider contactsProvider,
String? senderName,
) {
final normalized = senderName?.trim();
if (normalized == null || normalized.isEmpty) return null;
for (final contact in contactsProvider.contacts) {
if (contact.advName.trim().toLowerCase() == normalized.toLowerCase()) {
return contact;
}
}
return null;
}
}

View File

@@ -181,11 +181,11 @@ class VoicePacket {
/// Lightweight public/direct message envelope advertising voice availability. /// Lightweight public/direct message envelope advertising voice availability.
/// ///
/// Text format: /// Text format:
/// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver} /// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
/// Example: /// Example:
/// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1 /// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
class VoiceEnvelope { class VoiceEnvelope {
static const String _prefix = 'VE1:'; static const String _prefix = 'VE2:';
final String sessionId; final String sessionId;
final VoicePacketMode mode; final VoicePacketMode mode;
@@ -202,7 +202,7 @@ class VoiceEnvelope {
required this.durationMs, required this.durationMs,
required this.senderKey6, required this.senderKey6,
required this.timestampSec, required this.timestampSec,
this.version = 1, this.version = 2,
}); });
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix); static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
@@ -210,43 +210,41 @@ class VoiceEnvelope {
static VoiceEnvelope? tryParseText(String text) { static VoiceEnvelope? tryParseText(String text) {
if (!isVoiceEnvelopeText(text)) return null; if (!isVoiceEnvelopeText(text)) return null;
final body = text.substring(_prefix.length); final body = text.substring(_prefix.length);
return _tryParseCompact(body); return _tryParse(body);
} }
static VoiceEnvelope? _tryParseCompact(String body) { static VoiceEnvelope? _tryParse(String body) {
final parts = body.split(':'); final parts = body.split(':');
if (parts.length != 7) return null; if (parts.length != 6) return null;
try { try {
final sid = parts[0]; final sid = _decodeSessionId(parts[0]);
final mode = int.tryParse(parts[1]); final mode = _parseInt(parts[1], base36: true);
final total = int.tryParse(parts[2]); final total = _parseInt(parts[2], base36: true);
final durMs = int.tryParse(parts[3]); final durS = _parseInt(parts[3], base36: true);
final senderKey6 = parts[4]; final senderKey6 = parts[4];
final ts = int.tryParse(parts[5]); final ts = _parseInt(parts[5], base36: true);
final ver = int.tryParse(parts[6]);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) { if (sid == null) {
return null; return null;
} }
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) { if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
return null; return null;
} }
if (total == null || total < 1 || total > 255) return null; if (total == null || total < 1 || total > 255) return null;
if (durMs == null || durMs < 0 || durMs > 10 * 60 * 1000) return null; if (durS == null || durS < 0 || durS > 10 * 60) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) { if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
return null; return null;
} }
if (ts == null || ts <= 0) return null; if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return VoiceEnvelope( return VoiceEnvelope(
sessionId: sid.toLowerCase(), sessionId: sid,
mode: VoicePacketMode.fromId(mode), mode: VoicePacketMode.fromId(mode),
total: total, total: total,
durationMs: durMs, durationMs: durS * 1000,
senderKey6: senderKey6.toLowerCase(), senderKey6: senderKey6.toLowerCase(),
timestampSec: ts, timestampSec: ts,
version: ver, version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -254,7 +252,8 @@ class VoiceEnvelope {
} }
String encodeText() { String encodeText() {
return '$_prefix${sessionId.toLowerCase()}:${mode.id}:$total:$durationMs:${senderKey6.toLowerCase()}:$timestampSec:$version'; final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
} }
} }
@@ -415,11 +414,12 @@ int _resolveBandwidthHz(int? rawBw) {
/// Direct control-plane request to fetch voice packets for a session. /// Direct control-plane request to fetch voice packets for a session.
/// ///
/// Text format: /// Text format:
/// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} /// VR2:{sid}:{want}:{requesterKey6}:{ts}
/// Example: /// Example:
/// VR1:00112233:a:aabbccddeeff:1234567890:1 /// VR2:00112233:a:aabbccddeeff:kf12oi
class VoiceFetchRequest { class VoiceFetchRequest {
static const String _prefix = 'VR1:'; static const String _prefix = 'VR2:';
static const int _binaryMagic = 0x72; // 'r'
final String sessionId; final String sessionId;
final String want; final String want;
@@ -434,42 +434,82 @@ class VoiceFetchRequest {
this.missingIndices = const [], this.missingIndices = const [],
required this.requesterKey6, required this.requesterKey6,
required this.timestampSec, required this.timestampSec,
this.version = 1, this.version = 2,
}); });
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix); static bool isVoiceFetchRequestText(String text) =>
text.startsWith(_prefix);
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
static VoiceFetchRequest? tryParseText(String text) { static VoiceFetchRequest? tryParseText(String text) {
if (!isVoiceFetchRequestText(text)) return null; if (!isVoiceFetchRequestText(text)) return null;
final body = text.substring(_prefix.length); final body = text.substring(_prefix.length);
return _tryParseCompact(body); return _tryParse(body);
} }
static VoiceFetchRequest? _tryParseCompact(String body) { static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
final parts = body.split(':'); if (!isVoiceFetchRequestBinary(payload)) return null;
if (parts.length != 5) return null; if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
try { try {
final sid = parts[0]; final sidBytes = payload.sublist(1, 5);
final sid = sidBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final flags = payload[5];
final requesterKey6 = payload
.sublist(6, 12)
.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 wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[];
for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]);
}
return VoiceFetchRequest(
sessionId: sid,
want: wantMissing ? 'missing' : 'all',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: ts,
version: 2,
);
} catch (_) {
return null;
}
}
static VoiceFetchRequest? _tryParse(String body) {
final parts = body.split(':');
if (parts.length != 4) return null;
try {
final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1]; final wantToken = parts[1];
final requesterKey6 = parts[2]; final requesterKey6 = parts[2];
final ts = int.tryParse(parts[3]); final ts = _parseInt(parts[3], base36: true);
final ver = int.tryParse(parts[4]);
final normalizedWant = wantToken == 'a' final normalizedWant = wantToken == 'a'
? 'all' ? 'all'
: (wantToken.startsWith('m-') ? 'missing' : wantToken); : ((wantToken.startsWith('m'))
? 'missing'
: wantToken);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) { if (sid == null) {
return null; return null;
} }
final missingIndices = <int>[]; final missingIndices = <int>[];
if (normalizedWant == 'missing') { if (normalizedWant == 'missing') {
final encoded = wantToken.substring(2); final encoded = wantToken.substring(1);
if (encoded.isEmpty) return null; if (encoded.isEmpty) return null;
for (final raw in encoded.split(',')) { missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
final idx = int.tryParse(raw);
if (idx == null || idx < 0 || idx > 254) return null;
missingIndices.add(idx);
}
if (missingIndices.isEmpty) return null; if (missingIndices.isEmpty) return null;
} else if (normalizedWant != 'all') { } else if (normalizedWant != 'all') {
return null; return null;
@@ -478,15 +518,14 @@ class VoiceFetchRequest {
return null; return null;
} }
if (ts == null || ts <= 0) return null; if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return VoiceFetchRequest( return VoiceFetchRequest(
sessionId: sid.toLowerCase(), sessionId: sid,
want: normalizedWant, want: normalizedWant,
missingIndices: missingIndices, missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(), requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts, timestampSec: ts,
version: ver, version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -495,10 +534,168 @@ class VoiceFetchRequest {
String encodeText() { String encodeText() {
final wantToken = want == 'missing' && missingIndices.isNotEmpty final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm-${missingIndices.join(',')}' ? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want); : (want == 'all' ? 'a' : want);
return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version'; return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
} }
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
throw ArgumentError.value(
requesterKey6,
'requesterKey6',
'Expected 12 hex chars',
);
}
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
final missing = useMissing
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[];
final out = Uint8List(17 + 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);
}
out[5] = useMissing ? 0x01 : 0x00;
for (var i = 0; i < 6; i++) {
out[6 + i] = int.parse(
requesterKey6.substring(i * 2, i * 2 + 2),
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;
for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i];
}
return out;
}
}
/// Per-fragment ACK for raw voice payload packets.
///
/// Binary format:
/// [0x76 'v'][sessionId:4B][index:1B]
class VoiceFragmentAck {
static const int _binaryMagic = 0x76; // 'v'
final String sessionId; // 8 hex chars
final int index; // 0..254
const VoiceFragmentAck({required this.sessionId, required this.index});
static bool isVoiceFragmentAckBinary(Uint8List payload) =>
payload.length == 6 && payload[0] == _binaryMagic;
static VoiceFragmentAck? tryParseBinary(Uint8List payload) {
if (!isVoiceFragmentAckBinary(payload)) return null;
try {
final sid = payload
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final idx = payload[5];
return VoiceFragmentAck(sessionId: sid, index: idx);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (index < 0 || index > 254) {
throw ArgumentError.value(index, 'index', 'Expected 0..254');
}
final out = Uint8List(6);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
}
out[5] = index;
return out;
}
}
int? _parseInt(String token, {required bool base36}) =>
int.tryParse(token, radix: base36 ? 36 : 10);
String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
}
final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36);
}
String? _decodeSessionId(String token) {
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
return value.toRadixString(16).padLeft(8, '0');
}
String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort();
if (sorted.isEmpty) return '';
final chunks = <String>[];
var start = sorted.first;
var prev = sorted.first;
for (var i = 1; i < sorted.length; i++) {
final curr = sorted[i];
if (curr == prev + 1) {
prev = curr;
continue;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
start = curr;
prev = curr;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
return chunks.join('.');
}
List<int> _decodeMissingIndicesCompact(String encoded) {
final out = <int>[];
for (final token in encoded.split('.')) {
if (token.isEmpty) continue;
if (!token.contains('-')) {
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 254) return const [];
out.add(value);
continue;
}
final parts = token.split('-');
if (parts.length != 2) return const [];
final start = int.tryParse(parts[0], radix: 36);
final end = int.tryParse(parts[1], radix: 36);
if (start == null || end == null || start < 0 || end > 254 || start > end) {
return const [];
}
for (var i = start; i <= end; i++) {
out.add(i);
}
}
return out;
} }
/// Builds a compact visual waveform from real voice packet bytes. /// Builds a compact visual waveform from real voice packet bytes.

View File

@@ -20,7 +20,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final List<DiscoveredServer> _discoveredServers = []; final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
String? _connectingToServerUrl; // Track which server is being connected to String? _connectingToServerKey; // Track which server is being connected to (ip:port)
// Named listener method for proper cleanup // Named listener method for proper cleanup
void _onTabChanged() { void _onTabChanged() {
@@ -361,61 +361,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
Widget _buildNetworkServersTab() { Widget _buildNetworkServersTab() {
final connectionProvider = context.watch<ConnectionProvider>();
final bool showingCachedResults = final bool showingCachedResults =
!_networkScanner.isScanning && !_networkScanner.isScanning &&
_networkScanner.hasCachedResults && _networkScanner.hasCachedResults &&
_discoveredServers.isNotEmpty; _discoveredServers.isNotEmpty;
final bool isConnectingToSse = connectionProvider.isSseClientConnecting;
final int sseReconnectAttempt =
connectionProvider.sseClientReconnectionAttempt;
final int sseMaxReconnects =
connectionProvider.sseClientMaxReconnectionAttempts;
return Column( return Column(
children: [ children: [
// SSE Reconnection banner (show when reconnecting)
if (isConnectingToSse && sseReconnectAttempt > 0)
Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onTertiaryContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Reconnecting to server... (Attempt $sseReconnectAttempt/$sseMaxReconnects)',
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiaryContainer,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
// Info banner // Info banner
Container( Container(
margin: EdgeInsets.fromLTRB( margin: const EdgeInsets.fromLTRB(16, 16, 16, 16),
16,
isConnectingToSse && sseReconnectAttempt > 0 ? 8 : 16,
16,
16,
),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
@@ -432,7 +387,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
child: Text( child: Text(
showingCachedResults showingCachedResults
? 'Showing cached results. Tap refresh to rescan.' ? 'Showing cached results. Tap refresh to rescan.'
: 'Scanning local network for shared MeshCore devices on port 12929', : 'Scanning local network for MeshCore WiFi devices on port 5000',
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer, color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 13, fontSize: 13,
@@ -505,10 +460,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
itemCount: _discoveredServers.length, itemCount: _discoveredServers.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final server = _discoveredServers[index]; final server = _discoveredServers[index];
final serverKey = '${server.ipAddress}:${server.port}';
final isConnectingToThisServer = final isConnectingToThisServer =
_connectingToServerUrl == server.serverUrl; _connectingToServerKey == serverKey;
final isAnyConnectionInProgress = final isAnyConnectionInProgress =
isConnectingToSse || _connectingToServerUrl != null; _connectingToServerKey != null;
return Container( return Container(
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
@@ -593,7 +549,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
// Mark this server as connecting // Mark this server as connecting
setState(() { setState(() {
_connectingToServerUrl = server.serverUrl; _connectingToServerKey = serverKey;
}); });
try { try {
@@ -607,8 +563,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
await connectionProvider.connectToSseServer( await connectionProvider.connectTcp(
serverUrl: server.serverUrl, server.ipAddress,
server.port,
); );
await appProvider.initialize(); await appProvider.initialize();
@@ -619,7 +576,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
// Clear connecting state on error // Clear connecting state on error
if (mounted) { if (mounted) {
setState(() { setState(() {
_connectingToServerUrl = null; _connectingToServerKey = null;
}); });
// Clean up error message (remove "Exception: " prefix) // Clean up error message (remove "Exception: " prefix)

View File

@@ -1,18 +1,20 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_avif/flutter_avif.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/image_provider.dart' as ip; import '../../providers/image_provider.dart' as ip;
import '../../utils/image_message_parser.dart'; import '../../utils/image_message_parser.dart';
import '../../utils/transmission_target_resolver.dart';
import 'transfer_timeout.dart';
/// A message bubble that shows a received or sent image. /// A message bubble that shows a received or sent image.
/// ///
/// On first render the image is not yet fetched (only the IE1 envelope is /// On first render the image is not yet fetched (only the IE2 envelope is
/// known). The user taps the thumbnail placeholder → IR1 fetch request is /// known). The user taps the thumbnail placeholder → IR2 fetch request is
/// sent → binary fragments stream in → bubble rebuilds with the full image. /// sent → binary fragments stream in → bubble rebuilds with the full image.
class ImageMessageBubble extends StatefulWidget { class ImageMessageBubble extends StatefulWidget {
final Message message; final Message message;
@@ -29,8 +31,16 @@ class ImageMessageBubble extends StatefulWidget {
} }
class _ImageMessageBubbleState extends State<ImageMessageBubble> { class _ImageMessageBubbleState extends State<ImageMessageBubble> {
static const int _maxFetchHops = 3;
bool _isRequesting = false; bool _isRequesting = false;
String? _errorText; String? _errorText;
Timer? _requestTimeoutTimer;
@override
void dispose() {
_requestTimeoutTimer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -48,8 +58,24 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return Consumer<ip.ImageProvider>( return Consumer<ip.ImageProvider>(
builder: (context, imageProvider, _) { builder: (context, imageProvider, _) {
final contactsProvider = context.read<ContactsProvider>();
final session = imageProvider.session(envelope.sessionId); final session = imageProvider.session(envelope.sessionId);
final sender = TransmissionTargetResolver.resolveLocalTarget(
contactsProvider: contactsProvider,
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
: widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId); final isComplete = imageProvider.isComplete(envelope.sessionId);
final eta = imageProvider.estimateRemainingTransferTime(
envelope.sessionId,
);
if (_isRequesting && isComplete) { if (_isRequesting && isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -82,6 +108,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
received: received, received: received,
total: total, total: total,
envelope: envelope, envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -93,12 +123,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
received: received, received: received,
total: total, total: total,
envelope: envelope, envelope: envelope,
pathLen: widget.message.pathLen,
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
error: _errorText, error: _errorText,
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
eta: eta,
pathLen: effectivePathLen,
), ),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
@@ -123,6 +154,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required int received, required int received,
required int total, required int total,
required ImageEnvelope envelope, required ImageEnvelope envelope,
required int? radioBw,
required int? radioSf,
required int? radioCr,
required int pathLen,
}) { }) {
if (isComplete && imageBytes != null) { if (isComplete && imageBytes != null) {
return AspectRatio( return AspectRatio(
@@ -159,7 +194,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
] else ...[ ] else ...[
// Tap-to-load icon. // Tap-to-load icon.
IconButton( IconButton(
onPressed: () => _requestAndFetch(envelope), onPressed: () => _requestAndFetch(
envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: pathLen,
),
icon: const Icon(Icons.download_rounded, size: 40), icon: const Icon(Icons.download_rounded, size: 40),
color: Colors.white70, color: Colors.white70,
tooltip: 'Load image', tooltip: 'Load image',
@@ -171,18 +212,65 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
); );
} }
Future<void> _requestAndFetch(ImageEnvelope envelope) async { Future<void> _requestAndFetch(
ImageEnvelope envelope, {
int? radioBw,
int? radioSf,
int? radioCr,
int pathLen = 0,
}) async {
if (_isRequesting) return; if (_isRequesting) return;
final sender = _resolveSender(envelope); final conn = context.read<ConnectionProvider>();
if (sender == null) { final contactsProvider = context.read<ContactsProvider>();
setState(() => _errorText = 'Sender not reachable'); final 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) {
await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return; return;
} }
final conn = context.read<ConnectionProvider>(); final sender = resolution.target!;
if (sender.outPathLen >= 2) {
_showToast(
'Image fetch over ${sender.outPathLen} hops may take a while.',
);
}
setState(() => _errorText = null);
final imageProvider = context.read<ip.ImageProvider>();
final deviceKey = conn.deviceInfo.publicKey; final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) { if (deviceKey == null || deviceKey.length < 6) {
setState(() => _errorText = 'Device key unavailable'); await _showBlockingAlert(
'Cannot fetch image',
'Device key is unavailable.',
);
return; return;
} }
@@ -190,40 +278,101 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(); .join();
final request = ImageFetchRequest(
sessionId: envelope.sessionId, // If we already have some fragments, request only what's missing.
requesterKey6: requesterKey6, final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, final isPartialResume =
); missing.isNotEmpty && missing.length < envelope.total;
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(() { setState(() {
_isRequesting = true; _isRequesting = true;
_errorText = null; _errorText = null;
}); });
final sent = await conn.sendTextMessage( final payload = request.encodeBinary();
contactPublicKey: sender.publicKey, try {
text: request.encode(), await conn.sendRawVoicePacket(
contact: sender, contactPath: sender.outPath,
); contactPathLen: sender.outPathLen,
if (!sent && mounted) { payload: payload,
setState(() { );
_isRequesting = false; } catch (_) {
_errorText = 'Image unavailable right now'; if (mounted) {
}); _showToast('Image fetch failed to send request');
setState(() {
_isRequesting = false;
_errorText = 'Image unavailable right now';
});
}
return;
} }
if (!mounted) return;
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
sizeBytes: missing.isEmpty
? envelope.sizeBytes
: (envelope.sizeBytes * missing.length / envelope.total).round(),
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
_requestTimeoutTimer?.cancel();
_requestTimeoutTimer = TransferTimeout.start(
txEstimate: txEstimate,
onTimeout: () {
if (mounted &&
_isRequesting &&
!imageProvider.isComplete(envelope.sessionId)) {
_showToast('Image fetch timed out');
setState(() {
_isRequesting = false;
_errorText = 'Image fetch timed out';
});
}
},
);
} }
Contact? _resolveSender(ImageEnvelope envelope) { void _showToast(String message) {
final contactsProvider = context.read<ContactsProvider>(); if (!mounted) return;
final senderPrefix = widget.message.senderPublicKeyPrefix; ScaffoldMessenger.of(context).showSnackBar(
if (senderPrefix != null && senderPrefix.length >= 6) { SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
final c = contactsProvider.findContactByPrefix( );
Uint8List.fromList(senderPrefix.sublist(0, 6)), }
);
if (c != null) return c; Future<void> _showBlockingAlert(String title, String message) async {
} if (!mounted) return;
return contactsProvider.findContactByPrefixHex(envelope.senderKey6); _showToast('$title: $message');
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('OK'),
),
],
),
);
} }
static String _statusText({ static String _statusText({
@@ -238,6 +387,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required int? radioCr, required int? radioCr,
required String? error, required String? error,
required bool isSentByMe, required bool isSentByMe,
required Duration? eta,
}) { }) {
final txEstimate = estimateImageTransmitDuration( final txEstimate = estimateImageTransmitDuration(
fragmentCount: envelope.total, fragmentCount: envelope.total,
@@ -250,7 +400,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
final txEstimateLabel = _formatTransmitEstimate(txEstimate); final txEstimateLabel = _formatTransmitEstimate(txEstimate);
if (error != null) return error; if (error != null) return error;
if (isRequesting) return '📥 Loading… $received/$total · $txEstimateLabel'; if (isRequesting) {
final etaLabel = _formatEta(eta);
return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel';
}
if (isComplete) { if (isComplete) {
final base = final base =
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}'; '🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
@@ -268,6 +421,14 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return '~${minutes}m ${seconds}s tx'; return '~${minutes}m ${seconds}s tx';
} }
static String _formatEta(Duration? eta) {
if (eta == null || eta <= Duration.zero) return 'ETA --';
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
final minutes = eta.inMinutes;
final seconds = eta.inSeconds % 60;
return 'ETA ~${minutes}m ${seconds}s';
}
void _showFullScreen(BuildContext context, Uint8List imageBytes) { void _showFullScreen(BuildContext context, Uint8List imageBytes) {
showGeneralDialog<void>( showGeneralDialog<void>(
context: context, context: context,

View File

@@ -15,16 +15,20 @@ import '../../providers/voice_provider.dart';
import '../../providers/image_provider.dart' as ip; import '../../providers/image_provider.dart' as ip;
import '../contacts/direct_message_sheet.dart'; import '../contacts/direct_message_sheet.dart';
import '../drawing_minimap_preview.dart'; import '../drawing_minimap_preview.dart';
import '../../models/ble_packet_log.dart';
import '../../services/sar_template_service.dart'; import '../../services/sar_template_service.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
import '../../utils/sar_message_parser.dart'; import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart'; import '../../utils/key_comparison.dart';
import '../../utils/voice_message_parser.dart'; import '../../utils/voice_message_parser.dart';
import '../../utils/image_message_parser.dart'; import '../../utils/image_message_parser.dart';
import '../../utils/tictactoe_message_parser.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart';
import 'message_trace_sheet.dart';
/// Reusable message bubble widget that displays messages with various types: /// Reusable message bubble widget that displays messages with various types:
/// - Regular text messages (channel or direct) /// - Regular text messages (channel or direct)
@@ -56,10 +60,17 @@ class MessageBubble extends StatefulWidget {
class _MessageBubbleState extends State<MessageBubble> { class _MessageBubbleState extends State<MessageBubble> {
bool _isExpanded = false; bool _isExpanded = false;
bool _showReceivedStats = false;
@override @override
void didUpdateWidget(MessageBubble oldWidget) { void didUpdateWidget(MessageBubble oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.message.id != widget.message.id) {
_isExpanded = false;
_showReceivedStats = false;
return;
}
// Force rebuild when message properties change (especially recipient statuses) // Force rebuild when message properties change (especially recipient statuses)
if (oldWidget.message.id == widget.message.id) { if (oldWidget.message.id == widget.message.id) {
// Same message, but properties might have changed // Same message, but properties might have changed
@@ -75,6 +86,15 @@ class _MessageBubbleState extends State<MessageBubble> {
}); });
} }
void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) {
if (!widget.isCompact && !isSarMarker && !isDrawing) {
setState(() {
_showReceivedStats = !_showReceivedStats;
});
}
widget.onTap?.call();
}
Future<void> _retryFailedMessage( Future<void> _retryFailedMessage(
BuildContext context, BuildContext context,
Message failedMessage, Message failedMessage,
@@ -278,6 +298,17 @@ class _MessageBubbleState extends State<MessageBubble> {
_showTechnicalDetails(context); _showTechnicalDetails(context);
}, },
), ),
if (!isOwnMessage &&
widget.message.pathLen > 0 &&
widget.message.pathLen < 255)
ListTile(
leading: const Icon(Icons.route),
title: const Text('Trace'),
onTap: () {
Navigator.pop(context);
_showTraceSheet(context);
},
),
// Delete message option // Delete message option
ListTile( ListTile(
leading: const Icon(Icons.delete, color: Colors.red), leading: const Icon(Icons.delete, color: Colors.red),
@@ -296,6 +327,18 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
void _showTraceSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => MessageTraceSheet(message: widget.message),
);
}
void _showTechnicalDetails(BuildContext context) { void _showTechnicalDetails(BuildContext context) {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final radioBw = connectionProvider.deviceInfo.radioBw; final radioBw = connectionProvider.deviceInfo.radioBw;
@@ -401,9 +444,21 @@ class _MessageBubbleState extends State<MessageBubble> {
?.sublist(0, recipientKey.length < 6 ? recipientKey.length : 6) ?.sublist(0, recipientKey.length < 6 ? recipientKey.length : 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(''); .join('');
final snrDb = widget.message.lastEchoSnrRaw != null final matchedRxLog = _findBestMatchingRxLog(
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0) connectionProvider.bleService.packetLogs,
: null; widget.message,
);
final packetPathBytes = _extractPathBytesFromLog(matchedRxLog);
final packetPathHex = packetPathBytes
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
final snrDb =
matchedRxLog?.logRxDataInfo?.snrDb ??
(widget.message.lastEchoSnrRaw != null
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0)
: null);
final rssiDbm =
matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm;
final rawLines = <String>[ final rawLines = <String>[
'Message ID: ${widget.message.id}', 'Message ID: ${widget.message.id}',
@@ -415,11 +470,14 @@ class _MessageBubbleState extends State<MessageBubble> {
'Status: ${widget.message.deliveryStatus.name}', 'Status: ${widget.message.deliveryStatus.name}',
'Path length (nodes/hops): ${widget.message.pathLen}', 'Path length (nodes/hops): ${widget.message.pathLen}',
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})', 'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
'Received at: ${widget.message.receivedAt.toIso8601String()}', 'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
'Channel index: ${widget.message.channelIdx ?? '-'}', 'Channel index: ${widget.message.channelIdx ?? '-'}',
'Echo count: ${widget.message.echoCount}', 'Echo count: ${widget.message.echoCount}',
'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}', 'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}',
'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}', 'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
'Matched RX RSSI: ${rssiDbm ?? '-'}',
'Matched RX SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
'Matched path bytes: ${packetPathHex ?? '-'}',
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}', 'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}', 'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
@@ -527,304 +585,357 @@ class _MessageBubbleState extends State<MessageBubble> {
ToastLogger.success(context, l10n.textCopiedToClipboard); ToastLogger.success(context, l10n.textCopiedToClipboard);
} }
showDialog( showModalBottomSheet(
context: context, context: context,
builder: (context) => AlertDialog( isScrollControlled: true,
title: Text(l10n.messageTechnicalDetails), backgroundColor: Theme.of(context).colorScheme.surface,
content: SizedBox( shape: const RoundedRectangleBorder(
width: double.maxFinite, borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
child: SingleChildScrollView( ),
child: Column( builder: (sheetContext) => SafeArea(
crossAxisAlignment: CrossAxisAlignment.stretch, child: SizedBox(
children: [ height: MediaQuery.of(sheetContext).size.height * 0.85,
Wrap( child: Column(
spacing: 8, crossAxisAlignment: CrossAxisAlignment.stretch,
runSpacing: 8, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 8, 8),
child: Row(
children: [ children: [
_techBadge( Expanded(
context, child: Text(
icon: Icons.message, l10n.messageTechnicalDetails,
label: widget.message.messageType.name.toUpperCase(), style: Theme.of(sheetContext).textTheme.titleLarge,
),
_techBadge(
context,
icon: Icons.route,
label:
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
),
_techBadge(
context,
icon: Icons.account_tree_outlined,
label:
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
),
if (widget.message.channelIdx != null)
_techBadge(
context,
icon: Icons.group_work,
label: 'CH ${widget.message.channelIdx}',
), ),
),
IconButton(
onPressed: () => Navigator.pop(sheetContext),
icon: const Icon(Icons.close),
tooltip: l10n.close,
),
], ],
), ),
if (widget.message.lastEchoRssiDbm != null || ),
snrDb != null) ...[ Expanded(
const SizedBox(height: 12), child: SingleChildScrollView(
_techSection( padding: const EdgeInsets.symmetric(horizontal: 16),
context, child: Column(
icon: Icons.network_check, crossAxisAlignment: CrossAxisAlignment.stretch,
title: l10n.linkQuality, children: [
child: Column( Wrap(
children: [ spacing: 8,
if (widget.message.lastEchoRssiDbm != null) runSpacing: 8,
_signalRow( children: [
_techBadge(
context, context,
label: 'RSSI', icon: Icons.message,
valueLabel: '${widget.message.lastEchoRssiDbm} dBm', label: widget.message.messageType.name
normalized: .toUpperCase(),
((widget.message.lastEchoRssiDbm!.toDouble() +
120.0) /
70.0)
.clamp(0.0, 1.0),
color: widget.message.lastEchoRssiDbm! >= -80
? Colors.green
: widget.message.lastEchoRssiDbm! >= -95
? Colors.amber
: Colors.redAccent,
), ),
if (snrDb != null) ...[ _techBadge(
const SizedBox(height: 8),
_signalRow(
context, context,
label: 'SNR', icon: Icons.route,
valueLabel: '${snrDb.toStringAsFixed(1)} dB', label:
normalized: ((snrDb + 20.0) / 40.0).clamp(0.0, 1.0), '${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
color: snrDb >= 10 ),
? Colors.green _techBadge(
: snrDb >= 0 context,
? Colors.amber icon: Icons.account_tree_outlined,
: Colors.redAccent, label:
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
),
if (widget.message.channelIdx != null)
_techBadge(
context,
icon: Icons.group_work,
label: 'CH ${widget.message.channelIdx}',
),
],
),
if (widget.message.lastEchoRssiDbm != null ||
snrDb != null ||
rssiDbm != null) ...[
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.network_check,
title: l10n.linkQuality,
child: Column(
children: [
if (rssiDbm != null)
_signalRow(
context,
label: 'RSSI',
valueLabel: '$rssiDbm dBm',
normalized:
((rssiDbm.toDouble() + 120.0) / 70.0)
.clamp(0.0, 1.0),
color: rssiDbm >= -80
? Colors.green
: rssiDbm >= -95
? Colors.amber
: Colors.redAccent,
),
if (snrDb != null) ...[
const SizedBox(height: 8),
_signalRow(
context,
label: 'SNR',
valueLabel: '${snrDb.toStringAsFixed(1)} dB',
normalized: ((snrDb + 20.0) / 40.0).clamp(
0.0,
1.0,
),
color: snrDb >= 10
? Colors.green
: snrDb >= 0
? Colors.amber
: Colors.redAccent,
),
],
],
),
),
],
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.tune,
title: l10n.delivery,
child: Column(
children: [
_detailRow(
context,
label: l10n.status,
value: widget.message.deliveryStatus.name,
),
_detailRow(
context,
label: 'Received (RFC3339)',
value: _formatRfc3339(widget.message.receivedAt),
onCopy: () => copyField(
_formatRfc3339(widget.message.receivedAt),
),
),
if (widget.message.expectedAckTag != null)
_detailRow(
context,
label: l10n.expectedAckTag,
value: widget.message.expectedAckTag!
.toString(),
),
if (widget.message.roundTripTimeMs != null)
_detailRow(
context,
label: l10n.roundTrip,
value: '${widget.message.roundTripTimeMs} ms',
),
if (widget.message.retryAttempt > 0)
_detailRow(
context,
label: l10n.retryAttempt,
value: widget.message.retryAttempt.toString(),
),
if (widget.message.usedFloodFallback)
_detailRow(
context,
label: l10n.floodFallback,
value: l10n.yes,
),
if (packetPathHex != null)
_detailRow(
context,
label: 'Path bytes',
value: packetPathHex,
onCopy: () => copyField(packetPathHex),
),
],
),
),
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.badge,
title: l10n.identity,
child: Column(
children: [
_detailRow(
context,
label: l10n.messageId,
value: widget.message.id,
onCopy: () => copyField(widget.message.id),
),
_detailRow(
context,
label: l10n.sender,
value:
senderName ??
widget.message.senderName ??
'Unknown',
),
if (senderPrefixHex != null)
_detailRow(
context,
label: l10n.senderKey,
value: senderPrefixHex,
onCopy: () => copyField(senderPrefixHex),
),
if (recipientName != null)
_detailRow(
context,
label: l10n.recipient,
value: recipientName,
),
if (recipientPrefixHex != null)
_detailRow(
context,
label: l10n.recipientKey,
value: recipientPrefixHex,
onCopy: () => copyField(recipientPrefixHex),
),
],
),
),
if (widget.message.isVoice) ...[
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.graphic_eq,
title: l10n.voice,
child: Column(
children: [
_detailRow(
context,
label: l10n.voiceId,
value: widget.message.voiceId ?? '-',
),
_detailRow(
context,
label: l10n.envelope,
value: envelope != null
? 'VE1 compact'
: legacyVoicePacket != null
? 'Legacy V packet'
: l10n.unknown,
),
if (voiceSession != null)
_detailRow(
context,
label: l10n.sessionProgress,
value:
'${voiceSession.receivedCount}/${voiceSession.total} segments',
),
if (voiceSession != null)
_detailRow(
context,
label: l10n.complete,
value: voiceSession.isComplete
? l10n.yes
: l10n.no,
),
if (voiceTxEstimate > Duration.zero)
_detailRow(
context,
label: 'Estimated tx',
value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s'
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
),
],
),
),
],
if (imageEnvelope != null) ...[
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.image_outlined,
title: 'Image',
child: Column(
children: [
_detailRow(
context,
label: l10n.envelope,
value: 'IE1',
),
_detailRow(
context,
label: 'Format',
value: imageEnvelope.format.label,
),
_detailRow(
context,
label: 'Dimensions',
value:
'${imageEnvelope.width}×${imageEnvelope.height}',
),
_detailRow(
context,
label: 'Segments',
value: imageSession != null
? '${imageSession.receivedCount}/${imageSession.total}'
: '${imageEnvelope.total}',
),
if (imageSession != null)
_detailRow(
context,
label: l10n.complete,
value: imageSession.isComplete
? l10n.yes
: l10n.no,
),
if (imageTxEstimate > Duration.zero)
_detailRow(
context,
label: 'Estimated tx',
value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s'
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
),
],
),
),
],
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
title: Text(
l10n.rawDump,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
rawLines.join('\n'),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
), ),
], ],
],
),
),
],
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.tune,
title: l10n.delivery,
child: Column(
children: [
_detailRow(
context,
label: l10n.status,
value: widget.message.deliveryStatus.name,
),
_detailRow(
context,
label: l10n.expectedAckTag,
value: widget.message.expectedAckTag?.toString() ?? '-',
),
_detailRow(
context,
label: l10n.roundTrip,
value: widget.message.roundTripTimeMs != null
? '${widget.message.roundTripTimeMs} ms'
: '-',
),
_detailRow(
context,
label: l10n.retryAttempt,
value: widget.message.retryAttempt.toString(),
),
_detailRow(
context,
label: l10n.floodFallback,
value: widget.message.usedFloodFallback
? l10n.yes
: l10n.no,
), ),
], ],
), ),
), ),
const SizedBox(height: 12), ),
_techSection( ],
context,
icon: Icons.badge,
title: l10n.identity,
child: Column(
children: [
_detailRow(
context,
label: l10n.messageId,
value: widget.message.id,
onCopy: () => copyField(widget.message.id),
),
_detailRow(
context,
label: l10n.sender,
value: senderName ?? widget.message.senderName ?? '-',
),
_detailRow(
context,
label: l10n.senderKey,
value: senderPrefixHex ?? '-',
onCopy: senderPrefixHex != null
? () => copyField(senderPrefixHex)
: null,
),
_detailRow(
context,
label: l10n.recipient,
value: recipientName ?? '-',
),
_detailRow(
context,
label: l10n.recipientKey,
value: recipientPrefixHex ?? '-',
onCopy: recipientPrefixHex != null
? () => copyField(recipientPrefixHex)
: null,
),
],
),
),
if (widget.message.isVoice) ...[
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.graphic_eq,
title: l10n.voice,
child: Column(
children: [
_detailRow(
context,
label: l10n.voiceId,
value: widget.message.voiceId ?? '-',
),
_detailRow(
context,
label: l10n.envelope,
value: envelope != null
? 'VE1 compact'
: legacyVoicePacket != null
? 'Legacy V packet'
: l10n.unknown,
),
if (voiceSession != null)
_detailRow(
context,
label: l10n.sessionProgress,
value:
'${voiceSession.receivedCount}/${voiceSession.total} segments',
),
if (voiceSession != null)
_detailRow(
context,
label: l10n.complete,
value: voiceSession.isComplete ? l10n.yes : l10n.no,
),
if (voiceTxEstimate > Duration.zero)
_detailRow(
context,
label: 'Estimated tx',
value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s'
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
),
],
),
),
],
if (imageEnvelope != null) ...[
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.image_outlined,
title: 'Image',
child: Column(
children: [
_detailRow(context, label: l10n.envelope, value: 'IE1'),
_detailRow(
context,
label: 'Format',
value: imageEnvelope.format.label,
),
_detailRow(
context,
label: 'Dimensions',
value:
'${imageEnvelope.width}×${imageEnvelope.height}',
),
_detailRow(
context,
label: 'Segments',
value: imageSession != null
? '${imageSession.receivedCount}/${imageSession.total}'
: '${imageEnvelope.total}',
),
if (imageSession != null)
_detailRow(
context,
label: l10n.complete,
value: imageSession.isComplete ? l10n.yes : l10n.no,
),
if (imageTxEstimate > Duration.zero)
_detailRow(
context,
label: 'Estimated tx',
value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s'
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
),
],
),
),
],
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
title: Text(
l10n.rawDump,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
rawLines.join('\n'),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
],
),
],
),
), ),
), ),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close),
),
],
), ),
); );
} }
@@ -972,6 +1083,64 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
String _formatRfc3339(DateTime dateTime) {
final utc = dateTime.toUtc();
String two(int v) => v.toString().padLeft(2, '0');
String four(int v) => v.toString().padLeft(4, '0');
final fraction = utc.millisecond == 0
? ''
: '.${utc.millisecond.toString().padLeft(3, '0')}';
return '${four(utc.year)}-${two(utc.month)}-${two(utc.day)}'
'T${two(utc.hour)}:${two(utc.minute)}:${two(utc.second)}'
'${fraction}Z';
}
BlePacketLog? _findBestMatchingRxLog(
List<BlePacketLog> logs,
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 logs) {
if (log.responseCode != 0x88) continue; // pushLogRxData
if (log.rawData.length < 6) continue;
// Logged frame format:
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
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<int>? _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);
}
void _showReplySheet(BuildContext context) { void _showReplySheet(BuildContext context) {
// Find the sender contact by public key prefix // Find the sender contact by public key prefix
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -1352,6 +1521,55 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
Widget _buildReceivedSignalStatus(
BuildContext context,
Message message, {
required int? rssiDbm,
required double? snrDb,
}) {
final hopLabel = message.pathLen == 0
? 'Direct'
: '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
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,
),
],
],
);
}
Widget _techChip( Widget _techChip(
BuildContext context, { BuildContext context, {
required IconData icon, required IconData icon,
@@ -1464,6 +1682,13 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
final message = widget.message; final message = widget.message;
final ticTacToeEvent = message.isContactMessage
? TicTacToeMessageParser.tryParse(message.text)
: null;
if (ticTacToeEvent?.type == TicTacToeEventType.move) {
// Hide move control packets from chat; the game bubble updates itself.
return const SizedBox.shrink();
}
final isSarMarker = message.isSarMarker; final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
@@ -1472,6 +1697,19 @@ class _MessageBubbleState extends State<MessageBubble> {
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = final isOwnMessage =
message.isSentMessage || message.isFromSelf(selfPublicKey); message.isSentMessage || message.isFromSelf(selfPublicKey);
final matchedRxLog = !isOwnMessage
? _findBestMatchingRxLog(
connectionProvider.bleService.packetLogs,
message,
)
: null;
final snrDb =
matchedRxLog?.logRxDataInfo?.snrDb ??
(message.lastEchoSnrRaw != null
? (message.lastEchoSnrRaw!.toSigned(8) / 4.0)
: null);
final rssiDbm =
matchedRxLog?.logRxDataInfo?.rssiDbm ?? message.lastEchoRssiDbm;
// Look up contact information for rich display name // Look up contact information for rich display name
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -1547,7 +1785,10 @@ class _MessageBubbleState extends State<MessageBubble> {
: recipientDisplayName; : recipientDisplayName;
return GestureDetector( return GestureDetector(
onTap: widget.onTap, onTap: () => _handleBubbleTap(
isSarMarker: isSarMarker,
isDrawing: message.isDrawing,
),
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context), onLongPress: widget.isCompact ? null : () => _showMessageOptions(context),
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -1948,10 +2189,27 @@ class _MessageBubbleState extends State<MessageBubble> {
else if (ImageEnvelope.isEnvelope(message.text) && else if (ImageEnvelope.isEnvelope(message.text) &&
!widget.isCompact) !widget.isCompact)
ImageMessageBubble(message: message, isSentByMe: isOwnMessage) ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
// Tic-Tac-Toe control message content
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
!widget.isCompact)
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
// Regular message content // Regular message content
else if (!message.isDrawing || widget.isCompact) else if (!message.isDrawing || widget.isCompact)
Text(message.text, style: Theme.of(context).textTheme.bodyMedium), Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
if (!widget.isCompact &&
!isSarMarker &&
!message.isDrawing &&
_showReceivedStats) ...[
const SizedBox(height: 6),
_buildReceivedSignalStatus(
context,
message,
rssiDbm: rssiDbm,
snrDb: snrDb,
),
],
// Delivery status for sent messages (skip in compact mode) // Delivery status for sent messages (skip in compact mode)
if (message.isSentMessage && !widget.isCompact) ...[ if (message.isSentMessage && !widget.isCompact) ...[
const SizedBox(height: 6), const SizedBox(height: 6),

View File

@@ -0,0 +1,436 @@
// ignore_for_file: use_null_aware_elements
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../models/ble_packet_log.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
class MessageTraceSheet extends StatefulWidget {
final Message message;
const MessageTraceSheet({super.key, required this.message});
@override
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
}
class _MessageTraceSheetState extends State<MessageTraceSheet> {
late final Future<_TraceResult> _future;
@override
void initState() {
super.initState();
_future = _loadTrace();
}
Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final nodes = await MeshMapNodesService.fetchNodes();
final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
);
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
final recipientPrefix = widget.message.recipientPublicKey != null
? _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,
);
}
// Fallback when packet path is unavailable.
final inferred = _inferRelaysFromHopCount(
nodes: nodes,
sender: senderNode,
recipient: recipientNode,
relayCount: math.max(0, widget.message.pathLen),
);
final matchedPathNodes = <MeshMapNode?>[
if (senderNode != null) senderNode,
...inferred,
if (recipientNode != null) recipientNode,
];
return _TraceResult(
mode: TraceMode.hopCountInference,
sender: senderNode,
recipient: recipientNode,
pathHashes: const [],
matchedPathNodes: matchedPathNodes,
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: FutureBuilder<_TraceResult>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 360,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return SizedBox(
height: 360,
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Failed to load trace: ${snapshot.error}'),
),
),
);
}
final trace = snapshot.data!;
final mapPoints = trace.matchedPathNodes
.whereType<MeshMapNode>()
.map((n) => LatLng(n.latitude, n.longitude))
.toList();
final hasMapPath = mapPoints.length >= 2;
final relayNodes = _relayNodes(trace.matchedPathNodes);
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: Text(
'Trace',
style: Theme.of(context).textTheme.titleLarge,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
trace.mode == TraceMode.packetPath
? 'Route from packet path bytes'
: 'Route inferred from hop count (${widget.message.pathLen})',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 10),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 240,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
child: hasMapPath
? flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds(
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
padding: const EdgeInsets.all(28),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: mapPoints,
strokeWidth: 4,
color: Theme.of(context).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: trace.matchedPathNodes
.whereType<MeshMapNode>()
.toList()
.asMap()
.entries
.map(
(entry) => flutter_map.Marker(
point: LatLng(
entry.value.latitude,
entry.value.longitude,
),
width: 34,
height: 34,
child: CircleAvatar(
radius: 16,
backgroundColor: entry.key == 0
? Colors.green
: (entry.key ==
trace
.matchedPathNodes
.whereType<MeshMapNode>()
.length -
1
? Colors.red
: Colors.blue),
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
),
)
.toList(),
),
],
)
: const Center(
child: Text('Not enough geolocated nodes to draw path'),
),
),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Relays (${relayNodes.length})',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (relayNodes.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No relay nodes could be matched for this message.'),
),
...relayNodes.map(
(node) => ListTile(
leading: const Icon(Icons.router),
title: Text(node.name),
subtitle: Text(
'${node.publicKey.substring(0, math.min(12, node.publicKey.length))}'
'${node.latitude.toStringAsFixed(5)}, ${node.longitude.toStringAsFixed(5)}',
),
),
),
],
),
),
],
),
);
},
),
);
}
List<MeshMapNode> _relayNodes(List<MeshMapNode?> path) {
final concrete = path.whereType<MeshMapNode>().toList();
if (concrete.length <= 2) return const [];
return concrete.sublist(1, concrete.length - 1);
}
String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6;
return key
.take(take)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
if (prefixHex == null || prefixHex.isEmpty) return null;
final matches = nodes
.where((n) => n.publicKey.startsWith(prefixHex))
.toList()
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return matches.isEmpty ? null : matches.first;
}
List<int>? _extractPathFromPacketLogs({
required List<BlePacketLog> logs,
required 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 logs) {
if (log.responseCode != 0x88) continue; // pushLogRxData
if (log.rawData.length < 6) continue;
final raw = log.rawData;
final header = raw[3];
final payloadType = (header >> 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 (bestLog == null || bestDeltaMs > 30000) return null;
final raw = bestLog.rawData;
final pathLen = raw[4];
return raw.sublist(5, 5 + pathLen);
}
List<MeshMapNode?> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes,
required List<int> pathHashes,
required String? senderPrefix,
required String? recipientPrefix,
}) {
final result = <MeshMapNode?>[];
for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
final candidates = nodes.where((n) => n.publicKey.startsWith(hashHex)).toList();
if (candidates.isEmpty) {
result.add(null);
continue;
}
List<MeshMapNode> filtered = candidates;
if (i == 0 && senderPrefix != null) {
final senderMatches = filtered.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));
result.add(filtered.first);
}
return result;
}
List<MeshMapNode> _inferRelaysFromHopCount({
required List<MeshMapNode> nodes,
required MeshMapNode? sender,
required MeshMapNode? recipient,
required int relayCount,
}) {
if (relayCount <= 0 || sender == null || recipient == null) return const [];
final candidates = nodes.where((n) {
if (sender.publicKey == n.publicKey || recipient.publicKey == n.publicKey) {
return false;
}
return true;
}).toList();
final ranked = candidates
..sort((a, b) {
final da = _distanceToSegmentMeters(
p: LatLng(a.latitude, a.longitude),
a: LatLng(sender.latitude, sender.longitude),
b: LatLng(recipient.latitude, recipient.longitude),
);
final db = _distanceToSegmentMeters(
p: LatLng(b.latitude, b.longitude),
a: LatLng(sender.latitude, sender.longitude),
b: LatLng(recipient.latitude, recipient.longitude),
);
return da.compareTo(db);
});
return ranked.take(relayCount).toList();
}
double _distanceToSegmentMeters({
required LatLng p,
required LatLng a,
required LatLng b,
}) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return const Distance().as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return const Distance().as(LengthUnit.Meter, closest, p);
}
}
enum TraceMode { packetPath, hopCountInference }
class _TraceResult {
final TraceMode mode;
final MeshMapNode? sender;
final MeshMapNode? recipient;
final List<int> pathHashes;
final List<MeshMapNode?> matchedPathNodes;
const _TraceResult({
required this.mode,
required this.sender,
required this.recipient,
required this.pathHashes,
required this.matchedPathNodes,
});
}

View File

@@ -0,0 +1,297 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../utils/tictactoe_message_parser.dart';
import '../../utils/toast_logger.dart';
class TicTacToeMessageBubble extends StatelessWidget {
final Message message;
final bool isSentByMe;
const TicTacToeMessageBubble({
super.key,
required this.message,
required this.isSentByMe,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final event = TicTacToeMessageParser.tryParse(message.text);
if (event == null) {
return const SizedBox.shrink();
}
final conn = context.watch<ConnectionProvider>();
final contacts = context.read<ContactsProvider>();
final messages = context.watch<MessagesProvider>().messages;
final selfKey = conn.deviceInfo.publicKey;
if (selfKey == null || selfKey.length < 6) {
return const Text('Tic-Tac-Toe unavailable');
}
final selfKey6 = _key6Hex(selfKey);
final opponent = _resolveOpponentContact(
message: message,
contactsProvider: contacts,
isSentByMe: isSentByMe,
);
if (opponent == null) {
return const Text('Tic-Tac-Toe: opponent unknown');
}
final opponentKey6 = _key6Hex(opponent.publicKey);
final gameEvents = <TicTacToeEvent>[];
TicTacToeEvent? start;
for (final m in messages) {
if (!m.isContactMessage) continue;
final parsed = TicTacToeMessageParser.tryParse(m.text);
if (parsed == null || parsed.gameId != event.gameId) continue;
if (!_isSameDmThread(
message: m,
selfKey: selfKey,
opponentKey6: opponentKey6,
)) {
continue;
}
if (parsed.type == TicTacToeEventType.start) {
start ??= parsed;
} else {
gameEvents.add(parsed);
}
}
start ??= event.type == TicTacToeEventType.start ? event : null;
if (start == null) {
return const Text('Tic-Tac-Toe: waiting for start');
}
final xPlayer = start.playerKey6;
final oPlayer = xPlayer == selfKey6 ? opponentKey6 : selfKey6;
final state = buildTicTacToeState(
gameId: event.gameId,
xPlayerKey6: xPlayer,
oPlayerKey6: oPlayer,
events: gameEvents,
);
final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O';
final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol;
final titleColor = isSentByMe
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final statusColor = isSentByMe
? colorScheme.onPrimaryContainer.withValues(alpha: 0.85)
: colorScheme.onSurface.withValues(alpha: 0.85);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 230),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tic-Tac-Toe · Game ${state.gameId}',
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: titleColor,
),
),
const SizedBox(height: 8),
_BoardGrid(
board: state.board,
enabled: isMyTurn,
isSentByMe: isSentByMe,
onTapCell: (idx) => _onCellTap(
context: context,
idx: idx,
state: state,
selfKey6: selfKey6,
opponent: opponent,
connectionProvider: conn,
),
),
const SizedBox(height: 8),
Text(
_statusText(state: state, mySymbol: mySymbol),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: statusColor),
),
],
),
);
}
Future<void> _onCellTap({
required BuildContext context,
required int idx,
required TicTacToeGameState state,
required String selfKey6,
required Contact opponent,
required ConnectionProvider connectionProvider,
}) async {
if (idx < 0 || idx > 8 || state.board[idx] != null) return;
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
return;
}
final messagesProvider = context.read<MessagesProvider>();
final text = TicTacToeMessageParser.encodeMove(
gameId: state.gameId,
cell: idx,
playerKey6: selfKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final messageId = '${DateTime.now().millisecondsSinceEpoch}_ttt_move';
final senderPublicKeyPrefix = connectionProvider.deviceInfo.publicKey!
.sublist(0, 6);
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: opponent.publicKey,
);
messagesProvider.addSentMessage(sentMessage);
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: opponent.publicKey,
text: text,
messageId: messageId,
contact: opponent,
);
if (!sent) {
messagesProvider.markMessageFailed(messageId);
if (!context.mounted) return;
ToastLogger.error(context, 'Failed to send Tic-Tac-Toe move');
}
}
static String _statusText({
required TicTacToeGameState state,
required String mySymbol,
}) {
if (state.winnerSymbol != null) {
return state.winnerSymbol == mySymbol ? 'You won' : 'Opponent won';
}
if (state.isDraw) return 'Draw';
return state.nextSymbol == mySymbol ? 'Your turn' : 'Opponent turn';
}
static String _key6Hex(Uint8List key) => key
.sublist(0, math.min(6, key.length))
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('')
.toLowerCase();
static Contact? _resolveOpponentContact({
required Message message,
required ContactsProvider contactsProvider,
required bool isSentByMe,
}) {
if (isSentByMe && message.recipientPublicKey != null) {
return contactsProvider.findContactByKey(message.recipientPublicKey!);
}
final sender = message.senderPublicKeyPrefix;
if (sender == null || sender.length < 6) return null;
return contactsProvider.findContactByPrefix(
Uint8List.fromList(sender.sublist(0, 6)),
);
}
static bool _isSameDmThread({
required Message message,
required Uint8List selfKey,
required String opponentKey6,
}) {
final isOwn = message.isSentMessage || message.isFromSelf(selfKey);
if (isOwn) {
final recipient = message.recipientPublicKey;
if (recipient == null || recipient.length < 6) return false;
return _key6Hex(recipient) == opponentKey6;
}
final sender = message.senderPublicKeyPrefix;
if (sender == null || sender.length < 6) return false;
return _key6Hex(sender) == opponentKey6;
}
}
class _BoardGrid extends StatelessWidget {
final List<String?> board;
final bool enabled;
final bool isSentByMe;
final ValueChanged<int> onTapCell;
const _BoardGrid({
required this.board,
required this.enabled,
required this.isSentByMe,
required this.onTapCell,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final cellBackground = isSentByMe
? colorScheme.primaryContainer.withValues(alpha: 0.35)
: colorScheme.surface;
final cellBorder = isSentByMe
? colorScheme.primary.withValues(alpha: 0.45)
: colorScheme.outline.withValues(alpha: 0.35);
return SizedBox(
width: 180,
height: 180,
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
itemCount: 9,
itemBuilder: (context, idx) {
final value = board[idx];
return InkWell(
onTap: enabled && value == null ? () => onTapCell(idx) : null,
borderRadius: BorderRadius.circular(8),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: cellBackground,
border: Border.all(color: cellBorder),
),
child: Text(
value ?? '',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
color: value == 'X'
? colorScheme.primary
: value == 'O'
? colorScheme.tertiary
: null,
),
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'dart:async';
/// Calculates a transfer timeout as 2× the estimated LoRa airtime,
/// with a minimum of 30 seconds.
///
/// Used by [ImageMessageBubble] and [VoiceMessageBubble] to reset the
/// "loading" spinner when a transfer stalls, allowing the user to retry.
class TransferTimeout {
static const Duration _minimum = Duration(seconds: 30);
/// Start a one-shot timer based on [txEstimate] × 2 (min 30s).
///
/// [onTimeout] is called on the UI thread when the timer fires.
/// Returns the [Timer] so the caller can cancel it (e.g. on dispose or
/// when the transfer completes).
static Timer start({
required Duration txEstimate,
required void Function() onTimeout,
}) {
final timeout = txEstimate * 2;
final effective = timeout < _minimum ? _minimum : timeout;
return Timer(effective, onTimeout);
}
}

View File

@@ -1,13 +1,14 @@
import 'dart:typed_data'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/voice_provider.dart'; import '../../providers/voice_provider.dart';
import '../../utils/transmission_target_resolver.dart';
import '../../utils/voice_message_parser.dart'; import '../../utils/voice_message_parser.dart';
import 'transfer_timeout.dart';
/// A message bubble that shows a voice recording with play/stop controls. /// A message bubble that shows a voice recording with play/stop controls.
class VoiceMessageBubble extends StatefulWidget { class VoiceMessageBubble extends StatefulWidget {
@@ -25,9 +26,17 @@ class VoiceMessageBubble extends StatefulWidget {
} }
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> { class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
static const int _maxFetchHops = 3;
bool _isRequesting = false; bool _isRequesting = false;
bool _autoPlayWhenReady = false; bool _autoPlayWhenReady = false;
String? _errorText; String? _errorText;
Timer? _requestTimeoutTimer;
@override
void dispose() {
_requestTimeoutTimer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -45,8 +54,21 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return Consumer<VoiceProvider>( return Consumer<VoiceProvider>(
builder: (context, voiceProvider, _) { builder: (context, voiceProvider, _) {
final contactsProvider = context.read<ContactsProvider>();
final session = voiceProvider.session(voiceId); final session = voiceProvider.session(voiceId);
final envelope = VoiceEnvelope.tryParseText(widget.message.text); final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final sender = TransmissionTargetResolver.resolveLocalTarget(
contactsProvider: contactsProvider,
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
: widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId); final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId); final isComplete = voiceProvider.isComplete(voiceId);
@@ -86,12 +108,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
session: session, session: session,
envelope: envelope, envelope: envelope,
messageText: widget.message.text, messageText: widget.message.text,
pathLen: widget.message.pathLen, pathLen: effectivePathLen,
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
); );
final txEstimateLabel = _formatTransmitEstimate(txEstimate); final txEstimateLabel = _formatTransmitEstimate(txEstimate);
final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -106,7 +129,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
await voiceProvider.play(voiceId); await voiceProvider.play(voiceId);
return; return;
} }
await _requestAndPlayVoice(voiceId); await _requestAndPlayVoice(
voiceId,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
);
}, },
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: Container( child: Container(
@@ -158,6 +188,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
requestingLabel: AppLocalizations.of( requestingLabel: AppLocalizations.of(
context, context,
)!.requestingVoice, )!.requestingVoice,
eta: eta,
), ),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
@@ -174,18 +205,69 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
); );
} }
Future<void> _requestAndPlayVoice(String sessionId) async { Future<void> _requestAndPlayVoice(
String sessionId, {
VoiceEnvelope? envelope,
int? radioBw,
int? radioSf,
int? radioCr,
int pathLen = 0,
}) async {
if (_isRequesting) return; if (_isRequesting) return;
final sender = _resolveSenderContact(); final connectionProvider = context.read<ConnectionProvider>();
if (sender == null) { final contactsProvider = context.read<ContactsProvider>();
_setUnavailable(); final 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) {
await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return; return;
} }
final connectionProvider = context.read<ConnectionProvider>(); final sender = resolution.target!;
if (sender.outPathLen >= 2) {
_showToast(
'Voice fetch over ${sender.outPathLen} hops may take a while.',
);
}
if (!mounted) return;
setState(() {
_errorText = null;
});
final deviceKey = connectionProvider.deviceInfo.publicKey; final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) { if (deviceKey == null || deviceKey.length < 6) {
_setUnavailable(); await _showBlockingAlert(
'Cannot fetch voice',
'Device key is unavailable.',
);
return; return;
} }
@@ -197,7 +279,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
sessionId: sessionId, sessionId: sessionId,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1, version: 2,
); );
setState(() { setState(() {
@@ -206,18 +288,44 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
_errorText = null; _errorText = null;
}); });
final sent = await connectionProvider.sendTextMessage( try {
contactPublicKey: sender.publicKey, await connectionProvider.sendRawVoicePacket(
text: request.encodeText(), contactPath: sender.outPath,
contact: sender, contactPathLen: sender.outPathLen,
); payload: request.encodeBinary(),
if (!sent) { );
} catch (_) {
_setUnavailable(); _setUnavailable();
return;
} }
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final txEstimate = envelope != null
? estimateVoiceTransmitDuration(
packetCount: envelope.total,
mode: envelope.mode,
durationMs: envelope.durationMs,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
)
: const Duration(seconds: 15);
_requestTimeoutTimer?.cancel();
_requestTimeoutTimer = TransferTimeout.start(
txEstimate: txEstimate,
onTimeout: () {
if (mounted && _isRequesting) {
_setUnavailable();
}
},
);
} }
void _setUnavailable() { void _setUnavailable() {
if (!mounted) return; if (!mounted) return;
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_autoPlayWhenReady = false; _autoPlayWhenReady = false;
@@ -225,25 +333,29 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}); });
} }
Contact? _resolveSenderContact() { void _showToast(String message) {
final contactsProvider = context.read<ContactsProvider>(); if (!mounted) return;
final senderPrefix = widget.message.senderPublicKeyPrefix; ScaffoldMessenger.of(context).showSnackBar(
if (senderPrefix != null && senderPrefix.length >= 6) { SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
final contact = contactsProvider.findContactByPrefix( );
Uint8List.fromList(senderPrefix.sublist(0, 6)), }
);
if (contact != null) return contact;
}
final envelope = VoiceEnvelope.tryParseText(widget.message.text); Future<void> _showBlockingAlert(String title, String message) async {
if (envelope != null) { if (!mounted) return;
final contact = contactsProvider.findContactByPrefixHex( _showToast('$title: $message');
envelope.senderKey6, await showDialog<void>(
); context: context,
if (contact != null) return contact; builder: (dialogContext) => AlertDialog(
} title: Text(title),
content: Text(message),
return null; actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('OK'),
),
],
),
);
} }
static String _formatDuration(double seconds) { static String _formatDuration(double seconds) {
@@ -262,11 +374,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
required bool isRequesting, required bool isRequesting,
required String? errorText, required String? errorText,
required String requestingLabel, required String requestingLabel,
required Duration? eta,
}) { }) {
if (errorText != null) return errorText; if (errorText != null) return errorText;
final progress = total > 0 ? ' ($received/$total)' : ''; final progress = total > 0 ? ' ($received/$total)' : '';
if (isRequesting) { if (isRequesting) {
return '$requestingLabel$progress · $txEstimateLabel'; return '$requestingLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
} }
if (!isComplete && total > 0) { if (!isComplete && total > 0) {
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel'; return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
@@ -346,6 +459,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final seconds = value.inSeconds % 60; final seconds = value.inSeconds % 60;
return '~${minutes}m ${seconds}s tx'; return '~${minutes}m ${seconds}s tx';
} }
static String _formatEta(Duration? eta) {
if (eta == null || eta <= Duration.zero) return 'ETA --';
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
final minutes = eta.inMinutes;
final seconds = eta.inSeconds % 60;
return 'ETA ~${minutes}m ${seconds}s';
}
} }
/// Voice waveform rendered as a row of bars. /// Voice waveform rendered as a row of bars.

View File

@@ -883,7 +883,7 @@ packages:
description: description:
path: "." path: "."
ref: main ref: main
resolved-ref: "624e3d3cf6ea32d8245cc85d5b599f30ca910501" resolved-ref: d6f91774f19136ff71b0087feaf95fa5490524d9
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"

View File

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

View File

@@ -0,0 +1,145 @@
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/helpers/raw_session_retransmit.dart';
class _Fragment {
final int index;
final Uint8List payload;
_Fragment(this.index, this.payload);
}
Contact _buildContact({required int outPathLen}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: outPathLen,
outPath: Uint8List.fromList(List<int>.generate(8, (i) => i + 1)),
advName: 'Requester',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('serveCachedSessionFragments', () {
test('returns false when sender callback is missing', () 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: null,
);
expect(ok, isFalse);
});
test('sends only requested indices and waits for ack', () async {
final sent = <Uint8List>[];
final waited = <int>[];
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: 1),
fragments: [
_Fragment(0, Uint8List.fromList([10])),
_Fragment(1, Uint8List.fromList([20])),
_Fragment(2, Uint8List.fromList([30])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {
sent.add(payload);
},
waitForFragmentAck:
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) async {
waited.add(index);
return true;
},
requestedIndices: {1, 2},
);
expect(ok, isTrue);
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 {
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 {},
requestedIndices: {99},
);
expect(ok, isFalse);
});
});
}

View File

@@ -8,7 +8,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
test('marks VE1 envelope messages as voice', () { test('marks VE2 envelope messages as voice', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final envelope = VoiceEnvelope( final envelope = VoiceEnvelope(
sessionId: 'deafbead', sessionId: 'deafbead',

View File

@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
void main() {
group('ImageEnvelope', () {
test('encodes and parses IE2 with compressed session id', () {
final env = ImageEnvelope(
sessionId: '0000000a',
format: ImageFormat.avif,
total: 14,
width: 256,
height: 171,
sizeBytes: 2100,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
);
final text = env.encode();
expect(text.startsWith('IE2:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = ImageEnvelope.tryParse(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.format, equals(ImageFormat.avif));
expect(parsed.total, equals(14));
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));
});
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', () {
final req = ImageFetchRequest(
sessionId: '0000000a',
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final text = req.encode();
expect(text.startsWith('IR2:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = ImageFetchRequest.tryParse(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.want, equals('all'));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(2));
});
test('encodes and parses compact missing index ranges', () {
final req = ImageFetchRequest(
sessionId: '0000000a',
want: 'missing',
missingIndices: const [0, 1, 2, 5, 6, 8],
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final text = req.encode();
expect(text, contains(':m0-2.5-6.8:'));
final parsed = ImageFetchRequest.tryParse(text);
expect(parsed, isNotNull);
expect(parsed!.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 1, 2, 5, 6, 8]));
});
test('rejects IR1 legacy prefix', () {
const legacy = 'IR1:00112233:a:ffeeddccbbaa:1700000001:1';
expect(ImageFetchRequest.tryParse(legacy), isNull);
});
test('encodes and parses binary fetch request', () {
final req = ImageFetchRequest(
sessionId: '01020304',
want: 'missing',
missingIndices: const [0, 2, 5],
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final payload = req.encodeBinary();
expect(ImageFetchRequest.isRequestBinary(payload), isTrue);
final parsed = ImageFetchRequest.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 2, 5]));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(2));
});
});
group('ImageFragmentAck', () {
test('encodes and parses binary ack', () {
final ack = ImageFragmentAck(sessionId: '01020304', index: 9);
final payload = ack.encodeBinary();
expect(ImageFragmentAck.isImageFragmentAckBinary(payload), isTrue);
final parsed = ImageFragmentAck.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.index, equals(9));
});
});
}

View File

@@ -6,74 +6,122 @@ void main() {
group('VoiceEnvelope', () { group('VoiceEnvelope', () {
test('encodes and parses valid envelope', () { test('encodes and parses valid envelope', () {
final env = VoiceEnvelope( final env = VoiceEnvelope(
sessionId: 'deadbeef', sessionId: '0000000a',
mode: VoicePacketMode.mode1200, mode: VoicePacketMode.mode1200,
total: 4, total: 4,
durationMs: 3200, durationMs: 3000,
senderKey6: 'aabbccddeeff', senderKey6: 'aabbccddeeff',
timestampSec: 1700000000, timestampSec: 1700000000,
); );
final text = env.encodeText(); final text = env.encodeText();
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue); expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
expect(text.startsWith('VE2:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = VoiceEnvelope.tryParseText(text); final parsed = VoiceEnvelope.tryParseText(text);
expect(parsed, isNotNull); expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('deadbeef')); expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.mode, equals(VoicePacketMode.mode1200)); expect(parsed.mode, equals(VoicePacketMode.mode1200));
expect(parsed.total, equals(4)); expect(parsed.total, equals(4));
expect(parsed.durationMs, equals(3200)); expect(parsed.durationMs, equals(3000));
expect(parsed.senderKey6, equals('aabbccddeeff')); expect(parsed.senderKey6, equals('aabbccddeeff'));
expect(parsed.version, equals(1)); expect(parsed.version, equals(2));
}); });
test('rejects invalid envelope payload', () { test('rejects invalid envelope payload', () {
final text = 'VE1:nothex:1:2:1000:aabbccddeeff:1700000000:1'; final text = 'VE2:bad_sid:1:2:1000:aabbccddeeff:s44we8';
expect(VoiceEnvelope.tryParseText(text), isNull); expect(VoiceEnvelope.tryParseText(text), isNull);
}); });
test('rejects legacy v1 envelope prefix', () {
const legacy = 'VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1';
expect(VoiceEnvelope.tryParseText(legacy), isNull);
});
}); });
group('VoiceFetchRequest', () { group('VoiceFetchRequest', () {
test('encodes and parses valid request', () { test('encodes and parses valid request', () {
final req = VoiceFetchRequest( final req = VoiceFetchRequest(
sessionId: '00112233', sessionId: '0000000a',
requesterKey6: 'ffeeddccbbaa', requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001, timestampSec: 1700000001,
); );
final text = req.encodeText(); final text = req.encodeText();
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue); expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
expect(text.startsWith('VR2:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = VoiceFetchRequest.tryParseText(text); final parsed = VoiceFetchRequest.tryParseText(text);
expect(parsed, isNotNull); expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('00112233')); expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.want, equals('all')); expect(parsed.want, equals('all'));
expect(parsed.requesterKey6, equals('ffeeddccbbaa')); expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(1)); expect(parsed.version, equals(2));
}); });
test('rejects invalid request payload', () { test('rejects invalid request payload', () {
expect( expect(
VoiceFetchRequest.tryParseText( VoiceFetchRequest.tryParseText(
'VR1:00112233:chunk:ffeeddccbbaa:1700000001:1', 'VR2:a:chunk:ffeeddccbbaa:s44we9',
), ),
isNull, isNull,
); );
}); });
test('rejects legacy v1 request prefix', () {
const legacy = 'VR1:00112233:a:ffeeddccbbaa:1700000001:1';
expect(VoiceFetchRequest.tryParseText(legacy), isNull);
});
test('encodes and parses missing-packet request', () { test('encodes and parses missing-packet request', () {
final req = VoiceFetchRequest( final req = VoiceFetchRequest(
sessionId: '00112233', sessionId: '0000000a',
want: 'missing', want: 'missing',
missingIndices: const [0, 3, 7], missingIndices: const [0, 1, 2, 3, 7],
requesterKey6: 'ffeeddccbbaa', requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001, timestampSec: 1700000001,
); );
final text = req.encodeText(); final text = req.encodeText();
expect(text, contains(':m0-3.7:'));
final parsed = VoiceFetchRequest.tryParseText(text); final parsed = VoiceFetchRequest.tryParseText(text);
expect(parsed, isNotNull); expect(parsed, isNotNull);
expect(parsed!.want, equals('missing')); expect(parsed!.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 3, 7])); expect(parsed.missingIndices, equals([0, 1, 2, 3, 7]));
});
test('encodes and parses binary fetch request', () {
final req = VoiceFetchRequest(
sessionId: '01020304',
want: 'missing',
missingIndices: const [1, 4],
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final payload = req.encodeBinary();
expect(VoiceFetchRequest.isVoiceFetchRequestBinary(payload), isTrue);
final parsed = VoiceFetchRequest.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.want, equals('missing'));
expect(parsed.missingIndices, equals([1, 4]));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(2));
});
});
group('VoiceFragmentAck', () {
test('encodes and parses binary ack', () {
final ack = VoiceFragmentAck(sessionId: '01020304', index: 7);
final payload = ack.encodeBinary();
expect(VoiceFragmentAck.isVoiceFragmentAckBinary(payload), isTrue);
final parsed = VoiceFragmentAck.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.index, equals(7));
}); });
}); });