mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +00:00
Compare commits
54 Commits
v2026.0302
...
v2026.0306
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1856dce27a | ||
|
|
485ae995c3 | ||
|
|
0e1a56d765 | ||
|
|
ca8e8e6ecb | ||
|
|
43f5702666 | ||
|
|
85d0b26c67 | ||
|
|
7cd5351921 | ||
|
|
96feb75712 | ||
|
|
f6779e8776 | ||
|
|
a9d062dc34 | ||
|
|
3680b38302 | ||
|
|
e3902cea1e | ||
|
|
2482f94cd0 | ||
|
|
2b34bc4162 | ||
|
|
03c0c7ad38 | ||
|
|
42fc1f9cd2 | ||
|
|
7807ad7471 | ||
|
|
310eee1ff5 | ||
|
|
b8f03079e7 | ||
|
|
ab0c531a4b | ||
|
|
9709511e90 | ||
|
|
162d5333ce | ||
|
|
0cb1d49804 | ||
|
|
234edf5bb0 | ||
|
|
2d9cb0ddb9 | ||
|
|
76b093685e | ||
|
|
55a85659d3 | ||
|
|
972a9ba944 | ||
|
|
f4e3f2e834 | ||
|
|
50a4322f44 | ||
|
|
c83eaa4d98 | ||
|
|
5beac70644 | ||
|
|
99cd378e6a | ||
|
|
6a52e7cfa1 | ||
|
|
af2e969640 | ||
|
|
b12567f613 | ||
|
|
13b91e3cc9 | ||
|
|
40d786eb25 | ||
|
|
b6aa0bcfaa | ||
|
|
f31083694f | ||
|
|
1460bdb51f | ||
|
|
537d98e2d4 | ||
|
|
7ffc46438f | ||
|
|
efab2cd057 | ||
|
|
d6077d1588 | ||
|
|
84a6f81c26 | ||
|
|
c6b3b86c2f | ||
|
|
d1e6aaafbe | ||
|
|
3036ba620c | ||
|
|
9854dd2034 | ||
|
|
e9a08fab4d | ||
|
|
9814c95cee | ||
|
|
85ebf51bcb | ||
|
|
e85e290265 |
45
.github/workflows/build-artifacts.yml
vendored
45
.github/workflows/build-artifacts.yml
vendored
@@ -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'
|
||||||
|
|||||||
15
Makefile
15
Makefile
@@ -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!"
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -39,5 +39,13 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
|
# codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that
|
||||||
|
# fails to compile for iOS simulators. Voice codec support is only required
|
||||||
|
# on real iOS devices, so skip the native Codec2 sources for simulator SDKs.
|
||||||
|
if target.name == 'codec2_flutter'
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
config.build_settings['EXCLUDED_SOURCE_FILE_NAMES[sdk=iphonesimulator*]'] = '*.c *.cpp'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ PODS:
|
|||||||
- ObjectBox (= 4.4.1)
|
- ObjectBox (= 4.4.1)
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- path_provider_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- record_ios (1.2.0):
|
- record_ios (1.2.0):
|
||||||
@@ -102,6 +105,7 @@ DEPENDENCIES:
|
|||||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
@@ -149,6 +153,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
|
path_provider_foundation:
|
||||||
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
record_ios:
|
record_ios:
|
||||||
@@ -183,6 +189,7 @@ SPEC CHECKSUMS:
|
|||||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
||||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
||||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||||
@@ -193,6 +200,6 @@ SPEC CHECKSUMS:
|
|||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -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 = 92;
|
||||||
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 = 92;
|
||||||
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 = 92;
|
||||||
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 = 92;
|
||||||
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 = 92;
|
||||||
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 = 92;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>68</string>
|
<string>92</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>
|
||||||
|
|||||||
@@ -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.000216">
|
||||||
|
|
||||||
</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.451844">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="83.968525">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="110.228647">
|
||||||
|
|
||||||
</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="475.510001">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
2271
lib/l10n/app_localizations_zh.dart
Normal file
2271
lib/l10n/app_localizations_zh.dart
Normal file
File diff suppressed because it is too large
Load Diff
3848
lib/l10n/app_zh.arb
Normal file
3848
lib/l10n/app_zh.arb
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,12 +73,14 @@ class DeviceInfo {
|
|||||||
final String? selfName;
|
final String? selfName;
|
||||||
|
|
||||||
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
||||||
final int? maxContacts; // Max contacts device supports
|
final int? maxContacts; // Max contacts device supports
|
||||||
final int? maxChannels; // Max channels device supports
|
final int? maxChannels; // Max channels device supports
|
||||||
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
final int?
|
||||||
final int? blePin; // BLE PIN code
|
telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||||
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
final int? blePin; // BLE PIN code
|
||||||
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||||
|
final int?
|
||||||
|
advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||||
|
|
||||||
// Firmware info
|
// Firmware info
|
||||||
final int? firmwareVersion;
|
final int? firmwareVersion;
|
||||||
@@ -88,6 +90,9 @@ class DeviceInfo {
|
|||||||
|
|
||||||
// Repeat mode (firmware v9+)
|
// Repeat mode (firmware v9+)
|
||||||
final bool? clientRepeat;
|
final bool? clientRepeat;
|
||||||
|
final bool? supportsSpectrumScan;
|
||||||
|
final int? spectrumScanMinKhz;
|
||||||
|
final int? spectrumScanMaxKhz;
|
||||||
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
|
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
|
||||||
|
|
||||||
DeviceInfo({
|
DeviceInfo({
|
||||||
@@ -124,6 +129,9 @@ class DeviceInfo {
|
|||||||
this.manufacturerModel,
|
this.manufacturerModel,
|
||||||
this.semanticVersion,
|
this.semanticVersion,
|
||||||
this.clientRepeat,
|
this.clientRepeat,
|
||||||
|
this.supportsSpectrumScan,
|
||||||
|
this.spectrumScanMinKhz,
|
||||||
|
this.spectrumScanMaxKhz,
|
||||||
this.allowedRepeatFreqRanges,
|
this.allowedRepeatFreqRanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +168,9 @@ class DeviceInfo {
|
|||||||
|
|
||||||
/// Get storage usage percentage (0-100)
|
/// Get storage usage percentage (0-100)
|
||||||
double? get storageUsedPercent {
|
double? get storageUsedPercent {
|
||||||
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) {
|
if (storageUsedKb == null ||
|
||||||
|
storageTotalKb == null ||
|
||||||
|
storageTotalKb == 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (storageUsedKb! / storageTotalKb!) * 100.0;
|
return (storageUsedKb! / storageTotalKb!) * 100.0;
|
||||||
@@ -245,6 +255,9 @@ class DeviceInfo {
|
|||||||
String? manufacturerModel,
|
String? manufacturerModel,
|
||||||
String? semanticVersion,
|
String? semanticVersion,
|
||||||
bool? clientRepeat,
|
bool? clientRepeat,
|
||||||
|
bool? supportsSpectrumScan,
|
||||||
|
int? spectrumScanMinKhz,
|
||||||
|
int? spectrumScanMaxKhz,
|
||||||
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
||||||
}) {
|
}) {
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
@@ -281,7 +294,11 @@ class DeviceInfo {
|
|||||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||||
semanticVersion: semanticVersion ?? this.semanticVersion,
|
semanticVersion: semanticVersion ?? this.semanticVersion,
|
||||||
clientRepeat: clientRepeat ?? this.clientRepeat,
|
clientRepeat: clientRepeat ?? this.clientRepeat,
|
||||||
allowedRepeatFreqRanges: allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
||||||
|
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
||||||
|
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
||||||
|
allowedRepeatFreqRanges:
|
||||||
|
allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
lib/models/message_contact_location.dart
Normal file
63
lib/models/message_contact_location.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
class MessageContactLocation {
|
||||||
|
final LatLng location;
|
||||||
|
final String source;
|
||||||
|
final DateTime capturedAt;
|
||||||
|
final DateTime? sourceTimestamp;
|
||||||
|
|
||||||
|
const MessageContactLocation({
|
||||||
|
required this.location,
|
||||||
|
required this.source,
|
||||||
|
required this.capturedAt,
|
||||||
|
this.sourceTimestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get technicalSourceLabel {
|
||||||
|
switch (source) {
|
||||||
|
case 'telemetry':
|
||||||
|
return 'telemetry';
|
||||||
|
case 'advert':
|
||||||
|
return 'advert';
|
||||||
|
default:
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get formattedCoordinates =>
|
||||||
|
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'latitude': location.latitude,
|
||||||
|
'longitude': location.longitude,
|
||||||
|
'source': source,
|
||||||
|
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||||
|
'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageContactLocation? fromJson(Map<String, dynamic> json) {
|
||||||
|
final latitude = json['latitude'];
|
||||||
|
final longitude = json['longitude'];
|
||||||
|
final source = json['source'];
|
||||||
|
final capturedAtMillis = json['capturedAtMillis'];
|
||||||
|
if (latitude is! num ||
|
||||||
|
longitude is! num ||
|
||||||
|
source is! String ||
|
||||||
|
capturedAtMillis is! int) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessageContactLocation(
|
||||||
|
location: LatLng(latitude.toDouble(), longitude.toDouble()),
|
||||||
|
source: source,
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
|
||||||
|
sourceTimestamp: json['sourceTimestampMillis'] is int
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['sourceTimestampMillis'] as int,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,16 +8,21 @@ import 'drawing_provider.dart';
|
|||||||
import 'channels_provider.dart';
|
import 'channels_provider.dart';
|
||||||
import 'voice_provider.dart';
|
import 'voice_provider.dart';
|
||||||
import 'image_provider.dart' as ip;
|
import 'image_provider.dart' as ip;
|
||||||
|
import 'helpers/fragment_ack_wait_registry.dart';
|
||||||
|
import 'helpers/session_metadata_restore.dart';
|
||||||
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 +33,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 +44,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 +55,22 @@ 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 FragmentAckWaitRegistry _voiceFragmentAckWaiters =
|
||||||
|
FragmentAckWaitRegistry();
|
||||||
|
final FragmentAckWaitRegistry _imageFragmentAckWaiters =
|
||||||
|
FragmentAckWaitRegistry();
|
||||||
|
Timer? _packetCaptureFlushTimer;
|
||||||
|
String? _lastPersistedPacketSignature;
|
||||||
|
bool _isPersistingPacketCapture = false;
|
||||||
|
|
||||||
AppProvider({
|
AppProvider({
|
||||||
required this.connectionProvider,
|
required this.connectionProvider,
|
||||||
@@ -71,14 +87,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
|
||||||
@@ -92,12 +169,35 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Give DrawingProvider a moment to finish loading too
|
// Give DrawingProvider a moment to finish loading too
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
_restoreSessionMetadataFromMessages();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
|
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
|
||||||
);
|
);
|
||||||
messagesProvider.syncDrawingsWithProvider(drawingProvider);
|
messagesProvider.syncDrawingsWithProvider(drawingProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _restoreSessionMetadataFromMessages() {
|
||||||
|
final restored = restoreSessionMetadataFromMessages(
|
||||||
|
messagesProvider.messages.map((message) => message.text),
|
||||||
|
);
|
||||||
|
|
||||||
|
_voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession);
|
||||||
|
for (final entry in restored.imageEnvelopeBySession.entries) {
|
||||||
|
_imageSessionSenderKey6[entry.key] = entry.value.senderKey6.toLowerCase();
|
||||||
|
imageProvider.registerEnvelope(entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
final restoredVoice = restored.voiceSenderKeyBySession.length;
|
||||||
|
final restoredImage = restored.imageEnvelopeBySession.length;
|
||||||
|
if (restoredVoice > 0 || restoredImage > 0) {
|
||||||
|
debugPrint(
|
||||||
|
'🔄 [AppProvider] Restored session metadata from messages: '
|
||||||
|
'$restoredVoice voice, $restoredImage image',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load simple mode setting from shared preferences
|
/// Load simple mode setting from shared preferences
|
||||||
Future<void> _loadSimpleMode() async {
|
Future<void> _loadSimpleMode() async {
|
||||||
try {
|
try {
|
||||||
@@ -144,6 +244,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 +362,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 +460,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) {
|
||||||
@@ -440,54 +607,30 @@ class AppProvider with ChangeNotifier {
|
|||||||
connectionProvider.onMessageReceived = (message) {
|
connectionProvider.onMessageReceived = (message) {
|
||||||
// Enrich message with sender name from contacts first
|
// Enrich message with sender name from contacts first
|
||||||
Message enrichedMessage = message;
|
Message enrichedMessage = message;
|
||||||
|
Contact? senderContact;
|
||||||
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
||||||
final contact = contactsProvider.findContactByKey(
|
final contact = contactsProvider.findContactByKey(
|
||||||
message.senderPublicKeyPrefix!,
|
message.senderPublicKeyPrefix!,
|
||||||
);
|
);
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
|
senderContact = contact;
|
||||||
enrichedMessage = message.copyWith(senderName: contact.advName);
|
enrichedMessage = message.copyWith(senderName: contact.advName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
senderContact ??= message.senderPublicKeyPrefix != null
|
||||||
// Voice control plane: request sender to stream raw voice packets.
|
? contactsProvider.findContactByKey(message.senderPublicKeyPrefix!)
|
||||||
final voiceFetchRequest = VoiceFetchRequest.tryParseText(
|
: null;
|
||||||
enrichedMessage.text,
|
senderContact ??= enrichedMessage.senderName != null
|
||||||
);
|
? contactsProvider.contacts
|
||||||
if (voiceFetchRequest != null) {
|
.where((c) => c.advName == enrichedMessage.senderName)
|
||||||
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
|
.firstOrNull
|
||||||
if (senderPrefix == null) {
|
: null;
|
||||||
debugPrint(
|
final contactLocationSnapshot = senderContact != null
|
||||||
'⚠️ [AppProvider] Voice fetch request without sender prefix',
|
? contactsProvider.buildMessageContactLocationSnapshot(
|
||||||
);
|
senderContact,
|
||||||
return;
|
capturedAt: enrichedMessage.receivedAt,
|
||||||
}
|
)
|
||||||
final senderPrefixHex = senderPrefix
|
: null;
|
||||||
.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)) {
|
||||||
@@ -518,6 +661,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
messagesProvider.addMessage(
|
messagesProvider.addMessage(
|
||||||
updatedMessage,
|
updatedMessage,
|
||||||
contactLookup: (name) => '',
|
contactLookup: (name) => '',
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast drawing message to SSE clients if server is running
|
// Broadcast drawing message to SSE clients if server is running
|
||||||
@@ -553,43 +697,12 @@ class AppProvider with ChangeNotifier {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||||
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) {
|
||||||
@@ -612,6 +725,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||||
return;
|
return;
|
||||||
@@ -648,6 +762,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast message to SSE clients if server is running
|
// Broadcast message to SSE clients if server is running
|
||||||
@@ -676,19 +791,116 @@ 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 = _resolveImageFetchRequester(imageFetchRequest);
|
||||||
|
if (requester == null) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Image fetch requester contact not found (binary) '
|
||||||
|
'for session ${imageFetchRequest.sessionId} / '
|
||||||
|
'${imageFetchRequest.requesterKey6}',
|
||||||
|
);
|
||||||
|
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 for session '
|
||||||
|
'${imageFetchRequest.sessionId}',
|
||||||
|
);
|
||||||
|
messagesProvider.logSystemMessage(
|
||||||
|
text:
|
||||||
|
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
||||||
|
level: 'warning',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} '
|
||||||
|
'to ${requester.advName} via ${requester.outPathLen} hop(s)',
|
||||||
|
);
|
||||||
|
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 +909,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 +950,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',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -819,6 +1041,32 @@ class AppProvider with ChangeNotifier {
|
|||||||
retryAttempt: retryAttempt,
|
retryAttempt: retryAttempt,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
messagesProvider.onDirectPathFailedCallback =
|
||||||
|
({required contact, required failureStreak}) async {
|
||||||
|
debugPrint(
|
||||||
|
'🧭 [AppProvider] Clearing unhealthy path for ${contact.advName} after $failureStreak failed send chain(s)',
|
||||||
|
);
|
||||||
|
|
||||||
|
contactsProvider.markPathUnhealthy(contact.publicKey);
|
||||||
|
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectionProvider.resetPath(contact.publicKey);
|
||||||
|
Future.delayed(const Duration(milliseconds: 150), () {
|
||||||
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
|
connectionProvider.getContact(contact.publicKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Failed to reset path for ${contact.advName}: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the app (load contacts, sync time, etc.)
|
/// Initialize the app (load contacts, sync time, etc.)
|
||||||
@@ -1020,6 +1268,42 @@ class AppProvider with ChangeNotifier {
|
|||||||
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
|
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Contact? _resolveImageFetchRequester(ImageFetchRequest request) {
|
||||||
|
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
|
||||||
|
if (liveContact != null) {
|
||||||
|
return liveContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final message in messagesProvider.messages.reversed) {
|
||||||
|
final envelope = ImageEnvelope.tryParse(message.text);
|
||||||
|
if (envelope == null || envelope.sessionId != request.sessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final recipientKey = message.recipientPublicKey;
|
||||||
|
if (recipientKey == null || recipientKey.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final recipient = contactsProvider.findContactByKey(recipientKey);
|
||||||
|
if (recipient == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final recipientKey6 = recipient.publicKey
|
||||||
|
.sublist(0, 6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
if (recipientKey6 != request.requesterKey6) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'📷 [AppProvider] Resolved image requester from sent message metadata '
|
||||||
|
'for session ${request.sessionId}: ${recipient.advName}',
|
||||||
|
);
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _scheduleVoiceMissingRetry(
|
void _scheduleVoiceMissingRetry(
|
||||||
String sessionId, {
|
String sessionId, {
|
||||||
required bool justComplete,
|
required bool justComplete,
|
||||||
@@ -1077,15 +1361,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 +1381,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 +1421,96 @@ 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),
|
||||||
|
}) => _voiceFragmentAckWaiters.waitFor(
|
||||||
|
_fragmentAckKey(sessionId, index),
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
|
||||||
|
void _completeVoiceFragmentAck(String sessionId, int index) {
|
||||||
|
final completed = _voiceFragmentAckWaiters.complete(
|
||||||
|
_fragmentAckKey(sessionId, index),
|
||||||
|
);
|
||||||
|
if (completed == 0) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [AppProvider] Voice fragment ACK had no waiter: $sessionId#$index',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'✅ [AppProvider] Voice fragment ACK received for $sessionId#$index ($completed waiter(s))',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _waitForImageFragmentAck({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout = const Duration(seconds: 8),
|
||||||
|
}) => _imageFragmentAckWaiters.waitFor(
|
||||||
|
_fragmentAckKey(sessionId, index),
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
|
||||||
|
void _completeImageFragmentAck(String sessionId, int index) {
|
||||||
|
final completed = _imageFragmentAckWaiters.complete(
|
||||||
|
_fragmentAckKey(sessionId, index),
|
||||||
|
);
|
||||||
|
if (completed == 0) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [AppProvider] Image fragment ACK had no waiter: $sessionId#$index',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'✅ [AppProvider] Image fragment ACK received for $sessionId#$index ($completed waiter(s))',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendVoiceFragmentAck(VoicePacket packet) {
|
||||||
|
final senderKey6 = _voiceSessionSenderKey6[packet.sessionId];
|
||||||
|
if (senderKey6 == null) return;
|
||||||
|
final sender = _resolveContactByPrefixHex(senderKey6);
|
||||||
|
if (sender == null) return;
|
||||||
|
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
|
||||||
|
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 +1604,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 +1626,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 +1640,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
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
@@ -214,6 +215,52 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
List<Contact> get chatContactsWithLocation =>
|
List<Contact> get chatContactsWithLocation =>
|
||||||
chatContacts.where((c) => c.displayLocation != null).toList();
|
chatContacts.where((c) => c.displayLocation != null).toList();
|
||||||
|
|
||||||
|
MessageContactLocation? buildMessageContactLocationSnapshot(
|
||||||
|
Contact contact, {
|
||||||
|
DateTime? capturedAt,
|
||||||
|
}) {
|
||||||
|
final snapshotTime = capturedAt ?? DateTime.now();
|
||||||
|
final telemetryGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
|
||||||
|
final telemetryTimestamp = contact.telemetry?.timestamp;
|
||||||
|
|
||||||
|
AdvertLocation? advertLocation;
|
||||||
|
for (final point in contact.advertHistory) {
|
||||||
|
if (!point.timestamp.isAfter(snapshotTime)) {
|
||||||
|
advertLocation = point;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
advertLocation ??= contact.advertHistory.isNotEmpty
|
||||||
|
? contact.advertHistory.first
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (telemetryGps != null) {
|
||||||
|
final shouldUseTelemetry =
|
||||||
|
telemetryTimestamp == null ||
|
||||||
|
advertLocation == null ||
|
||||||
|
!telemetryTimestamp.isBefore(advertLocation.timestamp);
|
||||||
|
if (shouldUseTelemetry) {
|
||||||
|
return MessageContactLocation(
|
||||||
|
location: telemetryGps,
|
||||||
|
source: 'telemetry',
|
||||||
|
capturedAt: snapshotTime,
|
||||||
|
sourceTimestamp: telemetryTimestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advertLocation != null) {
|
||||||
|
return MessageContactLocation(
|
||||||
|
location: advertLocation.location,
|
||||||
|
source: 'advert',
|
||||||
|
capturedAt: snapshotTime,
|
||||||
|
sourceTimestamp: advertLocation.timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Sort contacts by last seen (most recent first)
|
/// Sort contacts by last seen (most recent first)
|
||||||
int _sortByLastSeen(Contact a, Contact b) {
|
int _sortByLastSeen(Contact a, Contact b) {
|
||||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||||
@@ -470,6 +517,22 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return _contacts[keyHex];
|
return _contacts[keyHex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear a contact's learned path locally so the UI and next send both
|
||||||
|
/// prefer flood routing until the radio reports a fresh route.
|
||||||
|
void markPathUnhealthy(Uint8List publicKey) {
|
||||||
|
final contact = findContactByKey(publicKey);
|
||||||
|
if (contact == null || !contact.hasPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_contacts[contact.publicKeyHex] = contact.copyWith(
|
||||||
|
outPathLen: -1,
|
||||||
|
outPath: Uint8List(0),
|
||||||
|
);
|
||||||
|
_persistContacts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
||||||
/// Excludes self key and existing contacts.
|
/// Excludes self key and existing contacts.
|
||||||
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||||||
|
|||||||
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// Tracks one or more in-flight waiters for the same fragment ACK key.
|
||||||
|
///
|
||||||
|
/// Duplicate fetch requests can race and wait on the same fragment ACK at once.
|
||||||
|
/// Completing all registered waiters avoids losing the earlier completer when a
|
||||||
|
/// later request registers for the same key.
|
||||||
|
class FragmentAckWaitRegistry {
|
||||||
|
final Map<String, List<Completer<void>>> _waiters = {};
|
||||||
|
|
||||||
|
Future<bool> waitFor(
|
||||||
|
String key, {
|
||||||
|
Duration timeout = const Duration(seconds: 8),
|
||||||
|
}) async {
|
||||||
|
final completer = Completer<void>();
|
||||||
|
final waiters = _waiters.putIfAbsent(key, () => <Completer<void>>[]);
|
||||||
|
waiters.add(completer);
|
||||||
|
try {
|
||||||
|
await completer.future.timeout(timeout);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
final pending = _waiters[key];
|
||||||
|
pending?.remove(completer);
|
||||||
|
if (pending != null && pending.isEmpty) {
|
||||||
|
_waiters.remove(key);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int complete(String key) {
|
||||||
|
final waiters = _waiters.remove(key);
|
||||||
|
if (waiters == null || waiters.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (final completer in waiters) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return waiters.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// Message delivery tracking helper
|
/// Message delivery tracking helper
|
||||||
///
|
///
|
||||||
/// Manages message delivery tracking for sent messages, including:
|
/// Manages message delivery tracking for sent messages, including:
|
||||||
@@ -16,6 +18,9 @@ class MessageDeliveryTracker {
|
|||||||
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
|
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
|
||||||
final List<String> _pendingMessageIds = [];
|
final List<String> _pendingMessageIds = [];
|
||||||
|
|
||||||
|
/// Contact-scoped FIFOs for matching direct-message SENT responses.
|
||||||
|
final Map<String, List<String>> _pendingMessageIdsByContact = {};
|
||||||
|
|
||||||
/// Map of ACK tag to message ID for delivery confirmation
|
/// Map of ACK tag to message ID for delivery confirmation
|
||||||
final Map<int, String> _ackTagToMessageId = {};
|
final Map<int, String> _ackTagToMessageId = {};
|
||||||
|
|
||||||
@@ -33,6 +38,15 @@ class MessageDeliveryTracker {
|
|||||||
_pendingMessageIds.add(messageId);
|
_pendingMessageIds.add(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Track a pending direct message ID for a specific contact.
|
||||||
|
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
|
||||||
|
trackPendingMessage(messageId);
|
||||||
|
final contactKey = _contactKey(contactPublicKey);
|
||||||
|
_pendingMessageIdsByContact
|
||||||
|
.putIfAbsent(contactKey, () => [])
|
||||||
|
.add(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pop the next pending message ID from FIFO queue
|
/// Pop the next pending message ID from FIFO queue
|
||||||
///
|
///
|
||||||
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
||||||
@@ -43,6 +57,24 @@ class MessageDeliveryTracker {
|
|||||||
return _pendingMessageIds.removeAt(0);
|
return _pendingMessageIds.removeAt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pop the next pending direct message ID for a specific contact.
|
||||||
|
///
|
||||||
|
/// Falls back to the legacy global FIFO if the contact queue is empty.
|
||||||
|
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
|
||||||
|
final contactKey = _contactKey(contactPublicKey);
|
||||||
|
final queue = _pendingMessageIdsByContact[contactKey];
|
||||||
|
if (queue == null || queue.isEmpty) {
|
||||||
|
return popPendingMessageId();
|
||||||
|
}
|
||||||
|
|
||||||
|
final messageId = queue.removeAt(0);
|
||||||
|
if (queue.isEmpty) {
|
||||||
|
_pendingMessageIdsByContact.remove(contactKey);
|
||||||
|
}
|
||||||
|
_pendingMessageIds.remove(messageId);
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
/// Map ACK tag to message ID after RESP_CODE_SENT received
|
/// Map ACK tag to message ID after RESP_CODE_SENT received
|
||||||
///
|
///
|
||||||
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
||||||
@@ -86,6 +118,17 @@ class MessageDeliveryTracker {
|
|||||||
_ackTagToMessageId.remove(ackTag);
|
_ackTagToMessageId.remove(ackTag);
|
||||||
_ackTagTimestamps.remove(ackTag);
|
_ackTagTimestamps.remove(ackTag);
|
||||||
}
|
}
|
||||||
|
_pendingMessageIds.remove(messageId);
|
||||||
|
final emptyKeys = <String>[];
|
||||||
|
for (final entry in _pendingMessageIdsByContact.entries) {
|
||||||
|
entry.value.remove(messageId);
|
||||||
|
if (entry.value.isEmpty) {
|
||||||
|
emptyKeys.add(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final key in emptyKeys) {
|
||||||
|
_pendingMessageIdsByContact.remove(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up stale ACK mappings
|
/// Clean up stale ACK mappings
|
||||||
@@ -114,6 +157,7 @@ class MessageDeliveryTracker {
|
|||||||
/// Clear all tracking state
|
/// Clear all tracking state
|
||||||
void clearTracking() {
|
void clearTracking() {
|
||||||
_pendingMessageIds.clear();
|
_pendingMessageIds.clear();
|
||||||
|
_pendingMessageIdsByContact.clear();
|
||||||
_ackTagToMessageId.clear();
|
_ackTagToMessageId.clear();
|
||||||
_messageIdToAckTag.clear();
|
_messageIdToAckTag.clear();
|
||||||
_ackTagTimestamps.clear();
|
_ackTagTimestamps.clear();
|
||||||
@@ -133,9 +177,7 @@ class MessageDeliveryTracker {
|
|||||||
/// Get oldest pending ACK timestamp (for debugging)
|
/// Get oldest pending ACK timestamp (for debugging)
|
||||||
DateTime? get oldestPendingTimestamp {
|
DateTime? get oldestPendingTimestamp {
|
||||||
if (_ackTagTimestamps.isEmpty) return null;
|
if (_ackTagTimestamps.isEmpty) return null;
|
||||||
return _ackTagTimestamps.values.reduce(
|
return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||||
(a, b) => a.isBefore(b) ? a : b,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get diagnostic info for debugging
|
/// Get diagnostic info for debugging
|
||||||
@@ -145,6 +187,15 @@ class MessageDeliveryTracker {
|
|||||||
'shouldRateLimit': shouldRateLimit,
|
'shouldRateLimit': shouldRateLimit,
|
||||||
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
||||||
'ackTags': _ackTagToMessageId.keys.toList(),
|
'ackTags': _ackTagToMessageId.keys.toList(),
|
||||||
|
'pendingByContact': _pendingMessageIdsByContact.map(
|
||||||
|
(key, value) => MapEntry(key, value.length),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _contactKey(Uint8List contactPublicKey) {
|
||||||
|
return contactPublicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
|
||||||
@@ -17,11 +20,18 @@ class MessageRetryManager {
|
|||||||
// Track retry state for each message ID
|
// Track retry state for each message ID
|
||||||
final Map<String, int> _retryAttempts = {};
|
final Map<String, int> _retryAttempts = {};
|
||||||
final Map<String, DateTime> _lastRetryTimes = {};
|
final Map<String, DateTime> _lastRetryTimes = {};
|
||||||
|
final Map<String, int> _pathFailureStreaks = {};
|
||||||
|
|
||||||
// Progressive timeout values in milliseconds
|
// Progressive timeout values in milliseconds
|
||||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
// These are app-level timeouts, separate from firmware's suggested timeout
|
||||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
// Firmware timeout is for ACK arrival, these are for retry attempts
|
||||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
static const List<int> _timeouts = [4000, 8000, 12000];
|
||||||
|
static const int _defaultLoRaSf = 10;
|
||||||
|
static const int _defaultLoRaCr = 5;
|
||||||
|
static const int _defaultLoRaBwHz = 250000;
|
||||||
|
static const int _defaultLoRaPreambleSymbols = 8;
|
||||||
|
static const int _defaultLoRaCrcEnabled = 1;
|
||||||
|
static const int _defaultLoRaExplicitHeader = 1;
|
||||||
|
|
||||||
/// Get timeout for a specific retry attempt (0-2)
|
/// Get timeout for a specific retry attempt (0-2)
|
||||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
||||||
@@ -32,6 +42,30 @@ class MessageRetryManager {
|
|||||||
return _timeouts[attempt];
|
return _timeouts[attempt];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
||||||
|
/// provide one or returns an invalid value.
|
||||||
|
int calculateAckTimeoutMs({
|
||||||
|
required String text,
|
||||||
|
required Contact? contact,
|
||||||
|
int? suggestedTimeoutMs,
|
||||||
|
}) {
|
||||||
|
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
||||||
|
return suggestedTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
final payloadBytes = utf8.encode(text).length;
|
||||||
|
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||||
|
final hopCount = contact?.hasPath == true
|
||||||
|
? math.max(contact!.outPathLen, 0)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (hopCount < 0) {
|
||||||
|
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a message is eligible for retry
|
/// Check if a message is eligible for retry
|
||||||
///
|
///
|
||||||
/// Returns true if:
|
/// Returns true if:
|
||||||
@@ -67,8 +101,8 @@ class MessageRetryManager {
|
|||||||
/// Contacts without paths already use flood mode automatically.
|
/// Contacts without paths already use flood mode automatically.
|
||||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
bool shouldUseFloodFallback(Message message, Contact contact) {
|
||||||
return message.retryAttempt >= 3 &&
|
return message.retryAttempt >= 3 &&
|
||||||
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
|
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
|
||||||
!message.usedFloodFallback;
|
!message.usedFloodFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track a retry attempt for a message
|
/// Track a retry attempt for a message
|
||||||
@@ -87,6 +121,7 @@ class MessageRetryManager {
|
|||||||
void clearAll() {
|
void clearAll() {
|
||||||
_retryAttempts.clear();
|
_retryAttempts.clear();
|
||||||
_lastRetryTimes.clear();
|
_lastRetryTimes.clear();
|
||||||
|
_pathFailureStreaks.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current retry attempt for a message (for debugging)
|
/// Get current retry attempt for a message (for debugging)
|
||||||
@@ -98,4 +133,50 @@ class MessageRetryManager {
|
|||||||
DateTime? getLastRetryTime(String messageId) {
|
DateTime? getLastRetryTime(String messageId) {
|
||||||
return _lastRetryTimes[messageId];
|
return _lastRetryTimes[messageId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a successful delivery for a contact and clear any accumulated
|
||||||
|
/// route failure streak for future sends.
|
||||||
|
void recordDeliverySuccess(Contact contact) {
|
||||||
|
_pathFailureStreaks.remove(contact.publicKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a permanent route failure for a contact.
|
||||||
|
///
|
||||||
|
/// Returns the updated failure streak so callers can decide when to reset
|
||||||
|
/// the learned path on the radio and in local state.
|
||||||
|
int recordPathFailure(Contact contact) {
|
||||||
|
final contactKey = contact.publicKeyHex;
|
||||||
|
final next = (_pathFailureStreaks[contactKey] ?? 0) + 1;
|
||||||
|
_pathFailureStreaks[contactKey] = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? getPathFailureStreak(Contact contact) {
|
||||||
|
return _pathFailureStreaks[contact.publicKeyHex];
|
||||||
|
}
|
||||||
|
|
||||||
|
int _estimateLoRaAirtimeMs(int payloadLenBytes) {
|
||||||
|
final sf = _defaultLoRaSf;
|
||||||
|
final bw = _defaultLoRaBwHz.toDouble();
|
||||||
|
final cr = (_defaultLoRaCr - 4).clamp(1, 4);
|
||||||
|
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
|
||||||
|
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
|
||||||
|
|
||||||
|
final symbolMs = ((1 << sf) / bw) * 1000.0;
|
||||||
|
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
|
||||||
|
|
||||||
|
final num =
|
||||||
|
(8 * payloadLenBytes) -
|
||||||
|
(4 * sf) +
|
||||||
|
28 +
|
||||||
|
(16 * _defaultLoRaCrcEnabled) -
|
||||||
|
(20 * ih);
|
||||||
|
final den = 4 * (sf - (2 * de));
|
||||||
|
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
|
||||||
|
final payloadSymbols =
|
||||||
|
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
|
||||||
|
final payloadMs = payloadSymbols * symbolMs;
|
||||||
|
|
||||||
|
return (preambleMs + payloadMs).ceil();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
lib/providers/helpers/raw_session_retransmit.dart
Normal file
102
lib/providers/helpers/raw_session_retransmit.dart
Normal 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;
|
||||||
|
}
|
||||||
38
lib/providers/helpers/session_metadata_restore.dart
Normal file
38
lib/providers/helpers/session_metadata_restore.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import '../../utils/image_message_parser.dart';
|
||||||
|
import '../../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
class RestoredSessionMetadata {
|
||||||
|
final Map<String, String> voiceSenderKeyBySession;
|
||||||
|
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
||||||
|
|
||||||
|
const RestoredSessionMetadata({
|
||||||
|
required this.voiceSenderKeyBySession,
|
||||||
|
required this.imageEnvelopeBySession,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
||||||
|
Iterable<String> messageTexts,
|
||||||
|
) {
|
||||||
|
final voiceSenderKeyBySession = <String, String>{};
|
||||||
|
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
||||||
|
|
||||||
|
for (final text in messageTexts) {
|
||||||
|
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
||||||
|
if (voiceEnvelope != null) {
|
||||||
|
voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope
|
||||||
|
.senderKey6
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageEnvelope = ImageEnvelope.tryParse(text);
|
||||||
|
if (imageEnvelope != null) {
|
||||||
|
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RestoredSessionMetadata(
|
||||||
|
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
||||||
|
imageEnvelopeBySession: imageEnvelopeBySession,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,9 +54,11 @@ 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 = {};
|
||||||
|
final Set<String> _ignoredIncomingSessions = {};
|
||||||
|
|
||||||
/// Outgoing sessions cached for deferred serving.
|
/// Outgoing sessions cached for deferred serving.
|
||||||
final Map<String, _OutgoingSession> _outgoing = {};
|
final Map<String, _OutgoingSession> _outgoing = {};
|
||||||
@@ -50,6 +70,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 +87,10 @@ 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();
|
||||||
|
bool isReceiveCanceled(String sessionId) =>
|
||||||
|
_ignoredIncomingSessions.contains(sessionId);
|
||||||
|
|
||||||
List<int> missingFragmentIndices(String sessionId) {
|
List<int> missingFragmentIndices(String sessionId) {
|
||||||
final session = _sessions[sessionId];
|
final session = _sessions[sessionId];
|
||||||
@@ -80,6 +110,12 @@ class ImageProvider with ChangeNotifier {
|
|||||||
///
|
///
|
||||||
/// Returns true when the session just became complete.
|
/// Returns true when the session just became complete.
|
||||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||||
|
if (_ignoredIncomingSessions.contains(fragment.sessionId)) {
|
||||||
|
debugPrint(
|
||||||
|
'⏹️ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
_sessions.putIfAbsent(
|
_sessions.putIfAbsent(
|
||||||
fragment.sessionId,
|
fragment.sessionId,
|
||||||
() => ImageSession(
|
() => ImageSession(
|
||||||
@@ -93,7 +129,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;
|
||||||
@@ -102,22 +144,27 @@ class ImageProvider with ChangeNotifier {
|
|||||||
return justComplete;
|
return justComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void cancelIncomingSession(String sessionId) {
|
||||||
|
_ignoredIncomingSessions.add(sessionId);
|
||||||
|
_sessions.remove(sessionId);
|
||||||
|
unawaited(_persist());
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resumeIncomingSession(String sessionId) {
|
||||||
|
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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(
|
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||||
envelope.sessionId,
|
return;
|
||||||
() => ImageSession(
|
}
|
||||||
sessionId: envelope.sessionId,
|
final existing = _sessions[envelope.sessionId];
|
||||||
format: envelope.format,
|
if (existing == null) {
|
||||||
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 +172,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 +255,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 ──────────────────────────────────────────────────────────
|
||||||
@@ -218,6 +274,7 @@ class ImageProvider with ChangeNotifier {
|
|||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
_sessions.clear();
|
_sessions.clear();
|
||||||
_outgoing.clear();
|
_outgoing.clear();
|
||||||
|
_ignoredIncomingSessions.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
import '../services/message_storage_service.dart';
|
import '../services/message_storage_service.dart';
|
||||||
@@ -20,6 +21,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
AppLocalizations? _localizations;
|
AppLocalizations? _localizations;
|
||||||
|
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||||
|
|
||||||
// Track pending sent messages by expected ACK/TAG
|
// Track pending sent messages by expected ACK/TAG
|
||||||
final Map<int, Message> _pendingSentMessages = {};
|
final Map<int, Message> _pendingSentMessages = {};
|
||||||
@@ -28,6 +30,13 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
||||||
final Map<String, Timer> _timeoutTimers = {};
|
final Map<String, Timer> _timeoutTimers = {};
|
||||||
|
|
||||||
|
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
|
||||||
|
final Map<int, DateTime> _completedAckHistory = {};
|
||||||
|
|
||||||
|
// Preserve ACK tags assigned to a message across retries.
|
||||||
|
final Map<String, Set<int>> _messageAckHistory = {};
|
||||||
|
final Map<int, (String, DateTime)> _ackHistoryLookup = {};
|
||||||
|
|
||||||
// Retry management
|
// Retry management
|
||||||
final MessageRetryManager _retryManager = MessageRetryManager();
|
final MessageRetryManager _retryManager = MessageRetryManager();
|
||||||
|
|
||||||
@@ -65,6 +74,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
})?
|
})?
|
||||||
sendMessageCallback;
|
sendMessageCallback;
|
||||||
|
|
||||||
|
Future<void> Function({
|
||||||
|
required Contact contact,
|
||||||
|
required int failureStreak,
|
||||||
|
})?
|
||||||
|
onDirectPathFailedCallback;
|
||||||
|
|
||||||
List<Message> get messages => List.unmodifiable(_messages);
|
List<Message> get messages => List.unmodifiable(_messages);
|
||||||
|
|
||||||
List<Message> get contactMessages =>
|
List<Message> get contactMessages =>
|
||||||
@@ -97,6 +112,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
String? get targetMessageId => _targetMessageId;
|
String? get targetMessageId => _targetMessageId;
|
||||||
|
|
||||||
|
MessageContactLocation? getMessageContactLocation(String messageId) =>
|
||||||
|
_messageContactLocations[messageId];
|
||||||
|
|
||||||
/// Set localizations for notifications
|
/// Set localizations for notifications
|
||||||
void setLocalizations(AppLocalizations localizations) {
|
void setLocalizations(AppLocalizations localizations) {
|
||||||
_localizations = localizations;
|
_localizations = localizations;
|
||||||
@@ -125,6 +143,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
|
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
|
||||||
final storedMessages = await _storageService.loadMessages();
|
final storedMessages = await _storageService.loadMessages();
|
||||||
|
final storedContactLocations = await _storageService
|
||||||
|
.loadMessageContactLocations();
|
||||||
|
_messageContactLocations
|
||||||
|
..clear()
|
||||||
|
..addAll(storedContactLocations);
|
||||||
|
|
||||||
// Add stored messages with enhancement to ensure SAR detection
|
// Add stored messages with enhancement to ensure SAR detection
|
||||||
for (final message in storedMessages) {
|
for (final message in storedMessages) {
|
||||||
@@ -287,6 +310,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void addMessage(
|
void addMessage(
|
||||||
Message message, {
|
Message message, {
|
||||||
String Function(String name)? contactLookup,
|
String Function(String name)? contactLookup,
|
||||||
|
MessageContactLocation? contactLocationSnapshot,
|
||||||
}) {
|
}) {
|
||||||
// Always enhance message with SAR parser to detect SAR markers
|
// Always enhance message with SAR parser to detect SAR markers
|
||||||
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||||
@@ -377,6 +401,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_messages.add(finalMessage);
|
_messages.add(finalMessage);
|
||||||
|
if (contactLocationSnapshot != null) {
|
||||||
|
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a SAR marker message, extract and store the marker
|
// If it's a SAR marker message, extract and store the marker
|
||||||
if (finalMessage.isSarMarker) {
|
if (finalMessage.isSarMarker) {
|
||||||
@@ -563,7 +590,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
/// Persist messages to storage (async, non-blocking)
|
/// Persist messages to storage (async, non-blocking)
|
||||||
Future<void> _persistMessages() async {
|
Future<void> _persistMessages() async {
|
||||||
try {
|
try {
|
||||||
await _storageService.saveMessages(_messages);
|
await _storageService.saveMessages(
|
||||||
|
_messages,
|
||||||
|
messageContactLocations: _messageContactLocations,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||||
}
|
}
|
||||||
@@ -678,6 +708,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_messageContactMap.remove(messageId);
|
_messageContactMap.remove(messageId);
|
||||||
_groupedMessageMapping.remove(messageId);
|
_groupedMessageMapping.remove(messageId);
|
||||||
|
_messageContactLocations.remove(messageId);
|
||||||
|
|
||||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||||
|
|
||||||
@@ -706,6 +737,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void clearMessages() {
|
void clearMessages() {
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_messageContactLocations.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -720,6 +752,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void clearAll() {
|
void clearAll() {
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_messageContactLocations.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -906,11 +939,13 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final (groupId, recipientPublicKey) = groupMapping;
|
final (groupId, recipientPublicKey) = groupMapping;
|
||||||
debugPrint(' ✅ This is part of a grouped message: $groupId');
|
debugPrint(' ✅ This is part of a grouped message: $groupId');
|
||||||
|
|
||||||
// Update the recipient status to "sent" in the grouped message
|
// ACK-tracked recipients stay pending until the delivery confirm arrives.
|
||||||
updateGroupedMessageRecipientStatus(
|
updateGroupedMessageRecipientStatus(
|
||||||
groupId,
|
groupId,
|
||||||
recipientPublicKey,
|
recipientPublicKey,
|
||||||
MessageDeliveryStatus.sent,
|
expectedAckTag > 0
|
||||||
|
? MessageDeliveryStatus.sending
|
||||||
|
: MessageDeliveryStatus.sent,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track the ACK for this specific recipient
|
// Track the ACK for this specific recipient
|
||||||
@@ -1015,6 +1050,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
final message = _messages[index];
|
final message = _messages[index];
|
||||||
|
final contact = _messageContactMap[messageId];
|
||||||
|
final effectiveTimeout = _retryManager.calculateAckTimeoutMs(
|
||||||
|
text: message.text,
|
||||||
|
contact: contact,
|
||||||
|
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
|
||||||
|
);
|
||||||
debugPrint(' Current status: ${message.deliveryStatus}');
|
debugPrint(' Current status: ${message.deliveryStatus}');
|
||||||
debugPrint(' Message type: ${message.messageType}');
|
debugPrint(' Message type: ${message.messageType}');
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1022,16 +1063,22 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final updatedMessage = message.copyWith(
|
final updatedMessage = message.copyWith(
|
||||||
deliveryStatus: MessageDeliveryStatus.sent,
|
deliveryStatus: expectedAckTag > 0
|
||||||
|
? MessageDeliveryStatus.sending
|
||||||
|
: MessageDeliveryStatus.sent,
|
||||||
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
||||||
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
|
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
|
||||||
);
|
);
|
||||||
_messages[index] = updatedMessage;
|
_messages[index] = updatedMessage;
|
||||||
|
|
||||||
// Only track and set timeout for direct messages (channel messages have expectedAckTag=0)
|
// Only track and set timeout for direct messages (channel messages have expectedAckTag=0)
|
||||||
if (expectedAckTag > 0 && suggestedTimeoutMs > 0) {
|
if (expectedAckTag > 0) {
|
||||||
// Track by ACK tag for matching with delivery confirmation
|
// Track by ACK tag for matching with delivery confirmation
|
||||||
_pendingSentMessages[expectedAckTag] = updatedMessage;
|
_pendingSentMessages[expectedAckTag] = updatedMessage;
|
||||||
|
_messageAckHistory
|
||||||
|
.putIfAbsent(messageId, () => <int>{})
|
||||||
|
.add(expectedAckTag);
|
||||||
|
_ackHistoryLookup[expectedAckTag] = (messageId, DateTime.now());
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ✅ Added to pending messages map with ACK: $expectedAckTag',
|
' ✅ Added to pending messages map with ACK: $expectedAckTag',
|
||||||
);
|
);
|
||||||
@@ -1042,7 +1089,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Start timeout timer using message ID as key
|
// Start timeout timer using message ID as key
|
||||||
_timeoutTimers[messageId] = Timer(
|
_timeoutTimers[messageId] = Timer(
|
||||||
Duration(milliseconds: suggestedTimeoutMs),
|
Duration(milliseconds: effectiveTimeout),
|
||||||
() {
|
() {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)',
|
'⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)',
|
||||||
@@ -1054,7 +1101,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)',
|
'⏱️ [MessagesProvider] Started ${effectiveTimeout}ms timeout timer for message $messageId (ACK $expectedAckTag)',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1245,6 +1292,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Update message status to delivered with RTT
|
/// Update message status to delivered with RTT
|
||||||
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
||||||
|
_cleanupCompletedAckHistory();
|
||||||
|
_cleanupAckHistoryLookup();
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||||||
);
|
);
|
||||||
@@ -1296,6 +1345,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
_ackTagToRecipients.remove(ackCode);
|
_ackTagToRecipients.remove(ackCode);
|
||||||
_pendingSentMessages.remove(ackCode);
|
_pendingSentMessages.remove(ackCode);
|
||||||
|
_rememberCompletedAck(ackCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1339,9 +1389,15 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Remove from pending
|
// Remove from pending
|
||||||
_pendingSentMessages.remove(ackCode);
|
_pendingSentMessages.remove(ackCode);
|
||||||
|
_rememberCompletedAck(ackCode);
|
||||||
|
_clearAckHistoryForMessage(message.id);
|
||||||
|
|
||||||
// Clear retry tracking on successful delivery
|
// Clear retry tracking on successful delivery
|
||||||
_retryManager.clearRetry(message.id);
|
_retryManager.clearRetry(message.id);
|
||||||
|
final deliveredContact = _messageContactMap[message.id];
|
||||||
|
if (deliveredContact != null) {
|
||||||
|
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)',
|
'✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)',
|
||||||
@@ -1362,6 +1418,43 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
final historicalMatch = _ackHistoryLookup[ackCode];
|
||||||
|
if (historicalMatch != null) {
|
||||||
|
final historicalMessageId = historicalMatch.$1;
|
||||||
|
final historicalIndex = _messages.indexWhere(
|
||||||
|
(m) => m.id == historicalMessageId,
|
||||||
|
);
|
||||||
|
if (historicalIndex != -1 &&
|
||||||
|
_messages[historicalIndex].deliveryStatus !=
|
||||||
|
MessageDeliveryStatus.delivered) {
|
||||||
|
_messages[historicalIndex] = _messages[historicalIndex].copyWith(
|
||||||
|
deliveryStatus: MessageDeliveryStatus.delivered,
|
||||||
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
|
deliveredAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
_timeoutTimers[historicalMessageId]?.cancel();
|
||||||
|
_timeoutTimers.remove(historicalMessageId);
|
||||||
|
_rememberCompletedAck(ackCode);
|
||||||
|
_clearAckHistoryForMessage(historicalMessageId);
|
||||||
|
_retryManager.clearRetry(historicalMessageId);
|
||||||
|
final deliveredContact = _messageContactMap[historicalMessageId];
|
||||||
|
if (deliveredContact != null) {
|
||||||
|
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||||
|
}
|
||||||
|
_persistMessages();
|
||||||
|
notifyListeners();
|
||||||
|
debugPrint(
|
||||||
|
'✅ [MessagesProvider] Historical ACK $ackCode matched message $historicalMessageId',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_completedAckHistory.containsKey(ackCode)) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
|
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
|
||||||
);
|
);
|
||||||
@@ -1482,18 +1575,31 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
||||||
);
|
);
|
||||||
|
final currentIndex = _messages.indexWhere((m) => m.id == messageId);
|
||||||
|
if (currentIndex == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final currentMessage = _messages[currentIndex];
|
||||||
|
if (currentMessage.deliveryStatus == MessageDeliveryStatus.delivered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: nextAttempt,
|
retryAttempt: nextAttempt,
|
||||||
);
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, currentMessage);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
|
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
|
||||||
);
|
);
|
||||||
|
_markAsPermanentlyFailed(messageId, currentMessage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1529,17 +1635,21 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Send with flood mode (no retry after this)
|
// Send with flood mode (no retry after this)
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: 0, // Reset attempt for flood
|
retryAttempt: 0, // Reset attempt for flood
|
||||||
);
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
||||||
);
|
);
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1562,10 +1672,27 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
if (message.expectedAckTag != null) {
|
if (message.expectedAckTag != null) {
|
||||||
_pendingSentMessages.remove(message.expectedAckTag);
|
_pendingSentMessages.remove(message.expectedAckTag);
|
||||||
}
|
}
|
||||||
|
_clearAckHistoryForMessage(messageId);
|
||||||
|
|
||||||
// Clear retry tracking
|
// Clear retry tracking
|
||||||
_retryManager.clearRetry(messageId);
|
_retryManager.clearRetry(messageId);
|
||||||
|
|
||||||
|
final failedContact = _messageContactMap[messageId];
|
||||||
|
if (failedContact != null && failedContact.hasPath) {
|
||||||
|
final failureStreak = _retryManager.recordPathFailure(failedContact);
|
||||||
|
debugPrint(
|
||||||
|
' Path failure streak for ${failedContact.advName}: $failureStreak',
|
||||||
|
);
|
||||||
|
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
|
||||||
|
unawaited(
|
||||||
|
onDirectPathFailedCallback!(
|
||||||
|
contact: failedContact,
|
||||||
|
failureStreak: failureStreak,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -1608,17 +1735,21 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Send again
|
// Send again
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: 0,
|
retryAttempt: 0,
|
||||||
);
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
|
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
|
||||||
);
|
);
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1631,10 +1762,62 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
_timeoutTimers.clear();
|
_timeoutTimers.clear();
|
||||||
|
_completedAckHistory.clear();
|
||||||
|
_messageAckHistory.clear();
|
||||||
|
_ackHistoryLookup.clear();
|
||||||
|
|
||||||
// Clear retry manager
|
// Clear retry manager
|
||||||
_retryManager.clearAll();
|
_retryManager.clearAll();
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _rememberCompletedAck(int ackCode) {
|
||||||
|
_completedAckHistory[ackCode] = DateTime.now();
|
||||||
|
_cleanupCompletedAckHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cleanupCompletedAckHistory({
|
||||||
|
Duration maxAge = const Duration(minutes: 15),
|
||||||
|
}) {
|
||||||
|
final cutoff = DateTime.now().subtract(maxAge);
|
||||||
|
final staleAcks = _completedAckHistory.entries
|
||||||
|
.where((entry) => entry.value.isBefore(cutoff))
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.toList();
|
||||||
|
for (final ack in staleAcks) {
|
||||||
|
_completedAckHistory.remove(ack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearAckHistoryForMessage(String messageId) {
|
||||||
|
final ackTags = _messageAckHistory.remove(messageId);
|
||||||
|
if (ackTags == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final ack in ackTags) {
|
||||||
|
_ackHistoryLookup.remove(ack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cleanupAckHistoryLookup({
|
||||||
|
Duration maxAge = const Duration(minutes: 15),
|
||||||
|
}) {
|
||||||
|
final cutoff = DateTime.now().subtract(maxAge);
|
||||||
|
final staleAcks = _ackHistoryLookup.entries
|
||||||
|
.where((entry) => entry.value.$2.isBefore(cutoff))
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.toList();
|
||||||
|
for (final ack in staleAcks) {
|
||||||
|
final messageId = _ackHistoryLookup.remove(ack)?.$1;
|
||||||
|
if (messageId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final history = _messageAckHistory[messageId];
|
||||||
|
history?.remove(ack);
|
||||||
|
if (history != null && history.isEmpty) {
|
||||||
|
_messageAckHistory.remove(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +52,14 @@ 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;
|
||||||
|
|
||||||
/// Active sessions keyed by sessionId.
|
/// Active sessions keyed by sessionId.
|
||||||
final Map<String, VoiceSession> _sessions = {};
|
final Map<String, VoiceSession> _sessions = {};
|
||||||
|
final Set<String> _ignoredIncomingSessions = {};
|
||||||
|
|
||||||
/// Currently playing session ID, or null.
|
/// Currently playing session ID, or null.
|
||||||
String? _playingSessionId;
|
String? _playingSessionId;
|
||||||
@@ -53,6 +71,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 +116,10 @@ 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();
|
||||||
|
bool isReceiveCanceled(String sessionId) =>
|
||||||
|
_ignoredIncomingSessions.contains(sessionId);
|
||||||
|
|
||||||
List<int> missingPacketIndices(String sessionId) {
|
List<int> missingPacketIndices(String sessionId) {
|
||||||
final session = _sessions[sessionId];
|
final session = _sessions[sessionId];
|
||||||
@@ -108,6 +136,12 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
||||||
/// Returns true if the session just became complete.
|
/// Returns true if the session just became complete.
|
||||||
bool addPacket(VoicePacket packet) {
|
bool addPacket(VoicePacket packet) {
|
||||||
|
if (_ignoredIncomingSessions.contains(packet.sessionId)) {
|
||||||
|
debugPrint(
|
||||||
|
'⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
_sessions.putIfAbsent(
|
_sessions.putIfAbsent(
|
||||||
packet.sessionId,
|
packet.sessionId,
|
||||||
() => VoiceSession(
|
() => VoiceSession(
|
||||||
@@ -119,7 +153,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;
|
||||||
@@ -128,6 +168,23 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
return justComplete;
|
return justComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void cancelIncomingSession(String sessionId) {
|
||||||
|
_ignoredIncomingSessions.add(sessionId);
|
||||||
|
_sessions.remove(sessionId);
|
||||||
|
if (_playingSessionId == sessionId) {
|
||||||
|
unawaited(_player.stop());
|
||||||
|
_playingSessionId = null;
|
||||||
|
}
|
||||||
|
_persistVoiceData();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resumeIncomingSession(String sessionId) {
|
||||||
|
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cache encoded packets for deferred voice serving.
|
/// Cache encoded packets for deferred voice serving.
|
||||||
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||||
if (packets.isEmpty) return;
|
if (packets.isEmpty) return;
|
||||||
@@ -151,36 +208,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 ─────────────────────────────────────────────────────────────
|
||||||
@@ -224,6 +263,7 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
Future<void> clearStoredVoiceData() async {
|
Future<void> clearStoredVoiceData() async {
|
||||||
_sessions.clear();
|
_sessions.clear();
|
||||||
_outgoingSessions.clear();
|
_outgoingSessions.clear();
|
||||||
|
_ignoredIncomingSessions.clear();
|
||||||
_playingSessionId = null;
|
_playingSessionId = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../services/validation_service.dart';
|
import '../services/validation_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -722,7 +722,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Client Repeat Mode'),
|
title: const Text('Client Repeat Mode'),
|
||||||
subtitle: deviceInfo.allowedRepeatFreqRanges != null &&
|
subtitle:
|
||||||
|
deviceInfo.allowedRepeatFreqRanges != null &&
|
||||||
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
||||||
? Text(
|
? Text(
|
||||||
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
|
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -16,12 +17,15 @@ import 'map_management_screen.dart';
|
|||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'device_config_screen.dart';
|
import 'device_config_screen.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
|
import 'spectrum_scan_screen.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/permission_request_dialog.dart';
|
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;
|
||||||
@@ -42,20 +46,41 @@ class HomeScreen extends StatefulWidget {
|
|||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen>
|
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
late final AppProvider _appProvider;
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
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();
|
||||||
|
_appProvider = context.read<AppProvider>();
|
||||||
|
_isMapEnabled = _appProvider.isMapEnabled;
|
||||||
|
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||||
|
_appProvider.addListener(_handleAppProviderChanged);
|
||||||
|
|
||||||
// Initialize synchronously so first build always has a valid controller.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
_initTabController();
|
_initTabController();
|
||||||
_loadMapEnabledAndInitTabs();
|
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// Show permission dialog after the first frame if needed
|
||||||
@@ -66,58 +91,102 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadMapEnabledAndInitTabs() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final mapEnabled = prefs.getBool('map_enabled') ?? true;
|
|
||||||
if (!mounted) return;
|
|
||||||
if (_isMapEnabled != mapEnabled) {
|
|
||||||
_updateTabController(mapEnabled);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
setState(() {
|
if (!mounted) return;
|
||||||
_currentIndex = _tabController.index;
|
_handleTabActivated(_currentTab);
|
||||||
// Exit fullscreen when switching away from map tab (only if map is enabled and is tab 2)
|
|
||||||
if (_isMapEnabled && _currentIndex != 2) {
|
|
||||||
_isMapFullscreen = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateTabController(bool mapEnabled) {
|
void _handleAppProviderChanged() {
|
||||||
if (_isMapEnabled == mapEnabled) return;
|
if (!mounted) return;
|
||||||
|
_updateTabController(
|
||||||
|
mapEnabled: _appProvider.isMapEnabled,
|
||||||
|
contactsEnabled: _appProvider.isContactsEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Save current index before rebuilding
|
void _onTabChanged() {
|
||||||
final oldIndex = _tabController.index;
|
final previousTab = _currentTab;
|
||||||
|
final nextIndex = _tabController.index;
|
||||||
|
|
||||||
// Remove old listener and dispose
|
setState(() {
|
||||||
_tabController.removeListener(_onTabChanged);
|
_currentIndex = nextIndex;
|
||||||
_tabController.dispose();
|
if (_currentTab != _HomeTab.map) {
|
||||||
|
_isMapFullscreen = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final nextTab = _currentTab;
|
||||||
|
if (previousTab != nextTab) {
|
||||||
|
_handleTabActivated(nextTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateTabController({
|
||||||
|
required bool mapEnabled,
|
||||||
|
required bool contactsEnabled,
|
||||||
|
}) {
|
||||||
|
if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final oldTabs = _enabledTabs;
|
||||||
|
final oldIndex = oldTabs.isEmpty
|
||||||
|
? 0
|
||||||
|
: _tabController.index.clamp(0, oldTabs.length - 1);
|
||||||
|
final oldTab = oldTabs[oldIndex];
|
||||||
|
|
||||||
|
final oldController = _tabController;
|
||||||
|
oldController.removeListener(_onTabChanged);
|
||||||
|
oldController.dispose();
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
_isMapEnabled = mapEnabled;
|
_isMapEnabled = mapEnabled;
|
||||||
|
_isContactsEnabled = contactsEnabled;
|
||||||
// Create new controller
|
if (!_isMapEnabled) {
|
||||||
final tabCount = mapEnabled ? 3 : 2;
|
_isMapFullscreen = false;
|
||||||
_tabController = TabController(length: tabCount, vsync: this);
|
|
||||||
_tabController.addListener(_onTabChanged);
|
|
||||||
|
|
||||||
// Restore index (clamp to valid range)
|
|
||||||
if (oldIndex < tabCount) {
|
|
||||||
_tabController.index = oldIndex;
|
|
||||||
_currentIndex = oldIndex;
|
|
||||||
} else {
|
|
||||||
_currentIndex = tabCount - 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final newTabs = _enabledTabs;
|
||||||
|
final newIndex = newTabs.indexOf(oldTab);
|
||||||
|
|
||||||
|
// Create new controller
|
||||||
|
_tabController = TabController(length: newTabs.length, vsync: this);
|
||||||
|
_tabController.addListener(_onTabChanged);
|
||||||
|
|
||||||
|
_currentIndex = newIndex >= 0 ? newIndex : 0;
|
||||||
|
_tabController.index = _currentIndex;
|
||||||
|
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
_handleTabActivated(_currentTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToTab(_HomeTab tab) {
|
||||||
|
final targetIndex = _enabledTabs.indexOf(tab);
|
||||||
|
if (targetIndex >= 0 && targetIndex != _tabController.index) {
|
||||||
|
_tabController.animateTo(targetIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTabActivated(_HomeTab tab) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
switch (tab) {
|
||||||
|
case _HomeTab.messages:
|
||||||
|
context.read<MessagesProvider>().markAllAsRead();
|
||||||
|
break;
|
||||||
|
case _HomeTab.contacts:
|
||||||
|
context.read<ContactsProvider>().markAllAsViewed();
|
||||||
|
break;
|
||||||
|
case _HomeTab.map:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadRxTxPreference() async {
|
Future<void> _loadRxTxPreference() async {
|
||||||
@@ -131,6 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_appProvider.removeListener(_handleAppProviderChanged);
|
||||||
_tabController.removeListener(_onTabChanged);
|
_tabController.removeListener(_onTabChanged);
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -275,18 +345,12 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
messagesProvider.setLocalizations(localizations);
|
messagesProvider.setLocalizations(localizations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if map enabled setting changed and update tab controller
|
context.watch<AppProvider>();
|
||||||
final appProvider = context.watch<AppProvider>();
|
|
||||||
if (_isMapEnabled != appProvider.isMapEnabled) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
_updateTabController(appProvider.isMapEnabled);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 +362,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 {
|
||||||
@@ -339,6 +403,26 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.radar),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Spectrum Scan'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const SpectrumScanScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -373,29 +457,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 +502,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 +538,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 +628,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 +653,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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
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 +28,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';
|
||||||
@@ -42,11 +45,13 @@ class MessagesTab extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MessagesTabState extends State<MessagesTab> {
|
class _MessagesTabState extends State<MessagesTab> {
|
||||||
|
static const int _maxContactMessageBytes = 156;
|
||||||
|
static const int _maxChannelMessageBytes = 127;
|
||||||
|
|
||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
int _characterCount = 0;
|
int _messageByteCount = 0;
|
||||||
static const int _maxCharacters = 160;
|
|
||||||
String? _highlightedMessageId;
|
String? _highlightedMessageId;
|
||||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||||
|
|
||||||
@@ -67,7 +72,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 = [];
|
||||||
@@ -164,10 +169,44 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
void _updateCharacterCount() {
|
void _updateCharacterCount() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_characterCount = _textController.text.length;
|
_messageByteCount = utf8.encode(_textController.text).length;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int get _maxMessageBytes =>
|
||||||
|
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||||
|
? _maxChannelMessageBytes
|
||||||
|
: _maxContactMessageBytes;
|
||||||
|
|
||||||
|
TextInputFormatter get _messageByteLimiter =>
|
||||||
|
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||||
|
if (utf8.encode(newValue.text).length <= _maxMessageBytes) {
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
return oldValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
void _enforceMessageByteLimit() {
|
||||||
|
final currentText = _textController.text;
|
||||||
|
if (utf8.encode(currentText).length <= _maxMessageBytes) {
|
||||||
|
_updateCharacterCount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var truncated = currentText;
|
||||||
|
while (truncated.isNotEmpty &&
|
||||||
|
utf8.encode(truncated).length > _maxMessageBytes) {
|
||||||
|
truncated = truncated.substring(0, truncated.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_textController.value = _textController.value.copyWith(
|
||||||
|
text: truncated,
|
||||||
|
selection: TextSelection.collapsed(offset: truncated.length),
|
||||||
|
composing: TextRange.empty,
|
||||||
|
);
|
||||||
|
_updateCharacterCount();
|
||||||
|
}
|
||||||
|
|
||||||
/// Load saved message destination from preferences
|
/// Load saved message destination from preferences
|
||||||
Future<void> _loadSavedDestination() async {
|
Future<void> _loadSavedDestination() async {
|
||||||
final savedDestination =
|
final savedDestination =
|
||||||
@@ -209,6 +248,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await MessageDestinationPreferences.clearDestination();
|
await MessageDestinationPreferences.clearDestination();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_enforceMessageByteLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show recipient selector bottom sheet
|
/// Show recipient selector bottom sheet
|
||||||
@@ -248,6 +289,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedRecipient = recipient;
|
_selectedRecipient = recipient;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
await MessageDestinationPreferences.setDestination(
|
await MessageDestinationPreferences.setDestination(
|
||||||
type,
|
type,
|
||||||
@@ -285,6 +328,18 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return 'Select recipient';
|
return 'Select recipient';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getDestinationLabel() {
|
||||||
|
if (_destinationType ==
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel &&
|
||||||
|
_selectedRecipient != null) {
|
||||||
|
return _selectedRecipient!.getLocalizedDisplayName(context);
|
||||||
|
}
|
||||||
|
if (_selectedRecipient != null) {
|
||||||
|
return _selectedRecipient!.displayName;
|
||||||
|
}
|
||||||
|
return 'Public Channel';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _sendMessage() async {
|
||||||
final text = _textController.text.trim();
|
final text = _textController.text.trim();
|
||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
@@ -369,7 +424,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
// Add to messages list with "sending" status
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||||
|
|
||||||
// Send to selected channel
|
// Send to selected channel
|
||||||
await connectionProvider.sendChannelMessage(
|
await connectionProvider.sendChannelMessage(
|
||||||
@@ -427,12 +482,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 +640,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 +655,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, contact: recipient);
|
||||||
|
|
||||||
// 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 +693,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
'${fragments.length} fragments, ${compressed.length}B, '
|
'${fragments.length} fragments, ${compressed.length}B, '
|
||||||
'chunk=${imageDataBytesPerFragment}B',
|
'chunk=${imageDataBytesPerFragment}B',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Image fragments are always served on demand after an explicit IR2
|
||||||
|
// fetch request, including direct contacts.
|
||||||
} 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 +832,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 +874,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 +883,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 +944,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, contact: recipient);
|
||||||
|
|
||||||
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 +1043,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 +1166,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();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1192,9 +1368,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
|
||||||
|
|
||||||
// Look up the room contact for path logging
|
// Look up the room contact for path logging
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final roomContact = contactsProvider.contacts.where((c) {
|
final roomContact = contactsProvider.contacts.where((c) {
|
||||||
@@ -1202,6 +1375,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
c.publicKey.matches(roomPublicKey);
|
c.publicKey.matches(roomPublicKey);
|
||||||
}).firstOrNull;
|
}).firstOrNull;
|
||||||
|
|
||||||
|
// Add to messages list with "sending" status
|
||||||
|
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
|
||||||
|
|
||||||
// Send SAR message to selected room (persisted and immutable)
|
// Send SAR message to selected room (persisted and immutable)
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: roomPublicKey!,
|
contactPublicKey: roomPublicKey!,
|
||||||
@@ -1257,13 +1433,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 ==
|
||||||
@@ -1421,137 +1604,399 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: Theme.of(context).dividerColor,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
child: Column(
|
||||||
child: Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
children: [
|
||||||
// Quick actions (+) button
|
SafeArea(
|
||||||
IconButton(
|
top: false,
|
||||||
icon: Icon(_isRecording ? Icons.stop : Icons.add),
|
child: Padding(
|
||||||
tooltip: _isRecording ? 'Stop recording' : 'More actions',
|
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
|
||||||
onPressed: _isRecording
|
child: Container(
|
||||||
? _stopAndSendVoice
|
decoration: BoxDecoration(
|
||||||
: _showComposerActions,
|
color: Theme.of(
|
||||||
style: IconButton.styleFrom(
|
context,
|
||||||
backgroundColor: Theme.of(
|
).colorScheme.surfaceContainerLow,
|
||||||
context,
|
borderRadius: BorderRadius.circular(28),
|
||||||
).colorScheme.primaryContainer,
|
border: Border.all(
|
||||||
foregroundColor: _isRecording
|
color: Theme.of(
|
||||||
? Colors.red
|
|
||||||
: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
// Destination switcher button
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(_getDestinationIcon()),
|
|
||||||
tooltip: _getDestinationTooltip(),
|
|
||||||
onPressed: _showRecipientSelector,
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor:
|
|
||||||
_destinationType ==
|
|
||||||
MessageDestinationPreferences
|
|
||||||
.destinationTypeChannel
|
|
||||||
? Theme.of(
|
|
||||||
context,
|
context,
|
||||||
).colorScheme.surfaceContainerHighest
|
).dividerColor.withValues(alpha: 0.35),
|
||||||
: Theme.of(context).colorScheme.secondaryContainer,
|
),
|
||||||
foregroundColor:
|
boxShadow: [
|
||||||
_destinationType ==
|
BoxShadow(
|
||||||
MessageDestinationPreferences
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
.destinationTypeChannel
|
blurRadius: 18,
|
||||||
? Theme.of(context).colorScheme.onSurface
|
offset: const Offset(0, 6),
|
||||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
),
|
||||||
),
|
],
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
// Text field with embedded send button
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _textController,
|
|
||||||
focusNode: _focusNode,
|
|
||||||
maxLength: _maxCharacters,
|
|
||||||
maxLines: null,
|
|
||||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
|
||||||
style: const TextStyle(fontSize: 14),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: AppLocalizations.of(context)!.typeYourMessage,
|
|
||||||
hintStyle: const TextStyle(fontSize: 14),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
child: Padding(
|
||||||
horizontal: 16,
|
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
||||||
vertical: 10,
|
child: Column(
|
||||||
),
|
mainAxisSize: MainAxisSize.min,
|
||||||
isDense: true,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
counterText: _characterCount >= 150
|
children: [
|
||||||
? '$_characterCount/$_maxCharacters'
|
Row(
|
||||||
: '',
|
children: [
|
||||||
counterStyle: TextStyle(
|
Container(
|
||||||
fontSize: 10,
|
width: 42,
|
||||||
color: _characterCount > _maxCharacters * 0.9
|
height: 42,
|
||||||
? Colors.orange
|
decoration: BoxDecoration(
|
||||||
: Theme.of(context).textTheme.bodySmall?.color,
|
color: Theme.of(
|
||||||
),
|
context,
|
||||||
suffixIcon: GestureDetector(
|
).colorScheme.surface,
|
||||||
onLongPressStart:
|
shape: BoxShape.circle,
|
||||||
(_voiceSupported && !_isSendingVoice)
|
border: Border.all(
|
||||||
? (_) => _startVoiceRecording()
|
color: Theme.of(
|
||||||
: null,
|
context,
|
||||||
onLongPressEnd: (_voiceSupported && _isRecording)
|
).dividerColor.withValues(alpha: 0.35),
|
||||||
? (_) => _stopAndSendVoice()
|
),
|
||||||
: null,
|
),
|
||||||
onLongPressCancel: (_voiceSupported && _isRecording)
|
child: IconButton(
|
||||||
? () => _stopAndSendVoice()
|
icon: Icon(
|
||||||
: null,
|
_isRecording ? Icons.stop : Icons.add,
|
||||||
child: IconButton(
|
size: 22,
|
||||||
icon: _isSendingVoice
|
),
|
||||||
? const SizedBox(
|
tooltip: _isRecording
|
||||||
width: 18,
|
? 'Stop recording'
|
||||||
height: 18,
|
: 'More actions',
|
||||||
child: CircularProgressIndicator(
|
onPressed: _isRecording
|
||||||
strokeWidth: 2,
|
? _stopAndSendVoice
|
||||||
|
: _showComposerActions,
|
||||||
|
color: _isRecording
|
||||||
|
? Colors.red
|
||||||
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
_isRecording
|
|
||||||
? Icons.mic
|
|
||||||
: Icons.send_rounded,
|
|
||||||
size: 22,
|
|
||||||
color: _isRecording
|
|
||||||
? Colors.red
|
|
||||||
: (_textController.text.trim().isEmpty
|
|
||||||
? Theme.of(context).disabledColor
|
|
||||||
: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary),
|
|
||||||
),
|
),
|
||||||
onPressed:
|
const SizedBox(width: 8),
|
||||||
_isRecording ||
|
Expanded(
|
||||||
_isSendingVoice ||
|
child: Material(
|
||||||
_textController.text.trim().isEmpty
|
color: Colors.transparent,
|
||||||
? null
|
child: InkWell(
|
||||||
: _sendMessage,
|
borderRadius: BorderRadius.circular(20),
|
||||||
tooltip: _isRecording
|
onTap: _showRecipientSelector,
|
||||||
? 'Recording... release to send voice'
|
child: Ink(
|
||||||
: (_isSendingVoice
|
height: 42,
|
||||||
? 'Sending voice...'
|
decoration: BoxDecoration(
|
||||||
: _voiceSupported
|
color: Theme.of(
|
||||||
? 'Send (long press to record voice)'
|
context,
|
||||||
: 'Send'),
|
).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
20,
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.dividerColor
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getDestinationIcon(),
|
||||||
|
size: 17,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_getDestinationLabel(),
|
||||||
|
overflow:
|
||||||
|
TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight:
|
||||||
|
FontWeight.w600,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.expand_more_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(
|
||||||
|
milliseconds: 180,
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minHeight: 46,
|
||||||
|
maxHeight: 132,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: _focusNode.hasFocus
|
||||||
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary
|
||||||
|
: Theme.of(context).dividerColor
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
|
width: _focusNode.hasFocus ? 1.4 : 1,
|
||||||
|
),
|
||||||
|
boxShadow: _focusNode.hasFocus
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.10),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _textController,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 4,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
inputFormatters: [
|
||||||
|
_messageByteLimiter,
|
||||||
|
],
|
||||||
|
style: const TextStyle(fontSize: 15),
|
||||||
|
textAlignVertical:
|
||||||
|
TextAlignVertical.center,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.typeYourMessage,
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant
|
||||||
|
.withValues(alpha: 0.9),
|
||||||
|
),
|
||||||
|
filled: false,
|
||||||
|
fillColor: Colors.transparent,
|
||||||
|
border: InputBorder.none,
|
||||||
|
isCollapsed: true,
|
||||||
|
),
|
||||||
|
textInputAction:
|
||||||
|
TextInputAction.newline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final canSendText =
|
||||||
|
!_isRecording &&
|
||||||
|
!_isSendingVoice &&
|
||||||
|
_textController.text
|
||||||
|
.trim()
|
||||||
|
.isNotEmpty;
|
||||||
|
final semanticsLabel = _isRecording
|
||||||
|
? 'Recording... release to send voice'
|
||||||
|
: (_isSendingVoice
|
||||||
|
? 'Sending voice...'
|
||||||
|
: _voiceSupported
|
||||||
|
? 'Send (long press to record voice)'
|
||||||
|
: 'Send');
|
||||||
|
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
enabled:
|
||||||
|
canSendText ||
|
||||||
|
(_voiceSupported &&
|
||||||
|
!_isSendingVoice),
|
||||||
|
label: semanticsLabel,
|
||||||
|
onTap: canSendText
|
||||||
|
? _sendMessage
|
||||||
|
: null,
|
||||||
|
onLongPress:
|
||||||
|
(_voiceSupported &&
|
||||||
|
!_isSendingVoice)
|
||||||
|
? () {
|
||||||
|
if (_isRecording) {
|
||||||
|
_stopAndSendVoice();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_startVoiceRecording();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: Tooltip(
|
||||||
|
message: semanticsLabel,
|
||||||
|
excludeFromSemantics: true,
|
||||||
|
child: GestureDetector(
|
||||||
|
excludeFromSemantics: true,
|
||||||
|
onLongPressStart:
|
||||||
|
(_voiceSupported &&
|
||||||
|
!_isSendingVoice)
|
||||||
|
? (_) => _startVoiceRecording()
|
||||||
|
: null,
|
||||||
|
onLongPressEnd:
|
||||||
|
(_voiceSupported &&
|
||||||
|
_isRecording)
|
||||||
|
? (_) => _stopAndSendVoice()
|
||||||
|
: null,
|
||||||
|
onLongPressCancel:
|
||||||
|
(_voiceSupported &&
|
||||||
|
_isRecording)
|
||||||
|
? () => _stopAndSendVoice()
|
||||||
|
: null,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(
|
||||||
|
milliseconds: 180,
|
||||||
|
),
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
canSendText ||
|
||||||
|
_isRecording
|
||||||
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary
|
||||||
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surface,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
canSendText ||
|
||||||
|
_isRecording
|
||||||
|
? Colors.transparent
|
||||||
|
: Theme.of(context)
|
||||||
|
.dividerColor
|
||||||
|
.withValues(
|
||||||
|
alpha: 0.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
boxShadow:
|
||||||
|
canSendText ||
|
||||||
|
_isRecording
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(
|
||||||
|
alpha:
|
||||||
|
0.22,
|
||||||
|
),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset:
|
||||||
|
const Offset(
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: _isSendingVoice
|
||||||
|
? Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
.colorScheme
|
||||||
|
.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
_isRecording
|
||||||
|
? Icons
|
||||||
|
.mic_rounded
|
||||||
|
: Icons
|
||||||
|
.send_rounded,
|
||||||
|
size: 22,
|
||||||
|
color:
|
||||||
|
canSendText ||
|
||||||
|
_isRecording
|
||||||
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
.colorScheme
|
||||||
|
.onPrimary
|
||||||
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$_messageByteCount/$_maxMessageBytes',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color:
|
||||||
|
_messageByteCount >
|
||||||
|
_maxMessageBytes *
|
||||||
|
0.9
|
||||||
|
? Colors.orange.shade800
|
||||||
|
: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant
|
||||||
|
.withValues(
|
||||||
|
alpha: 0.9,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.send,
|
|
||||||
onSubmitted: (_) => _sendMessage(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -403,25 +403,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
channelCount: 2,
|
channelCount: 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
final sarMessages = SampleDataGenerator.generateSarMarkerMessages(
|
final sampleMessages = SampleDataGenerator.generateAllMessages(
|
||||||
centerLocation: centerLocation,
|
centerLocation: centerLocation,
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
foundPersonCount: 2,
|
foundPersonCount: 2,
|
||||||
fireCount: 1,
|
fireCount: 1,
|
||||||
stagingCount: 1,
|
stagingCount: 1,
|
||||||
objectCount: 1,
|
objectCount: 1,
|
||||||
);
|
|
||||||
|
|
||||||
final channelMessages = SampleDataGenerator.generateChannelMessages(
|
|
||||||
centerLocation: centerLocation,
|
|
||||||
l10n: l10n,
|
|
||||||
generalChannelMessages: 8,
|
generalChannelMessages: 8,
|
||||||
emergencyChannelMessages: 5,
|
emergencyChannelMessages: 5,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combine all messages
|
|
||||||
final allMessages = [...sarMessages, ...channelMessages];
|
|
||||||
|
|
||||||
// Add to providers
|
// Add to providers
|
||||||
final contactsProvider = Provider.of<ContactsProvider>(
|
final contactsProvider = Provider.of<ContactsProvider>(
|
||||||
context,
|
context,
|
||||||
@@ -433,7 +425,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
contactsProvider.addContacts(contacts);
|
contactsProvider.addContacts(contacts);
|
||||||
messagesProvider.addMessages(allMessages);
|
for (final message in sampleMessages.messages) {
|
||||||
|
messagesProvider.addMessage(
|
||||||
|
message,
|
||||||
|
contactLocationSnapshot: sampleMessages.contactLocations[message.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -446,8 +443,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
AppLocalizations.of(context)!.loadedSampleData(
|
AppLocalizations.of(context)!.loadedSampleData(
|
||||||
teamCount,
|
teamCount,
|
||||||
channelCount,
|
channelCount,
|
||||||
sarMessages.length,
|
sampleMessages.messages.where((m) => m.isSarMarker).length,
|
||||||
channelMessages.length,
|
sampleMessages.messages.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
@@ -621,6 +618,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 +698,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 +724,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 +744,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
|
||||||
|
|||||||
601
lib/screens/spectrum_scan_screen.dart
Normal file
601
lib/screens/spectrum_scan_screen.dart
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/device_info.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../widgets/device/spectrum_scan_panel.dart';
|
||||||
|
|
||||||
|
class SpectrumScanScreen extends StatefulWidget {
|
||||||
|
const SpectrumScanScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpectrumScanScreen> createState() => _SpectrumScanScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
|
||||||
|
static const List<String> _bandwidthOptions = [
|
||||||
|
'7.8 kHz',
|
||||||
|
'10.4 kHz',
|
||||||
|
'15.6 kHz',
|
||||||
|
'20.8 kHz',
|
||||||
|
'31.25 kHz',
|
||||||
|
'41.7 kHz',
|
||||||
|
'62.5 kHz',
|
||||||
|
'125 kHz',
|
||||||
|
'250 kHz',
|
||||||
|
'500 kHz',
|
||||||
|
];
|
||||||
|
|
||||||
|
String _selectedBandwidth = '62.5 kHz';
|
||||||
|
bool _isSpectrumScanRunning = false;
|
||||||
|
bool _rangeInitialized = false;
|
||||||
|
String? _lastRangeSourceKey;
|
||||||
|
late double _scanRangeMinMhz;
|
||||||
|
late double _scanRangeMaxMhz;
|
||||||
|
late RangeValues _scanRangeValues;
|
||||||
|
List<SpectrumScanCandidate> _scanCandidates = const [];
|
||||||
|
int? _selectedScanFrequencyKhz;
|
||||||
|
int _completedScanSectors = 0;
|
||||||
|
int _totalScanSectors = 0;
|
||||||
|
|
||||||
|
List<SpectrumScanCandidate> get _recommendedScanCandidates =>
|
||||||
|
_scanCandidates.take(8).toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final deviceInfo = _deviceInfo;
|
||||||
|
if (deviceInfo.radioBw != null &&
|
||||||
|
deviceInfo.radioBw! >= 0 &&
|
||||||
|
deviceInfo.radioBw! < _bandwidthOptions.length) {
|
||||||
|
_selectedBandwidth = _bandwidthOptions[deviceInfo.radioBw!];
|
||||||
|
}
|
||||||
|
_syncRangeFromDevice(deviceInfo);
|
||||||
|
_selectedScanFrequencyKhz = deviceInfo.radioFreq;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceInfo get _deviceInfo => context.read<ConnectionProvider>().deviceInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_syncRangeFromDevice(context.watch<ConnectionProvider>().deviceInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncRangeFromDevice(DeviceInfo deviceInfo) {
|
||||||
|
final minKhz = deviceInfo.spectrumScanMinKhz;
|
||||||
|
final maxKhz = deviceInfo.spectrumScanMaxKhz;
|
||||||
|
late double normalizedMinMhz;
|
||||||
|
late double normalizedMaxMhz;
|
||||||
|
late String sourceKey;
|
||||||
|
|
||||||
|
if (minKhz != null && maxKhz != null && maxKhz > minKhz) {
|
||||||
|
final normalized = _normalizeScanRange(
|
||||||
|
minKhz / 1000.0,
|
||||||
|
maxKhz / 1000.0,
|
||||||
|
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
|
||||||
|
);
|
||||||
|
normalizedMinMhz = normalized.$1;
|
||||||
|
normalizedMaxMhz = normalized.$2;
|
||||||
|
sourceKey = 'fw:$minKhz:$maxKhz';
|
||||||
|
} else {
|
||||||
|
final normalized = _normalizeScanRange(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
|
||||||
|
);
|
||||||
|
normalizedMinMhz = normalized.$1;
|
||||||
|
normalizedMaxMhz = normalized.$2;
|
||||||
|
sourceKey =
|
||||||
|
'fallback:${deviceInfo.radioFreq != null ? deviceInfo.radioFreq! ~/ 1000 : 869525}';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_rangeInitialized && _lastRangeSourceKey == sourceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scanRangeMinMhz = normalizedMinMhz;
|
||||||
|
_scanRangeMaxMhz = normalizedMaxMhz;
|
||||||
|
|
||||||
|
if (_scanRangeMaxMhz <= _scanRangeMinMhz) {
|
||||||
|
_scanRangeMaxMhz = _scanRangeMinMhz + 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scanRangeValues = RangeValues(_scanRangeMinMhz, _scanRangeMaxMhz);
|
||||||
|
_rangeInitialized = true;
|
||||||
|
_lastRangeSourceKey = sourceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
(double, double) _normalizeScanRange(
|
||||||
|
double? minMhz,
|
||||||
|
double? maxMhz,
|
||||||
|
double? centerMhz,
|
||||||
|
) {
|
||||||
|
const hardMinMhz = 800.0;
|
||||||
|
const hardMaxMhz = 950.0;
|
||||||
|
final center = centerMhz ?? 869.525;
|
||||||
|
|
||||||
|
if (minMhz != null && maxMhz != null) {
|
||||||
|
final clampedMin = minMhz.clamp(hardMinMhz, hardMaxMhz);
|
||||||
|
final clampedMax = maxMhz.clamp(hardMinMhz, hardMaxMhz);
|
||||||
|
if (clampedMax > clampedMin) {
|
||||||
|
return (clampedMin, clampedMax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (center >= 900.0 && center <= 930.0) {
|
||||||
|
return (902.0, 928.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (863.0, 870.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _bandwidthToKhz(String bw) {
|
||||||
|
switch (bw) {
|
||||||
|
case '7.8 kHz':
|
||||||
|
return 7.8;
|
||||||
|
case '10.4 kHz':
|
||||||
|
return 10.4;
|
||||||
|
case '15.6 kHz':
|
||||||
|
return 15.6;
|
||||||
|
case '20.8 kHz':
|
||||||
|
return 20.8;
|
||||||
|
case '31.25 kHz':
|
||||||
|
return 31.25;
|
||||||
|
case '41.7 kHz':
|
||||||
|
return 41.7;
|
||||||
|
case '62.5 kHz':
|
||||||
|
return 62.5;
|
||||||
|
case '125 kHz':
|
||||||
|
return 125.0;
|
||||||
|
case '250 kHz':
|
||||||
|
return 250.0;
|
||||||
|
case '500 kHz':
|
||||||
|
return 500.0;
|
||||||
|
default:
|
||||||
|
return 62.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _currentParamProfile(DeviceInfo deviceInfo) {
|
||||||
|
final sf = deviceInfo.radioSf ?? 8;
|
||||||
|
final cr = deviceInfo.radioCr ?? 8;
|
||||||
|
return 'BW $_selectedBandwidth | SF$sf | CR 4/$cr';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _recommendationTitle(int index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
return 'Best candidate';
|
||||||
|
case 1:
|
||||||
|
return 'Alternate';
|
||||||
|
case 2:
|
||||||
|
return 'Fallback';
|
||||||
|
default:
|
||||||
|
return 'Candidate ${index + 1}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _possibleBracketFrequenciesKhz() {
|
||||||
|
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
|
||||||
|
final halfBandwidthKhz = bandwidthKhz / 2.0;
|
||||||
|
final startKhz = (_scanRangeValues.start * 1000).round();
|
||||||
|
final stopKhz = (_scanRangeValues.end * 1000).round();
|
||||||
|
final firstCenterKhz = (startKhz + halfBandwidthKhz).round();
|
||||||
|
final lastCenterKhz = (stopKhz - halfBandwidthKhz).round();
|
||||||
|
|
||||||
|
if (lastCenterKhz < firstCenterKhz) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final stepKhz = bandwidthKhz >= 125.0 ? bandwidthKhz.round() : 25;
|
||||||
|
final centers = <int>[];
|
||||||
|
for (
|
||||||
|
var centerKhz = firstCenterKhz;
|
||||||
|
centerKhz <= lastCenterKhz && centers.length < 8;
|
||||||
|
centerKhz += stepKhz
|
||||||
|
) {
|
||||||
|
centers.add(centerKhz);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (centers.isEmpty || centers.last != lastCenterKhz) {
|
||||||
|
centers.add(lastCenterKhz);
|
||||||
|
}
|
||||||
|
|
||||||
|
return centers.toSet().toList()..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetDerivedScanResults() {
|
||||||
|
_scanCandidates = const [];
|
||||||
|
_selectedScanFrequencyKhz = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(int startKhz, int stopKhz)> _buildScanSectors(double bandwidthKhz) {
|
||||||
|
final startKhz = (_scanRangeValues.start * 1000).round();
|
||||||
|
final stopKhz = (_scanRangeValues.end * 1000).round();
|
||||||
|
final sectorWidthKhz = (bandwidthKhz * 24).round().clamp(250, 1200);
|
||||||
|
final overlapKhz = bandwidthKhz.round().clamp(8, 500);
|
||||||
|
final sectors = <(int startKhz, int stopKhz)>[];
|
||||||
|
|
||||||
|
var sectorStartKhz = startKhz;
|
||||||
|
while (sectorStartKhz < stopKhz) {
|
||||||
|
final sectorStopKhz = (sectorStartKhz + sectorWidthKhz).clamp(
|
||||||
|
sectorStartKhz + overlapKhz,
|
||||||
|
stopKhz,
|
||||||
|
);
|
||||||
|
sectors.add((sectorStartKhz, sectorStopKhz));
|
||||||
|
if (sectorStopKhz >= stopKhz) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sectorStartKhz = sectorStopKhz - overlapKhz;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SpectrumScanCandidate> _mergeSectorCandidates(
|
||||||
|
Iterable<SpectrumScanCandidate> candidates,
|
||||||
|
) {
|
||||||
|
final byFrequency = <int, SpectrumScanCandidate>{};
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
final existing = byFrequency[candidate.centerFrequencyKhz];
|
||||||
|
if (existing == null ||
|
||||||
|
candidate.occupancyPercent < existing.occupancyPercent ||
|
||||||
|
(candidate.occupancyPercent == existing.occupancyPercent &&
|
||||||
|
candidate.peakRssiDbm < existing.peakRssiDbm) ||
|
||||||
|
(candidate.occupancyPercent == existing.occupancyPercent &&
|
||||||
|
candidate.peakRssiDbm == existing.peakRssiDbm &&
|
||||||
|
candidate.avgRssiDbm < existing.avgRssiDbm)) {
|
||||||
|
byFrequency[candidate.centerFrequencyKhz] = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final merged = byFrequency.values.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final occupancyCompare = a.occupancyPercent.compareTo(
|
||||||
|
b.occupancyPercent,
|
||||||
|
);
|
||||||
|
if (occupancyCompare != 0) return occupancyCompare;
|
||||||
|
final peakCompare = a.peakRssiDbm.compareTo(b.peakRssiDbm);
|
||||||
|
if (peakCompare != 0) return peakCompare;
|
||||||
|
return a.avgRssiDbm.compareTo(b.avgRssiDbm);
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runSpectrumScan() async {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
|
||||||
|
final sectors = _buildScanSectors(bandwidthKhz);
|
||||||
|
final sectorCandidates = <SpectrumScanCandidate>[];
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSpectrumScanRunning = true;
|
||||||
|
_resetDerivedScanResults();
|
||||||
|
_completedScanSectors = 0;
|
||||||
|
_totalScanSectors = sectors.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (var i = 0; i < sectors.length; i++) {
|
||||||
|
final sector = sectors[i];
|
||||||
|
final result = await connectionProvider.scanSpectrum(
|
||||||
|
startFrequencyKhz: sector.$1,
|
||||||
|
stopFrequencyKhz: sector.$2,
|
||||||
|
bandwidthKhz: bandwidthKhz.round(),
|
||||||
|
stepKhz: (bandwidthKhz / 2).round().clamp(1, 1000),
|
||||||
|
dwellMs: 160,
|
||||||
|
thresholdDb: 8,
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
sectorCandidates.addAll(result.candidates);
|
||||||
|
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_scanCandidates = mergedCandidates;
|
||||||
|
if (_selectedScanFrequencyKhz == null &&
|
||||||
|
mergedCandidates.isNotEmpty) {
|
||||||
|
_selectedScanFrequencyKhz =
|
||||||
|
mergedCandidates.first.centerFrequencyKhz;
|
||||||
|
}
|
||||||
|
_completedScanSectors = i + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_completedScanSectors = i + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSpectrumScanRunning = false;
|
||||||
|
if (_completedScanSectors == 0) {
|
||||||
|
_totalScanSectors = sectors.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
|
||||||
|
if (mergedCandidates.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Spectrum scan returned no candidate frequencies'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_scanCandidates = mergedCandidates;
|
||||||
|
_selectedScanFrequencyKhz = mergedCandidates.first.centerFrequencyKhz;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applySelectedScanFrequency() async {
|
||||||
|
if (_selectedScanFrequencyKhz == null) return;
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
|
||||||
|
await connectionProvider.setRadioParams(
|
||||||
|
frequency: _selectedScanFrequencyKhz!,
|
||||||
|
bandwidth: _bandwidthOptions.indexOf(_selectedBandwidth),
|
||||||
|
spreadingFactor: deviceInfo.radioSf ?? 8,
|
||||||
|
codingRate: deviceInfo.radioCr ?? 8,
|
||||||
|
repeat: deviceInfo.clientRepeat,
|
||||||
|
);
|
||||||
|
await connectionProvider.refreshDeviceInfo();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Applied ${(_selectedScanFrequencyKhz! / 1000.0).toStringAsFixed(3)} MHz',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _currentPreviewFrequencyKhz(DeviceInfo deviceInfo) {
|
||||||
|
if (_selectedScanFrequencyKhz != null) {
|
||||||
|
return _selectedScanFrequencyKhz;
|
||||||
|
}
|
||||||
|
return deviceInfo.radioFreq;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final possibleBracketFrequencies = _possibleBracketFrequenciesKhz();
|
||||||
|
final recommendedScanCandidates = _recommendedScanCandidates;
|
||||||
|
final recommendationFrequencies = recommendedScanCandidates.isNotEmpty
|
||||||
|
? recommendedScanCandidates
|
||||||
|
.map((candidate) => candidate.centerFrequencyKhz)
|
||||||
|
.toList()
|
||||||
|
: possibleBracketFrequencies;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Spectrum Scan')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _selectedBandwidth,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Bandwidth',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText:
|
||||||
|
'Scan and apply frequencies for this bandwidth',
|
||||||
|
),
|
||||||
|
items: _bandwidthOptions.map((value) {
|
||||||
|
return DropdownMenuItem<String>(
|
||||||
|
value: value,
|
||||||
|
child: Text(value),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedBandwidth = value;
|
||||||
|
_resetDerivedScanResults();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
deviceInfo.spectrumScanMinKhz != null &&
|
||||||
|
deviceInfo.spectrumScanMaxKhz != null
|
||||||
|
? 'Firmware scan range: ${_scanRangeMinMhz.toStringAsFixed(3)}-${_scanRangeMaxMhz.toStringAsFixed(3)} MHz'
|
||||||
|
: 'Fallback scan range selected from current band. MeshCore commonly uses EU868 or US915.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isSpectrumScanRunning) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Scanning sector ${_completedScanSectors + 1} of $_totalScanSectors. Results update as each sector completes.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SpectrumScanPanel(
|
||||||
|
theme: theme,
|
||||||
|
scanSupported: deviceInfo.supportsSpectrumScan == true,
|
||||||
|
isRunning: _isSpectrumScanRunning,
|
||||||
|
rangeMinMhz: _scanRangeMinMhz,
|
||||||
|
rangeMaxMhz: _scanRangeMaxMhz,
|
||||||
|
rangeValues: _scanRangeValues,
|
||||||
|
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
|
||||||
|
selectedFrequencyKhz: _currentPreviewFrequencyKhz(
|
||||||
|
deviceInfo,
|
||||||
|
),
|
||||||
|
graphCandidates: _scanCandidates,
|
||||||
|
selectableCandidates: recommendedScanCandidates,
|
||||||
|
onRangeChanged: (values) {
|
||||||
|
setState(() {
|
||||||
|
_scanRangeValues = values;
|
||||||
|
_resetDerivedScanResults();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCandidateChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedScanFrequencyKhz = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onRunScan: _runSpectrumScan,
|
||||||
|
onApplySelected: _applySelectedScanFrequency,
|
||||||
|
),
|
||||||
|
if (recommendationFrequencies.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
_scanCandidates.isNotEmpty
|
||||||
|
? 'Recommended profiles'
|
||||||
|
: 'Possible brackets',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_scanCandidates.isNotEmpty
|
||||||
|
? 'Suggested frequencies from the latest scan with the radio parameters to keep alongside them.'
|
||||||
|
: 'Usable frequency brackets derived from the selected span and bandwidth, even without live scan data.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
for (
|
||||||
|
var i = 0;
|
||||||
|
i < recommendationFrequencies.length;
|
||||||
|
i++
|
||||||
|
) ...[
|
||||||
|
_RecommendationTile(
|
||||||
|
title: _recommendationTitle(i),
|
||||||
|
frequencyKhz: recommendationFrequencies[i],
|
||||||
|
candidate: recommendedScanCandidates.isNotEmpty
|
||||||
|
? recommendedScanCandidates[i]
|
||||||
|
: null,
|
||||||
|
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
|
||||||
|
paramsLabel: _currentParamProfile(deviceInfo),
|
||||||
|
isSelected:
|
||||||
|
_selectedScanFrequencyKhz ==
|
||||||
|
recommendationFrequencies[i],
|
||||||
|
onSelect: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedScanFrequencyKhz =
|
||||||
|
recommendationFrequencies[i];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (i != recommendationFrequencies.length - 1)
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecommendationTile extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final int frequencyKhz;
|
||||||
|
final SpectrumScanCandidate? candidate;
|
||||||
|
final double bandwidthKhz;
|
||||||
|
final String paramsLabel;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onSelect;
|
||||||
|
|
||||||
|
const _RecommendationTile({
|
||||||
|
required this.title,
|
||||||
|
required this.frequencyKhz,
|
||||||
|
required this.candidate,
|
||||||
|
required this.bandwidthKhz,
|
||||||
|
required this.paramsLabel,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onSelect,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? scheme.primaryContainer.withValues(alpha: 0.55)
|
||||||
|
: scheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? scheme.primary : scheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Icon(Icons.check_circle, color: scheme.primary, size: 18),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${(frequencyKhz / 1000.0).toStringAsFixed(3)} MHz',
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(paramsLabel, style: theme.textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
candidate != null
|
||||||
|
? 'Occupancy ${candidate!.occupancyPercent}% | Avg ${candidate!.avgRssiDbm} dBm | Peak ${candidate!.peakRssiDbm} dBm'
|
||||||
|
: 'Bracket ${(frequencyKhz / 1000.0 - bandwidthKhz / 2000.0).toStringAsFixed(3)}-${(frequencyKhz / 1000.0 + bandwidthKhz / 2000.0).toStringAsFixed(3)} MHz',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,14 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
int fieldCount = 0;
|
int fieldCount = 0;
|
||||||
while (reader.hasRemaining) {
|
while (reader.hasRemaining) {
|
||||||
|
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||||
|
debugPrint(
|
||||||
|
' Detected zero-padded telemetry tail, stopping parse at position '
|
||||||
|
'${data.length - reader.remainingBytesCount}',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fieldCount++;
|
fieldCount++;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -250,6 +258,14 @@ class CayenneLppParser {
|
|||||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
||||||
|
final start = data.length - remainingBytes;
|
||||||
|
for (int i = start; i < data.length; i++) {
|
||||||
|
if (data[i] != 0) return false;
|
||||||
|
}
|
||||||
|
return remainingBytes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Create Cayenne LPP data for GPS location
|
/// Create Cayenne LPP data for GPS location
|
||||||
/// Standard Cayenne LPP GPS format (type 0x88):
|
/// Standard Cayenne LPP GPS format (type 0x88):
|
||||||
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000
|
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Greek';
|
return 'Greek';
|
||||||
|
case 'cn':
|
||||||
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
return locale.languageCode;
|
return locale.languageCode;
|
||||||
}
|
}
|
||||||
@@ -88,6 +90,8 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Ελληνικά';
|
return 'Ελληνικά';
|
||||||
|
case 'cn':
|
||||||
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
return locale.languageCode;
|
return locale.languageCode;
|
||||||
}
|
}
|
||||||
|
|||||||
75
lib/services/mesh_map_nodes_service.dart
Normal file
75
lib/services/mesh_map_nodes_service.dart
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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 const Duration traceCacheTtl = Duration(minutes: 10);
|
||||||
|
static List<MeshMapNode>? _cachedNodes;
|
||||||
|
static DateTime? _cachedAt;
|
||||||
|
|
||||||
|
static Future<List<MeshMapNode>> fetchNodes({
|
||||||
|
bool forceRefresh = false,
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
}) 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,21 @@ 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/message.dart';
|
import '../models/message.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
class MessageStorageService {
|
class MessageStorageService {
|
||||||
static const String _messagesKey = 'stored_messages';
|
static const String _messagesKey = 'stored_messages';
|
||||||
|
static const String _messageContactLocationsKey =
|
||||||
|
'stored_message_contact_locations';
|
||||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// Save messages to persistent storage
|
||||||
Future<void> saveMessages(List<Message> messages) async {
|
Future<void> saveMessages(
|
||||||
|
List<Message> messages, {
|
||||||
|
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
@@ -24,6 +30,19 @@ class MessageStorageService {
|
|||||||
|
|
||||||
final jsonString = jsonEncode(limitedList);
|
final jsonString = jsonEncode(limitedList);
|
||||||
await prefs.setString(_messagesKey, jsonString);
|
await prefs.setString(_messagesKey, jsonString);
|
||||||
|
final retainedMessageIds = limitedList
|
||||||
|
.map((entry) => entry['id'] as String)
|
||||||
|
.toSet();
|
||||||
|
final locationJson = <String, dynamic>{};
|
||||||
|
for (final entry in messageContactLocations.entries) {
|
||||||
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
|
locationJson[entry.key] = entry.value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await prefs.setString(
|
||||||
|
_messageContactLocationsKey,
|
||||||
|
jsonEncode(locationJson),
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||||
@@ -33,6 +52,36 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageContactLocation>> loadMessageContactLocations()
|
||||||
|
async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messageContactLocationsKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageContactLocation>{};
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageContactLocation.fromJson(value);
|
||||||
|
if (snapshot != null) {
|
||||||
|
result[entry.key] = snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error loading contact locations: $e');
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load messages from persistent storage
|
/// Load messages from persistent storage
|
||||||
Future<List<Message>> loadMessages() async {
|
Future<List<Message>> loadMessages() async {
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +115,7 @@ class MessageStorageService {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_messagesKey);
|
await prefs.remove(_messagesKey);
|
||||||
|
await prefs.remove(_messageContactLocationsKey);
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
146
lib/services/packet_capture_storage_service.dart
Normal file
146
lib/services/packet_capture_storage_service.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
89
lib/services/voice_codec_service_io.dart
Normal file
89
lib/services/voice_codec_service_io.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
lib/services/voice_codec_service_stub.dart
Normal file
71
lib/services/voice_codec_service_stub.dart
Normal 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'));
|
||||||
|
}
|
||||||
32
lib/utils/avatar_label_helper.dart
Normal file
32
lib/utils/avatar_label_helper.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
class AvatarLabelHelper {
|
||||||
|
static String buildLabel(String name) {
|
||||||
|
final trimmed = name.trim();
|
||||||
|
if (trimmed.isEmpty) return '?';
|
||||||
|
|
||||||
|
if (trimmed.startsWith('#')) {
|
||||||
|
final hashBody = trimmed.substring(1).replaceAll(RegExp(r'[\s_-]+'), '');
|
||||||
|
if (hashBody.isEmpty) {
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
final parts = trimmed
|
||||||
|
.split(RegExp(r'[\s_-]+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
final first = _take(parts[0], 1);
|
||||||
|
final second = _take(parts[1], 1);
|
||||||
|
return '$first$second'.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _take(trimmed, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _take(String value, int count) {
|
||||||
|
if (value.length <= count) return value;
|
||||||
|
return value.substring(0, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,7 +125,10 @@ int safeImageDataBytesForPath(int pathLen) {
|
|||||||
? maxRawPayloadFromCommandFrame
|
? maxRawPayloadFromCommandFrame
|
||||||
: maxRawPayloadFromMesh;
|
: maxRawPayloadFromMesh;
|
||||||
final maxData = maxRawPayload - ImagePacket._headerLen;
|
final maxData = maxRawPayload - ImagePacket._headerLen;
|
||||||
return maxData.clamp(1, 255).toInt();
|
// Keep a safety margin below the theoretical direct-route ceiling.
|
||||||
|
// Fragments at the absolute 172-byte command-frame limit have proven flaky
|
||||||
|
// in practice, so cap to the conservative protocol default.
|
||||||
|
return maxData.clamp(1, ImagePacket.maxDataBytes).toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa.
|
/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa.
|
||||||
@@ -243,11 +246,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 +271,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 +308,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 +316,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 +345,87 @@ 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 +434,171 @@ 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.
|
||||||
|
|||||||
@@ -24,15 +24,30 @@ extension MessageLocalization on Message {
|
|||||||
|
|
||||||
switch (deliveryStatus) {
|
switch (deliveryStatus) {
|
||||||
case MessageDeliveryStatus.sending:
|
case MessageDeliveryStatus.sending:
|
||||||
|
if (isContactMessage) {
|
||||||
|
if (retryAttempt > 0) {
|
||||||
|
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||||
|
}
|
||||||
|
return l10n.pending;
|
||||||
|
}
|
||||||
return l10n.sending;
|
return l10n.sending;
|
||||||
case MessageDeliveryStatus.sent:
|
case MessageDeliveryStatus.sent:
|
||||||
return l10n.sent;
|
return l10n.sent;
|
||||||
case MessageDeliveryStatus.delivered:
|
case MessageDeliveryStatus.delivered:
|
||||||
|
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
||||||
|
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||||
|
}
|
||||||
|
if (retryAttempt > 0) {
|
||||||
|
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||||
|
}
|
||||||
if (roundTripTimeMs != null) {
|
if (roundTripTimeMs != null) {
|
||||||
return l10n.deliveredWithTime(roundTripTimeMs!);
|
return l10n.deliveredWithTime(roundTripTimeMs!);
|
||||||
}
|
}
|
||||||
return l10n.delivered;
|
return l10n.delivered;
|
||||||
case MessageDeliveryStatus.failed:
|
case MessageDeliveryStatus.failed:
|
||||||
|
if (retryAttempt > 0) {
|
||||||
|
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||||
|
}
|
||||||
return l10n.failed;
|
return l10n.failed;
|
||||||
case MessageDeliveryStatus.received:
|
case MessageDeliveryStatus.received:
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -4,12 +4,66 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Generates sample data for testing/demo purposes
|
/// Generates sample data for testing/demo purposes
|
||||||
class SampleDataGenerator {
|
class SampleDataGenerator {
|
||||||
static final Random _random = Random();
|
static final Random _random = Random();
|
||||||
|
|
||||||
|
static MessageContactLocation _sampleSnapshot({
|
||||||
|
required LatLng location,
|
||||||
|
required DateTime receivedAt,
|
||||||
|
required String source,
|
||||||
|
}) {
|
||||||
|
return MessageContactLocation(
|
||||||
|
location: location,
|
||||||
|
source: source,
|
||||||
|
capturedAt: receivedAt,
|
||||||
|
sourceTimestamp: receivedAt.subtract(const Duration(minutes: 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LatLng _randomNearbyLocation(LatLng center, double spread) {
|
||||||
|
final latOffset = (_random.nextDouble() - 0.5) * spread;
|
||||||
|
final lonOffset = (_random.nextDouble() - 0.5) * spread;
|
||||||
|
return LatLng(center.latitude + latOffset, center.longitude + lonOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SampleMessageBatch generateAllMessages({
|
||||||
|
required LatLng centerLocation,
|
||||||
|
required AppLocalizations l10n,
|
||||||
|
int foundPersonCount = 2,
|
||||||
|
int fireCount = 1,
|
||||||
|
int stagingCount = 1,
|
||||||
|
int objectCount = 1,
|
||||||
|
int generalChannelMessages = 8,
|
||||||
|
int emergencyChannelMessages = 5,
|
||||||
|
}) {
|
||||||
|
final sarBatch = generateSarMarkerMessages(
|
||||||
|
centerLocation: centerLocation,
|
||||||
|
l10n: l10n,
|
||||||
|
foundPersonCount: foundPersonCount,
|
||||||
|
fireCount: fireCount,
|
||||||
|
stagingCount: stagingCount,
|
||||||
|
objectCount: objectCount,
|
||||||
|
);
|
||||||
|
final channelBatch = generateChannelMessages(
|
||||||
|
centerLocation: centerLocation,
|
||||||
|
l10n: l10n,
|
||||||
|
generalChannelMessages: generalChannelMessages,
|
||||||
|
emergencyChannelMessages: emergencyChannelMessages,
|
||||||
|
);
|
||||||
|
|
||||||
|
return SampleMessageBatch(
|
||||||
|
messages: [...sarBatch.messages, ...channelBatch.messages],
|
||||||
|
contactLocations: {
|
||||||
|
...sarBatch.contactLocations,
|
||||||
|
...channelBatch.contactLocations,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Generate sample contacts around a center location
|
/// Generate sample contacts around a center location
|
||||||
static List<Contact> generateContacts({
|
static List<Contact> generateContacts({
|
||||||
required LatLng centerLocation,
|
required LatLng centerLocation,
|
||||||
@@ -113,7 +167,7 @@ class SampleDataGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate sample SAR markers around a center location
|
/// Generate sample SAR markers around a center location
|
||||||
static List<Message> generateSarMarkerMessages({
|
static SampleMessageBatch generateSarMarkerMessages({
|
||||||
required LatLng centerLocation,
|
required LatLng centerLocation,
|
||||||
required AppLocalizations l10n,
|
required AppLocalizations l10n,
|
||||||
int foundPersonCount = 2,
|
int foundPersonCount = 2,
|
||||||
@@ -122,6 +176,7 @@ class SampleDataGenerator {
|
|||||||
int objectCount = 1,
|
int objectCount = 1,
|
||||||
}) {
|
}) {
|
||||||
final messages = <Message>[];
|
final messages = <Message>[];
|
||||||
|
final contactLocations = <String, MessageContactLocation>{};
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
int messageId = 1;
|
int messageId = 1;
|
||||||
|
|
||||||
@@ -137,7 +192,7 @@ class SampleDataGenerator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
|
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_fp_$messageId',
|
id: 'sample_fp_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
@@ -150,7 +205,13 @@ class SampleDataGenerator {
|
|||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '🧑',
|
sarCustomEmoji: '🧑',
|
||||||
senderName: l10n.sampleTeamMember,
|
senderName: l10n.sampleTeamMember,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'telemetry',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +227,7 @@ class SampleDataGenerator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
|
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_fire_$messageId',
|
id: 'sample_fire_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
@@ -179,7 +240,13 @@ class SampleDataGenerator {
|
|||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '🔥',
|
sarCustomEmoji: '🔥',
|
||||||
senderName: l10n.sampleScout,
|
senderName: l10n.sampleScout,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'advert',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +262,7 @@ class SampleDataGenerator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
|
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_staging_$messageId',
|
id: 'sample_staging_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
@@ -208,7 +275,13 @@ class SampleDataGenerator {
|
|||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '🏕️',
|
sarCustomEmoji: '🏕️',
|
||||||
senderName: l10n.sampleBase,
|
senderName: l10n.sampleBase,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'advert',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,24 +304,34 @@ class SampleDataGenerator {
|
|||||||
l10n.sampleObjectTrailMarker,
|
l10n.sampleObjectTrailMarker,
|
||||||
];
|
];
|
||||||
|
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_object_$messageId',
|
id: 'sample_object_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
pathLen: 1,
|
pathLen: 1,
|
||||||
textType: MessageTextType.plain,
|
textType: MessageTextType.plain,
|
||||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||||
text: 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
|
text:
|
||||||
|
'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
|
||||||
receivedAt: timestamp,
|
receivedAt: timestamp,
|
||||||
isSarMarker: true,
|
isSarMarker: true,
|
||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '📦',
|
sarCustomEmoji: '📦',
|
||||||
senderName: l10n.sampleSearcher,
|
senderName: l10n.sampleSearcher,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'telemetry',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return SampleMessageBatch(
|
||||||
|
messages: messages,
|
||||||
|
contactLocations: contactLocations,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate sample map drawings
|
/// Generate sample map drawings
|
||||||
@@ -272,7 +355,9 @@ class SampleDataGenerator {
|
|||||||
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
||||||
'color': Colors.blue.toARGB32(),
|
'color': Colors.blue.toARGB32(),
|
||||||
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
|
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
|
||||||
'points': linePoints.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
|
'points': linePoints
|
||||||
|
.map((p) => {'lat': p.latitude, 'lon': p.longitude})
|
||||||
|
.toList(),
|
||||||
'sender': l10n.sampleTeamMember,
|
'sender': l10n.sampleTeamMember,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +382,7 @@ class SampleDataGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate sample channel messages for public channels
|
/// Generate sample channel messages for public channels
|
||||||
static List<Message> generateChannelMessages({
|
static SampleMessageBatch generateChannelMessages({
|
||||||
LatLng? centerLocation,
|
LatLng? centerLocation,
|
||||||
required AppLocalizations l10n,
|
required AppLocalizations l10n,
|
||||||
int generalChannelMessages = 8,
|
int generalChannelMessages = 8,
|
||||||
@@ -306,6 +391,7 @@ class SampleDataGenerator {
|
|||||||
// Use provided location or default to Ljubljana, Slovenia
|
// Use provided location or default to Ljubljana, Slovenia
|
||||||
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
|
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
|
||||||
final messages = <Message>[];
|
final messages = <Message>[];
|
||||||
|
final contactLocations = <String, MessageContactLocation>{};
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
int messageId = 1000; // Start with high ID to avoid conflicts
|
int messageId = 1000; // Start with high ID to avoid conflicts
|
||||||
|
|
||||||
@@ -352,7 +438,11 @@ class SampleDataGenerator {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Generate General channel messages
|
// Generate General channel messages
|
||||||
for (int i = 0; i < generalChannelMessages && i < generalMessages.length; i++) {
|
for (
|
||||||
|
int i = 0;
|
||||||
|
i < generalChannelMessages && i < generalMessages.length;
|
||||||
|
i++
|
||||||
|
) {
|
||||||
final senderKey = Uint8List.fromList(
|
final senderKey = Uint8List.fromList(
|
||||||
List.generate(32, (_) => _random.nextInt(256)),
|
List.generate(32, (_) => _random.nextInt(256)),
|
||||||
);
|
);
|
||||||
@@ -361,7 +451,7 @@ class SampleDataGenerator {
|
|||||||
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
||||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||||
|
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_general_$messageId',
|
id: 'sample_general_$messageId',
|
||||||
messageType: MessageType.channel,
|
messageType: MessageType.channel,
|
||||||
channelIdx: 0, // General channel
|
channelIdx: 0, // General channel
|
||||||
@@ -372,12 +462,22 @@ class SampleDataGenerator {
|
|||||||
text: generalMessages[i],
|
text: generalMessages[i],
|
||||||
receivedAt: timestamp,
|
receivedAt: timestamp,
|
||||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(center, 0.018),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: i.isEven ? 'telemetry' : 'advert',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Emergency channel messages
|
// Generate Emergency channel messages
|
||||||
for (int i = 0; i < emergencyChannelMessages && i < emergencyMessages.length; i++) {
|
for (
|
||||||
|
int i = 0;
|
||||||
|
i < emergencyChannelMessages && i < emergencyMessages.length;
|
||||||
|
i++
|
||||||
|
) {
|
||||||
final senderKey = Uint8List.fromList(
|
final senderKey = Uint8List.fromList(
|
||||||
List.generate(32, (_) => _random.nextInt(256)),
|
List.generate(32, (_) => _random.nextInt(256)),
|
||||||
);
|
);
|
||||||
@@ -386,7 +486,7 @@ class SampleDataGenerator {
|
|||||||
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
||||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||||
|
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_emergency_$messageId',
|
id: 'sample_emergency_$messageId',
|
||||||
messageType: MessageType.channel,
|
messageType: MessageType.channel,
|
||||||
channelIdx: 1, // Emergency channel
|
channelIdx: 1, // Emergency channel
|
||||||
@@ -397,10 +497,29 @@ class SampleDataGenerator {
|
|||||||
text: emergencyMessages[i],
|
text: emergencyMessages[i],
|
||||||
receivedAt: timestamp,
|
receivedAt: timestamp,
|
||||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(center, 0.012),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: i.isEven ? 'advert' : 'telemetry',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return SampleMessageBatch(
|
||||||
|
messages: messages,
|
||||||
|
contactLocations: contactLocations,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SampleMessageBatch {
|
||||||
|
final List<Message> messages;
|
||||||
|
final Map<String, MessageContactLocation> contactLocations;
|
||||||
|
|
||||||
|
const SampleMessageBatch({
|
||||||
|
required this.messages,
|
||||||
|
required this.contactLocations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
163
lib/utils/tictactoe_message_parser.dart
Normal file
163
lib/utils/tictactoe_message_parser.dart
Normal 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;
|
||||||
|
}
|
||||||
157
lib/utils/transmission_target_resolver.dart
Normal file
157
lib/utils/transmission_target_resolver.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
129
lib/widgets/common/contact_avatar.dart
Normal file
129
lib/widgets/common/contact_avatar.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../models/contact.dart';
|
||||||
|
import '../../services/trail_color_service.dart';
|
||||||
|
import '../../utils/avatar_label_helper.dart';
|
||||||
|
|
||||||
|
class ContactAvatar extends StatelessWidget {
|
||||||
|
final Contact contact;
|
||||||
|
final double radius;
|
||||||
|
final String? displayName;
|
||||||
|
|
||||||
|
const ContactAvatar({
|
||||||
|
super.key,
|
||||||
|
required this.contact,
|
||||||
|
this.radius = 20,
|
||||||
|
this.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final backgroundColor = _getBackgroundColor(context);
|
||||||
|
final foregroundColor = _getForegroundColor(backgroundColor);
|
||||||
|
final emoji = contact.roleEmoji;
|
||||||
|
|
||||||
|
if (emoji != null && emoji.isNotEmpty) {
|
||||||
|
return _buildAvatarFrame(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_shouldUseLabelFallback) {
|
||||||
|
return _buildAvatarFrame(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
child: Text(
|
||||||
|
AvatarLabelHelper.buildLabel(displayName ?? contact.displayName),
|
||||||
|
style: TextStyle(
|
||||||
|
color: foregroundColor,
|
||||||
|
fontSize: radius * 0.68,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildAvatarFrame(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
child: Icon(
|
||||||
|
_getTypeIcon(contact.type),
|
||||||
|
color: foregroundColor,
|
||||||
|
size: radius,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatarFrame({
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
if (_usesSquareShape) {
|
||||||
|
return Container(
|
||||||
|
width: radius * 2,
|
||||||
|
height: radius * 2,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(radius * 0.6),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _shouldUseLabelFallback =>
|
||||||
|
contact.type == ContactType.chat ||
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
|
contact.type == ContactType.room;
|
||||||
|
|
||||||
|
bool get _usesSquareShape =>
|
||||||
|
contact.type == ContactType.channel || contact.type == ContactType.room;
|
||||||
|
|
||||||
|
Color _getBackgroundColor(BuildContext context) {
|
||||||
|
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
|
||||||
|
return TrailColorService.getTrailColor(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (contact.type) {
|
||||||
|
case ContactType.none:
|
||||||
|
return Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||||
|
case ContactType.chat:
|
||||||
|
return Colors.blue;
|
||||||
|
case ContactType.repeater:
|
||||||
|
return Colors.orange;
|
||||||
|
case ContactType.room:
|
||||||
|
return Colors.purple;
|
||||||
|
case ContactType.channel:
|
||||||
|
return Colors.teal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getForegroundColor(Color backgroundColor) {
|
||||||
|
return ThemeData.estimateBrightnessForColor(backgroundColor) ==
|
||||||
|
Brightness.dark
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black87;
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getTypeIcon(ContactType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ContactType.none:
|
||||||
|
return Icons.help_outline;
|
||||||
|
case ContactType.chat:
|
||||||
|
return Icons.person;
|
||||||
|
case ContactType.repeater:
|
||||||
|
return Icons.router;
|
||||||
|
case ContactType.room:
|
||||||
|
return Icons.meeting_room;
|
||||||
|
case ContactType.channel:
|
||||||
|
return Icons.public;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,12 @@ class ConnectionDialog extends StatefulWidget {
|
|||||||
class _ConnectionDialogState extends State<ConnectionDialog>
|
class _ConnectionDialogState extends State<ConnectionDialog>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
late final ConnectionProvider _connectionProvider;
|
||||||
final NetworkScannerService _networkScanner = NetworkScannerService();
|
final NetworkScannerService _networkScanner = NetworkScannerService();
|
||||||
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() {
|
||||||
@@ -46,13 +47,14 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_connectionProvider = Provider.of<ConnectionProvider>(context, listen: false);
|
||||||
|
|
||||||
// Start BLE scan by default
|
// Defer scan startup until after the first frame so Provider listeners
|
||||||
final connectionProvider = Provider.of<ConnectionProvider>(
|
// are not notified while this dialog is still being built.
|
||||||
context,
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
listen: false,
|
if (!mounted) return;
|
||||||
);
|
_connectionProvider.startScan();
|
||||||
connectionProvider.startScan();
|
});
|
||||||
|
|
||||||
// Set up network scanner callbacks
|
// Set up network scanner callbacks
|
||||||
_networkScanner.onServerDiscovered = (server) {
|
_networkScanner.onServerDiscovered = (server) {
|
||||||
@@ -81,11 +83,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
final connectionProvider = Provider.of<ConnectionProvider>(
|
_connectionProvider.stopScan();
|
||||||
context,
|
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
connectionProvider.stopScan();
|
|
||||||
_networkScanner.stopScan();
|
_networkScanner.stopScan();
|
||||||
// Remove listener before disposing to prevent memory leaks
|
// Remove listener before disposing to prevent memory leaks
|
||||||
_tabController.removeListener(_onTabChanged);
|
_tabController.removeListener(_onTabChanged);
|
||||||
@@ -361,61 +359,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 +385,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 +458,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 +547,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 +561,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 +574,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)
|
||||||
|
|||||||
394
lib/widgets/device/spectrum_scan_panel.dart
Normal file
394
lib/widgets/device/spectrum_scan_panel.dart
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
|
||||||
|
class SpectrumScanPanel extends StatelessWidget {
|
||||||
|
final ThemeData theme;
|
||||||
|
final bool scanSupported;
|
||||||
|
final bool isRunning;
|
||||||
|
final double rangeMinMhz;
|
||||||
|
final double rangeMaxMhz;
|
||||||
|
final RangeValues rangeValues;
|
||||||
|
final double bandwidthKhz;
|
||||||
|
final int? selectedFrequencyKhz;
|
||||||
|
final List<SpectrumScanCandidate> graphCandidates;
|
||||||
|
final List<SpectrumScanCandidate> selectableCandidates;
|
||||||
|
final ValueChanged<RangeValues> onRangeChanged;
|
||||||
|
final ValueChanged<int?> onCandidateChanged;
|
||||||
|
final VoidCallback onRunScan;
|
||||||
|
final VoidCallback onApplySelected;
|
||||||
|
|
||||||
|
const SpectrumScanPanel({
|
||||||
|
super.key,
|
||||||
|
required this.theme,
|
||||||
|
required this.scanSupported,
|
||||||
|
required this.isRunning,
|
||||||
|
required this.rangeMinMhz,
|
||||||
|
required this.rangeMaxMhz,
|
||||||
|
required this.rangeValues,
|
||||||
|
required this.bandwidthKhz,
|
||||||
|
required this.selectedFrequencyKhz,
|
||||||
|
required this.graphCandidates,
|
||||||
|
required this.selectableCandidates,
|
||||||
|
required this.onRangeChanged,
|
||||||
|
required this.onCandidateChanged,
|
||||||
|
required this.onRunScan,
|
||||||
|
required this.onApplySelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.45,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.tune, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Power Scan',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: scanSupported && !isRunning ? onRunScan : null,
|
||||||
|
icon: isRunning
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.radar),
|
||||||
|
label: Text(
|
||||||
|
scanSupported
|
||||||
|
? (isRunning ? 'Scanning' : 'Scan')
|
||||||
|
: 'Unavailable',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
scanSupported
|
||||||
|
? 'Full range with bandwidth footprint. Firmware enforces hardware band limits and pauses the mesh while scanning.'
|
||||||
|
: 'Full range with bandwidth footprint. This companion does not support spectrum scan mode, so scanning is disabled.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_FrequencyRangePreview(
|
||||||
|
minMhz: rangeMinMhz,
|
||||||
|
maxMhz: rangeMaxMhz,
|
||||||
|
selectedRange: rangeValues,
|
||||||
|
selectedBandwidthKhz: bandwidthKhz,
|
||||||
|
selectedFrequencyKhz: selectedFrequencyKhz,
|
||||||
|
candidates: graphCandidates,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 14,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
_LegendChip(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
label: 'Quiet',
|
||||||
|
),
|
||||||
|
const _LegendChip(color: Colors.orange, label: 'Moderate'),
|
||||||
|
_LegendChip(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
label: 'Busy',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${rangeValues.start.toStringAsFixed(3)} MHz',
|
||||||
|
style: theme.textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${rangeValues.end.toStringAsFixed(3)} MHz',
|
||||||
|
style: theme.textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
values: rangeValues,
|
||||||
|
min: rangeMinMhz,
|
||||||
|
max: rangeMaxMhz,
|
||||||
|
divisions: (((rangeMaxMhz - rangeMinMhz) * 20).round()).clamp(
|
||||||
|
1,
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
labels: RangeLabels(
|
||||||
|
rangeValues.start.toStringAsFixed(3),
|
||||||
|
rangeValues.end.toStringAsFixed(3),
|
||||||
|
),
|
||||||
|
onChanged: onRangeChanged,
|
||||||
|
),
|
||||||
|
if (selectableCandidates.isEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
scanSupported
|
||||||
|
? 'No scan results yet. Adjust the range and run a scan to populate candidate frequencies.'
|
||||||
|
: 'Spectrum preview only. This companion can display the configured span, but cannot scan for open channels.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
initialValue: selectedFrequencyKhz,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Candidate frequency',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: 'Best frequencies for the current bandwidth',
|
||||||
|
),
|
||||||
|
items: selectableCandidates.map((candidate) {
|
||||||
|
return DropdownMenuItem<int>(
|
||||||
|
value: candidate.centerFrequencyKhz,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${candidate.occupancyPercent}% occupied | peak ${candidate.peakRssiDbm} dBm',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
selectedItemBuilder: (context) {
|
||||||
|
return selectableCandidates.map((candidate) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
onChanged: onCandidateChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: selectedFrequencyKhz == null
|
||||||
|
? null
|
||||||
|
: onApplySelected,
|
||||||
|
icon: const Icon(Icons.north_east),
|
||||||
|
label: const Text('Use selected frequency'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LegendChip extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _LegendChip({required this.color, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FrequencyRangePreview extends StatelessWidget {
|
||||||
|
final double minMhz;
|
||||||
|
final double maxMhz;
|
||||||
|
final RangeValues selectedRange;
|
||||||
|
final double selectedBandwidthKhz;
|
||||||
|
final int? selectedFrequencyKhz;
|
||||||
|
final List<SpectrumScanCandidate> candidates;
|
||||||
|
|
||||||
|
const _FrequencyRangePreview({
|
||||||
|
required this.minMhz,
|
||||||
|
required this.maxMhz,
|
||||||
|
required this.selectedRange,
|
||||||
|
required this.selectedBandwidthKhz,
|
||||||
|
required this.selectedFrequencyKhz,
|
||||||
|
required this.candidates,
|
||||||
|
});
|
||||||
|
|
||||||
|
double _positionFor(double mhz) {
|
||||||
|
final span = maxMhz - minMhz;
|
||||||
|
if (span <= 0) return 0;
|
||||||
|
return ((mhz - minMhz) / span).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _candidateColor(BuildContext context, int occupancyPercent) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
if (occupancyPercent <= 10) return scheme.primary;
|
||||||
|
if (occupancyPercent <= 35) return Colors.orange;
|
||||||
|
return scheme.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final spanMhz = maxMhz - minMhz;
|
||||||
|
final selectedFreqMhz = selectedFrequencyKhz != null
|
||||||
|
? selectedFrequencyKhz! / 1000.0
|
||||||
|
: null;
|
||||||
|
final bwMhz = selectedBandwidthKhz / 1000.0;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
final rangeLeft = _positionFor(selectedRange.start) * width;
|
||||||
|
final rangeRight = _positionFor(selectedRange.end) * width;
|
||||||
|
|
||||||
|
double? bwLeft;
|
||||||
|
double? bwWidth;
|
||||||
|
if (selectedFreqMhz != null && spanMhz > 0) {
|
||||||
|
bwLeft = _positionFor(selectedFreqMhz - (bwMhz / 2)) * width;
|
||||||
|
final bwRight = _positionFor(selectedFreqMhz + (bwMhz / 2)) * width;
|
||||||
|
bwWidth = (bwRight - bwLeft).clamp(4.0, width);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 108,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
scheme.surface,
|
||||||
|
scheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border.all(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
scheme.primary.withValues(alpha: 0.12),
|
||||||
|
scheme.tertiary.withValues(alpha: 0.08),
|
||||||
|
scheme.primary.withValues(alpha: 0.12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: rangeLeft,
|
||||||
|
top: 12,
|
||||||
|
width: (rangeRight - rangeLeft).clamp(8.0, width),
|
||||||
|
height: 56,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: scheme.primary.withValues(alpha: 0.20),
|
||||||
|
border: Border.all(color: scheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (bwLeft != null && bwWidth != null)
|
||||||
|
Positioned(
|
||||||
|
left: bwLeft,
|
||||||
|
top: 28,
|
||||||
|
width: bwWidth,
|
||||||
|
height: 24,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
color: scheme.tertiary.withValues(alpha: 0.32),
|
||||||
|
border: Border.all(color: scheme.tertiary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final candidate in candidates)
|
||||||
|
Positioned(
|
||||||
|
left: (_positionFor(candidate.centerFrequencyMhz) * width)
|
||||||
|
.clamp(10.0, width - 18.0),
|
||||||
|
top: 72,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _candidateColor(
|
||||||
|
context,
|
||||||
|
candidate.occupancyPercent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
candidate.centerFrequencyMhz.toStringAsFixed(3),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -849,6 +849,7 @@ class DrawingToolbar extends StatelessWidget {
|
|||||||
contactPublicKey: room.publicKey,
|
contactPublicKey: room.publicKey,
|
||||||
text: message,
|
text: message,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
contact: room,
|
||||||
);
|
);
|
||||||
debugPrint(' ✅ Sent successfully');
|
debugPrint(' ✅ Sent successfully');
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +58,32 @@ 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((_) {
|
||||||
if (mounted) setState(() => _isRequesting = false);
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = false;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +112,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 +127,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 +158,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(
|
||||||
@@ -154,12 +193,48 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
'$received/$total',
|
'$received/$total',
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => _cancelReceive(envelope.sessionId),
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
color: Colors.white70,
|
||||||
|
tooltip: 'Cancel image receive',
|
||||||
|
),
|
||||||
|
),
|
||||||
] else if (_errorText != null) ...[
|
] else if (_errorText != null) ...[
|
||||||
const Icon(Icons.broken_image, color: Colors.red, size: 36),
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.broken_image, color: Colors.red, size: 36),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _requestAndFetch(
|
||||||
|
envelope,
|
||||||
|
radioBw: radioBw,
|
||||||
|
radioSf: radioSf,
|
||||||
|
radioCr: radioCr,
|
||||||
|
pathLen: pathLen,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
] 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 +246,66 @@ 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 imageProvider = context.read<ip.ImageProvider>();
|
||||||
setState(() => _errorText = 'Sender not reachable');
|
imageProvider.resumeIncomingSession(envelope.sessionId);
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
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 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 +313,114 @@ 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;
|
void _cancelReceive(String sessionId) {
|
||||||
}
|
if (!mounted) return;
|
||||||
return contactsProvider.findContactByPrefixHex(envelope.senderKey6);
|
_requestTimeoutTimer?.cancel();
|
||||||
|
context.read<ip.ImageProvider>().cancelIncomingSession(sessionId);
|
||||||
|
_showToast('Image receive canceled');
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = false;
|
||||||
|
_errorText = 'Image receive canceled';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showBlockingAlert(String title, String message) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
_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 +435,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 +448,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 +469,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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
463
lib/widgets/messages/message_trace_sheet.dart
Normal file
463
lib/widgets/messages/message_trace_sheet.dart
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
// 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(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
295
lib/widgets/messages/tictactoe_message_bubble.dart
Normal file
295
lib/widgets/messages/tictactoe_message_bubble.dart
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
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, contact: opponent);
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
lib/widgets/messages/transfer_timeout.dart
Normal file
24
lib/widgets/messages/transfer_timeout.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,20 @@ 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);
|
||||||
|
|
||||||
@@ -55,6 +76,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRequesting = false;
|
_isRequesting = false;
|
||||||
|
_errorText = null;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
@@ -102,11 +125,22 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
await voiceProvider.stop();
|
await voiceProvider.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_isRequesting) {
|
||||||
|
_cancelReceive(voiceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
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(
|
||||||
@@ -121,7 +155,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
isPlaying
|
isPlaying
|
||||||
? Icons.stop
|
? Icons.stop
|
||||||
: (_isRequesting ? Icons.downloading : Icons.play_arrow),
|
: (_isRequesting ? Icons.close : Icons.play_arrow),
|
||||||
size: 28,
|
size: 28,
|
||||||
color: widget.isSentByMe
|
color: widget.isSentByMe
|
||||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||||
@@ -158,6 +192,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 +209,71 @@ 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 voiceProvider = context.read<VoiceProvider>();
|
||||||
_setUnavailable();
|
voiceProvider.resumeIncomingSession(sessionId);
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
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 +285,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 +294,46 @@ 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 +341,41 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact? _resolveSenderContact() {
|
void _cancelReceive(String sessionId) {
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
if (!mounted) return;
|
||||||
final senderPrefix = widget.message.senderPublicKeyPrefix;
|
_requestTimeoutTimer?.cancel();
|
||||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
context.read<VoiceProvider>().cancelIncomingSession(sessionId);
|
||||||
final contact = contactsProvider.findContactByPrefix(
|
_showToast('Voice receive canceled');
|
||||||
Uint8List.fromList(senderPrefix.sublist(0, 6)),
|
setState(() {
|
||||||
);
|
_isRequesting = false;
|
||||||
if (contact != null) return contact;
|
_autoPlayWhenReady = false;
|
||||||
}
|
_errorText = 'Voice receive canceled';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
void _showToast(String message) {
|
||||||
if (envelope != null) {
|
if (!mounted) return;
|
||||||
final contact = contactsProvider.findContactByPrefixHex(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
envelope.senderKey6,
|
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
|
||||||
);
|
);
|
||||||
if (contact != null) return contact;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
Future<void> _showBlockingAlert(String title, String message) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
_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 _formatDuration(double seconds) {
|
static String _formatDuration(double seconds) {
|
||||||
@@ -262,11 +394,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 +479,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.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import geolocator_apple
|
|||||||
import nsd_macos
|
import nsd_macos
|
||||||
import objectbox_flutter_libs
|
import objectbox_flutter_libs
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
|
import path_provider_foundation
|
||||||
import record_macos
|
import record_macos
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@@ -34,6 +35,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
||||||
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
70
pubspec.lock
70
pubspec.lock
@@ -129,14 +129,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
code_assets:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: code_assets
|
|
||||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -243,7 +235,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.0"
|
version: "3.3.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
@@ -646,14 +638,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.5"
|
version: "0.2.5"
|
||||||
glob:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: glob
|
|
||||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.3"
|
|
||||||
gsettings:
|
gsettings:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -662,14 +646,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.8"
|
version: "0.2.8"
|
||||||
hooks:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: hooks
|
|
||||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.1"
|
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -846,14 +822,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.2"
|
version: "2.6.2"
|
||||||
logging:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: logging
|
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.0"
|
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -883,7 +851,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: main
|
||||||
resolved-ref: "624e3d3cf6ea32d8245cc85d5b599f30ca910501"
|
resolved-ref: "3f870e98ee9527a3137bfcbdd1454036912fb609"
|
||||||
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"
|
||||||
@@ -911,14 +879,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
native_toolchain_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: native_toolchain_c
|
|
||||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.17.4"
|
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -991,14 +951,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.3.1"
|
version: "4.3.1"
|
||||||
objective_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: objective_c
|
|
||||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.3.0"
|
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1040,13 +992,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.22"
|
version: "2.2.22"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.0"
|
version: "2.5.1"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1183,14 +1135,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
pub_semver:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: pub_semver
|
|
||||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.0"
|
|
||||||
record:
|
record:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1702,5 +1646,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.3 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.38.0"
|
||||||
|
|||||||
@@ -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.0306.2+12
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -144,6 +144,10 @@ dev_dependencies:
|
|||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: "^0.14.4"
|
flutter_launcher_icons: "^0.14.4"
|
||||||
|
fake_async: ^1.3.3
|
||||||
|
|
||||||
|
dependency_overrides:
|
||||||
|
path_provider_foundation: 2.5.1
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: "launcher_icon"
|
android: "launcher_icon"
|
||||||
|
|||||||
@@ -194,5 +194,43 @@ void main() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('builds message snapshot from latest valid telemetry', () {
|
||||||
|
final telemetryData = CayenneLppParser.createGpsData(
|
||||||
|
latitude: 45.0001,
|
||||||
|
longitude: 13.9999,
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
|
||||||
|
final contact = provider.findContactByKey(publicKey)!;
|
||||||
|
final snapshot = provider.buildMessageContactLocationSnapshot(
|
||||||
|
contact,
|
||||||
|
capturedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot, isNotNull);
|
||||||
|
expect(snapshot!.source, equals('telemetry'));
|
||||||
|
expect(snapshot.location.latitude, closeTo(45.0001, 0.0001));
|
||||||
|
expect(snapshot.location.longitude, closeTo(13.9999, 0.0001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds message snapshot from advert when telemetry is invalid', () {
|
||||||
|
final invalidTelemetry = CayenneLppParser.createGpsData(
|
||||||
|
latitude: 0.0,
|
||||||
|
longitude: 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.updateTelemetry(publicKey.sublist(0, 6), invalidTelemetry);
|
||||||
|
final contact = provider.findContactByKey(publicKey)!;
|
||||||
|
final snapshot = provider.buildMessageContactLocationSnapshot(
|
||||||
|
contact,
|
||||||
|
capturedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot, isNotNull);
|
||||||
|
expect(snapshot!.source, equals('advert'));
|
||||||
|
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
|
||||||
|
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal file
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/helpers/fragment_ack_wait_registry.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('FragmentAckWaitRegistry', () {
|
||||||
|
test('completes multiple waiters registered for the same key', () async {
|
||||||
|
final registry = FragmentAckWaitRegistry();
|
||||||
|
|
||||||
|
final first = registry.waitFor(
|
||||||
|
'voice:1',
|
||||||
|
timeout: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
final second = registry.waitFor(
|
||||||
|
'voice:1',
|
||||||
|
timeout: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registry.complete('voice:1'), equals(2));
|
||||||
|
expect(await first, isTrue);
|
||||||
|
expect(await second, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('times out and cleans up a waiter when no ack arrives', () async {
|
||||||
|
final registry = FragmentAckWaitRegistry();
|
||||||
|
|
||||||
|
final completed = await registry.waitFor(
|
||||||
|
'voice:2',
|
||||||
|
timeout: const Duration(milliseconds: 20),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(completed, isFalse);
|
||||||
|
expect(registry.complete('voice:2'), equals(0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
37
test/providers/helpers/message_delivery_tracker_test.dart
Normal file
37
test/providers/helpers/message_delivery_tracker_test.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/helpers/message_delivery_tracker.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('MessageDeliveryTracker', () {
|
||||||
|
test('matches pending direct messages by contact', () {
|
||||||
|
final tracker = MessageDeliveryTracker();
|
||||||
|
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
|
||||||
|
final bob = Uint8List.fromList(List<int>.filled(32, 0xBB));
|
||||||
|
|
||||||
|
tracker.trackPendingDirectMessage('alice-1', alice);
|
||||||
|
tracker.trackPendingDirectMessage('bob-1', bob);
|
||||||
|
tracker.trackPendingDirectMessage('alice-2', alice);
|
||||||
|
|
||||||
|
expect(tracker.popPendingDirectMessageId(alice), 'alice-1');
|
||||||
|
expect(tracker.popPendingDirectMessageId(bob), 'bob-1');
|
||||||
|
expect(tracker.popPendingDirectMessageId(alice), 'alice-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeByMessageId clears pending queue state', () {
|
||||||
|
final tracker = MessageDeliveryTracker();
|
||||||
|
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
|
||||||
|
|
||||||
|
tracker.trackPendingDirectMessage('alice-1', alice);
|
||||||
|
tracker.mapAckTagToMessageId(42, 'alice-1');
|
||||||
|
|
||||||
|
tracker.removeByMessageId('alice-1');
|
||||||
|
|
||||||
|
expect(tracker.getMessageIdForAck(42), isNull);
|
||||||
|
expect(tracker.popPendingDirectMessageId(alice), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
145
test/providers/helpers/raw_session_retransmit_test.dart
Normal file
145
test/providers/helpers/raw_session_retransmit_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
86
test/providers/helpers/session_metadata_restore_test.dart
Normal file
86
test/providers/helpers/session_metadata_restore_test.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/helpers/session_metadata_restore.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('restoreSessionMetadataFromMessages', () {
|
||||||
|
test(
|
||||||
|
'restores voice and image session senders from persisted envelopes',
|
||||||
|
() {
|
||||||
|
final voiceEnvelope = VoiceEnvelope(
|
||||||
|
sessionId: '00112233',
|
||||||
|
mode: VoicePacketMode.mode1200,
|
||||||
|
total: 4,
|
||||||
|
durationMs: 4000,
|
||||||
|
senderKey6: 'AABBCCDDEEFF',
|
||||||
|
timestampSec: 123456,
|
||||||
|
);
|
||||||
|
final imageEnvelope = ImageEnvelope(
|
||||||
|
sessionId: '195cb2fb',
|
||||||
|
format: ImageFormat.avif,
|
||||||
|
total: 7,
|
||||||
|
width: 118,
|
||||||
|
height: 256,
|
||||||
|
sizeBytes: 1069,
|
||||||
|
senderKey6: 'FE8B30EE05FC',
|
||||||
|
timestampSec: 123457,
|
||||||
|
);
|
||||||
|
|
||||||
|
final restored = restoreSessionMetadataFromMessages([
|
||||||
|
'plain text',
|
||||||
|
voiceEnvelope.encodeText(),
|
||||||
|
imageEnvelope.encode(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
restored.voiceSenderKeyBySession,
|
||||||
|
equals({'00112233': 'aabbccddeeff'}),
|
||||||
|
);
|
||||||
|
expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'}));
|
||||||
|
expect(
|
||||||
|
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
|
||||||
|
equals('fe8b30ee05fc'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('keeps latest envelope when a session appears multiple times', () {
|
||||||
|
final first = ImageEnvelope(
|
||||||
|
sessionId: '195cb2fb',
|
||||||
|
format: ImageFormat.avif,
|
||||||
|
total: 7,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
sizeBytes: 900,
|
||||||
|
senderKey6: '001122334455',
|
||||||
|
timestampSec: 100,
|
||||||
|
);
|
||||||
|
final second = ImageEnvelope(
|
||||||
|
sessionId: '195cb2fb',
|
||||||
|
format: ImageFormat.jpeg,
|
||||||
|
total: 8,
|
||||||
|
width: 118,
|
||||||
|
height: 256,
|
||||||
|
sizeBytes: 1069,
|
||||||
|
senderKey6: 'AABBCCDDEEFF',
|
||||||
|
timestampSec: 101,
|
||||||
|
);
|
||||||
|
|
||||||
|
final restored = restoreSessionMetadataFromMessages([
|
||||||
|
first.encode(),
|
||||||
|
second.encode(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(restored.imageEnvelopeBySession.length, equals(1));
|
||||||
|
expect(
|
||||||
|
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
|
||||||
|
equals('aabbccddeeff'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
restored.imageEnvelopeBySession['195cb2fb']?.format,
|
||||||
|
equals(ImageFormat.jpeg),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
52
test/providers/image_provider_cancel_test.dart
Normal file
52
test/providers/image_provider_cancel_test.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/image_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
group('ImageProvider cancel receive', () {
|
||||||
|
test('ignores incoming fragments after cancel until resumed', () {
|
||||||
|
final provider = ImageProvider();
|
||||||
|
const sessionId = '01020304';
|
||||||
|
const envelope = ImageEnvelope(
|
||||||
|
sessionId: sessionId,
|
||||||
|
format: ImageFormat.avif,
|
||||||
|
total: 2,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
sizeBytes: 4,
|
||||||
|
senderKey6: 'aabbccddeeff',
|
||||||
|
timestampSec: 1700000000,
|
||||||
|
);
|
||||||
|
final fragment = ImagePacket(
|
||||||
|
sessionId: sessionId,
|
||||||
|
format: ImageFormat.avif,
|
||||||
|
index: 0,
|
||||||
|
total: 2,
|
||||||
|
data: Uint8List.fromList([1, 2]),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.registerEnvelope(envelope);
|
||||||
|
provider.cancelIncomingSession(sessionId);
|
||||||
|
|
||||||
|
expect(provider.isReceiveCanceled(sessionId), isTrue);
|
||||||
|
expect(provider.session(sessionId), isNull);
|
||||||
|
|
||||||
|
provider.addFragment(fragment, width: 32, height: 32);
|
||||||
|
|
||||||
|
expect(provider.session(sessionId), isNull);
|
||||||
|
|
||||||
|
provider.resumeIncomingSession(sessionId);
|
||||||
|
provider.registerEnvelope(envelope);
|
||||||
|
provider.addFragment(fragment, width: 32, height: 32);
|
||||||
|
|
||||||
|
expect(provider.isReceiveCanceled(sessionId), isFalse);
|
||||||
|
expect(provider.session(sessionId)?.receivedCount, equals(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
236
test/providers/messages_provider_retransmission_test.dart
Normal file
236
test/providers/messages_provider_retransmission_test.dart
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
|
||||||
|
Contact _buildContact() {
|
||||||
|
return Contact(
|
||||||
|
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 1,
|
||||||
|
outPath: Uint8List.fromList([1, 2, 3, 4]),
|
||||||
|
advName: 'Teammate',
|
||||||
|
lastAdvert: 1700000000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: 1700000000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Message _buildDirectMessage(String id) {
|
||||||
|
return Message(
|
||||||
|
id: id,
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'hello',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
recipientPublicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('MessagesProvider retransmission', () {
|
||||||
|
test('direct messages stay pending until delivery ACK arrives', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m1'),
|
||||||
|
contact: _buildContact(),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('m1', 77, 250);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.sending,
|
||||||
|
);
|
||||||
|
expect(provider.messages.single.expectedAckTag, 77);
|
||||||
|
|
||||||
|
provider.markMessageDelivered(77, 180);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.delivered,
|
||||||
|
);
|
||||||
|
expect(provider.messages.single.roundTripTimeMs, 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel messages are marked sent immediately', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
Message(
|
||||||
|
id: 'c1',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'broadcast',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('c1', 0, 0);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.sent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing ACK schedules a delayed retransmission', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
var retryCalls = 0;
|
||||||
|
provider.sendMessageCallback =
|
||||||
|
({
|
||||||
|
required contactPublicKey,
|
||||||
|
required text,
|
||||||
|
required messageId,
|
||||||
|
required contact,
|
||||||
|
retryAttempt = 0,
|
||||||
|
}) async {
|
||||||
|
retryCalls += 1;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m2'),
|
||||||
|
contact: _buildContact(),
|
||||||
|
);
|
||||||
|
provider.markMessageSent('m2', 88, 10);
|
||||||
|
|
||||||
|
async.elapse(const Duration(milliseconds: 11));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(provider.messages.single.retryAttempt, 1);
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.sending,
|
||||||
|
);
|
||||||
|
expect(retryCalls, 0);
|
||||||
|
|
||||||
|
async.elapse(const Duration(seconds: 4));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(retryCalls, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses calculated timeout when radio timeout is missing', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m3'),
|
||||||
|
contact: _buildContact(),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('m3', 99, 0);
|
||||||
|
|
||||||
|
expect(provider.messages.single.suggestedTimeoutMs, isNotNull);
|
||||||
|
expect(
|
||||||
|
provider.messages.single.suggestedTimeoutMs!,
|
||||||
|
greaterThanOrEqualTo(4000),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('older retry ack still marks message delivered', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m4'),
|
||||||
|
contact: _buildContact(),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('m4', 111, 10);
|
||||||
|
provider.markMessageSent('m4', 112, 10);
|
||||||
|
provider.markMessageDelivered(111, 220);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.delivered,
|
||||||
|
);
|
||||||
|
expect(provider.messages.single.roundTripTimeMs, 220);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated max-retry failures request path reset', () async {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final contact = _buildContact();
|
||||||
|
final resetRequests = <(String, int)>[];
|
||||||
|
provider.onDirectPathFailedCallback =
|
||||||
|
({required contact, required failureStreak}) async {
|
||||||
|
resetRequests.add((contact.advName, failureStreak));
|
||||||
|
};
|
||||||
|
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m5').copyWith(
|
||||||
|
retryAttempt: 3,
|
||||||
|
usedFloodFallback: true,
|
||||||
|
),
|
||||||
|
contact: contact,
|
||||||
|
);
|
||||||
|
provider.markMessageFailed('m5');
|
||||||
|
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m6').copyWith(
|
||||||
|
retryAttempt: 3,
|
||||||
|
usedFloodFallback: true,
|
||||||
|
),
|
||||||
|
contact: contact,
|
||||||
|
);
|
||||||
|
provider.markMessageFailed('m6');
|
||||||
|
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(resetRequests, [('Teammate', 2)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful delivery clears path failure streak', () async {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final contact = _buildContact();
|
||||||
|
final resetRequests = <int>[];
|
||||||
|
provider.onDirectPathFailedCallback =
|
||||||
|
({required contact, required failureStreak}) async {
|
||||||
|
resetRequests.add(failureStreak);
|
||||||
|
};
|
||||||
|
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m7').copyWith(
|
||||||
|
retryAttempt: 3,
|
||||||
|
usedFloodFallback: true,
|
||||||
|
),
|
||||||
|
contact: contact,
|
||||||
|
);
|
||||||
|
provider.markMessageFailed('m7');
|
||||||
|
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m8'),
|
||||||
|
contact: contact,
|
||||||
|
);
|
||||||
|
provider.markMessageSent('m8', 123, 10);
|
||||||
|
provider.markMessageDelivered(123, 150);
|
||||||
|
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m9').copyWith(
|
||||||
|
retryAttempt: 3,
|
||||||
|
usedFloodFallback: true,
|
||||||
|
),
|
||||||
|
contact: contact,
|
||||||
|
);
|
||||||
|
provider.markMessageFailed('m9');
|
||||||
|
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(resetRequests, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.dart';
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
||||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('MessagesProvider voice detection', () {
|
group('MessagesProvider voice detection', () {
|
||||||
test('marks VE1 envelope messages as voice', () {
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks VE2 envelope messages as voice', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
final envelope = VoiceEnvelope(
|
final envelope = VoiceEnvelope(
|
||||||
sessionId: 'deafbead',
|
sessionId: 'deafbead',
|
||||||
@@ -65,5 +72,39 @@ void main() {
|
|||||||
expect(stored.isVoice, isTrue);
|
expect(stored.isVoice, isTrue);
|
||||||
expect(stored.voiceId, equals('00112233'));
|
expect(stored.voiceId, equals('00112233'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('persists received contact location snapshots', () async {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final message = Message(
|
||||||
|
id: 'm3',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000002,
|
||||||
|
text: 'status update',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
message,
|
||||||
|
contactLocationSnapshot: MessageContactLocation(
|
||||||
|
location: const LatLng(46.0569, 14.5058),
|
||||||
|
source: 'advert',
|
||||||
|
capturedAt: DateTime.now(),
|
||||||
|
sourceTimestamp: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
final restoredProvider = MessagesProvider();
|
||||||
|
await restoredProvider.initialize();
|
||||||
|
final snapshot = restoredProvider.getMessageContactLocation('m3');
|
||||||
|
|
||||||
|
expect(snapshot, isNotNull);
|
||||||
|
expect(snapshot!.source, equals('advert'));
|
||||||
|
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
|
||||||
|
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,6 +410,24 @@ void main() {
|
|||||||
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
|
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stops parsing at zero-padded telemetry tail', () {
|
||||||
|
final payload = Uint8List.fromList([
|
||||||
|
0x01, 0x74, 0x01, 0x5F, // voltage 3.51V
|
||||||
|
0x01, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00,
|
||||||
|
]);
|
||||||
|
|
||||||
|
final decoded = CayenneLppParser.parse(payload);
|
||||||
|
|
||||||
|
expect(decoded.batteryMilliVolts, closeTo(3510, 1));
|
||||||
|
expect(decoded.batteryPercentage, closeTo(42.5, 0.1));
|
||||||
|
expect(decoded.gpsLocation, isNotNull);
|
||||||
|
expect(decoded.gpsLocation!.latitude, 0.0);
|
||||||
|
expect(decoded.gpsLocation!.longitude, 0.0);
|
||||||
|
expect(decoded.extraSensorData?['digital_input_0'], isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('empty data returns empty telemetry', () {
|
test('empty data returns empty telemetry', () {
|
||||||
final empty = Uint8List(0);
|
final empty = Uint8List(0);
|
||||||
final decoded = CayenneLppParser.parse(empty);
|
final decoded = CayenneLppParser.parse(empty);
|
||||||
|
|||||||
26
test/utils/avatar_label_helper_test.dart
Normal file
26
test/utils/avatar_label_helper_test.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/avatar_label_helper.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AvatarLabelHelper.buildLabel', () {
|
||||||
|
test('returns two initials for multi-word names', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('John Smith'), 'JS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns first two characters for single-word names', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('Alpha'), 'AL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows three compact characters for hash-prefixed names', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('#ops'), '#OP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes spacing after hash-prefixed names', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('# foo alpha'), '#FO');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps non-hash labels at two characters', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
129
test/utils/image_message_parser_test.dart
Normal file
129
test/utils/image_message_parser_test.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('safeImageDataBytesForPath', () {
|
||||||
|
test('caps direct-route fragments to conservative default size', () {
|
||||||
|
expect(safeImageDataBytesForPath(0), equals(ImagePacket.maxDataBytes));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shrinks for longer paths but never exceeds conservative default', () {
|
||||||
|
expect(
|
||||||
|
safeImageDataBytesForPath(2),
|
||||||
|
lessThanOrEqualTo(ImagePacket.maxDataBytes),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
106
test/widgets/contact_avatar_test.dart
Normal file
106
test/widgets/contact_avatar_test.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Contact buildContact({
|
||||||
|
required String name,
|
||||||
|
required ContactType type,
|
||||||
|
int secondByte = 0,
|
||||||
|
}) {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[1] = secondByte;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: type,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(0),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: 0,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpAvatar(WidgetTester tester, Contact contact) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(body: Center(child: ContactAvatar(contact: contact))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('renders label avatar for rooms', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'Operations Room', type: ContactType.room),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('OR'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.meeting_room), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders hash label avatar for rooms', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: '#ops-room', type: ContactType.room),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('#OP'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.meeting_room), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders compact hash label avatar when name includes spacing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: '# foo alpha', type: ContactType.room),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('#FO'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.meeting_room), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders label avatar for channels', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: '#ops', type: ContactType.channel, secondByte: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('#OP'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.public), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders non-hash label avatar for channels', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('CN'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.public), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders round avatar for chat contacts', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'John Smith', type: ContactType.chat),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('JS'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.person), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user