Compare commits

..

2 Commits

Author SHA1 Message Date
Winston Lowe 5ad9263cc4 feat: Refactor repeater resolution logic across multiple screens 2026-03-19 16:17:25 -07:00
Winston Lowe 3f780ac667 feat: Enhance privacy settings and telemetry
- Implemented telemetry options for contacts, allowing users to enable or disable telemetry data sharing.
- Introduced a clear chat option in the chat interface for better message management.
- Updated the telemetry screen to handle telemetry data for contacts, including battery level.
- Refactored contact settings to include telemetry options and improved UI for better user experience.
2026-03-19 15:56:52 -07:00
79 changed files with 1900 additions and 7646 deletions
+1 -2
View File
@@ -58,7 +58,6 @@ secrets.dart
.DS_Store
.AppleDouble
.LSOverride
macos/Flutter/GeneratedPluginRegistrant.swift
# iOS
**/ios/Pods/
@@ -86,4 +85,4 @@ keystore.properties
.vscode/settings.json
# Cloudflare Wrangler
.wrangler
.wrangler
+1 -1
View File
@@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "com.meshcore.meshcore_open"
compileSdk = flutter.compileSdkVersion
ndkVersion = "29.0.14206865"
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
-30
View File
@@ -1,30 +0,0 @@
# MeshCore Open - Feature Documentation
MeshCore Open is an open-source Flutter client for MeshCore LoRa mesh networking devices. This documentation covers every user-facing feature, how to access it, and what it does.
## Table of Contents
1. [Scanner & Connection](scanner-and-connection.md) - BLE scanning, USB serial, and TCP connection
2. [Navigation](navigation.md) - App flow, device screen, and quick-switch navigation
3. [Contacts](contacts.md) - Contact management, groups, discovery, and sharing
4. [Chat & Messaging](chat-and-messaging.md) - Direct messages, message status, reactions, and retries
5. [Channels](channels.md) - Broadcast channels, communities, and channel chat
6. [Map & Location](map-and-location.md) - Node map, path tracing, line-of-sight, and offline caching
7. [Settings](settings.md) - Device settings, app settings, radio configuration, and exports
8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences
9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors
10. [Additional Features](additional-features.md) - GIF picker, localization, debug logs, SMAZ compression, and more
11. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture
## App Overview
MeshCore Open connects to MeshCore LoRa mesh radios over BLE, USB, or TCP. Once connected, users can:
- **Chat** with other mesh nodes via encrypted direct messages
- **Broadcast** on shared channels (public, hashtag, private, or community-scoped)
- **View nodes on a map** with GPS locations, predicted positions, and path traces
- **Manage repeaters** with CLI access, telemetry, neighbor info, and settings
- **Share contacts** via `meshcore://` URIs and QR codes
- **Configure radio settings** including frequency, power, bandwidth, and spreading factor
- **Cache offline maps** for use without internet connectivity
- **Analyze line-of-sight** between nodes with terrain elevation profiles
-187
View File
@@ -1,187 +0,0 @@
# Additional Features
## GIF Picker (Giphy Integration)
### How to Access
In any chat screen (direct or channel), tap the GIF button in the message input bar.
### What the User Sees
A bottom sheet with a search field and a grid of GIF thumbnails.
### Key Interactions
- On open, loads trending GIFs (G-rated, 25 results)
- Type to search and press the keyboard submit button (search triggers on submit, not on each keystroke). Clearing the search field reloads trending GIFs
- On network/API errors, a "Retry" button is shown in-place
- Tap a GIF to select it — the chat input shows an inline preview with an X button to dismiss
- Send the message to transmit the GIF reference (`g:<giphy-id>`)
- Recipients see the GIF rendered inline via Giphy CDN
- "Powered by Giphy" attribution is always shown at the bottom of the picker
- The bottom sheet occupies 70% of screen height
---
## Localization / Multi-Language Support
### How to Access
App Settings → Appearance → Language
### Supported Languages (15)
English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian
### How It Works
- All UI strings go through Flutter's ARB localization system
- Language can follow the system locale or be explicitly overridden
- Changes take effect immediately
---
## Discovered Contacts Screen
### How to Access
From Contacts screen → overflow menu → "Discovered Contacts"
### What the User Sees
A list of nodes heard passively over the air but not yet added as contacts. Each shows:
- Color-coded avatar (by type)
- Name
- Short public key
- Last-seen time
### Key Interactions
- Search bar with debounced filtering
- Sort by last seen or name; filter by type
- **Tap**: Import the contact (adds to your contact list)
- **Long-press**: Add Contact, Copy `meshcore://` URI to clipboard, or Delete
- Overflow menu → "Delete All" (with confirmation)
- Already-known contacts and your own node are filtered out
---
## SMAZ Compression
### What It Is
An optional per-contact and per-channel text compression feature using the SMAZ algorithm (optimized for short English text).
### How to Enable
- **Per contact**: Chat screen → info button → toggle "SMAZ compression"
- **Per channel**: Long-press channel → Edit → toggle "SMAZ compression"
### How It Works
- When enabled, compression is applied using a "compress only if smaller" strategy — the message is only transmitted compressed if the encoded result is actually shorter than the original. Otherwise, the original text is sent uncompressed
- Compressed messages are transmitted with a `s:` prefix followed by base64-encoded data
- Recipients using MeshCore Open will decompress automatically. **Recipients using other software** that is not SMAZ-aware will see garbled `s:...` text
- The codec operates on ASCII. Non-ASCII / non-English text generally does not benefit from compression and may even expand. Best suited for short English messages
- Disabled by default
---
## Community QR Scanner
### How to Access
From Channels screen → "+" FAB → "Scan Community QR"
### What the User Sees
A live QR scanner view with instruction text overlay.
### Key Interactions
- Scan a community QR code shared by another member
- On valid scan: confirmation dialog showing community name and ID
- Option to "Add public channel to device" on join
- If already a member: shows an "Already a member" dialog
- Invalid QR: shows an orange error snackbar
---
## Channel Message Path Viewing
### How to Access
In a channel chat, tap a message bubble (mobile) or use the "Path" action (desktop).
### What the User Sees
- Summary card: sender, time, repeat count, path type, observed hops
- "Other Observed Paths" section (if multiple paths detected)
- "Repeater Hops" section listing each hop with hex prefix, resolved name, and GPS coordinates
### Actions
- **Radar icon**: Opens path trace map for live trace
- **Map icon**: Opens a map with hop markers and polyline
- **Path dropdown**: Switch between observed path variants (if multiple)
---
## Debug Logging
### BLE Debug Log
**Access**: Settings → BLE Debug Log
Two views:
- **Frames**: Each BLE frame with direction, description, hex preview, timestamp. Long-press to copy hex.
- **Raw Log RX**: Decoded LoRa packets with route type, payload type, path bytes, and summary.
### App Debug Log
**Access**: Settings → App Debug Log (must be enabled first in App Settings → Debug)
Structured log entries with level (Info/Warning/Error), tag, message, and timestamp.
Both logs support copy-all and clear operations.
---
## Chrome Required Screen
### When It Appears
Automatically shown on web platforms when a non-Chromium browser is detected.
### What the User Sees
A full-screen informational page explaining that Web Bluetooth requires a Chromium-based browser. No interactive elements — purely informational.
---
## Path History Service
### What It Does (Background Service)
Maintains an in-memory LRU cache of up to 50 contacts, each with up to 100 route history entries, tracking:
- Hop count and trip time
- Success/failure counts and route weights
- Flood vs. direct discovery
### Path Scoring
Paths are scored using a weighted formula: reliability (45%), route weight (20%), latency (25%), and freshness (10%). These weights are internal and not user-configurable. Paths whose weight drops to zero or below are automatically deleted. Flood deliveries that receive an ACK give a weight boost (+0.5) to the specific return path.
Used internally for:
- **Auto route rotation**: Cycles through known paths using configurable weights on retries, with a diversity window to avoid re-using recently tried paths
- **Path selection**: Picks the best-scored path for each retry attempt
- **Flood statistics**: Tracks flood vs. direct discovery ratios
---
## Message Retry Service
### What It Does (Background Service)
Handles reliable delivery of outgoing direct messages:
1. Assigns a UUID and sends immediately. Only one message per contact can be in-flight at a time (avoids overflowing the firmware's 8-entry ACK table); subsequent messages are queued
2. Listens for ACK frames matched via SHA-256 hash of `[timestamp][attempt][text][sender_pubkey]`
3. On timeout, retries with exponential backoff: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s...)
4. Each retry may use a different path (via path history diversity window)
5. After max retries: marks failed but keeps a **30-second grace window** during which a late ACK can still resolve the message to "delivered". Optionally clears the contact's path
6. Reports RTT and path data for quality learning
7. Maintains an ACK hash history (last 50 entries) to handle duplicate ACKs
### Configurable Settings (App Settings → Messaging)
- Max retries (210, default 5)
- Clear path on max retry (on/off)
- Auto route rotation with weight parameters
---
## Timeout Prediction (ML)
### What It Does (Background Service)
An ML-based service that predicts expected delivery timeouts:
- Collects delivery observations (path length, message size, time since last RX, delivery time) in a sliding window of up to 100 observations (oldest evicted first)
- Requires **10 minimum observations** before first training. After that, retrains every 5 new observations
- Applies a **1.5x safety margin** to raw predictions (the actual timeout issued is 1.5× the model's predicted delivery time)
- Features with zero variance are automatically excluded from training
- Blends per-contact statistics with ML predictions
- Falls back to `3000 + 3000 × pathLength` ms when insufficient data
- Observations are persisted to storage via a 2-second debounced timer (observations within 2s of app termination may be lost)
-249
View File
@@ -1,249 +0,0 @@
# BLE Protocol & Data Layer
This is a technical reference for the communication protocol and data architecture.
## Transport Layer
The app supports three transports, all sharing the same command/response protocol:
| Transport | Method | Implementation |
|---|---|---|
| Bluetooth LE | Nordic UART Service (NUS) GATT | `flutter_blue_plus` |
| USB Serial | Packet-framed serial | `MeshCoreUsbManager` |
| TCP | Packet-framed socket | `MeshCoreTcpConnector` |
### BLE (Nordic UART Service)
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
- **RX Characteristic** (write to device): `6e400002-b5a3-f393-e0a9-e50e24dcca9e`
- **TX Characteristic** (notify from device): `6e400003-b5a3-f393-e0a9-e50e24dcca9e`
Raw `Uint8List` payloads are written directly to the RX characteristic. Writes use "write without response" if supported, falling back to "write with response".
### USB and TCP Framing
Both use a lightweight packet framing codec:
```
TX (host → device): [0x3C][len_lo][len_hi][payload...]
RX (device → host): [0x3E][len_lo][len_hi][payload...]
```
- Frame start: `0x3C` (`<`) for outgoing, `0x3E` (`>`) for incoming
- Length: 2-byte little-endian, payload only
- Max payload: 172 bytes
- TCP: `tcpNoDelay: true` (Nagle disabled), writes serialized to prevent interleaving
- USB: 10ms post-write delay between frames
## Connection State Machine
```
enum MeshCoreConnectionState {
disconnected,
scanning,
connecting,
connected,
disconnecting,
}
```
## BLE Connection Lifecycle
1. **Scan** with keyword filters `["MeshCore-", "Whisper-"]`
2. **Connect** with 15-second timeout
3. **Request MTU** 185 bytes (non-web only)
4. **Discover services** and locate NUS
5. **Enable TX notifications** (up to 3 attempts on native)
6. **Subscribe** to TX characteristic for incoming frames
7. **Initial sync**: device info query, time sync, channel sync
## Auto-Reconnect (BLE Only)
On unexpected disconnection, auto-reconnect with exponential backoff:
- Delays: 1s, 2s, 4s, 8s, 16s, 30s, 30s...
- Resets on successful connection
- Disabled for manual disconnects
- Not available for USB or TCP
## Protocol Constants
| Constant | Value | Description |
|---|---|---|
| Max frame size | 172 bytes | BLE/USB/TCP payload limit |
| Public key size | 32 bytes | Ed25519 public key |
| Max path size | 64 bytes | Maximum path data |
| Max name size | 32 bytes | Maximum node name |
| Max text payload | 160 bytes | Firmware `MAX_TEXT_LEN` |
| App protocol version | 3 | Sent in device query |
| Contact frame size | 148 bytes | Fixed-size contact record |
## Command Codes (App → Device)
| Code | Name | Description |
|------|------|-------------|
| 1 | CMD_APP_START | Announce app connection |
| 2 | CMD_SEND_TXT_MSG | Send direct text message |
| 3 | CMD_SEND_CHANNEL_TXT_MSG | Send channel text message |
| 4 | CMD_GET_CONTACTS | Request contact list |
| 5 | CMD_GET_DEVICE_TIME | Query device clock |
| 6 | CMD_SET_DEVICE_TIME | Set device clock |
| 7 | CMD_SEND_SELF_ADVERT | Broadcast own advertisement |
| 8 | CMD_SET_ADVERT_NAME | Set node name |
| 9 | CMD_ADD_UPDATE_CONTACT | Add or update a contact |
| 10 | CMD_SYNC_NEXT_MESSAGE | Request next queued message |
| 11 | CMD_SET_RADIO_PARAMS | Set radio parameters |
| 12 | CMD_SET_RADIO_TX_POWER | Set TX power |
| 13 | CMD_RESET_PATH | Reset contact path |
| 14 | CMD_SET_ADVERT_LATLON | Set advertised location |
| 15 | CMD_REMOVE_CONTACT | Remove a contact |
| 16 | CMD_SHARE_CONTACT | Share contact to mesh |
| 17 | CMD_EXPORT_CONTACT | Export contact as bytes |
| 18 | CMD_IMPORT_CONTACT | Import contact from bytes |
| 19 | CMD_REBOOT | Reboot device |
| 20 | CMD_GET_BATT_AND_STORAGE | Query battery and storage |
| 22 | CMD_DEVICE_QUERY | Query device info |
| 26 | CMD_SEND_LOGIN | Login to repeater/room |
| 27 | CMD_SEND_STATUS_REQ | Request repeater status |
| 30 | CMD_GET_CONTACT_BY_KEY | Get contact by public key |
| 31 | CMD_GET_CHANNEL | Get channel definition |
| 32 | CMD_SET_CHANNEL | Set channel name and PSK |
| 36 | CMD_SEND_TRACE_PATH | Request path trace |
| 38 | CMD_SET_OTHER_PARAMS | Set misc parameters |
| 39 | CMD_GET_TELEMETRY_REQ | Request sensor telemetry |
| 40 | CMD_GET_CUSTOM_VAR | Get custom variables |
| 41 | CMD_SET_CUSTOM_VAR | Set a custom variable |
| 50 | CMD_SEND_BINARY_REQ | Send binary request |
| 57 | CMD_SEND_ANON_REQ | Send anonymous request |
| 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration |
| 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration |
## Response / Push Codes (Device → App)
| Code | Name | Description |
|------|------|-------------|
| 0 | RESP_CODE_OK | Generic success |
| 1 | RESP_CODE_ERR | Generic error |
| 2 | RESP_CODE_CONTACTS_START | Contact list begins |
| 3 | RESP_CODE_CONTACT | Single contact data |
| 4 | RESP_CODE_END_OF_CONTACTS | Contact list complete |
| 5 | RESP_CODE_SELF_INFO | Device self-info response |
| 6 | RESP_CODE_SENT | Message transmitted; carries `[1]=is_flood, [25]=ack_hash, [69]=estimated_timeout_ms` |
| 7 | RESP_CODE_CONTACT_MSG_RECV | Incoming direct message (v2) |
| 8 | RESP_CODE_CHANNEL_MSG_RECV | Incoming channel message (v2) |
| 10 | RESP_CODE_NO_MORE_MESSAGES | No more queued messages |
| 11 | RESP_CODE_EXPORT_CONTACT | Exported contact data |
| 9 | RESP_CODE_CURR_TIME | Current device time |
| 12 | RESP_CODE_BATT_AND_STORAGE | Battery mV (uint16 LE) + storage used/total (uint32 LE each) |
| 13 | RESP_CODE_DEVICE_INFO | Firmware info |
| 16 | RESP_CODE_CONTACT_MSG_RECV_V3 | Incoming direct message (v3) |
| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) |
| 18 | RESP_CODE_CHANNEL_INFO | Channel definition |
| 21 | RESP_CODE_CUSTOM_VARS | Custom variables |
| 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags |
| 0x80 | PUSH_CODE_ADVERT | Known contact re-seen |
| 0x81 | PUSH_CODE_PATH_UPDATED | Better path found; carries the 32-byte public key of the updated contact |
| 0x82 | PUSH_CODE_SEND_CONFIRMED | Delivery ACK from remote; carries ACK hash (4 bytes) + trip time (4 bytes) |
| 0x83 | PUSH_CODE_MSG_WAITING | Offline messages queued |
| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Repeater/room login succeeded |
| 0x86 | PUSH_CODE_LOGIN_FAIL | Repeater/room login failed |
| 0x87 | PUSH_CODE_STATUS_RESPONSE | Repeater status response |
| 0x88 | PUSH_CODE_LOG_RX_DATA | Radio RX data with SNR (int8, units 1/4 dB), RSSI, and raw radio packet |
| 0x89 | PUSH_CODE_TRACE_DATA | Path trace result |
| 0x8A | PUSH_CODE_NEW_ADVERT | New node discovered |
| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Sensor telemetry data |
| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary data response |
## Data Models
### Contact
32-byte public key (primary identity), name, type (chat/repeater/room/sensor), flags, path data, GPS coordinates, last-seen timestamp. Parsed from 148-byte firmware frames with this layout:
```
[0] = resp_code
[132] = public key (32 bytes)
[33] = type (1=chat, 2=repeater, 3=room, 4=sensor)
[34] = flags (bit 0 = favorite)
[35] = path_length
[3699] = path (64 bytes)
[100131] = name (32 bytes, null-padded)
[132135] = timestamp (uint32 LE)
[136139] = latitude (int32 LE, × 1e-6 degrees)
[140143] = longitude (int32 LE, × 1e-6 degrees)
[144147] = last_modified (uint32 LE)
```
### Message (Direct)
Sender key, text, timestamp, outgoing flag, status (pending/sent/delivered/failed), message ID (UUID), retry count, ACK hash, trip time, path data, reactions.
### Channel Message
Sender name, text, timestamp, status (pending/sent/failed), repeater hops, path variants, channel index, reactions, reply threading fields.
### Channel
Index (07), name, 16-byte PSK, unread count. PSK derivation methods for hashtag (SHA-256) and community (HMAC-SHA256) channels.
### Community
UUID, name, 32-byte secret, hashtag channel list. Shared via QR code.
## Persistence
All data is stored via `SharedPreferences` (JSON-serialized). No SQLite or other database.
| Data | Storage Key Pattern | Scope |
|---|---|---|
| Contacts | `contacts<pubKey10>` | Per device identity |
| Messages | `messages_<pubKey10><contactKey>` | Per device + contact |
| Channel Messages | `channel_messages_<pubKey10><index>` | Per device + channel |
| Channels | `channels<pubKey10>` | Per device identity |
| Channel Order | `channel_order_<pubKey10>` | Per device identity |
| Contact Groups | `contact_groups<pubKey10>` | Per device identity |
| Communities | `communities_v1<pubKey10>` | Per device identity |
| Unread Counts | `contact_unread_count<pubKey10>` | Per device identity |
| Discovered Contacts | `discovered_contacts` | Global |
| App Settings | `app_settings` | Global |
| Path History | `path_history_<contactKey>` | Per contact |
## Auto-Add Configuration Bitmask
Used by `CMD_SET_AUTO_ADD_CONFIG` (58) and `RESP_CODE_AUTO_ADD_CONFIG` (25):
| Bit | Flag | Description |
|-----|------|-------------|
| 0 | 0x01 | Overwrite oldest contact when list is full |
| 1 | 0x02 | Auto-add chat users |
| 2 | 0x04 | Auto-add repeaters |
| 3 | 0x08 | Auto-add room servers |
| 4 | 0x10 | Auto-add sensors |
## Radio Packet Payload Types
Seen inside `PUSH_CODE_LOG_RX_DATA` raw packets:
| Code | Type |
|------|------|
| 0x00 | REQ (request) |
| 0x01 | RESPONSE |
| 0x02 | TXTMSG (text message) |
| 0x03 | ACK |
| 0x04 | ADVERT |
| 0x05 | GRPTXT (group/channel text) |
| 0x06 | GRPDATA (group data) |
| 0x07 | ANONREQ (anonymous request) |
| 0x08 | PATH |
| 0x09 | TRACE |
| 0x0A | MULTIPART |
| 0x0B | CONTROL |
| 0x0F | RAW_CUSTOM |
## State Management
Uses Flutter `Provider` with `ChangeNotifier`. The central state holder is `MeshCoreConnector`, which owns all in-memory collections and fires debounced (50ms) `notifyListeners()` to update the UI. In-memory conversations are windowed to 200 messages per contact; older messages remain on disk and are loaded on demand.
### Data Flow
1. Raw frames arrive over BLE/USB/TCP
2. First byte is parsed as response/push code
3. Appropriate model factory (`fromFrame()`) parses the data
4. In-memory collections are updated
5. Storage stores are persisted (async)
6. `notifyListeners()` triggers UI rebuilds
7. Screens read current state via getters
-164
View File
@@ -1,164 +0,0 @@
# Channels
## Overview
Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh.
Up to 8 channels (indices 07) can be active simultaneously on one device.
## How to Access
QuickSwitchBar tab 1 (middle) from any main screen.
## Channel Types
| Type | Icon | Color | Description |
|---|---|---|---|
| Public | Globe | Green | Fixed well-known PSK; any device can join |
| Hashtag | Hash tag | Blue | PSK derived from the hashtag name via SHA-256; discoverable by convention |
| Private | Lock | Blue | Random PSK; requires out-of-band sharing of the 32-hex key |
| Community | Groups/Tag | Purple | PSK derived via HMAC-SHA256 from a community's shared secret |
## Channels List Screen
### What the User Sees
- **Search bar** with live text filtering (300ms debounce)
- **Sort/filter button**
- **Scrollable list of channel cards**, each showing:
- Type icon with color coding (purple badge overlay for community channels)
- Channel name (or "Channel N" if unnamed)
- Subtitle: "Public channel", "Hashtag channel", "Private channel", or "Community channel - {name}"
- Unread badge (if messages are unread)
- Drag handle (when manual sort is active)
- **"+" FAB** to add a new channel
- **Overflow menu**: Disconnect, Manage Communities (only shown when at least one community exists), Settings
If no channels exist, an empty state with an "Add Public Channel" shortcut is shown. If a search produces no results, a separate "no results" empty state with a search-off icon is shown.
Pull-to-refresh (swipe down) forces a re-fetch of channels from the device firmware.
### Sorting Options
- **Manual** (default): Drag-and-drop reordering, persisted (drag handles are hidden when a search query is active)
- **AZ**: Alphabetical
- **Latest messages**: Most recent first
- **Unread**: Most unread first
## Adding a Channel
Tap the "+" FAB to open a dialog with six options:
1. **Create Private Channel** — Enter a name (max 31 characters); a random PSK is generated
2. **Join Private Channel** — Enter a name and a 32-hex PSK (non-hex characters like spaces and dashes are silently stripped, so pasted keys with formatting are accepted)
3. **Join Public Channel** — One tap; uses the well-known public PSK (only shown if no public channel exists)
4. **Join Hashtag Channel** — Enter a hashtag name; PSK is derived from the name. If communities exist, choose between regular hashtag (SHA-256) or community hashtag (HMAC)
5. **Scan Community QR** — Opens QR scanner to join a community
6. **Create Community** — Enter a name; generates a random 32-byte secret; optionally adds a community public channel; shows QR code for sharing
## Channel Actions (Long-Press / Right-Click)
| Action | Description |
|---|---|
| Edit | Change name, PSK (with a dice icon to generate a random PSK), or SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit) |
| Mute / Unmute | Toggle push notification suppression for this channel |
| Delete | Remove the channel from the device (confirmation required) |
## Channel Chat
Tap a channel card to open the channel chat screen.
### App Bar
- Type icon (public/private/hashtag)
- Channel name
- Subtitle: "{type} - {N} unread"
### Message Display
- Reverse-scrolling list (newest at bottom)
- **Incoming messages**: Colored avatar with sender's initial (or first emoji if name starts with one; color is deterministic from sender name hash), sender name in primary color, message bubble
- **Outgoing messages**: Primary container color bubble with a small status icon: pending (clock), sent (checkmark), or failed (red error circle)
- Automatic older-message loading on scroll-to-top
- Jump-to-bottom button when scrolled up
- **Pinch-to-zoom**: Two-finger zoom (0.8x1.8x) and double-tap to reset text size
- **Message tracing mode** (when enabled in App Settings): Each bubble additionally shows path prefix bytes (`via XX,YY,...`), a timestamp, and a repeat count icon
### Message Types in Chat
- **Plain text** with linkified URLs
- **GIFs** (`g:{gifId}`) rendered inline via Giphy CDN
- **Location pins** (`m:{lat},{lon}|{label}|`) shown as tappable location cards
- **Reactions** displayed as emoji pills below target messages
### Replies (Channel Chat Only)
- **Mobile**: Swipe an **incoming** message left to trigger reply (with haptic feedback). You cannot swipe your own outgoing messages. Swipe reply is not available on desktop.
- **All platforms**: Long-press → "Reply"
- Reply banner appears above the input bar with the quoted message (tap X to cancel)
- Sent replies are prefixed `@[{senderName}] {text}`
- Received replies show a bordered quote block inside the bubble; tapping scrolls to the original. Reply previews render GIF thumbnails and location pin icons, not just text.
### Message Path Viewing
- **Mobile**: Tap a message bubble to view its routing path
- **Desktop**: Long-press/right-click → "Path" (tapping the bubble does nothing on desktop)
- Opens the Channel Message Path Screen (see [Additional Features](additional-features.md))
### Context Actions (Long-Press / Right-Click)
| Action | Availability | Description |
|---|---|---|
| Reply | All messages | Triggers reply mode |
| Path | Desktop only | Opens message path view |
| Add Reaction | Incoming messages only | Opens emoji picker (cannot react to your own messages) |
| Copy | All messages | Copies text to clipboard |
| Delete | All messages | Removes locally (not from mesh) |
### Message Path Viewing
Tap a message bubble to open the Channel Message Path Screen, which shows:
- Each hop in the path as a visual chain
- Known contacts identified by name at each hop
- Observed vs. declared hop counts
- Alternative path variants (if received via multiple paths)
- Map view buttons for geographic path visualization
## Communities
Communities are a layer above channels that provide a private namespace.
### What is a Community?
A community has a name and a 32-byte random secret. Channel PSKs are derived from this secret:
- **Public channel**: `HMAC-SHA256(secret, "channel:v1:__public__")[:16]`
- **Hashtag channel**: `HMAC-SHA256(secret, "channel:v1:{hashtag}")[:16]`
Outsiders who don't know the secret cannot discover or join community channels.
### Sharing a Community
Communities are shared via QR codes containing a JSON payload:
```json
{"v": 1, "type": "meshcore_community", "name": "...", "k": "<base64url-secret>"}
```
### Managing Communities
From the channels screen overflow menu → "Manage Communities". Opens a draggable scrollable sheet (resizable 3090% of screen height):
- Each community shows its name and a short community ID (first 8 hex characters)
- **Tap a community** to directly show its QR code for sharing
- **Popup menu** per community:
- **Show QR** — displays the QR code for sharing with new members
- **Delete** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed)
## How Channels Differ from Direct Messages
| Aspect | Channels | Direct Messages |
|---|---|---|
| Addressing | Broadcast to all nodes with matching PSK | Point-to-point to a specific contact |
| Encryption | Shared PSK (symmetric) | Contact's public key (asymmetric) |
| Sender identity | Plain text prefix in payload | Verified via public key |
| Replies | Supported (swipe or long-press) | Not supported |
| Retry mechanism | No automatic retry | Exponential backoff with path rotation |
-120
View File
@@ -1,120 +0,0 @@
# Chat & Messaging
## Overview
The app supports two chat modes:
- **Direct messages**: Encrypted point-to-point messages to individual contacts
- **Channel messages**: Broadcast messages to shared channels (see [Channels](channels.md))
This page covers direct messaging. For channel chat, see the Channels documentation.
## How to Access
From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
## Chat Screen Layout
### App Bar
- **Title**: Contact name
- **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details.
- **Action buttons**:
- **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing
- **Path management** (timeline icon): View recent paths with hop count, round-trip time, age, and success count. Paths are color-coded by direct repeater (green/yellow/red/blue for ranked repeaters, grey for unknown). Tap a path to activate it (the device verifies and confirms via snackbar), long-press to view full path details, set custom paths, or force flood mode. A warning banner appears when history reaches 100 entries.
- **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle
### Message List
- Scrollable list with newest messages at the bottom
- **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background
- **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name)
- Bubble width capped at 65% of screen width
- Hyperlinks rendered as tappable green underlined text
- **Pinch-to-zoom**: Two-finger zoom (0.8x1.8x) and double-tap to reset
- **Jump to bottom**: Floating button appears when scrolled away from the bottom
- **Lazy loading**: Scrolling to top loads older messages from storage
### Input Bar
- **GIF button** (left): Opens GIF picker bottom sheet
- **Text field** (center): Auto-capitalization, enforces UTF-8 byte limit in real-time
- **Send button** (right): Submits the message
- On desktop: Enter/Numpad Enter also submits
- When a GIF is selected, the text field shows an inline GIF preview with a dismiss button
## Message Types
| Type | Wire Format | Display |
|---|---|---|
| Plain text | Raw UTF-8 string | Inline text with link detection |
| GIF | `g:<giphy-id>` | Inline GIF image from Giphy CDN |
| Location pin | `m:<lat>,<lon>\|<label>\|...` | Location icon + label; tap to open map |
| Reaction | `r:<hash>:<emoji-index>` | Applied to target message as emoji pill |
## Message Status
Outgoing messages display a status indicator:
| Status | Icon | Meaning |
|---|---|---|
| Pending | Grey double-check | Queued, waiting for device to transmit (visually identical to Sent) |
| Sent | Grey double-check | Device confirmed transmission (visually identical to Pending) |
| Delivered | Green double-check | Remote node acknowledged receipt |
| Failed | Red X | All retries exhausted |
### Message Tracing Mode
When enabled in App Settings, additional metadata appears inside each bubble:
- Timestamp (HH:MM)
- Retry count (e.g., "Retry 2 of 4")
- Status icon
- Round-trip time in seconds (if delivered)
## Message Length Limits
- **Direct messages**: 156 bytes (UTF-8) — enforced in real-time by the input formatter
- **Channel messages**: 160 minus sender name length minus 2 bytes for the `"<name>: "` prefix
- Over-length paste shows a snackbar error
## Send Queue
Only one message per contact can be in-flight at a time (to avoid overflowing the firmware's 8-entry ACK table). If you send multiple messages rapidly, they are queued and sent sequentially — each waits for the previous one to be delivered, fail, or exhaust retries before transmitting.
## Retry Mechanism
When a direct message is sent:
1. The app computes an expected ACK hash: `SHA256([timestamp][attempt][text][selfPubKey])[0:4]` — matching the firmware's hash calculation. If SMAZ compression is enabled, the compressed text (not the original) is hashed
2. On device acknowledgment (`RESP_CODE_SENT`), the message transitions to "sent" and a timeout timer starts
3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise `3000 + 3000 × path_length` milliseconds (15000ms for flood)
4. On timeout, the message is retried with **exponential backoff**: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s, 16s...)
5. **Max retries**: Configurable (default 5, range 210)
6. After max retries, the message is marked "failed" — but a **30-second grace window** remains during which a late ACK can still resolve the message to "delivered"
7. If **Clear Path on Max Retry** is enabled (App Settings), the contact's stored routing path is automatically cleared when max retries are exhausted
8. **Auto route rotation**: When enabled (and no manual path override is set), the retry service uses a diversity window to avoid re-using recently tried paths, cycling through known routes on each attempt
### Manual Retry
Long-press a failed message → "Retry" to re-send using the current routing settings.
## Reactions
Add emoji reactions to incoming messages (not your own):
1. Long-press (or right-click on desktop) a message
2. Select "Add reaction" from the context menu
3. Choose from quick emojis (thumbs up, heart, laugh, party, clap, fire) or browse the full emoji picker
4. Reactions appear as pills below the message bubble with emoji and count
5. Pending reactions show at 50% opacity with a spinner
6. Failed reactions show a red retry icon (tap to retry)
## Context Actions (Long-Press / Right-Click)
| Action | Availability | Description |
|---|---|---|
| Add reaction | Incoming messages only | Opens emoji picker |
| View path | Mobile: tap bubble directly; Desktop: long-press/right-click menu | Shows message routing path |
| Copy | All messages | Copies text to clipboard |
| Delete | All messages | Removes locally (not from mesh) |
| Retry | Failed outgoing messages | Re-sends the message |
| Open chat with sender | Room server chats | Opens 1:1 chat with the message sender |
-118
View File
@@ -1,118 +0,0 @@
# Contacts
## Overview
The Contacts screen is the primary hub for managing mesh nodes your radio has a relationship with. A "contact" is any node whose cryptographic advertisement has been received — it can be a chat user, repeater, room server, or sensor.
## How to Access
- Automatically shown after connecting to a device
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens
- Back navigation from Chat or Settings screens
## Contact Types
| Type | Avatar Color | Icon | Description |
|---|---|---|---|
| Chat | Blue | Chat bubble | Another user's mesh radio |
| Repeater | Orange | Cell tower | A mesh repeater/relay node |
| Room | Purple | Group | A room server for group chat |
| Sensor | Green | Sensors | A sensor device |
## Contact List
Each contact is displayed as a list tile showing:
- **Avatar**: Color-coded circle with type icon (or first emoji of the contact's name if it starts with one)
- **Name**: Contact name (single line)
- **Path label**: "Direct", "N hops", or "Flood" (with forced variants if a path override is active)
- **Public key**: Shortened hex format `<XXXXXXXX...XXXXXXXX>`
- **Unread badge**: Red pill with count (if unread messages exist)
- **Last seen**: Relative timestamp ("Now", "5 mins ago", "2 hours ago", "3 days ago"). For chat contacts, this shows whichever is more recent: the last advertisement time or the last message time
- **Favorite star**: Amber star icon if favorited
- **Location pin**: Grey pin icon if the contact has GPS coordinates
Pull-to-refresh re-fetches the full contact list from the device.
## Search and Filter
A toolbar at the top provides:
**Search**: Matches contact name (case-insensitive) or public key hex prefix. Debounced at 300ms.
**Sort options**:
- Latest Messages (by most recent message)
- Heard Recently (by last seen / last message)
- AZ (alphabetical)
**Filter options**:
- All, Favorites, Users, Repeaters, Room Servers, Unread Only
## Contact Groups
Groups are a client-side organizational feature for grouping contacts.
- **Create a group**: Tap the group dropdown → "+" icon → enter name → select members → Save
- **Edit a group**: Group dropdown → pencil icon next to the group
- **Delete a group**: Group dropdown → trash icon next to the group
- **Filter by group**: Select a group from the dropdown to show only its members
Groups are stored per radio identity (scoped by public key).
**Validation rules**: Group names cannot be empty, cannot be "all" (reserved, case-insensitive), and must be unique (case-insensitive). The group creation dialog includes a built-in search field to filter contacts when selecting members. Creating a new group automatically selects it as the active filter.
## Tap Actions
| Contact Type | Action on Tap |
|---|---|
| Chat / Sensor | Opens ChatScreen for direct messaging |
| Repeater | Shows password login dialog → opens RepeaterHubScreen |
| Room | Shows password login dialog → opens ChatScreen for room chat |
## Long-Press / Right-Click Menu
| Action | Availability | Description |
|---|---|---|
| Path Trace / Ping | Repeaters, Rooms (always); Chat if `pathLength > 0` | Opens PathTraceMapScreen. Label shows "Ping" when no path bytes are known, "Path Trace" otherwise |
| Manage Repeater | Repeaters only | Login dialog → RepeaterHubScreen |
| Room Login | Rooms only | Login dialog → ChatScreen |
| Room Management | Rooms only | Login dialog → RepeaterHubScreen (management mode) |
| Open Chat | Chat/Sensor | Same as single tap |
| Add/Remove Favorite | All types | Toggles the favorite flag |
| Share Contact | All types | Copies `meshcore://<hex>` URI to clipboard |
| Share Contact Zero-Hop | All types | Broadcasts the contact's advertisement one hop |
| Delete Contact | All types | Confirmation dialog → removes from device and clears messages |
## App Bar Menus
The Contacts screen has **two separate popup menus** in the app bar:
**Antenna icon menu** (contact sharing):
- Zero-Hop Advert — broadcasts your advertisement to immediately adjacent nodes
- Flood Advert — broadcasts across the full mesh network
- Copy Advert to Clipboard — copies your `meshcore://<hex>` URI for sharing externally
- Add Contact from Clipboard — reads a `meshcore://<hex>` URI from clipboard and imports it
**Three-dot overflow menu**:
- Disconnect — disconnects from the device
- Discovered Contacts — opens the DiscoveryScreen
- Settings — opens the Settings screen
## Adding Contacts
### Automatic (Passive)
When the radio hears an advertisement, the contact appears automatically if auto-add is enabled for that type (configurable in Settings → Contact Settings).
### Import from Clipboard
Antenna menu → "Add Contact from Clipboard". Reads a `meshcore://<hex>` URI from clipboard and imports it to the device.
### Import from Discovered Contacts
Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Add, Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms, Favorites), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts.
## Contact Sharing Format
Contacts are shared using the `meshcore://` URI scheme:
```
meshcore://<hex-encoded-advertisement-packet>
```
This contains the node's public key and metadata. Paste it into another MeshCore app to import.
-186
View File
@@ -1,186 +0,0 @@
# Map & Location
## Overview
The Map feature is a full-featured node-location visualization and radio-planning tool built on OpenStreetMap tiles. It is one of the three primary views accessible from the QuickSwitchBar.
## How to Access
- **QuickSwitchBar tab 2** (rightmost) from Contacts or Channels
- **Deep-link from a chat message**: Tapping a shared location pin in a chat opens the map centered on that pin
- **Settings → Offline Map Cache**: Opens the tile cache management screen
## What the Map Displays
### Self Location (Teal Circle)
Your own node's position, obtained from the device firmware. Displayed as a teal `person_pin_circle` icon. Only appears if the device has GPS data or a manually-set location.
### Contact / Node Markers (Color-Coded)
All contacts with known GPS coordinates are plotted:
| Type | Color | Icon |
|---|---|---|
| Chat user | Blue | Person |
| Repeater | Green | Router |
| Room | Purple | Meeting room |
| Sensor | Orange | Sensors |
Node name labels appear automatically at zoom level 12 and above.
### Shared Map Pins (Flag Icons)
Location pins shared in chat messages are displayed as flags:
- **Blue flag**: From a direct message
- **Purple flag**: From a private channel
- **Orange flag**: From a public channel
Tap a pin to see its info. Options to "Hide" (session only) or "Remove" (persistent).
### Predicted / Guessed Locations (Semi-Transparent)
Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as semi-transparent markers with a `not_listed_location` icon, visually distinct from confirmed-location markers.
#### Why guessed locations exist
In a mesh network, every message hops through one or more repeaters on its way to the destination. Each repeater in the path is identified by the first byte of its public key. If any of those repeaters have a known GPS location (because they advertise it), then a contact that routes through those repeaters must be somewhere within radio range of them. By combining the positions of multiple repeaters a contact is known to use, the app can triangulate a rough area where the contact is likely located.
#### How the algorithm works
1. **Build a repeater index**: The app collects all known contacts of type Repeater that have a valid GPS position and indexes them by the first byte of their public key.
2. **Collect anchor points**: For each contact that lacks GPS, the app looks at the **last-hop byte** of the contact's current path and also searches the `PathHistoryService` for recent paths. Each last-hop byte that matches a located repeater becomes an "anchor point" — a GPS coordinate the contact is likely near.
3. **Resolve ambiguity**: If multiple repeaters share the same first public-key byte (a hash collision), that byte is discarded as ambiguous. Only unambiguous one-to-one matches are kept.
4. **Filter geometric inconsistencies**: Two anchor points separated by more than `2 × maxRangeKm` (the estimated LoRa radio range, computed from the current frequency, bandwidth, spreading factor, and TX power using a free-space path loss model) cannot both be in range of the same node. Outlier anchors are removed to keep only a geometrically consistent set.
5. **Compute the estimated position**:
- **Single anchor**: The contact is placed on a small circle (330m radius) around the repeater. The angle on the circle is deterministic — derived from an FNV-1a hash of the contact's public key — so the same contact always appears at the same offset, preventing markers from stacking on top of each other.
- **Two or more anchors**: The position is the average (centroid) of all anchor coordinates, with a smaller offset radius (80120m) applied for visual separation.
6. **Assign confidence level**:
- **High confidence** (2+ anchors): Displayed at 55% opacity.
- **Low confidence** (1 anchor): Displayed at 30% opacity.
7. **Cache the result**: The computation is cached using a key derived from the contact's paths, anchor positions, path-history version, and radio parameters. The cache is only invalidated when any of these inputs change, avoiding recomputation on every UI rebuild.
#### How to read guessed locations on the map
- **Semi-transparent marker** with a `not_listed_location` icon: This is a guessed position, not a confirmed GPS fix.
- **More opaque** (55%): Higher confidence — the contact was seen through 2 or more repeaters with known positions.
- **More transparent** (30%): Lower confidence — based on a single repeater anchor only.
- Coordinates shown in the marker info dialog are prefixed with `~` to indicate they are estimated.
- Guessed locations can be toggled on/off in the map filter dialog (FAB → "Guessed locations" toggle).
## Map Interactions
### Zoom and Pan
Standard pinch-to-zoom (range 218). Initial camera position is calculated from the statistical spread of all plotted points.
### Tap on a Node Marker
Opens a dialog showing: type, path (hop chain), coordinates, last-seen time, and public key. Action buttons vary by type:
- **Chat nodes**: "Open Chat"
- **Repeaters**: "Manage Repeater"
- **Rooms**: "Join Room"
### Long-Press on Empty Map Area
Shows a bottom sheet with:
- **Share marker here**: Prompts for a label, then pick a DM contact or channel to send the location to. Wire format: `m:<lat>,<lon>|<label>|poi`
- **Set as my location**: Updates your device's advertised location
### Filter Dialog (FAB)
Toggle visibility of: chat nodes, repeaters, other nodes, guessed locations, discovery contacts.
Additional filters:
- **Key prefix filter**: Show only contacts whose public key starts with a given prefix
- **Last-seen time slider**: From 1 hour to "all time"
### Legend Card (Top-Right)
Shows node count and pin count. Tappable to expand a legend of all marker types.
---
## Path Trace Map
### How to Access
- From the main map's radar icon
- From a contact's long-press menu → "Path Trace / Ping"
- From a message's path view → radar icon
### What the User Sees
A map with a polyline showing the route from your node through repeater hops to the target:
- **Green circles**: Hops with known GPS coordinates
- **Orange circles** (`~HH`): Inferred positions (no GPS but deducible from contacts)
- **Red endpoint**: Target contact with known GPS
- **Purple semi-transparent endpoint**: Target with guessed position
A legend card at the bottom lists each hop pair with SNR quality icons and total path distance.
### How It Works
Sends a trace request frame over the mesh. The repeater network traces the path hop-by-hop and returns per-hop SNR data. For hops without GPS, positions are inferred by averaging GPS coordinates of contacts sharing that last-hop byte.
---
## Line-of-Sight (LOS) Analysis
### How to Access
From the main map, tap the terrain/antenna icon.
### What the User Sees
A full-screen map with a collapsible control panel containing:
- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow)
- **Status**: Clear (green) or blocked (red) with distance and minimum clearance
- **Options panel**: Node toggles, endpoint dropdowns, antenna height sliders (0400 ft), Run LOS button
### Key Interactions
- **Long-press the map** to add custom endpoints (orange pushpin markers, renameable/deleteable)
- **Tap a marker** to select it as Point A or B; LOS runs automatically when both are set
- **Antenna heights** are adjustable for both endpoints
- **Map line** between endpoints is colored green (clear) or red (blocked)
- Terrain elevation is fetched from the Open-Meteo API (2181 sample points, cached 24 hours)
- K-factor is adjusted per radio frequency from a baseline of 4/3 at 915 MHz
---
## Offline Map Cache
### How to Access
Settings → App Settings → Map Display → Offline Map Cache
### What the User Sees
- Map with a blue polygon overlay showing previously selected cache bounds
- Bounding box coordinates card
- **Cache Area** controls: "Use Current View" and Clear buttons
- **Zoom Range** slider (318) with estimated tile count
- **Download progress** bar (when downloading)
- **Download Tiles** and **Clear Cache** buttons
### Key Interactions
1. Pan/zoom the map to the desired area
2. Tap "Use Current View" to capture the viewport as cache bounds
3. Adjust the zoom range slider
4. Tap "Download Tiles" (confirmation dialog shows estimated count)
5. Tiles are downloaded with up to 8 concurrent connections
6. Once cached, tiles are served from disk without internet (365-day stale period)
---
## GPX Export
### How to Access
Settings → Export section
### What It Does
Exports contacts with GPS coordinates to a `.gpx` file via the OS share sheet. Three export options:
- **Export Repeaters**: Repeater and Room contacts with locations
- **Export Contacts**: Chat contacts with locations
- **Export All**: All contacts with locations
Each waypoint includes: name, lat/lon, type label, and public key hex.
---
## Location Data Sources
The phone's own GPS is **never used**. All location data comes from the mesh:
1. **Device self-location**: Read from firmware device-info response. Set manually in Settings → Location, or updated automatically if the device has a GPS module.
2. **Remote node locations**: Extracted from advertisement packets received over the mesh. Encoded as integer lat/lon × 1,000,000.
-87
View File
@@ -1,87 +0,0 @@
# Navigation
## App Flow
The app follows this general flow:
```
Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Contacts Screen
```
After connecting, the three main screens (Contacts, Channels, Map) are accessible via a persistent bottom navigation bar called the **QuickSwitchBar**.
## Quick Switch Bar
The QuickSwitchBar is a Material 3 `NavigationBar` with a frosted-glass visual treatment (blur backdrop, transparent theme, rounded corners). It appears at the bottom of all three main screens.
| Index | Icon | Label | Screen |
|---|---|---|---|
| 0 | People | Contacts | ContactsScreen |
| 1 | Tag | Channels | ChannelsScreen |
| 2 | Map | Map | MapScreen |
Tapping a tab replaces the current screen with a subtle fade + slight horizontal nudge transition (220ms forward, 200ms reverse). The back button is suppressed on all three main screens — navigation between them is flat, not stacked. All icons use outline variants (`people_outline`, `tag`, `map_outlined`) following Material 3 conventions.
## Device Screen
The Device Screen is a transitional hub that shows after connection. In practice, the app navigates directly to Contacts after connecting, but the Device Screen is reachable via the QuickSwitchBar.
### What the User Sees
**App Bar**:
- Left: Battery indicator chip (tappable — toggles between percentage and voltage display). Icon changes based on level: `battery_unknown` when data unavailable, `battery_alert` (orange) at 15% or below, `battery_full` otherwise
- Left-aligned title (`centerTitle: false`): Two-line layout — small grey "MeshCore" label above the device name in bold
- Right: Disconnect button (`bluetooth_disabled` crossed-out icon) and Settings button (tune icon)
**Body**:
- **Connection Card**: Device avatar, device name, device ID, "Connected" chip, and battery chip
- **Quick Switch** section: The QuickSwitchBar widget for navigating to Contacts/Channels/Map
### Disconnection
- The disconnect button shows a confirmation dialog before disconnecting
- If the device disconnects unexpectedly, the app automatically navigates back to the Scanner screen (fires after the current frame completes via a post-frame callback)
- This auto-navigation behavior (`DisconnectNavigationMixin`) is shared across all main screens
## Theme and Locale
- **Theme mode** is user-configurable in App Settings (System / Light / Dark) — not locked to system
- **Language** can be overridden to one of 15 supported languages, or follow the system locale
- On web, if a non-Chromium browser is detected, the app shows a `ChromeRequiredScreen` instead of the Scanner (Web Bluetooth requires Chromium)
## Full Navigation Graph
```
ScannerScreen (root, always on stack)
├─ [BLE connect] → push → ContactsScreen
├─ [TCP FAB] → push → TcpScreen
│ └─ [TCP connected] → pushReplacement → ContactsScreen
└─ [USB FAB] → push → UsbScreen
└─ [USB connected] → pushReplacement → ContactsScreen
ContactsScreen (selected=0)
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
├─ [quick-switch 2] → pushReplacement → MapScreen
├─ [tap contact] → push → ChatScreen
├─ [overflow > Settings] → push → SettingsScreen
└─ [overflow > Discovered] → push → DiscoveryScreen
ChannelsScreen (selected=1)
├─ [quick-switch 0] → pushReplacement → ContactsScreen
├─ [quick-switch 2] → pushReplacement → MapScreen
├─ [tap channel] → push → ChannelChatScreen
└─ [overflow > Settings] → push → SettingsScreen
MapScreen (selected=2)
├─ [quick-switch 0] → pushReplacement → ContactsScreen
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
├─ [radar button] → push → PathTraceMapScreen
├─ [terrain button] → push → LineOfSightMapScreen
└─ [long-press] → share marker / set location
Settings (push from any main screen)
└─ [App Settings] → push → AppSettingsScreen
└─ [Offline Map Cache] → push → MapCacheScreen
```
Any disconnection from any screen triggers `popUntil(route.isFirst)`, returning to the Scanner.
-92
View File
@@ -1,92 +0,0 @@
# Notifications
## Overview
MeshCore Open provides both **system notifications** (push-style OS alerts) and **in-app unread badges** to inform users of new activity.
## Notification Types
### 1. Direct Message Notifications
- **Triggered when**: A new incoming message arrives from a Chat or Room contact
- **Title**: Contact's name
- **Body**: Message text (reactions show "Reacted [emoji]", GIFs show "Sent a GIF")
- **Priority**: High
- **Android channel**: `messages`
### 2. Channel Message Notifications
- **Triggered when**: A new message arrives on a non-muted channel
- **Title**: Channel name (or "Channel N" if unnamed)
- **Body**: `"<senderName>: <message text>"`
- **Priority**: High
- **Android channel**: `channel_messages`
### 3. Advertisement Notifications
- **Triggered when**: A new node is discovered on the mesh for the first time
- **Title**: "New [type] discovered" (e.g., "New chat node discovered")
- **Body**: Contact's name
- **Priority**: Default
- **Android channel**: `adverts`
### 4. Background Service Notification (Android Only)
- A persistent low-priority notification: "MeshCore running — Keeping BLE connected"
- Required by Android for foreground services to keep BLE alive in the background
- Tap to re-launch the app
- **Does not auto-start on reboot** — the user must re-open the app manually after a phone restart
### Notification Tap Behavior
Tapping a notification currently re-launches the app at the root route. It does **not** navigate directly to the relevant chat or channel.
## In-App Unread Badges
Red numeric badges appear throughout the UI:
- **Contacts list**: Each contact row shows a red pill badge (e.g., "3") for unread messages
- **Channels list**: Each channel row shows an unread badge
- **Chat screen subtitle**: Shows unread count inline
- Badges cap at "99+" for display
### How Unread Counts Work
- Stored per contact (by public key) and per channel, **scoped to the connected device's identity** (first 10 hex characters of its public key). Switching between different radios gives each its own independent unread state
- **Suppressed when viewing**: Opening a chat resets the count to 0 and cancels the OS notification
- **Ignored for**: Outgoing messages, CLI messages, and repeater contacts
- Debounced writes (500ms) to avoid excessive storage I/O during message bursts
## Notification Settings
Access via **App Settings → Notifications**:
| Setting | Default | Description |
|---|---|---|
| Enable Notifications | On | Master toggle; requests OS permission when turned on |
| Message Notifications | On | DM alerts (greyed out if master is off) |
| Channel Message Notifications | On | Channel alerts (greyed out if master is off) |
| Advertisement Notifications | On | New node alerts (greyed out if master is off) |
### Per-Channel Muting
Long-press a channel in the channels list → "Mute channel" / "Unmute channel". Muted channels do not generate OS notifications.
There is no per-contact muting.
## Rate Limiting
The notification system prevents notification storms:
- **Minimum interval**: 3 seconds between individual notifications
- **Batch window**: If multiple notifications arrive within 5 seconds, they are combined into a single summary notification on a fourth Android channel (`batch_summary`): "MeshCore Activity — 2 messages, 1 channel message, 3 new nodes". Note: batch summaries are Android-only; on Apple platforms individual notifications are shown
## Notification Clearing
- **Opening a contact chat**: Cancels the OS notification and resets unread count
- **Opening a channel**: Cancels the channel notification and resets unread count
- **Opening Contacts screen**: Cancels all advertisement notifications
## Platform Support
| Platform | Message Notifs | Badge | Background Service |
|---|---|---|---|
| Android | Yes | Via notification number | Yes (foreground service) |
| iOS | Yes | Yes (app badge) | No |
| macOS | Yes | Yes | No |
| Windows | Yes | No | No |
| Linux | Yes (if D-Bus available) | No | No |
-186
View File
@@ -1,186 +0,0 @@
# Repeater Management
## Overview
Repeater Management provides tools for administering MeshCore repeater and room server nodes. It includes device status monitoring, CLI access, telemetry reading, neighbor discovery, and remote configuration.
## How to Access
From the Contacts screen:
1. Long-press a **Repeater** or **Room** contact
2. Select "Manage Repeater" or "Room Management"
3. Enter the admin password in the login dialog
4. Navigate to the Repeater Hub Screen
### Login Dialog
- Password field with show/hide toggle
- "Save password" checkbox (persists for future logins). If a saved password exists, it is pre-filled and the checkbox is pre-checked, making login one-tap
- Routing mode selector and "Manage Paths" link are available directly in the dialog (configure routing before login)
- Auto-retries up to 5 times on timeout, showing progress ("Attempt 2 of 5"). A wrong password stops immediately after the first attempt — only timeouts trigger retries
- After 5 failed attempts, further login attempts are blocked
---
## Repeater Hub Screen
The central management screen showing:
- **Header card**: Repeater name, short public key, path label, GPS coordinates (if known)
- **Battery chemistry selector**: NMC / LiFePO4 / LiPo (saved per repeater)
- **Management tool cards** (full-width cards with chevron arrows, not a grid). Title dynamically shows "Repeater Management" or "Room Management" based on contact type:
| Card | Destination |
|---|---|
| Status | Repeater Status Screen |
| Telemetry | Telemetry Screen |
| CLI | Repeater CLI Screen |
| Neighbors | Neighbors Screen |
| Settings | Repeater Settings Screen |
---
## Repeater Status
### What the User Sees
Three information cards:
**System Information**:
- Battery percentage
- Uptime
- Queue length
- Error flags
- Clock at login time
**Radio Statistics**:
- Last RSSI and SNR
- Noise floor
- TX and RX airtime
**Packet Statistics**:
- Packets sent, received, and duplicates
- Broken down by flood vs. direct
### Key Interactions
- Auto-queries the repeater on open; shows a loading spinner until data arrives
- On timeout: red snackbar error. On success: data appears with a green snackbar confirmation
- Pull-to-refresh or refresh button to re-query
- Routing mode popup and path management dialog in app bar (these controls appear on **all** management sub-screens, not just Status)
---
## Repeater CLI
A terminal-style interface for sending commands directly to the repeater.
### What the User Sees
- **Quick-command bar** (horizontal scroll): Shortcut buttons for common commands (get name, get radio, get tx, neighbors, ver, advert, clock)
- **Command history list**: Sent commands in primary color, responses in secondary color
- **Input bar**: Up/down history arrows, monospace text field with `> ` prefix, send button
### Key Interactions
- Type a command and press send (or Enter on desktop)
- Up/down arrows navigate through command history
- Quick-command buttons populate and send common commands
- Bug report icon: Shows raw frame debug info for the next typed command (shows error snackbar if input field is empty)
- Help icon: Opens a scrollable reference of all known CLI commands. Tapping any command populates the input field immediately
- Clear icon: Wipes the command/response history
- Failed/timed-out commands are automatically retried once
### Available CLI Commands
**General**: `advert`, `reboot`, `clock`, `password`, `ver`, `clear stats`
**Settings**: `set name`, `set af`, `set tx`, `set repeat`, `set allow.read.only`, `set flood.max`, `set int.thresh`, `set agc.reset.interval`, `set multi.acks`, `set advert.interval`, `set flood.advert.interval`, `set guest.password`, `set lat`, `set lon`, `set radio`, `set rxdelay`, `set txdelay`, `set direct.txdelay`, `set bridge.*`, `set adc.multiplier`, `tempradio`, `setperm`
**Bridge**: `get bridge.type`
**Logging**: `log start`, `log stop`, `log erase`
**Neighbors**: `neighbors`, `neighbor.remove`
**Region Management**: `region`, `region load/get/put/remove/allowf/denyf/home/save`
**GPS**: `gps`, `gps on/off/sync/setloc/advert`
---
## Telemetry
### What the User Sees
A list of Cayenne LPP sensor channel cards:
- **Channel 1** (special): Battery voltage (shown as percentage or raw mV) and MCU temperature
- **Other channels**: Raw sensor values with appropriate labels
Shows "No data" until a response arrives from the repeater.
### Key Interactions
- Auto-queries on open
- Pull-to-refresh
- Temperature respects metric/imperial setting
- Battery readings are stored for the repeater's battery snapshot
---
## Neighbors
### What the User Sees
A card titled "Repeater's Neighbors - N" listing each neighbor as:
- Repeater name (or hex key prefix if unknown)
- Time since last heard
- SNR quality icon with color coding and label
### Key Interactions
- Auto-queries up to 15 neighbors on open
- Matches public key prefixes against known contacts to show names
- Pull-to-refresh
---
## Repeater Settings
### What the User Sees
Five configuration cards:
**1. Basic Settings**
- Name field
- Admin password field
- Guest password field
**2. Radio Settings**
- Frequency (MHz)
- TX Power (dBm)
- Bandwidth dropdown (kHz)
- Spreading Factor (SF5SF12)
- Coding Rate (4/54/8)
**3. Location Settings**
- Latitude and longitude fields
**4. Features**
- Packet forwarding toggle
- Guest access toggle
**5. Advertisement Settings**
- Local advert interval slider (60240 minutes) with enable/disable toggle
- Flood advert interval slider (3168 hours) with enable/disable toggle
**6. Danger Zone** (red-styled card)
- Reboot repeater
- Erase filesystem (serial-only warning)
### Key Interactions
- **Settings are NOT auto-fetched on open**. Only name and location are pre-filled from locally cached contact data. You must tap each section's refresh button to fetch live values from the repeater
- TX Power has its own separate refresh button, independent from the main Radio Settings refresh
- Save button appears when changes are detected
- Settings are sent sequentially with 200ms delays between commands (fire-and-forget, no per-command acknowledgment wait)
- Validation prevents invalid values (e.g., frequency range, LoRa parameter compatibility)
- Advertisement interval sliders reset to defaults when re-enabled (local: 60 min, flood: 3 hours)
- **Erase Filesystem** does NOT send any command over the air — tapping it only shows a snackbar explaining the operation requires physical serial access. It is effectively non-functional when connected wirelessly
-124
View File
@@ -1,124 +0,0 @@
# Scanner & Connection
## BLE Scanner (Home Screen)
The BLE Scanner is the app's home screen, displayed immediately on launch.
### How to Access
- Opens automatically when the app starts
- Returns here when disconnecting from any device
- Accessible by navigating back from a connected session
### What the User Sees
**App Bar**: Centered title "Scanner".
**Bluetooth-Off Warning Banner** (conditional): Appears when the Bluetooth adapter is off, showing a `bluetooth_disabled` icon, a warning message, and on Android, an "Enable Bluetooth" button.
**Status Bar**: A full-width colored strip reflecting the current connection state:
| State | Text | Color |
|---|---|---|
| Disconnected | "Not connected" | Grey |
| Scanning | "Scanning..." | Blue |
| Connecting | "Connecting..." | Orange |
| Connected | "Connected to \<device name\>" | Green |
| Disconnecting | "Disconnecting..." | Orange |
**Device List**: When no devices are found, shows a large Bluetooth icon with a prompt. The prompt text is dynamic: "Searching for devices..." while actively scanning, or "Tap Scan to search" when idle. When devices are found, shows a scrollable list of `DeviceTile` widgets.
**Bottom FAB Row**: Up to three floating action buttons:
- **USB** button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
- **TCP/IP** button - Opens TCP connection screen (all non-web platforms)
- **BLE Scan** button - Toggles BLE scanning on/off; shows a spinner when scanning. **Disabled** (greyed out, not tappable) when Bluetooth is off
### Device Tile
Each discovered device is displayed as a list tile showing:
- **Signal strength icon** (color-coded by RSSI):
- Green: >= -60 dBm (excellent)
- Light green: -60 to -70 dBm (good)
- Amber: -70 to -80 dBm (fair)
- Orange: -80 to -90 dBm (weak)
- Red: < -90 dBm (poor)
- **RSSI value** in dBm (e.g., "-72 dBm")
- **Device name** (falls back to "Unknown Device")
- **Device ID** (BLE MAC address on Android; a system-assigned UUID on iOS/macOS)
- **Connect button** (the entire tile row is also tappable — both trigger connection)
Note: The weak (-80 to -90 dBm) and poor (< -90 dBm) tiers share the same icon shape and are only differentiated by color (orange vs. red).
### How Scanning Works
- Filters for devices with names starting with `MeshCore-` or `Whisper-`
- Uses low-latency scan mode on Android
- Scans for 10 seconds then auto-stops
- On iOS/macOS, waits for BLE adapter initialization before starting
- If Bluetooth is turned off during a scan, scanning stops immediately
### Connecting to a Device
Tap a device tile or its Connect button:
1. The connector stops scanning and transitions to "connecting"
2. Connects to the device with a 15-second timeout
3. Requests MTU 185 bytes for optimal throughput
4. Discovers BLE services and locates the Nordic UART Service
5. Subscribes to TX notifications for receiving data
6. On success, automatically navigates to the Contacts screen
7. On failure, shows a red error snackbar
---
## USB Connection
### How to Access
From the Scanner screen, tap the **USB** FAB button.
### What the User Sees
- A colored status bar at the top (same color scheme as BLE scanner)
- A list of detected USB serial ports, each showing:
- Friendly display name
- Raw port name (subtitle, only shown when it differs from the display name)
- "Connect" button
- FABs at the bottom to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
### Key Interactions
- On desktop (Windows, Linux, macOS): ports are polled every 2 seconds for hot-plug detection (polling pauses while connecting/connected)
- On mobile: tap the "Scan" FAB to manually refresh
- Tap a port or its Connect button to connect
- On successful connection, navigates to Contacts screen
- On connection failure, the port list automatically refreshes
- Platform-specific error messages for common USB failures (permission denied, device missing, device detached, device busy, driver missing, port invalid, timeout, and more)
---
## TCP Connection
### How to Access
From the Scanner screen, tap the **TCP/IP** FAB button.
### What the User Sees
- A colored status bar at the top
- **Host address** text field
- **Port number** text field
- **Connect** button
- FABs at the bottom to switch to USB or BLE
### Key Interactions
- Last-used host and port are pre-populated from saved settings
- Tap Connect to validate inputs and connect
- Host must not be empty
- Port must be a number between 1 and 65535
- Validation errors are shown as red snackbars
- The Connect button shows a spinner and "Connecting..." label while in progress
- The status bar shows the specific host:port being connected to (e.g., "Connecting to 192.168.1.1:5000")
- On success, navigates to Contacts screen and saves the host/port to settings
- On connection, the status bar shows the active TCP endpoint (e.g., "Connected to 192.168.1.1:5000")
- Error messages for timeout, unsupported platform, and connection failures
-169
View File
@@ -1,169 +0,0 @@
# Settings
## How to Access
- From the Device Screen: tap the tune/sliders icon in the app bar
- From Contacts or Channels: overflow menu (three-dot) → Settings
Settings are only accessible while a device is connected.
## Settings Screen Layout
The settings screen is a scrollable list of cards:
1. [Device Info](#device-info)
2. [App Settings](#app-settings) (link to sub-screen)
3. [Node Settings](#node-settings)
4. [Actions](#actions)
5. [Debug](#debug)
6. [Export](#export)
7. [About](#about)
---
## Device Info
A collapsible card showing read-only device information. **Collapsed by default** — tap the header to expand with an animated chevron indicator:
| Field | Description |
|---|---|
| Name | Connected device's display name |
| ID | Device identifier |
| Status | Connected / Disconnected |
| Battery | Percentage or voltage (tap to toggle) |
| Node Name | The node's mesh identity name |
| Public Key | First 16 hex characters + "..." |
| Contacts Count | Number of known contacts |
| Channel Count | Number of configured channels |
Battery shows an alert icon and orange text when at 15% or below. The toggle only works when millivolt data is available from the firmware.
---
## App Settings
A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
### Appearance
- **Theme**: System / Light / Dark
- **Language**: System default or one of 15 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian)
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
### Notifications
- **Master enable/disable**: Requests OS permission when enabling
- **Message notifications**: New direct message alerts
- **Channel message notifications**: New channel message alerts
- **Advertisement notifications**: New node discovery alerts
### Messaging
- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
- Max Route Weight (110, default 5, integer steps)
- Initial Route Weight (0.55.0, default 3.0)
- Success Increment (0.12.0, default 0.5, 0.1 steps)
- Failure Decrement (0.12.0, default 0.2, 0.1 steps)
- Max Message Retries (210, default 5)
### Battery
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
### Map Display
- **Show Repeaters**: Toggle repeater markers on map
- **Show Chat Nodes**: Toggle chat node markers
- **Show Other Nodes**: Toggle room/sensor markers
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
- **Units**: Metric / Imperial
- **Offline Map Cache**: Navigate to tile download screen
### Debug
- **App Debug Logging**: Enable the in-app debug log
---
## Node Settings
These settings are sent directly to the connected device firmware.
### Node Name
- Opens a dialog with a text field (max 31 characters)
- Sends the new name to the device
- Confirmed via snackbar
### Radio Settings
Opens a dialog pre-populated with the device's current radio settings. Contains:
- **Preset dropdown**: 19 regional presets — selecting a preset immediately fills all fields below. Full list: Australia, Australia (Narrow), Australia SA/WA/QLD, Czech Republic, EU 433MHz, EU/UK (Long Range), EU/UK (Medium Range), EU/UK (Narrow), New Zealand, New Zealand (Narrow), Portugal 433, Portugal 869, Switzerland, USA Arizona, USA/Canada, Vietnam, Off-Grid 433, Off-Grid 869, Off-Grid 918
- **Frequency** (MHz): Free text, validated 3002500 MHz
- **Bandwidth**: Dropdown (7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500 kHz)
- **Spreading Factor**: SF5SF12
- **Coding Rate**: 4/5, 4/6, 4/7, 4/8
- **TX Power** (dBm): Validated 0 to device max (typically 22 dBm)
- **Client Repeat** toggle: Only shown on firmware v9+; requires frequency to be exactly 433.000, 869.000, or 918.000 MHz (the Off-Grid presets). Save is blocked with a warning if enabled on other frequencies
### Location
Opens a dialog pre-populated with the device's current coordinates (if known):
- Latitude and longitude fields (decimal, 6 decimal places). If only one field is provided, the other uses the device's current value
- If GPS-capable hardware (detected via `gps` custom variable):
- GPS Update Interval (seconds, 6086399, default 900 = 15 minutes). Validated and sent separately before lat/lon
- Enable GPS toggle (takes effect immediately, not deferred to Save)
- Validation: lat ±90, lon ±180
### Contact Settings
Five toggles controlling which node types are auto-added when heard:
- Auto-add Chat Users
- Auto-add Repeaters
- Auto-add Room Servers
- Auto-add Sensors
- Overwrite Oldest (when contact list is full)
### Privacy Mode
Opens a confirmation dialog with three buttons: Cancel, Enable, and Disable. Both states can be set from the same dialog regardless of current state. A snackbar confirms which state was applied. When on, the node stops broadcasting its location in advertisements.
---
## Actions
One-tap device operations:
| Action | Description |
|---|---|
| Send Advertisement | Floods the mesh with your node's advertisement |
| Sync Time | Sends current Unix timestamp to the device |
| Refresh Contacts | Re-requests the full contact list |
| Reboot Device | Confirmation dialog → reboots the device (shown in orange) |
---
## Debug
Two log viewers accessible via list tiles:
### BLE Debug Log
Two views (togglable via segmented button):
- **Frames view**: Direction icon, description, hex preview, timestamp per frame. Long-press to copy hex.
- **Raw Log RX view**: Decoded LoRa packets with route type, payload type, path, and summary.
- Copy-all and Clear buttons in the app bar.
### App Debug Log
Structured log entries (Info / Warning / Error), with tag, message, and timestamp.
- Must be enabled first in App Settings → Debug
- Copy-all and Clear buttons
---
## Export
Three GPX export options (not available on web):
| Option | Exports |
|---|---|
| Export Repeaters | Repeaters and Rooms with GPS coordinates |
| Export Contacts | Chat contacts with GPS coordinates |
| Export All | All contacts with GPS coordinates |
Each creates a `.gpx` file and opens the OS share sheet. Feedback via snackbar for four outcomes: success, no contacts with coordinates, feature not available (web), or error.
---
## About
Shows the standard Flutter about dialog with app name, version, and legal notice.
File diff suppressed because it is too large Load Diff
+65 -43
View File
@@ -1,8 +1,6 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
// Buffer Reader - sequential binary data reader with pointer tracking
class BufferReader {
int _pointer = 0;
@@ -39,6 +37,16 @@ class BufferReader {
Uint8List readRemainingBytes() => readBytes(remaining);
String readString() {
_lastPointer = _pointer;
final value = readRemainingBytes();
try {
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
} catch (e) {
return String.fromCharCodes(value); // Latin-1 fallback
}
}
String readCStringGreedy(int maxLength) {
_lastPointer = _pointer;
final value = <int>[];
@@ -54,12 +62,11 @@ class BufferReader {
}
}
String readCString({int maxLength = -1}) {
String readCString(int maxLength) {
final backupPointer = _pointer;
final value = <int>[];
int counter = 0;
final maxLen = maxLength >= 0 ? maxLength : remaining;
while (counter < maxLen) {
while (counter < maxLength) {
final byte = readByte();
if (byte == 0) break;
value.add(byte);
@@ -213,7 +220,6 @@ const int cmdGetAutoAddConfig = 59;
// Text message types
const int txtTypePlain = 0;
const int txtTypeCliData = 1;
const int txtTypeSigned = 2;
// Repeater request types (for server requests)
const int reqTypeGetStatus = 0x01;
@@ -308,7 +314,6 @@ const int autoAddSensorFlag =
// Sizes
const int pubKeySize = 32;
const int signatureSize = 64;
const int maxPathSize = 64;
const int pathHashSize = 1;
const int maxNameSize = 32;
@@ -372,44 +377,52 @@ const int msgTextOffset = 38;
class ParsedContactText {
final Uint8List senderPrefix;
final String text;
const ParsedContactText({required this.senderPrefix, required this.text});
}
ParsedContactText? parseContactMessageText(Uint8List frame) {
if (frame.isEmpty) return null;
final message = BufferReader(frame);
try {
final code = message.readByte();
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
// Companion radio layout:
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
if (code == respCodeContactMsgRecvV3) {
// Skip SNR and reserved bytes in v3 layout
message.skipBytes(3);
}
final senderPrefix = message.readBytes(6); // public key
message.skipBytes(1); // path length
final textType = message.readByte();
message.skipBytes(4); // timestamp (4 bytes)
final shiftedType = textType >> 2;
final isSigned = shiftedType == txtTypeSigned || textType == txtTypeSigned;
if (isSigned) {
// Signed messages have a 4-byte signature after the timestamp, before the text
message.skipBytes(4);
}
final text = message.readCString();
if (text.isEmpty) return null;
return ParsedContactText(senderPrefix: senderPrefix, text: text);
} catch (e) {
debugPrint('Error parsing contact message text: $e');
final code = frame[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
// Companion radio layout:
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
final isV3 = code == respCodeContactMsgRecvV3;
final prefixOffset = isV3 ? 4 : 1;
const prefixLen = 6;
final txtTypeOffset = prefixOffset + prefixLen + 1;
final timestampOffset = txtTypeOffset + 1;
final baseTextOffset = timestampOffset + 4;
if (frame.length <= baseTextOffset) return null;
final flags = frame[txtTypeOffset];
final shiftedType = flags >> 2;
final rawType = flags;
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
if (!isPlain && !isCli) {
return null;
}
var text = readCString(
frame,
baseTextOffset,
frame.length - baseTextOffset,
).trim();
if (text.isEmpty && frame.length > baseTextOffset + 4) {
text = readCString(
frame,
baseTextOffset + 4,
frame.length - (baseTextOffset + 4),
).trim();
}
if (text.isEmpty) return null;
final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
return ParsedContactText(senderPrefix: senderPrefix, text: text);
}
// Helper to read uint32 little-endian
@@ -432,9 +445,18 @@ int readInt32LE(Uint8List data, int offset) {
return val;
}
// Helper to convert uint32 to hex string
String ackHashToHex(int ackHash) {
return ackHash.toRadixString(16).padLeft(8, '0');
// Helper to read null-terminated UTF-8 string
String readCString(Uint8List data, int offset, int maxLen) {
int end = offset;
while (end < offset + maxLen && end < data.length && data[end] != 0) {
end++;
}
try {
return utf8.decode(data.sublist(offset, end), allowMalformed: true);
} catch (e) {
// Fallback to Latin-1 if UTF-8 decoding fails
return String.fromCharCodes(data.sublist(offset, end));
}
}
// Helper to convert public key to hex string
@@ -494,7 +516,7 @@ Uint8List buildSendTextMsgFrame(
final writer = BufferWriter();
writer.writeByte(cmdSendTxtMsg);
writer.writeByte(txtTypePlain);
writer.writeByte(attempt.clamp(0, 255));
writer.writeByte(attempt.clamp(0, 3));
writer.writeUInt32LE(timestamp);
writer.writeBytes(recipientPubKey.sublist(0, 6));
writer.writeString(text);
@@ -823,7 +845,7 @@ Uint8List buildSendCliCommandFrame(
final writer = BufferWriter();
writer.writeByte(cmdSendTxtMsg);
writer.writeByte(txtTypeCliData);
writer.writeByte(attempt.clamp(0, 255));
writer.writeByte(attempt.clamp(0, 3));
writer.writeUInt32LE(timestamp);
writer.writeBytes(repeaterPubKey.sublist(0, 6));
writer.writeString(command);
-39
View File
@@ -1,47 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/l10n.dart';
import '../utils/platform_info.dart';
class LinkHandler {
/// Returns a [SelectableLinkify] on desktop or a [Linkify] on mobile.
static Widget buildLinkifyText({
required BuildContext context,
required String text,
required TextStyle style,
TextStyle? linkStyle,
}) {
final effectiveLinkStyle =
linkStyle ??
style.copyWith(
color: Colors.green,
decoration: TextDecoration.underline,
);
const options = LinkifyOptions(humanize: false, defaultToHttps: false);
const linkifiers = [UrlLinkifier()];
void onOpen(LinkableElement link) => handleLinkTap(context, link.url);
if (PlatformInfo.isDesktop) {
return SelectableLinkify(
text: text,
style: style,
linkStyle: effectiveLinkStyle,
options: options,
linkifiers: linkifiers,
onOpen: onOpen,
);
}
return Linkify(
text: text,
style: style,
linkStyle: effectiveLinkStyle,
options: options,
linkifiers: linkifiers,
onOpen: onOpen,
);
}
static Future<void> handleLinkTap(BuildContext context, String url) async {
// Show confirmation dialog
final shouldOpen = await showDialog<bool>(
-31
View File
@@ -1,31 +0,0 @@
import '../models/contact.dart';
import '../connector/meshcore_protocol.dart';
class PathHelper {
static String formatPathHex(List<int> pathBytes) {
return pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
}
static String resolvePathNames(
List<int> pathBytes,
List<Contact> allContacts,
) {
return pathBytes
.map((b) {
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase();
final matches = allContacts
.where(
(c) =>
c.publicKey.first == b &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
if (matches.isEmpty) return hex;
if (matches.length == 1) return matches.first.name;
return matches.map((c) => c.name).join(' | ');
})
.join(' \u2192 ');
}
}
-44
View File
@@ -8,50 +8,6 @@ class ReactionInfo {
}
class ReactionHelper {
/// Apply a reaction to a list of messages by matching the reaction hash.
///
/// [messages] - the message list to search
/// [reactionInfo] - the parsed reaction
/// [getTimestampSecs] - extract timestamp seconds from a message
/// [getSenderName] - extract sender name for hash (null for 1:1 implicit)
/// [getMessageText] - extract message text
/// [getReactions] - extract current reactions map
/// [shouldSkip] - filter function to skip messages (e.g., skip outgoing for incoming reactions)
/// [updateMessage] - callback to update the message at index with new reactions
///
/// Returns whether a match was found.
static bool applyReaction<T>({
required List<T> messages,
required ReactionInfo reactionInfo,
required int Function(T) getTimestampSecs,
required String? Function(T) getSenderName,
required String Function(T) getMessageText,
required Map<String, int> Function(T) getReactions,
required bool Function(T) shouldSkip,
required void Function(int index, Map<String, int> newReactions)
updateMessage,
}) {
final targetHash = reactionInfo.targetHash;
for (int i = messages.length - 1; i >= 0; i--) {
final msg = messages[i];
if (shouldSkip(msg)) continue;
final msgHash = computeReactionHash(
getTimestampSecs(msg),
getSenderName(msg),
getMessageText(msg),
);
if (msgHash == targetHash) {
final currentReactions = Map<String, int>.from(getReactions(msg));
currentReactions[reactionInfo.emoji] =
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
updateMessage(i, currentReactions);
return true;
}
}
return false;
}
static List<String>? _cachedEmojis;
/// Combined list of all reaction emojis in fixed order.
+2 -25
View File
@@ -1890,16 +1890,6 @@
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване",
"map_setAsMyLocation": "Задайте като моя местоположение",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_denyAll": "Откажи всичко",
"settings_allowAll": "Позволи всичко",
"settings_allowByContact": "Позволи по флагове за контакт",
@@ -1929,19 +1919,6 @@
}
}
},
"appSettings_initialRouteWeight": "Първоначална тежест на маршрута",
"appSettings_maxRouteWeight": "Максимално допустимо тегло на маршрута",
"appSettings_initialRouteWeightSubtitle": "Начално тегло за новооткрити маршрути",
"appSettings_maxRouteWeightSubtitle": "Максималното тегло, което един маршрут може да събере от успешни доставки.",
"appSettings_routeWeightSuccessIncrement": "Увеличение на теглото за успех",
"appSettings_routeWeightSuccessIncrementSubtitle": "Тегло, добавено към път след успешно доставяне.",
"appSettings_routeWeightFailureDecrement": "Намаляване на теглото, свързано с неуспех",
"appSettings_routeWeightFailureDecrementSubtitle": "Тегло, което е било премахнато от пътя след неуспешен опит за доставка.",
"appSettings_maxMessageRetries": "Максимален брой опити за изпращане на съобщение",
"appSettings_maxMessageRetriesSubtitle": "Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "Мулти-потвърди: {value}",
"settings_telemetryModeUpdated": "Режим на телеметрията е обновен",
"map_showOverlaps": "Покриване на ключа на повтаряча",
"map_runTraceWithReturnPath": "Върни се по същия път."
}
"settings_telemetryModeUpdated": "Режим на телеметрията е обновен"
}
+2 -25
View File
@@ -1918,16 +1918,6 @@
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
"map_setAsMyLocation": "Als meine aktuelle Position festlegen",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_allowByContact": "Zulassen durch Kontaktflaggen",
"settings_privacy": "Datenschutzeinstellungen",
"settings_allowAll": "Alles zulassen",
@@ -1957,19 +1947,6 @@
}
}
},
"appSettings_initialRouteWeightSubtitle": "Ausgangsgewicht für neu entdeckte Pfade",
"appSettings_maxRouteWeightSubtitle": "Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.",
"appSettings_maxRouteWeight": "Maximale Gesamtstreckenlänge",
"appSettings_initialRouteWeight": "Anfangs-Streckengewicht",
"appSettings_routeWeightSuccessIncrement": "Erhöhung des Erfolgsgewichts",
"appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.",
"appSettings_routeWeightFailureDecrement": "Reduzierung des Gewichts bei Fehlern",
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde",
"appSettings_maxMessageRetries": "Maximale Anzahl an Wiederholungsversuchen",
"appSettings_maxMessageRetriesSubtitle": "Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Telemetriemodus aktualisiert",
"settings_multiAck": "Mehrfach-Bestätigungen: {value}",
"map_showOverlaps": "Überlappungen der Repeater-Taste",
"map_runTraceWithReturnPath": "Auf dem gleichen Pfad zurückkehren."
}
"settings_multiAck": "Mehrfach-Bestätigungen: {value}"
}
+1 -20
View File
@@ -289,23 +289,6 @@
"appSettings_autoRouteRotationSubtitle": "Cycle between best paths and flood mode",
"appSettings_autoRouteRotationEnabled": "Auto route rotation enabled",
"appSettings_autoRouteRotationDisabled": "Auto route rotation disabled",
"appSettings_maxRouteWeight": "Max Route Weight",
"appSettings_maxRouteWeightSubtitle": "Maximum weight a path can accumulate from successful deliveries",
"appSettings_initialRouteWeight": "Initial Route Weight",
"appSettings_initialRouteWeightSubtitle": "Starting weight for newly discovered paths",
"appSettings_routeWeightSuccessIncrement": "Success Weight Increment",
"appSettings_routeWeightSuccessIncrementSubtitle": "Weight added to a path after successful delivery",
"appSettings_routeWeightFailureDecrement": "Failure Weight Decrement",
"appSettings_routeWeightFailureDecrementSubtitle": "Weight removed from a path after failed delivery",
"appSettings_maxMessageRetries": "Max Message Retries",
"appSettings_maxMessageRetriesSubtitle": "Number of retry attempts before marking a message as failed",
"path_routeWeight": "{weight}/{max}",
"@path_routeWeight": {
"placeholders": {
"weight": { "type": "String" },
"max": { "type": "String" }
}
},
"appSettings_battery": "Battery",
"appSettings_batteryChemistry": "Battery Chemistry",
"appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})",
@@ -878,7 +861,6 @@
"map_chatNodes": "Chat Nodes",
"map_repeaters": "Repeaters",
"map_otherNodes": "Other Nodes",
"map_showOverlaps": "Repeater Key Overlaps",
"map_keyPrefix": "Key Prefix",
"map_filterByKeyPrefix": "Filter by key prefix",
"map_publicKeyPrefix": "Public key prefix",
@@ -892,8 +874,7 @@
"map_joinRoom": "Join Room",
"map_manageRepeater": "Manage Repeater",
"map_tapToAdd": "Tap on nodes to add them to the path.",
"map_runTrace": "Run path trace",
"map_runTraceWithReturnPath": "Return back on the same path.",
"map_runTrace": "Run Path Trace",
"map_removeLast": "Remove Last",
"map_pathTraceCancelled": "Path trace cancelled.",
"mapCache_title": "Offline Map Cache",
+2 -25
View File
@@ -1918,16 +1918,6 @@
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
"map_setAsMyLocation": "Establecer mi ubicación",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySubtitle": "Controlar qué información se comparte.",
"settings_allowByContact": "Permitir por banderas de contacto",
"settings_denyAll": "Denegar todo",
@@ -1957,19 +1947,6 @@
}
}
},
"appSettings_initialRouteWeight": "Peso inicial de la ruta",
"appSettings_maxRouteWeight": "Peso máximo permitido para la ruta",
"appSettings_initialRouteWeightSubtitle": "Peso inicial para rutas recién descubiertas",
"appSettings_maxRouteWeightSubtitle": "Peso máximo que una ruta puede acumular gracias a entregas exitosas.",
"appSettings_routeWeightSuccessIncrement": "Incremento de peso para el éxito",
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso añadido a una ruta después de una entrega exitosa.",
"appSettings_routeWeightFailureDecrement": "Reducción del peso asociado al fallo",
"appSettings_routeWeightFailureDecrementSubtitle": "Peso retirado de un camino después de un intento de entrega fallido.",
"appSettings_maxMessageRetries": "Número máximo de reintentos de envío de mensajes",
"appSettings_maxMessageRetriesSubtitle": "Número de intentos de reintento antes de marcar un mensaje como fallido.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Modo de telemetría actualizado",
"settings_multiAck": "Multi-ACKs: {value}",
"map_showOverlaps": "Superposiciones de tecla repetidora",
"map_runTraceWithReturnPath": "Volver atrás por el mismo camino."
}
"settings_multiAck": "Multi-ACKs: {value}"
}
+2 -25
View File
@@ -1890,16 +1890,6 @@
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
"map_setAsMyLocation": "Définir comme ma localisation",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Paramètres de confidentialité",
"settings_privacySubtitle": "Contrôlez les informations partagées",
"settings_telemetryLocationMode": "Mode d'emplacement de télémétrie",
@@ -1929,19 +1919,6 @@
}
}
},
"appSettings_maxRouteWeightSubtitle": "Poids maximal qu'un itinéraire peut accumuler grâce à des livraisons réussies.",
"appSettings_initialRouteWeight": "Poids initial de l'itinéraire",
"appSettings_maxRouteWeight": "Poids maximal autorisé pour le trajet",
"appSettings_initialRouteWeightSubtitle": "Poids de départ pour les nouveaux chemins découverts",
"appSettings_routeWeightSuccessIncrement": "Augmentation du poids de réussite",
"appSettings_routeWeightSuccessIncrementSubtitle": "Poids ajouté à un itinéraire après une livraison réussie.",
"appSettings_routeWeightFailureDecrement": "Réduction du poids de pénalité",
"appSettings_routeWeightFailureDecrementSubtitle": "Poids retiré d'un itinéraire après une tentative de livraison infructueuse.",
"appSettings_maxMessageRetries": "Nombre maximal de tentatives de récupération de messages",
"appSettings_maxMessageRetriesSubtitle": "Nombre de tentatives de relance avant de marquer un message comme ayant échoué.",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "Multi-ACKs : {value}",
"settings_telemetryModeUpdated": "Le mode télémétrie a été mis à jour",
"map_showOverlaps": "Chevauchement de la touche répétitive",
"map_runTraceWithReturnPath": "Revenir sur le même chemin."
}
"settings_telemetryModeUpdated": "Le mode télémétrie a été mis à jour"
}
+2 -25
View File
@@ -1890,16 +1890,6 @@
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
"map_setAsMyLocation": "Imposta come la mia posizione",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySettingsDescription": "Scegli le informazioni che il tuo dispositivo condivide con gli altri.",
"settings_allowByContact": "Consenti in base ai flag di contatto",
"settings_telemetryLocationMode": "Modalità di posizionamento telemetrico",
@@ -1929,19 +1919,6 @@
}
}
},
"appSettings_initialRouteWeight": "Peso iniziale del percorso",
"appSettings_initialRouteWeightSubtitle": "Peso di partenza per nuovi percorsi",
"appSettings_maxRouteWeightSubtitle": "Il peso massimo che un percorso può accumulare grazie a consegne di successo.",
"appSettings_maxRouteWeight": "Massimo peso consentito per il percorso",
"appSettings_routeWeightSuccessIncrement": "Aumento del peso del successo",
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso aggiunto a un percorso dopo una consegna riuscita.",
"appSettings_routeWeightFailureDecrement": "Riduzione del peso associato al fallimento",
"appSettings_routeWeightFailureDecrementSubtitle": "Peso rimosso da un percorso dopo un tentativo di consegna fallito.",
"appSettings_maxMessageRetries": "Numero massimo di tentativi di invio del messaggio",
"appSettings_maxMessageRetriesSubtitle": "Numero di tentativi di riprova prima di considerare un messaggio come fallito.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Modalità telemetria aggiornata",
"settings_multiAck": "Multi-ACKs: {value}",
"map_showOverlaps": "Sovrapposizioni della chiave ripetitore",
"map_runTraceWithReturnPath": "Tornare indietro sullo stesso percorso"
}
"settings_multiAck": "Multi-ACKs: {value}"
}
+1 -79
View File
@@ -1438,72 +1438,6 @@ abstract class AppLocalizations {
/// **'Auto route rotation disabled'**
String get appSettings_autoRouteRotationDisabled;
/// No description provided for @appSettings_maxRouteWeight.
///
/// In en, this message translates to:
/// **'Max Route Weight'**
String get appSettings_maxRouteWeight;
/// No description provided for @appSettings_maxRouteWeightSubtitle.
///
/// In en, this message translates to:
/// **'Maximum weight a path can accumulate from successful deliveries'**
String get appSettings_maxRouteWeightSubtitle;
/// No description provided for @appSettings_initialRouteWeight.
///
/// In en, this message translates to:
/// **'Initial Route Weight'**
String get appSettings_initialRouteWeight;
/// No description provided for @appSettings_initialRouteWeightSubtitle.
///
/// In en, this message translates to:
/// **'Starting weight for newly discovered paths'**
String get appSettings_initialRouteWeightSubtitle;
/// No description provided for @appSettings_routeWeightSuccessIncrement.
///
/// In en, this message translates to:
/// **'Success Weight Increment'**
String get appSettings_routeWeightSuccessIncrement;
/// No description provided for @appSettings_routeWeightSuccessIncrementSubtitle.
///
/// In en, this message translates to:
/// **'Weight added to a path after successful delivery'**
String get appSettings_routeWeightSuccessIncrementSubtitle;
/// No description provided for @appSettings_routeWeightFailureDecrement.
///
/// In en, this message translates to:
/// **'Failure Weight Decrement'**
String get appSettings_routeWeightFailureDecrement;
/// No description provided for @appSettings_routeWeightFailureDecrementSubtitle.
///
/// In en, this message translates to:
/// **'Weight removed from a path after failed delivery'**
String get appSettings_routeWeightFailureDecrementSubtitle;
/// No description provided for @appSettings_maxMessageRetries.
///
/// In en, this message translates to:
/// **'Max Message Retries'**
String get appSettings_maxMessageRetries;
/// No description provided for @appSettings_maxMessageRetriesSubtitle.
///
/// In en, this message translates to:
/// **'Number of retry attempts before marking a message as failed'**
String get appSettings_maxMessageRetriesSubtitle;
/// No description provided for @path_routeWeight.
///
/// In en, this message translates to:
/// **'{weight}/{max}'**
String path_routeWeight(String weight, String max);
/// No description provided for @appSettings_battery.
///
/// In en, this message translates to:
@@ -3052,12 +2986,6 @@ abstract class AppLocalizations {
/// **'Other Nodes'**
String get map_otherNodes;
/// No description provided for @map_showOverlaps.
///
/// In en, this message translates to:
/// **'Repeater Key Overlaps'**
String get map_showOverlaps;
/// No description provided for @map_keyPrefix.
///
/// In en, this message translates to:
@@ -3139,15 +3067,9 @@ abstract class AppLocalizations {
/// No description provided for @map_runTrace.
///
/// In en, this message translates to:
/// **'Run path trace'**
/// **'Run Path Trace'**
String get map_runTrace;
/// No description provided for @map_runTraceWithReturnPath.
///
/// In en, this message translates to:
/// **'Return back on the same path.'**
String get map_runTraceWithReturnPath;
/// No description provided for @map_removeLast.
///
/// In en, this message translates to:
-51
View File
@@ -741,51 +741,6 @@ class AppLocalizationsBg extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Автоматично маршрутизирането е деактивирано';
@override
String get appSettings_maxRouteWeight =>
'Максимално допустимо тегло на маршрута';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Максималното тегло, което един маршрут може да събере от успешни доставки.';
@override
String get appSettings_initialRouteWeight =>
'Първоначална тежест на маршрута';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Начално тегло за новооткрити маршрути';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Увеличение на теглото за успех';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Тегло, добавено към път след успешно доставяне.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Намаляване на теглото, свързано с неуспех';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Тегло, което е било премахнато от пътя след неуспешен опит за доставка.';
@override
String get appSettings_maxMessageRetries =>
'Максимален брой опити за изпращане на съобщение';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Батерия';
@@ -1689,9 +1644,6 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_otherNodes => 'Други възли';
@override
String get map_showOverlaps => 'Покриване на ключа на повтаряча';
@override
String get map_keyPrefix => 'Префикс на ключа';
@@ -1736,9 +1688,6 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_runTrace => 'Изпълни Път на Следване';
@override
String get map_runTraceWithReturnPath => 'Върни се по същия път.';
@override
String get map_removeLast => 'Премахни Последно';
-50
View File
@@ -739,49 +739,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Automatische Routenrotation deaktiviert';
@override
String get appSettings_maxRouteWeight => 'Maximale Gesamtstreckenlänge';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.';
@override
String get appSettings_initialRouteWeight => 'Anfangs-Streckengewicht';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Ausgangsgewicht für neu entdeckte Pfade';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Erhöhung des Erfolgsgewichts';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Reduzierung des Gewichts bei Fehlern';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde';
@override
String get appSettings_maxMessageRetries =>
'Maximale Anzahl an Wiederholungsversuchen';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Akku';
@@ -1686,9 +1643,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get map_otherNodes => 'Andere Knoten';
@override
String get map_showOverlaps => 'Überlappungen der Repeater-Taste';
@override
String get map_keyPrefix => 'Schlüsselpräfix';
@@ -1733,10 +1687,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get map_runTrace => 'Pfadverlauf ausführen';
@override
String get map_runTraceWithReturnPath =>
'Auf dem gleichen Pfad zurückkehren.';
@override
String get map_removeLast => 'Letztes Entfernen';
+1 -49
View File
@@ -726,48 +726,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Auto route rotation disabled';
@override
String get appSettings_maxRouteWeight => 'Max Route Weight';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Maximum weight a path can accumulate from successful deliveries';
@override
String get appSettings_initialRouteWeight => 'Initial Route Weight';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Starting weight for newly discovered paths';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Success Weight Increment';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Weight added to a path after successful delivery';
@override
String get appSettings_routeWeightFailureDecrement =>
'Failure Weight Decrement';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Weight removed from a path after failed delivery';
@override
String get appSettings_maxMessageRetries => 'Max Message Retries';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Number of retry attempts before marking a message as failed';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Battery';
@@ -1656,9 +1614,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get map_otherNodes => 'Other Nodes';
@override
String get map_showOverlaps => 'Repeater Key Overlaps';
@override
String get map_keyPrefix => 'Key Prefix';
@@ -1699,10 +1654,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get map_tapToAdd => 'Tap on nodes to add them to the path.';
@override
String get map_runTrace => 'Run path trace';
@override
String get map_runTraceWithReturnPath => 'Return back on the same path.';
String get map_runTrace => 'Run Path Trace';
@override
String get map_removeLast => 'Remove Last';
-49
View File
@@ -739,49 +739,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Rotación de ruta automática desactivada';
@override
String get appSettings_maxRouteWeight => 'Peso máximo permitido para la ruta';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Peso máximo que una ruta puede acumular gracias a entregas exitosas.';
@override
String get appSettings_initialRouteWeight => 'Peso inicial de la ruta';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Peso inicial para rutas recién descubiertas';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Incremento de peso para el éxito';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Peso añadido a una ruta después de una entrega exitosa.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Reducción del peso asociado al fallo';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Peso retirado de un camino después de un intento de entrega fallido.';
@override
String get appSettings_maxMessageRetries =>
'Número máximo de reintentos de envío de mensajes';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Número de intentos de reintento antes de marcar un mensaje como fallido.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batería';
@@ -1685,9 +1642,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get map_otherNodes => 'Otros Nodos';
@override
String get map_showOverlaps => 'Superposiciones de tecla repetidora';
@override
String get map_keyPrefix => 'Prefijo de clave';
@@ -1731,9 +1685,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get map_runTrace => 'Ejecutar Rastreo de Ruta';
@override
String get map_runTraceWithReturnPath => 'Volver atrás por el mismo camino.';
@override
String get map_removeLast => 'Eliminar último';
-50
View File
@@ -744,50 +744,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Rotation de l\'itinéraire automatique désactivée';
@override
String get appSettings_maxRouteWeight =>
'Poids maximal autorisé pour le trajet';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Poids maximal qu\'un itinéraire peut accumuler grâce à des livraisons réussies.';
@override
String get appSettings_initialRouteWeight => 'Poids initial de l\'itinéraire';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Poids de départ pour les nouveaux chemins découverts';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Augmentation du poids de réussite';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Poids ajouté à un itinéraire après une livraison réussie.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Réduction du poids de pénalité';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Poids retiré d\'un itinéraire après une tentative de livraison infructueuse.';
@override
String get appSettings_maxMessageRetries =>
'Nombre maximal de tentatives de récupération de messages';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Nombre de tentatives de relance avant de marquer un message comme ayant échoué.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batterie';
@@ -1695,9 +1651,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get map_otherNodes => 'Autres nœuds';
@override
String get map_showOverlaps => 'Chevauchement de la touche répétitive';
@override
String get map_keyPrefix => 'Préfixe clé';
@@ -1742,9 +1695,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get map_runTrace => 'Exécuter la traçage de chemin';
@override
String get map_runTraceWithReturnPath => 'Revenir sur le même chemin.';
@override
String get map_removeLast => 'Supprimer le dernier';
-51
View File
@@ -741,50 +741,6 @@ class AppLocalizationsIt extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Rotazione del percorso automatico disabilitata';
@override
String get appSettings_maxRouteWeight =>
'Massimo peso consentito per il percorso';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Il peso massimo che un percorso può accumulare grazie a consegne di successo.';
@override
String get appSettings_initialRouteWeight => 'Peso iniziale del percorso';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Peso di partenza per nuovi percorsi';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Aumento del peso del successo';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Peso aggiunto a un percorso dopo una consegna riuscita.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Riduzione del peso associato al fallimento';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Peso rimosso da un percorso dopo un tentativo di consegna fallito.';
@override
String get appSettings_maxMessageRetries =>
'Numero massimo di tentativi di invio del messaggio';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Numero di tentativi di riprova prima di considerare un messaggio come fallito.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batteria';
@@ -1687,9 +1643,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_otherNodes => 'Altri Nodi';
@override
String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore';
@override
String get map_keyPrefix => 'Prefisso Chiave';
@@ -1732,10 +1685,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_runTrace => 'Esegui Path Trace';
@override
String get map_runTraceWithReturnPath =>
'Tornare indietro sullo stesso percorso';
@override
String get map_removeLast => 'Rimuovi ultimo';
-49
View File
@@ -733,49 +733,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Automatische route rotatie is uitgeschakeld';
@override
String get appSettings_maxRouteWeight => 'Maximale gewicht voor de route';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.';
@override
String get appSettings_initialRouteWeight => 'เริ่มต้น gewicht van de route';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Startgewicht voor nieuwe, ontdekte routes';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Toename in het gewicht van het succes';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Gewicht wordt toegevoegd aan een route na een succesvolle levering.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Vermindering van het gewicht van fouten';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Gewicht verwijderd van een pad na een mislukte levering';
@override
String get appSettings_maxMessageRetries =>
'Aantal pogingen om berichten te versturen';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batterij';
@@ -1674,9 +1631,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get map_otherNodes => 'Andere Nodes';
@override
String get map_showOverlaps => 'Herhalingssleutel overlapt';
@override
String get map_keyPrefix => 'Prefix sleutel';
@@ -1721,9 +1675,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get map_runTrace => 'Padeshulp traceren';
@override
String get map_runTraceWithReturnPath => 'Terugkeren op hetzelfde pad.';
@override
String get map_removeLast => 'Verwijder Laatste';
File diff suppressed because it is too large Load Diff
-49
View File
@@ -741,49 +741,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Rotação de roteamento automático desativada';
@override
String get appSettings_maxRouteWeight => 'Peso Máximo da Rota';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.';
@override
String get appSettings_initialRouteWeight => 'Peso Inicial da Rota';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Peso inicial para novos caminhos descobertos';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Aumento do peso para indicar sucesso';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Peso adicionado a um caminho após a entrega bem-sucedida.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Redução do peso da falha';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Peso removido de um caminho após uma tentativa de entrega malsucedida.';
@override
String get appSettings_maxMessageRetries =>
'Número máximo de tentativas de envio de mensagens';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Número de tentativas de reenvio antes de classificar uma mensagem como falha.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Bateria';
@@ -1686,9 +1643,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get map_otherNodes => 'Outros Nós';
@override
String get map_showOverlaps => 'Sobreposições da Chave Repeater';
@override
String get map_keyPrefix => 'Prefixo Chave';
@@ -1732,9 +1686,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get map_runTrace => 'Executar Traçado de Caminho';
@override
String get map_runTraceWithReturnPath => 'Retornar ao mesmo caminho.';
@override
String get map_removeLast => 'Remover Último';
-50
View File
@@ -741,50 +741,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Автоматическое переключение маршрутов отключено';
@override
String get appSettings_maxRouteWeight =>
'Максимальный допустимый вес маршрута';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.';
@override
String get appSettings_initialRouteWeight => 'Начальный вес маршрута';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Начальный вес для новых, только что открытых маршрутов';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Увеличение веса успеха';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Вес, добавленный к маршруту после успешной доставки.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Уменьшение веса неудачи';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Вес, который был удален с пути после неудачной доставки.';
@override
String get appSettings_maxMessageRetries =>
'Максимальное количество повторных попыток отправки сообщения';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Батарея';
@@ -1689,9 +1645,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_otherNodes => 'Другие ноды';
@override
String get map_showOverlaps => 'Перекрытия ключа повтора';
@override
String get map_keyPrefix => 'Префикс ключа';
@@ -1735,9 +1688,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_runTrace => 'Запустить трассировку пути';
@override
String get map_runTraceWithReturnPath => 'Вернуться обратно по тому же пути';
@override
String get map_removeLast => 'Удалить последний';
-48
View File
@@ -730,48 +730,6 @@ class AppLocalizationsSk extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Automatické prekladanie trás pozastavené';
@override
String get appSettings_maxRouteWeight => 'Maximálna hmotnosť trasy';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.';
@override
String get appSettings_initialRouteWeight => 'Počiatočná váha trasy';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Počiatočná váha pre nové, objavené cesty';
@override
String get appSettings_routeWeightSuccessIncrement => 'Zvyšenie váhy úspechu';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Hmotnosť pridaná k trase po úspešnej doručení';
@override
String get appSettings_routeWeightFailureDecrement =>
'Sníženie váhy, ktorá sa používa na odhad rizika.';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Hmotnosť odstránená z cesty po neúspešnej doručenie';
@override
String get appSettings_maxMessageRetries =>
'Maximalný počet pokusov o doručenie správ';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Počet pokusov o odošleť pred označením správy ako neúspešnej';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batéria';
@@ -1675,9 +1633,6 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get map_otherNodes => 'Ostatné uzly';
@override
String get map_showOverlaps => 'Prekrývanie opakovača kľúča';
@override
String get map_keyPrefix => 'Päťciferné predpona';
@@ -1721,9 +1676,6 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get map_runTrace => 'Spustiť trasovaním cesty';
@override
String get map_runTraceWithReturnPath => 'Vráťte sa späť po tej istej ceste.';
@override
String get map_removeLast => 'Odstrániť posledný';
-49
View File
@@ -731,49 +731,6 @@ class AppLocalizationsSl extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Samodejno krmilno rotiranje je onemogočeno';
@override
String get appSettings_maxRouteWeight => 'Največja dovoljena teža poti';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.';
@override
String get appSettings_initialRouteWeight => 'Izvirna teža poti';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Izguba teže za nove, odkriti poti';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Učinkovitost: povečanje';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Težava, dodana poti po uspešni dostavi';
@override
String get appSettings_routeWeightFailureDecrement =>
'Zmanjšanje teže, ki je povezana s pomanjkanjem';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Težo, ki ni bila uspešno dostavljena, odstranili s poti.';
@override
String get appSettings_maxMessageRetries =>
'Najve število poskusov pošiljanja sporočil';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Baterija';
@@ -1671,9 +1628,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_otherNodes => 'Druge vozlišča';
@override
String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja';
@override
String get map_keyPrefix => 'Predpona ključa';
@@ -1716,9 +1670,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_runTrace => 'Zaženi sledenje poti';
@override
String get map_runTraceWithReturnPath => 'Vrni se nazaj po isti poti.';
@override
String get map_removeLast => 'Odstrani Zadnji';
-48
View File
@@ -725,48 +725,6 @@ class AppLocalizationsSv extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Automatisk ruttrotation är avstängd';
@override
String get appSettings_maxRouteWeight => 'Maximalt tillåtet vikt för rutten';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.';
@override
String get appSettings_initialRouteWeight => 'Initial vikt för rutt';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Initial vikt för nyligen upptäckta vägar';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Ökning av vikt för framgång';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Vikt läggs till en väg efter en lyckad leverans.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Minskning av vikten för misslyckande';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Vikt som tagits bort från en väg efter ett misslyckat leveransförsök';
@override
String get appSettings_maxMessageRetries => 'Maximalt antal försök';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Antal försök att skicka om ett meddelande innan det markeras som misslyckat.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batteri';
@@ -1664,9 +1622,6 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get map_otherNodes => 'Andra noder';
@override
String get map_showOverlaps => 'Repeater-nyckelöverlappningar';
@override
String get map_keyPrefix => 'Nyckelprefix';
@@ -1710,9 +1665,6 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get map_runTrace => 'Kör spårsökning';
@override
String get map_runTraceWithReturnPath => 'Gå tillbaka på samma väg';
@override
String get map_removeLast => 'Ta bort sista';
-49
View File
@@ -736,49 +736,6 @@ class AppLocalizationsUk extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Авторотація маршрутизації вимкнена';
@override
String get appSettings_maxRouteWeight => 'Максимальна вага маршруту';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.';
@override
String get appSettings_initialRouteWeight => 'Початкова вартість маршруту';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Початкова вага для нових відкритих шляхів';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Збільшення ваги успіху';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Вага, додана до маршруту після успішної доставки';
@override
String get appSettings_routeWeightFailureDecrement =>
'Зменшення ваги помилки';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Вага, яка була знята з маршруту після невдалої доставки';
@override
String get appSettings_maxMessageRetries =>
'Максимальна кількість повторних спроб надсилання повідомлення';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Батарея';
@@ -1684,9 +1641,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_otherNodes => 'Інші вузли';
@override
String get map_showOverlaps => 'Перекриття ключа повторювача';
@override
String get map_keyPrefix => 'Префікс ключа';
@@ -1730,9 +1684,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_runTrace => 'Виконати трасування шляху';
@override
String get map_runTraceWithReturnPath => 'Повернутися назад тим же шляхом';
@override
String get map_removeLast => 'Видалити останній';
-43
View File
@@ -689,43 +689,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get appSettings_autoRouteRotationDisabled => '自动路径轮换已禁用';
@override
String get appSettings_maxRouteWeight => '最大路径重量';
@override
String get appSettings_maxRouteWeightSubtitle => '一条路径可以累积的最大重量,取决于成功交付的数量。';
@override
String get appSettings_initialRouteWeight => '初始路线权重';
@override
String get appSettings_initialRouteWeightSubtitle => '新发现路径的初始重量';
@override
String get appSettings_routeWeightSuccessIncrement => '成功权重增加';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'在成功交付后,将重量添加到路径中';
@override
String get appSettings_routeWeightFailureDecrement => '失败权重降低';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'从一条路径上移除的货物,由于无法成功交付而移除。';
@override
String get appSettings_maxMessageRetries => '最大消息重试次数';
@override
String get appSettings_maxMessageRetriesSubtitle => '在将消息标记为失败之前,允许尝试的次数';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => '电池';
@@ -1582,9 +1545,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_otherNodes => '其他节点';
@override
String get map_showOverlaps => '重复键重叠';
@override
String get map_keyPrefix => '关键字前缀';
@@ -1627,9 +1587,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_runTrace => '运行路径追踪';
@override
String get map_runTraceWithReturnPath => '沿着相同的路径返回';
@override
String get map_removeLast => '移除最后一个';
+2 -25
View File
@@ -1890,16 +1890,6 @@
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
"map_setAsMyLocation": "Stel dit in als mijn locatie",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Privacyinstellingen",
"settings_privacySubtitle": "Beheer welke informatie wordt gedeeld",
"settings_telemetryLocationMode": "Telemetrie-locatiemodus",
@@ -1929,19 +1919,6 @@
}
}
},
"appSettings_maxRouteWeightSubtitle": "Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.",
"appSettings_initialRouteWeight": "เริ่มต้น gewicht van de route",
"appSettings_maxRouteWeight": "Maximale gewicht voor de route",
"appSettings_initialRouteWeightSubtitle": "Startgewicht voor nieuwe, ontdekte routes",
"appSettings_routeWeightSuccessIncrement": "Toename in het gewicht van het succes",
"appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht wordt toegevoegd aan een route na een succesvolle levering.",
"appSettings_routeWeightFailureDecrement": "Vermindering van het gewicht van fouten",
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht verwijderd van een pad na een mislukte levering",
"appSettings_maxMessageRetries": "Aantal pogingen om berichten te versturen",
"appSettings_maxMessageRetriesSubtitle": "Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Telemetrie-modus bijgewerkt",
"settings_multiAck": "Multi-ACKs: {value}",
"map_showOverlaps": "Herhalingssleutel overlapt",
"map_runTraceWithReturnPath": "Terugkeren op hetzelfde pad."
}
"settings_multiAck": "Multi-ACKs: {value}"
}
+225 -286
View File
File diff suppressed because it is too large Load Diff
+2 -25
View File
@@ -1890,16 +1890,6 @@
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
"map_setAsMyLocation": "Defina minha localização",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySettingsDescription": "Escolha quais informações o seu dispositivo compartilha com os outros.",
"settings_allowByContact": "Permitir por bandeiras de contato",
"settings_telemetryLocationMode": "Modo de Localização de Telemetria",
@@ -1929,19 +1919,6 @@
}
}
},
"appSettings_initialRouteWeight": "Peso Inicial da Rota",
"appSettings_maxRouteWeight": "Peso Máximo da Rota",
"appSettings_maxRouteWeightSubtitle": "Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.",
"appSettings_initialRouteWeightSubtitle": "Peso inicial para novos caminhos descobertos",
"appSettings_routeWeightSuccessIncrement": "Aumento do peso para indicar sucesso",
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso adicionado a um caminho após a entrega bem-sucedida.",
"appSettings_routeWeightFailureDecrement": "Redução do peso da falha",
"appSettings_routeWeightFailureDecrementSubtitle": "Peso removido de um caminho após uma tentativa de entrega malsucedida.",
"appSettings_maxMessageRetries": "Número máximo de tentativas de envio de mensagens",
"appSettings_maxMessageRetriesSubtitle": "Número de tentativas de reenvio antes de classificar uma mensagem como falha.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Modo de telemetria atualizado",
"settings_multiAck": "Multi-ACKs: {value}",
"map_showOverlaps": "Sobreposições da Chave Repeater",
"map_runTraceWithReturnPath": "Retornar ao mesmo caminho."
}
"settings_multiAck": "Multi-ACKs: {value}"
}
+2 -25
View File
@@ -1130,16 +1130,6 @@
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery",
"map_setAsMyLocation": "Установить мое местоположение",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Настройки конфиденциальности",
"settings_privacySubtitle": "Контролируйте, какую информацию делиться.",
"settings_telemetryLocationMode": "Режим местоположения телеметрии",
@@ -1169,19 +1159,6 @@
}
}
},
"appSettings_maxRouteWeight": "Максимальный допустимый вес маршрута",
"appSettings_maxRouteWeightSubtitle": "Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.",
"appSettings_initialRouteWeightSubtitle": "Начальный вес для новых, только что открытых маршрутов",
"appSettings_initialRouteWeight": "Начальный вес маршрута",
"appSettings_routeWeightSuccessIncrement": "Увеличение веса успеха",
"appSettings_routeWeightSuccessIncrementSubtitle": "Вес, добавленный к маршруту после успешной доставки.",
"appSettings_routeWeightFailureDecrement": "Уменьшение веса неудачи",
"appSettings_routeWeightFailureDecrementSubtitle": "Вес, который был удален с пути после неудачной доставки.",
"appSettings_maxMessageRetries": "Максимальное количество повторных попыток отправки сообщения",
"appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Режим телеметрии обновлен",
"settings_multiAck": "Мульти-ACK: {value}",
"map_showOverlaps": "Перекрытия ключа повтора",
"map_runTraceWithReturnPath": "Вернуться обратно по тому же пути"
}
"settings_multiAck": "Мульти-ACK: {value}"
}
+2 -25
View File
@@ -1890,16 +1890,6 @@
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
"map_setAsMyLocation": "Nastavte ako moju polohu",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Nastavenia súkromia",
"settings_privacySubtitle": "Ovládni, aké informácie sa zdieľajú.",
"settings_telemetryLocationMode": "Režim umiestnenia telemetrie",
@@ -1929,19 +1919,6 @@
}
}
},
"appSettings_maxRouteWeightSubtitle": "Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.",
"appSettings_initialRouteWeightSubtitle": "Počiatočná váha pre nové, objavené cesty",
"appSettings_initialRouteWeight": "Počiatočná váha trasy",
"appSettings_maxRouteWeight": "Maximálna hmotnosť trasy",
"appSettings_routeWeightSuccessIncrement": "Zvyšenie váhy úspechu",
"appSettings_routeWeightSuccessIncrementSubtitle": "Hmotnosť pridaná k trase po úspešnej doručení",
"appSettings_routeWeightFailureDecrement": "Sníženie váhy, ktorá sa používa na odhad rizika.",
"appSettings_routeWeightFailureDecrementSubtitle": "Hmotnosť odstránená z cesty po neúspešnej doručenie",
"appSettings_maxMessageRetries": "Maximalný počet pokusov o doručenie správ",
"appSettings_maxMessageRetriesSubtitle": "Počet pokusov o odošleť pred označením správy ako neúspešnej",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Režim telemetrie bol aktualizovaný",
"settings_multiAck": "Viaceré ACK: {value}",
"map_showOverlaps": "Prekrývanie opakovača kľúča",
"map_runTraceWithReturnPath": "Vráťte sa späť po tej istej ceste."
}
"settings_multiAck": "Viaceré ACK: {value}"
}
+2 -25
View File
@@ -1890,16 +1890,6 @@
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Nastavitve zasebnosti",
"settings_privacySettingsDescription": "Izberite, katere informacije vaš naprava deli z drugimi.",
"settings_telemetryBaseMode": "Osnovni način telemetrije",
@@ -1929,19 +1919,6 @@
}
}
},
"appSettings_maxRouteWeightSubtitle": "Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.",
"appSettings_initialRouteWeight": "Izvirna teža poti",
"appSettings_initialRouteWeightSubtitle": "Izguba teže za nove, odkriti poti",
"appSettings_maxRouteWeight": "Največja dovoljena teža poti",
"appSettings_routeWeightSuccessIncrement": "Učinkovitost: povečanje",
"appSettings_routeWeightSuccessIncrementSubtitle": "Težava, dodana poti po uspešni dostavi",
"appSettings_routeWeightFailureDecrement": "Zmanjšanje teže, ki je povezana s pomanjkanjem",
"appSettings_routeWeightFailureDecrementSubtitle": "Težo, ki ni bila uspešno dostavljena, odstranili s poti.",
"appSettings_maxMessageRetries": "Najve število poskusov pošiljanja sporočil",
"appSettings_maxMessageRetriesSubtitle": "Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "Večkratni potrditvi: {value}",
"settings_telemetryModeUpdated": "Način telemetrije posodobljen",
"map_showOverlaps": "Prekrivanje ključa ponovnega predvajanja",
"map_runTraceWithReturnPath": "Vrni se nazaj po isti poti."
}
"settings_telemetryModeUpdated": "Način telemetrije posodobljen"
}
+2 -25
View File
@@ -1890,16 +1890,6 @@
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
"map_setAsMyLocation": "Ange som min plats",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Inställningar för sekretess",
"settings_allowAll": "Tillåt alla",
"settings_privacySubtitle": "Kontrollera vilken information som delas.",
@@ -1929,19 +1919,6 @@
}
}
},
"appSettings_initialRouteWeightSubtitle": "Initial vikt för nyligen upptäckta vägar",
"appSettings_maxRouteWeight": "Maximalt tillåtet vikt för rutten",
"appSettings_maxRouteWeightSubtitle": "Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.",
"appSettings_initialRouteWeight": "Initial vikt för rutt",
"appSettings_routeWeightSuccessIncrement": "Ökning av vikt för framgång",
"appSettings_routeWeightSuccessIncrementSubtitle": "Vikt läggs till en väg efter en lyckad leverans.",
"appSettings_routeWeightFailureDecrement": "Minskning av vikten för misslyckande",
"appSettings_routeWeightFailureDecrementSubtitle": "Vikt som tagits bort från en väg efter ett misslyckat leveransförsök",
"appSettings_maxMessageRetries": "Maximalt antal försök",
"appSettings_maxMessageRetriesSubtitle": "Antal försök att skicka om ett meddelande innan det markeras som misslyckat.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Telemetri-läge uppdaterat",
"settings_multiAck": "Multi-ACKs: {value}",
"map_showOverlaps": "Repeater-nyckelöverlappningar",
"map_runTraceWithReturnPath": "Gå tillbaka på samma väg"
}
"settings_multiAck": "Multi-ACKs: {value}"
}
+2 -25
View File
@@ -1890,16 +1890,6 @@
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття",
"map_setAsMyLocation": "Встановити моє місцезнаходження",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySubtitle": "Керуйте інформацією, яку буде спільно використовуватися",
"settings_privacy": "Налаштування приватності",
"settings_telemetryBaseMode": "Режим базової телеметрії",
@@ -1929,19 +1919,6 @@
}
}
},
"appSettings_initialRouteWeight": "Початкова вартість маршруту",
"appSettings_initialRouteWeightSubtitle": "Початкова вага для нових відкритих шляхів",
"appSettings_maxRouteWeight": "Максимальна вага маршруту",
"appSettings_maxRouteWeightSubtitle": "Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.",
"appSettings_routeWeightSuccessIncrement": "Збільшення ваги успіху",
"appSettings_routeWeightSuccessIncrementSubtitle": "Вага, додана до маршруту після успішної доставки",
"appSettings_routeWeightFailureDecrement": "Зменшення ваги помилки",
"appSettings_routeWeightFailureDecrementSubtitle": "Вага, яка була знята з маршруту після невдалої доставки",
"appSettings_maxMessageRetries": "Максимальна кількість повторних спроб надсилання повідомлення",
"appSettings_maxMessageRetriesSubtitle": "Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Режим телеметрії оновлено",
"settings_multiAck": "Багатократне підтвердження: {value}",
"map_showOverlaps": "Перекриття ключа повторювача",
"map_runTraceWithReturnPath": "Повернутися назад тим же шляхом"
}
"settings_multiAck": "Багатократне підтвердження: {value}"
}
+2 -25
View File
@@ -1895,16 +1895,6 @@
"tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人",
"map_setAsMyLocation": "设置为我的位置",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySubtitle": "控制要共享的信息。",
"settings_privacySettingsDescription": "选择您的设备与他人共享的信息。",
"settings_telemetryBaseMode": "遥测基础模式",
@@ -1934,19 +1924,6 @@
}
}
},
"appSettings_maxRouteWeight": "最大路径重量",
"appSettings_initialRouteWeightSubtitle": "新发现路径的初始重量",
"appSettings_initialRouteWeight": "初始路线权重",
"appSettings_maxRouteWeightSubtitle": "一条路径可以累积的最大重量,取决于成功交付的数量。",
"appSettings_routeWeightSuccessIncrement": "成功权重增加",
"appSettings_routeWeightSuccessIncrementSubtitle": "在成功交付后,将重量添加到路径中",
"appSettings_routeWeightFailureDecrement": "失败权重降低",
"appSettings_routeWeightFailureDecrementSubtitle": "从一条路径上移除的货物,由于无法成功交付而移除。",
"appSettings_maxMessageRetries": "最大消息重试次数",
"appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "多重ACK{value}",
"settings_telemetryModeUpdated": "遥测模式已更新",
"map_showOverlaps": "重复键重叠",
"map_runTraceWithReturnPath": "沿着相同的路径返回"
}
"settings_telemetryModeUpdated": "遥测模式已更新"
}
-41
View File
@@ -18,7 +18,6 @@ class AppSettings {
final bool mapShowRepeaters;
final bool mapShowChatNodes;
final bool mapShowOtherNodes;
final bool mapShowOverlaps;
final double mapTimeFilterHours; // 0 = all time
final bool mapKeyPrefixEnabled;
final String mapKeyPrefix;
@@ -33,11 +32,6 @@ class AppSettings {
final bool notifyOnNewChannelMessage;
final bool notifyOnNewAdvert;
final bool autoRouteRotationEnabled;
final double maxRouteWeight;
final double initialRouteWeight;
final double routeWeightSuccessIncrement;
final double routeWeightFailureDecrement;
final int maxMessageRetries;
final String themeMode;
final String? languageOverride; // null = system default
final bool appDebugLogEnabled;
@@ -54,7 +48,6 @@ class AppSettings {
this.mapShowRepeaters = true,
this.mapShowChatNodes = true,
this.mapShowOtherNodes = true,
this.mapShowOverlaps = false,
this.mapTimeFilterHours = 0, // Default to all time
this.mapKeyPrefixEnabled = false,
this.mapKeyPrefix = '',
@@ -69,11 +62,6 @@ class AppSettings {
this.notifyOnNewChannelMessage = true,
this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false,
this.maxRouteWeight = 5.0,
this.initialRouteWeight = 3.0,
this.routeWeightSuccessIncrement = 0.5,
this.routeWeightFailureDecrement = 0.2,
this.maxMessageRetries = 5,
this.themeMode = 'system',
this.languageOverride,
this.appDebugLogEnabled = false,
@@ -94,7 +82,6 @@ class AppSettings {
'map_show_repeaters': mapShowRepeaters,
'map_show_chat_nodes': mapShowChatNodes,
'map_show_other_nodes': mapShowOtherNodes,
'map_show_overlaps': mapShowOverlaps,
'map_time_filter_hours': mapTimeFilterHours,
'map_key_prefix_enabled': mapKeyPrefixEnabled,
'map_key_prefix': mapKeyPrefix,
@@ -109,11 +96,6 @@ class AppSettings {
'notify_on_new_channel_message': notifyOnNewChannelMessage,
'notify_on_new_advert': notifyOnNewAdvert,
'auto_route_rotation_enabled': autoRouteRotationEnabled,
'max_route_weight': maxRouteWeight,
'initial_route_weight': initialRouteWeight,
'route_weight_success_increment': routeWeightSuccessIncrement,
'route_weight_failure_decrement': routeWeightFailureDecrement,
'max_message_retries': maxMessageRetries,
'theme_mode': themeMode,
'language_override': languageOverride,
'app_debug_log_enabled': appDebugLogEnabled,
@@ -140,7 +122,6 @@ class AppSettings {
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false,
mapTimeFilterHours:
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
@@ -161,14 +142,6 @@ class AppSettings {
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled:
json['auto_route_rotation_enabled'] as bool? ?? false,
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
initialRouteWeight:
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
routeWeightSuccessIncrement:
(json['route_weight_success_increment'] as num?)?.toDouble() ?? 0.5,
routeWeightFailureDecrement:
(json['route_weight_failure_decrement'] as num?)?.toDouble() ?? 0.2,
maxMessageRetries: json['max_message_retries'] as int? ?? 5,
themeMode: json['theme_mode'] as String? ?? 'system',
languageOverride: json['language_override'] as String?,
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
@@ -200,7 +173,6 @@ class AppSettings {
bool? mapShowRepeaters,
bool? mapShowChatNodes,
bool? mapShowOtherNodes,
bool? mapShowOverlaps,
double? mapTimeFilterHours,
bool? mapKeyPrefixEnabled,
String? mapKeyPrefix,
@@ -215,11 +187,6 @@ class AppSettings {
bool? notifyOnNewChannelMessage,
bool? notifyOnNewAdvert,
bool? autoRouteRotationEnabled,
double? maxRouteWeight,
double? initialRouteWeight,
double? routeWeightSuccessIncrement,
double? routeWeightFailureDecrement,
int? maxMessageRetries,
String? themeMode,
Object? languageOverride = _unset,
bool? appDebugLogEnabled,
@@ -236,7 +203,6 @@ class AppSettings {
mapShowRepeaters: mapShowRepeaters ?? this.mapShowRepeaters,
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps,
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
@@ -256,13 +222,6 @@ class AppSettings {
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled:
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
maxRouteWeight: maxRouteWeight ?? this.maxRouteWeight,
initialRouteWeight: initialRouteWeight ?? this.initialRouteWeight,
routeWeightSuccessIncrement:
routeWeightSuccessIncrement ?? this.routeWeightSuccessIncrement,
routeWeightFailureDecrement:
routeWeightFailureDecrement ?? this.routeWeightFailureDecrement,
maxMessageRetries: maxMessageRetries ?? this.maxMessageRetries,
themeMode: themeMode ?? this.themeMode,
languageOverride: languageOverride == _unset
? this.languageOverride
+9 -12
View File
@@ -24,23 +24,20 @@ class Channel {
bool get isPublicChannel => pskHex == publicChannelPsk;
static Channel? fromFrame(Uint8List frame) {
static Channel? fromFrame(Uint8List data) {
// CHANNEL_INFO format:
// [0] = RESP_CODE_CHANNEL_INFO (18)
// [1] = channel_idx
// [2-33] = name (32 bytes, null-terminated)
// [34-49] = psk (16 bytes)
if (frame.length < 50) return null;
final reader = BufferReader(frame);
try {
if (reader.readByte() != respCodeChannelInfo) return null;
final index = reader.readByte();
final name = reader.readCStringGreedy(32);
final psk = reader.readBytes(16);
return Channel(index: index, name: name, psk: psk);
} catch (e) {
return null;
}
if (data.length < 50) return null;
if (data[0] != respCodeChannelInfo) return null;
final index = data[1];
final name = readCString(data, 2, 32);
final psk = Uint8List.fromList(data.sublist(34, 50));
return Channel(index: index, name: name, psk: psk);
}
static Channel empty(int index) {
+77 -75
View File
@@ -2,7 +2,6 @@ import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/smaz.dart';
import '../utils/app_logger.dart';
enum ChannelMessageStatus { pending, sent, failed }
@@ -37,7 +36,6 @@ class ChannelMessage {
final List<Uint8List> pathVariants;
final int? channelIndex;
final String messageId;
final String? packetHash;
final String? replyToMessageId;
final String? replyToSenderName;
final String? replyToText;
@@ -57,7 +55,6 @@ class ChannelMessage {
List<Uint8List>? pathVariants,
this.channelIndex,
String? messageId,
this.packetHash,
this.replyToMessageId,
this.replyToSenderName,
this.replyToText,
@@ -82,7 +79,6 @@ class ChannelMessage {
int? pathLength,
Uint8List? pathBytes,
List<Uint8List>? pathVariants,
String? packetHash,
String? replyToMessageId,
String? replyToSenderName,
String? replyToText,
@@ -102,7 +98,6 @@ class ChannelMessage {
pathVariants: pathVariants ?? this.pathVariants,
channelIndex: channelIndex,
messageId: messageId,
packetHash: packetHash ?? this.packetHash,
replyToMessageId: replyToMessageId ?? this.replyToMessageId,
replyToSenderName: replyToSenderName ?? this.replyToSenderName,
replyToText: replyToText ?? this.replyToText,
@@ -110,82 +105,89 @@ class ChannelMessage {
);
}
static ChannelMessage? fromFrame(Uint8List frame) {
static ChannelMessage? fromFrame(Uint8List data) {
// CHANNEL_MSG_RECV format varies by version:
// V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...]
// Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text
if (frame.length < 8) return null;
try {
final reader = BufferReader(frame);
final code = reader.readByte();
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
return null;
}
if (data.length < 8) return null;
int pathLen;
int txtType;
Uint8List pathBytes = Uint8List(0);
int channelIdx;
if (code == respCodeChannelMsgRecvV3) {
reader.skipBytes(1); // Skip SNR
final flags = reader.readByte();
final hasPath = (flags & 0x01) != 0;
reader.skipBytes(1); // Skip reserved byte
channelIdx = reader.readByte();
pathLen = reader.readInt8();
txtType = reader.readByte();
if (hasPath && pathLen > 0) {
reader.rewind(); // Rewind to read path length again for pathBytes
pathBytes = reader.readBytes(pathLen);
}
} else {
channelIdx = reader.readByte();
pathLen = reader.readInt8();
txtType = reader.readByte();
}
final timestampRaw = reader.readUInt32LE();
if (txtType != txtTypePlain) {
return null;
}
final text = reader.readCString();
// Extract sender name and actual message from "name: msg" format
String senderName = 'Unknown';
String actualText = text;
final colonIndex = text.indexOf(':');
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
actualText = text.substring(offset);
}
}
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: pathLen,
pathBytes: pathBytes,
channelIndex: channelIdx,
);
} catch (e) {
appLogger.error('Error parsing channel message frame: $e');
// If parsing fails, return null to avoid crashes
final code = data[0];
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
return null;
}
int timestampOffset, textOffset, pathLenOffset, txtTypeOffset;
Uint8List pathBytes = Uint8List(0);
int channelIdx;
if (code == respCodeChannelMsgRecvV3) {
channelIdx = data[4];
pathLenOffset = 5;
final pathLen = data[pathLenOffset].toSigned(8);
var cursor = 6;
final hasPathBytesFlag = (data[2] & 0x01) != 0;
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
final hasValidTxtType =
cursor < data.length &&
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen;
}
txtTypeOffset = cursor;
cursor += 1; // txt_type
timestampOffset = cursor;
textOffset = cursor + 4;
} else {
channelIdx = data[1];
pathLenOffset = 2;
txtTypeOffset = 3;
timestampOffset = 4;
textOffset = 8;
}
if (data.length < textOffset + 1) return null;
final txtType = data[txtTypeOffset];
if (txtType != txtTypePlain) {
return null;
}
final pathLen = data[pathLenOffset].toSigned(8);
final timestampRaw = readUint32LE(data, timestampOffset);
final text = readCString(data, textOffset, data.length - textOffset);
// Extract sender name and actual message from "name: msg" format
String senderName = 'Unknown';
String actualText = text;
final colonIndex = text.indexOf(':');
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
actualText = text.substring(offset);
}
}
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: pathLen,
pathBytes: pathBytes,
channelIndex: channelIdx,
);
}
static ChannelMessage outgoing(
+7 -22
View File
@@ -18,7 +18,6 @@ class Contact {
final DateTime lastSeen;
final DateTime lastMessageAt;
final bool isActive;
final bool wasPulled;
final Uint8List? rawPacket;
Contact({
@@ -35,7 +34,6 @@ class Contact {
required this.lastSeen,
DateTime? lastMessageAt,
this.isActive = true,
this.wasPulled = false,
this.rawPacket,
}) : lastMessageAt = lastMessageAt ?? lastSeen;
@@ -159,12 +157,6 @@ class Contact {
return null;
}
final pubKey = reader.readBytes(pubKeySize);
// Guard: reject contacts with zeroed or mostly-zeroed public keys
// (indicates corrupt flash storage on the firmware side)
final zeroCount = pubKey.where((b) => b == 0).length;
if (zeroCount > pubKeySize ~/ 2) return null;
final type = reader.readByte();
final flags = reader.readByte();
final pathLen = reader.readByte();
@@ -174,22 +166,15 @@ class Contact {
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
final name = reader.readCStringGreedy(maxNameSize);
// Guard: reject contacts with non-printable names (corrupt flash data)
if (name.isNotEmpty &&
name.codeUnits.every((c) => c < 0x20 || c == 0xFFFD)) {
return null;
}
final lastMod = reader.readUInt32LE();
double? lat, lon;
if (reader.remaining >= 8) {
final latRaw = reader.readInt32LE();
final lonRaw = reader.readInt32LE();
if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6;
lon = lonRaw / 1e6;
}
final latRaw = reader.readInt32LE();
final lonRaw = reader.readInt32LE();
if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6;
lon = lonRaw / 1e6;
}
return Contact(
@@ -197,7 +182,7 @@ class Contact {
name: name.isEmpty ? 'Unknown' : name,
type: type,
flags: flags,
pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen,
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
path: pathBytes,
latitude: lat,
longitude: lon,
+27 -34
View File
@@ -16,14 +16,13 @@ class Message {
final String? messageId;
final int retryCount;
final int? estimatedTimeoutMs;
final int? expectedAckHash;
final Uint8List? expectedAckHash;
final DateTime? sentAt;
final DateTime? deliveredAt;
final int? tripTimeMs;
final int? pathLength;
final Uint8List pathBytes;
final Map<String, int> reactions;
final Map<String, MessageStatus> reactionStatuses;
final Uint8List fourByteRoomContactKey;
Message({
@@ -44,11 +43,9 @@ class Message {
Uint8List? pathBytes,
Uint8List? fourByteRoomContactKey,
Map<String, int>? reactions,
Map<String, MessageStatus>? reactionStatuses,
}) : pathBytes = pathBytes ?? Uint8List(0),
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
reactions = reactions ?? {},
reactionStatuses = reactionStatuses ?? {};
reactions = reactions ?? {};
String get senderKeyHex => pubKeyToHex(senderKey);
@@ -56,7 +53,7 @@ class Message {
MessageStatus? status,
int? retryCount,
int? estimatedTimeoutMs,
int? expectedAckHash,
Uint8List? expectedAckHash,
DateTime? sentAt,
DateTime? deliveredAt,
int? tripTimeMs,
@@ -64,7 +61,6 @@ class Message {
Uint8List? pathBytes,
bool? isCli,
Map<String, int>? reactions,
Map<String, MessageStatus>? reactionStatuses,
Uint8List? fourByteRoomContactKey,
}) {
return Message(
@@ -84,41 +80,38 @@ class Message {
pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes,
reactions: reactions ?? this.reactions,
reactionStatuses: reactionStatuses ?? this.reactionStatuses,
fourByteRoomContactKey:
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
);
}
static Message? fromFrame(Uint8List frame, Uint8List selfPubKey) {
if (frame.length < msgTextOffset + 1) return null;
final reader = BufferReader(frame);
try {
final code = reader.readByte();
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) {
if (data.length < msgTextOffset + 1) return null;
final senderKey = reader.readBytes(pubKeySize);
final timestampRaw = reader.readInt32LE();
final flags = reader.readByte();
if ((flags >> 2) != txtTypePlain) {
return null;
}
final text = reader.readCString();
return Message(
senderKey: senderKey,
text: text,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
pathBytes: Uint8List(0),
);
} catch (e) {
final code = data[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
final senderKey = Uint8List.fromList(
data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize),
);
final timestampRaw = readUint32LE(data, msgTimestampOffset);
final flags = data[msgFlagsOffset];
if ((flags >> 2) != txtTypePlain) {
return null;
}
final text = readCString(data, msgTextOffset, data.length - msgTextOffset);
return Message(
senderKey: senderKey,
text: text,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
pathBytes: Uint8List(0),
);
}
static Message outgoing(
+3 -9
View File
@@ -1,12 +1,11 @@
class PathRecord {
final int hopCount;
final int tripTimeMs;
final DateTime? timestamp;
final DateTime timestamp;
final bool wasFloodDiscovery;
final List<int> pathBytes;
final int successCount;
final int failureCount;
final double routeWeight;
PathRecord({
required this.hopCount,
@@ -16,7 +15,6 @@ class PathRecord {
required this.pathBytes,
required this.successCount,
required this.failureCount,
this.routeWeight = 1.0,
});
String get displayText =>
@@ -26,12 +24,11 @@ class PathRecord {
return {
'hop_count': hopCount,
'trip_time_ms': tripTimeMs,
'timestamp': timestamp?.toIso8601String(),
'timestamp': timestamp.toIso8601String(),
'was_flood': wasFloodDiscovery,
'path_bytes': pathBytes,
'success_count': successCount,
'failure_count': failureCount,
'route_weight': routeWeight,
};
}
@@ -39,15 +36,12 @@ class PathRecord {
return PathRecord(
hopCount: json['hop_count'] as int,
tripTimeMs: json['trip_time_ms'] as int,
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: null,
timestamp: DateTime.parse(json['timestamp'] as String),
wasFloodDiscovery: json['was_flood'] as bool,
pathBytes:
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
routeWeight: (json['route_weight'] as num?)?.toDouble() ?? 1.0,
);
}
}
-41
View File
@@ -1,9 +1,3 @@
import 'dart:typed_data';
import 'contact.dart';
const int recentAttemptDiversityWindow = 2;
class PathSelection {
final List<int> pathBytes;
final int hopCount;
@@ -15,38 +9,3 @@ class PathSelection {
required this.useFlood,
});
}
PathSelection resolvePathSelection(
Contact contact, {
PathSelection? selection,
bool forceFlood = false,
}) {
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
return PathSelection(
pathBytes: contact.pathOverrideBytes ?? Uint8List(0),
hopCount: contact.pathOverride!,
useFlood: false,
);
}
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
if (selection != null && selection.pathBytes.isNotEmpty) {
return PathSelection(
pathBytes: selection.pathBytes,
hopCount: selection.hopCount,
useFlood: false,
);
}
return PathSelection(
pathBytes: contact.path,
hopCount: contact.pathLength,
useFlood: false,
);
}
-112
View File
@@ -310,118 +310,6 @@ class AppSettingsScreen extends StatelessWidget {
);
},
),
if (settingsService.settings.autoRouteRotationEnabled) ...[
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_maxRouteWeight),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.appSettings_maxRouteWeightSubtitle),
Slider(
value: settingsService.settings.maxRouteWeight,
min: 1,
max: 10,
divisions: 9,
label: settingsService.settings.maxRouteWeight
.round()
.toString(),
onChanged: (value) =>
settingsService.setMaxRouteWeight(value),
),
],
),
),
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_initialRouteWeight),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.appSettings_initialRouteWeightSubtitle),
Slider(
value: settingsService.settings.initialRouteWeight,
min: 0.5,
max: 5.0,
divisions: 9,
label: settingsService.settings.initialRouteWeight
.toStringAsFixed(1),
onChanged: (value) =>
settingsService.setInitialRouteWeight(value),
),
],
),
),
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_routeWeightSuccessIncrement),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context
.l10n
.appSettings_routeWeightSuccessIncrementSubtitle,
),
Slider(
value: settingsService.settings.routeWeightSuccessIncrement,
min: 0.1,
max: 2.0,
divisions: 19,
label: settingsService.settings.routeWeightSuccessIncrement
.toStringAsFixed(1),
onChanged: (value) =>
settingsService.setRouteWeightSuccessIncrement(value),
),
],
),
),
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_routeWeightFailureDecrement),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context
.l10n
.appSettings_routeWeightFailureDecrementSubtitle,
),
Slider(
value: settingsService.settings.routeWeightFailureDecrement,
min: 0.1,
max: 2.0,
divisions: 19,
label: settingsService.settings.routeWeightFailureDecrement
.toStringAsFixed(1),
onChanged: (value) =>
settingsService.setRouteWeightFailureDecrement(value),
),
],
),
),
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_maxMessageRetries),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.appSettings_maxMessageRetriesSubtitle),
Slider(
value: settingsService.settings.maxMessageRetries
.toDouble(),
min: 2,
max: 10,
divisions: 8,
label: settingsService.settings.maxMessageRetries
.toString(),
onChanged: (value) =>
settingsService.setMaxMessageRetries(value.toInt()),
),
],
),
),
],
],
),
);
+55 -55
View File
@@ -283,66 +283,66 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
if (payload.length < 101) {
return 'ADVERT (short)';
}
final reader = BufferReader(payload);
try {
final pubKey = _bytesToHex(reader.readBytes(pubKeySize), spaced: false);
final timestamp = reader.readUInt32LE();
reader.skipBytes(signatureSize);
final flags = reader.readByte();
final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0;
final hasFeature2 = (flags & 0x40) != 0;
final hasName = (flags & 0x80) != 0;
String? name;
double? lat;
double? lon;
if (hasLocation) {
lat = reader.readInt32LE() / 1000000.0;
lon = reader.readInt32LE() / 1000000.0;
}
if (hasFeature1) reader.skipBytes(2);
if (hasFeature2) reader.skipBytes(2);
if (hasName) {
name = reader.readCStringGreedy(maxNameSize);
}
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
} catch (e) {
return 'ADVERT (invalid)';
var offset = 0;
final pubKey = _bytesToHex(
payload.sublist(offset, offset + 32),
spaced: false,
);
offset += 32;
final timestamp = readUint32LE(payload, offset);
offset += 4;
offset += 64; // signature
final flags = payload[offset++];
final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0;
final hasFeature2 = (flags & 0x40) != 0;
final hasName = (flags & 0x80) != 0;
String? name;
double? lat;
double? lon;
if (hasLocation && payload.length >= offset + 8) {
lat = readInt32LE(payload, offset) / 1000000.0;
lon = readInt32LE(payload, offset + 4) / 1000000.0;
offset += 8;
}
if (hasFeature1) offset += 2;
if (hasFeature2) offset += 2;
if (hasName && payload.length > offset) {
final rawName = String.fromCharCodes(payload.sublist(offset));
final nul = rawName.indexOf('\u0000');
name = nul >= 0 ? rawName.substring(0, nul) : rawName;
name = name.trim();
}
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
}
String _decodeControlSummary(Uint8List payload) {
final reader = BufferReader(payload);
try {
final flags = reader.readByte();
final subType = flags & 0xF0;
if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = reader.readByte();
final tag = reader.readInt32LE();
final since = payload.length >= 10 ? reader.readInt32LE() : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
}
if (subType == 0x90) {
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
final nodeType = flags & 0x0F;
final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0;
final tag = reader.readInt32LE();
final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
}
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
} catch (e) {
return 'CONTROL (invalid)';
if (payload.isEmpty) return 'CONTROL (empty)';
final flags = payload[0];
final subType = flags & 0xF0;
if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = payload[1];
final tag = readUint32LE(payload, 2);
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
}
if (subType == 0x90) {
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
final nodeType = flags & 0x0F;
final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0;
final tag = readUint32LE(payload, 2);
final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
}
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
}
String _payloadTypeLabel(int payloadType) {
+13 -19
View File
@@ -4,11 +4,11 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../utils/platform_info.dart';
import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/link_handler.dart';
@@ -338,13 +338,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
Flexible(
child: GestureDetector(
onTap: PlatformInfo.isDesktop
? null
: () => _showMessagePathInfo(message),
onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
onSecondaryTapUp: PlatformInfo.isDesktop
? (_) => _showMessageActions(message)
: null,
child: Container(
padding: gifId != null
? const EdgeInsets.all(4)
@@ -462,8 +457,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: LinkHandler.buildLinkifyText(
context: context,
child: Linkify(
text: message.text,
style: TextStyle(
fontSize: bodyFontSize * textScale,
@@ -473,6 +467,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) => LinkHandler.handleLinkTap(
context,
link.url,
),
),
),
if (!enableTracing && isOutgoing) ...[
@@ -581,7 +584,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
);
if (!isOutgoing && !PlatformInfo.isDesktop) {
if (!isOutgoing) {
return _SwipeReplyBubble(
maxSwipeOffset: maxSwipeOffset,
replySwipeThreshold: replySwipeThreshold,
@@ -1136,15 +1139,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_setReplyingTo(message);
},
),
if (PlatformInfo.isDesktop)
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_showMessagePathInfo(message);
},
),
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(
+43 -93
View File
@@ -40,7 +40,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp;
final hops = _buildPathHops(primaryPath, connector, l10n);
final contacts = connector.allContacts;
final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
@@ -302,12 +303,10 @@ class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen> {
static const double _labelZoomThreshold = 8.5;
final MapController _mapController = MapController();
Uint8List? _selectedPath;
double _pathDistance = 0.0;
bool _showNodeLabels = true;
bool _didReceivePositionUpdate = false;
int? _focusedHopIndex;
@override
void initState() {
@@ -338,22 +337,6 @@ class _ChannelMessagePathMapScreenState
return totalDistance;
}
void _focusHop(_PathHop hop) {
if (!hop.hasLocation) return;
final targetZoom = _didReceivePositionUpdate
? max(_mapController.camera.zoom, 10.0)
: 12.0;
_mapController.move(hop.position!, targetZoom);
}
void _onHopTapped(_PathHop hop) {
_focusHop(hop);
if (!mounted) return;
setState(() {
_focusedHopIndex = hop.index;
});
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
@@ -382,7 +365,8 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector, context.l10n);
final contacts = connector.allContacts;
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
final points = <LatLng>[];
@@ -437,7 +421,6 @@ class _ChannelMessagePathMapScreenState
children: [
FlutterMap(
key: mapKey,
mapController: _mapController,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
@@ -489,7 +472,6 @@ class _ChannelMessagePathMapScreenState
) {
setState(() {
_selectedPath = observedPaths[index].pathBytes;
_focusedHopIndex = null;
});
}),
if (points.isEmpty)
@@ -745,17 +727,8 @@ class _ChannelMessagePathMapScreenState
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final hop = hops[index];
final isFocused = _focusedHopIndex == hop.index;
return ListTile(
dense: true,
enabled: hop.hasLocation,
selected: isFocused,
selectedTileColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
onTap: hop.hasLocation
? () => _onHopTapped(hop)
: null,
leading: CircleAvatar(
radius: 14,
child: Text(
@@ -814,71 +787,19 @@ class _ObservedPath {
List<_PathHop> _buildPathHops(
Uint8List pathBytes,
MeshCoreConnector connector,
List<Contact> contacts,
AppLocalizations l10n,
) {
if (pathBytes.isEmpty) return const [];
final candidatesByPrefix = <int, List<Contact>>{};
for (final contact in connector.allContacts) {
if (contact.publicKey.isEmpty) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue;
}
final prefix = contact.publicKey.first;
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
}
for (final candidates in candidatesByPrefix.values) {
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
}
final startPoint =
(connector.selfLatitude != null && connector.selfLongitude != null)
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
: null;
var previousPosition = startPoint;
final distance = Distance();
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final searchPoint = i == 0 ? startPoint : previousPosition;
final candidates = candidatesByPrefix[pathBytes[i]];
Contact? contact;
if (candidates != null && candidates.isNotEmpty) {
var bestIndex = 0;
if (searchPoint != null) {
var bestDistance = double.infinity;
for (var j = 0; j < candidates.length; j++) {
final candidate = candidates[j];
if (!candidate.hasLocation ||
candidate.latitude == null ||
candidate.longitude == null) {
continue;
}
final currentDistance = distance(
searchPoint,
LatLng(candidate.latitude!, candidate.longitude!),
);
if (currentDistance < bestDistance) {
bestDistance = currentDistance;
bestIndex = j;
}
}
}
contact = candidates.removeAt(bestIndex);
if (candidates.isEmpty) {
candidatesByPrefix.remove(pathBytes[i]);
}
}
final resolvedPosition = _resolvePosition(contact);
if (resolvedPosition != null) {
previousPosition = resolvedPosition;
}
final prefix = pathBytes[i];
final contact = _matchContactForPrefix(contacts, prefix);
hops.add(
_PathHop(
index: i + 1,
prefix: pathBytes[i],
prefix: prefix,
contact: contact,
position: resolvedPosition,
position: _resolvePosition(contact),
l10n: l10n,
),
);
@@ -886,13 +807,42 @@ List<_PathHop> _buildPathHops(
return hops;
}
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
final matches = contacts
.where(
(contact) =>
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix,
)
.toList();
if (matches.isEmpty) return null;
Contact? pickWhere(bool Function(Contact) predicate) {
for (final contact in matches) {
if (predicate(contact)) return contact;
}
return null;
}
return pickWhere((c) => c.type == advTypeRepeater && _hasValidLocation(c)) ??
pickWhere((c) => c.type == advTypeRepeater) ??
pickWhere(_hasValidLocation) ??
matches.first;
}
LatLng? _resolvePosition(Contact? contact) {
if (contact == null) return null;
if (!contact.hasLocation) return null;
final latitude = contact.latitude;
final longitude = contact.longitude;
if (latitude == null || longitude == null) return null;
return LatLng(latitude, longitude);
if (!_hasValidLocation(contact)) return null;
return LatLng(contact.latitude!, contact.longitude!);
}
bool _hasValidLocation(Contact contact) {
final lat = contact.latitude;
final lon = contact.longitude;
if (lat == null || lon == null) return false;
if (lat == 0 && lon == 0) return false;
return true;
}
String _formatPrefix(int prefix) {
+69 -88
View File
@@ -4,7 +4,6 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:meshcore_open/storage/channel_message_store.dart';
import 'package:meshcore_open/utils/platform_info.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
@@ -418,96 +417,78 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return Card(
key: ValueKey('channel_${channel.index}'),
margin: const EdgeInsets.only(bottom: 12),
child: GestureDetector(
onSecondaryTapUp: PlatformInfo.isDesktop
? (_) => _showChannelActions(
context,
connector,
channelMessageStore,
channel,
)
: null,
child: ListTile(
dense: true,
minVerticalPadding: 0,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
visualDensity: const VisualDensity(vertical: -2),
leading: Stack(
children: [
CircleAvatar(
backgroundColor: bgColor,
child: Icon(icon, color: iconColor),
child: ListTile(
dense: true,
minVerticalPadding: 0,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
visualDensity: const VisualDensity(vertical: -2),
leading: Stack(
children: [
CircleAvatar(
backgroundColor: bgColor,
child: Icon(icon, color: iconColor),
),
if (isCommunityChannel)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.purple,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).cardColor,
width: 2,
),
),
child: const Icon(Icons.people, size: 8, color: Colors.white),
),
),
if (isCommunityChannel)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.purple,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).cardColor,
width: 2,
),
),
child: const Icon(
Icons.people,
size: 8,
color: Colors.white,
),
),
),
],
),
title: Text(
channel.name.isEmpty
? context.l10n.channels_channelIndex(channel.index)
: channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(width: 4),
],
),
title: Text(
channel.name.isEmpty
? context.l10n.channels_channelIndex(channel.index)
: channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(width: 4),
],
if (showDragHandle && dragIndex != null)
ReorderableDelayedDragStartListener(
index: dragIndex,
child: Icon(
Icons.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
if (showDragHandle && dragIndex != null)
ReorderableDelayedDragStartListener(
index: dragIndex,
child: Icon(
Icons.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
onTap: () async {
connector.markChannelRead(channel.index);
await Future.delayed(const Duration(milliseconds: 50));
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(channel: channel),
),
);
}
},
onLongPress: () => _showChannelActions(
context,
connector,
channelMessageStore,
channel,
),
),
],
),
onTap: () async {
connector.markChannelRead(channel.index);
await Future.delayed(const Duration(milliseconds: 50));
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(channel: channel),
),
);
}
},
onLongPress: () => _showChannelActions(
context,
connector,
channelMessageStore,
channel,
),
),
);
+42 -100
View File
@@ -5,10 +5,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart';
import '../utils/platform_info.dart';
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart';
@@ -17,7 +16,6 @@ import '../helpers/reaction_helper.dart';
import '../widgets/message_status_icon.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/link_handler.dart';
import '../helpers/path_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
@@ -433,8 +431,6 @@ class _ChatScreenState extends State<ChatScreen> {
textScale: textScale,
onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact),
onRetryReaction: (msg, emoji) =>
_sendReaction(msg, contact, emoji),
);
},
);
@@ -893,8 +889,7 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
String _formatRelativeTime(DateTime? time) {
if (time == null) return '';
String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return context.l10n.time_justNow;
if (diff.inMinutes < 60) {
@@ -915,31 +910,15 @@ class _ChatScreenState extends State<ChatScreen> {
return;
}
final connector = context.read<MeshCoreConnector>();
final allContacts = connector.allContacts;
final formattedPath = PathHelper.formatPathHex(pathBytes);
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
final formattedPath = pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.chat_fullPath),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(formattedPath),
const SizedBox(height: 8),
SelectableText(
resolvedNames,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
content: SelectableText(formattedPath),
actions: [
TextButton(
onPressed: () => Navigator.push(
@@ -1316,15 +1295,6 @@ class _ChatScreenState extends State<ChatScreen> {
_showEmojiPicker(message, contact);
},
),
if (PlatformInfo.isDesktop)
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_openMessagePath(message, contact);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy),
@@ -1435,7 +1405,6 @@ class _MessageBubble extends StatelessWidget {
final bool isRoomServer;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final void Function(Message message, String emoji)? onRetryReaction;
final double textScale;
const _MessageBubble({
@@ -1445,7 +1414,6 @@ class _MessageBubble extends StatelessWidget {
required this.textScale,
this.onTap,
this.onLongPress,
this.onRetryReaction,
});
@override
@@ -1479,11 +1447,8 @@ class _MessageBubble extends StatelessWidget {
: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: PlatformInfo.isDesktop ? null : onTap,
onTap: onTap,
onLongPress: onLongPress,
onSecondaryTapUp: PlatformInfo.isDesktop
? (_) => onLongPress?.call()
: null,
child: Row(
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
@@ -1600,8 +1565,7 @@ class _MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: LinkHandler.buildLinkifyText(
context: context,
child: Linkify(
text: messageText,
style: TextStyle(
color: textColor,
@@ -1612,6 +1576,15 @@ class _MessageBubble extends StatelessWidget {
decoration: TextDecoration.underline,
fontSize: bodyFontSize * textScale,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) => LinkHandler.handleLinkTap(
context,
link.url,
),
),
),
if (!enableTracing && isOutgoing) ...[
@@ -1801,65 +1774,34 @@ class _MessageBubble extends StatelessWidget {
children: message.reactions.entries.map((entry) {
final emoji = entry.key;
final count = entry.value;
final status = message.reactionStatuses[emoji];
final isPending =
status == MessageStatus.pending || status == MessageStatus.sent;
final isFailed = status == MessageStatus.failed;
return GestureDetector(
onTap: isFailed && onRetryReaction != null
? () => onRetryReaction!(message, emoji)
: null,
child: Opacity(
opacity: isPending ? 0.5 : 1.0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isFailed
? colorScheme.errorContainer
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isFailed
? colorScheme.error
: colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSecondaryContainer,
),
),
],
if (isPending) ...[
const SizedBox(width: 2),
SizedBox(
width: 8,
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colorScheme.onSecondaryContainer,
),
),
],
if (isFailed) ...[
const SizedBox(width: 2),
Icon(Icons.replay, size: 10, color: colorScheme.error),
],
],
),
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSecondaryContainer,
),
),
],
],
),
);
}).toList(),
);
+59 -71
View File
@@ -5,7 +5,6 @@ import 'package:flutter/services.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:meshcore_open/services/notification_service.dart';
import 'package:meshcore_open/utils/app_logger.dart';
import 'package:meshcore_open/utils/platform_info.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart';
@@ -1443,77 +1442,66 @@ class _ContactTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
child: _buildContactAvatar(contact),
),
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
contact.pathLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
contact.shortPubKeyHex,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
],
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: SizedBox(
width: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isFavorite)
Icon(Icons.star, size: 14, color: Colors.amber[700]),
if (isFavorite && contact.hasLocation)
const SizedBox(width: 2),
if (contact.hasLocation)
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[400],
),
],
),
],
),
),
),
onTap: onTap,
onLongPress: onLongPress,
return ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
child: _buildContactAvatar(contact),
),
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contact.pathLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
Text(
contact.shortPubKeyHex,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
],
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: SizedBox(
width: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isFavorite)
Icon(Icons.star, size: 14, color: Colors.amber[700]),
if (isFavorite && contact.hasLocation)
const SizedBox(width: 2),
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
],
),
],
),
),
),
onTap: onTap,
onLongPress: onLongPress,
);
}
+1 -10
View File
@@ -9,7 +9,6 @@ import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../utils/contact_search.dart';
import '../utils/platform_info.dart';
import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart';
@@ -89,7 +88,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
itemCount: filteredAndSorted.length,
itemBuilder: (context, index) {
final contact = filteredAndSorted[index];
final tile = ListTile(
return ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
child: Icon(
@@ -121,14 +120,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
onLongPress: () =>
_showContactContextMenu(contact, connector),
);
if (PlatformInfo.isDesktop) {
return GestureDetector(
onSecondaryTapUp: (_) =>
_showContactContextMenu(contact, connector),
child: tile,
);
}
return tile;
},
),
),
+96 -259
View File
@@ -1,4 +1,3 @@
import 'dart:collection';
import 'dart:math';
import 'dart:typed_data';
@@ -53,7 +52,7 @@ class MapScreen extends StatefulWidget {
class _MapScreenState extends State<MapScreen> {
// Zoom level at which node labels start to appear
static const double _labelZoomThreshold = 14.0;
static const double _labelZoomThreshold = 12.0;
final MapController _mapController = MapController();
final MapMarkerService _markerService = MapMarkerService();
@@ -330,9 +329,7 @@ class _MapScreenState extends State<MapScreen> {
if (!_isBuildingPathTrace)
IconButton(
icon: const Icon(Icons.radar),
onPressed: () => _startPath(
LatLng(connector.selfLatitude!, connector.selfLongitude!),
),
onPressed: () => _startPath(),
tooltip: context.l10n.contacts_pathTrace,
),
if (!_isBuildingPathTrace)
@@ -480,12 +477,10 @@ class _MapScreenState extends State<MapScreen> {
point: highlightPosition,
width: 40,
height: 40,
child: IgnorePointer(
child: Icon(
Icons.location_on_outlined,
color: Colors.red[600],
size: 34,
),
child: Icon(
Icons.location_on_outlined,
color: Colors.red[600],
size: 34,
),
),
if (!_isBuildingPathTrace)
@@ -508,33 +503,28 @@ class _MapScreenState extends State<MapScreen> {
),
width: 40,
height: 40,
child: IgnorePointer(
ignoring: true,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.teal,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.3,
),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: const Icon(
Icons.person_pin_circle,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.teal,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
size: 20,
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: const Icon(
Icons.person_pin_circle,
color: Colors.white,
size: 20,
),
),
),
@@ -554,7 +544,6 @@ class _MapScreenState extends State<MapScreen> {
),
if (!_isBuildingPathTrace)
_buildLegend(
contacts,
contactsWithLocation,
settings,
sharedMarkers.length,
@@ -591,7 +580,6 @@ class _MapScreenState extends State<MapScreen> {
// Index known-location repeaters by their 1-byte hash.
// null value = two repeaters share the same hash byte (ambiguous collision).
final repeaterByHash = <int, Contact?>{};
for (final c in withLocation) {
if (c.type == advTypeRepeater) {
if (repeaterByHash.containsKey(c.publicKey[0])) {
@@ -607,11 +595,6 @@ class _MapScreenState extends State<MapScreen> {
for (final contact in allContacts) {
if (contact.hasLocation) continue;
if (contact.lastSeen.isBefore(
DateTime.now().subtract(const Duration(hours: 24)),
)) {
continue; // skip stale contacts
}
final anchorSet = <LatLng>{};
@@ -634,6 +617,19 @@ class _MapScreenState extends State<MapScreen> {
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
}
// Fallback: for any last-hop byte with no GPS repeater, average the
// positions of contacts with known GPS that share the same last hop.
// Those contacts are all adjacent to the same unknown repeater, so their
// centroid is a reasonable proxy for its location.
for (final byte in lastHopBytes) {
if (repeaterByHash.containsKey(byte)) continue;
for (final c in withLocation) {
if (c.path.isNotEmpty && c.path.last == byte) {
anchorSet.add(LatLng(c.latitude!, c.longitude!));
}
}
}
// Filter anchors that are geometrically inconsistent with radio range.
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
// range of the same node, so isolated outliers are removed.
@@ -645,12 +641,15 @@ class _MapScreenState extends State<MapScreen> {
final LatLng position;
if (anchors.length == 1) {
// Spread single-anchor guesses around the anchor so they remain visible.
position = _offsetGuessedPosition(
anchors[0],
contact,
radiusMeters: 330,
// Offset single-anchor guesses so they don't overlap the repeater marker.
// Use the contact's public key byte as a deterministic angle seed.
const offsetDeg = 0.003; // ~330 m at the equator
final angle = (contact.publicKey[1] / 255.0) * 2 * pi;
position = LatLng(
anchors[0].latitude + offsetDeg * cos(angle),
anchors[0].longitude + offsetDeg * sin(angle),
);
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
@@ -658,25 +657,12 @@ class _MapScreenState extends State<MapScreen> {
continue; // discard implausible guesses near (0, 0)
}
} else {
double lat = 0, lon = 0, weight = 1.0;
int counted = 0;
double lat = 0, lon = 0;
for (final a in anchors) {
if (counted == 0) {
lat = a.latitude;
lon = a.longitude;
} else {
lat += a.latitude * weight;
lon += a.longitude * weight;
}
// weight subsequent anchors less to create a bias towards the first (if more than 2)
weight = weight / 2;
counted++;
lat += a.latitude;
lon += a.longitude;
}
position = _offsetGuessedPosition(
LatLng(lat / anchors.length, lon / anchors.length),
contact,
radiusMeters: anchors.length >= 3 ? 80 : 120,
);
position = LatLng(lat / anchors.length, lon / anchors.length);
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
@@ -696,31 +682,6 @@ class _MapScreenState extends State<MapScreen> {
return result;
}
LatLng _offsetGuessedPosition(
LatLng anchor,
Contact contact, {
required double radiusMeters,
}) {
final seed = _guessSeed(contact.publicKey);
final angle = ((seed & 0xFFFF) / 0x10000) * 2 * pi;
final latOffsetDeg = (radiusMeters / 111320.0) * cos(angle);
final lonScale = max(cos(anchor.latitude * pi / 180.0).abs(), 0.2);
final lonOffsetDeg = (radiusMeters / (111320.0 * lonScale)) * sin(angle);
return LatLng(
anchor.latitude + latOffsetDeg,
anchor.longitude + lonOffsetDeg,
);
}
int _guessSeed(Uint8List publicKey) {
var seed = 0x811C9DC5;
for (final byte in publicKey) {
seed ^= byte;
seed = (seed * 0x01000193) & 0x7FFFFFFF;
}
return seed;
}
/// Estimates the free-space maximum LoRa range in km from the connected
/// device's current radio parameters. Returns null if parameters are unknown.
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
@@ -838,70 +799,31 @@ class _MapScreenState extends State<MapScreen> {
return markers;
}
List<Contact> _filterContactsBySettings(
List<Contact> contacts,
dynamic settings, {
bool noLocations = false,
}) {
List<Contact> filtered = [];
bool addContact = false;
for (final contact in contacts) {
addContact = false;
if (!contact.hasLocation && !noLocations) {
continue;
}
// Apply node type filters
if (contact.type == advTypeRepeater &&
(settings.mapShowRepeaters ||
_isBuildingPathTrace ||
settings.mapShowOverlaps)) {
addContact = true;
}
if (contact.type == advTypeChat &&
(settings.mapShowChatNodes || _isBuildingPathTrace)) {
addContact = true;
}
if (contact.type != advTypeChat &&
contact.type != advTypeRepeater &&
(settings.mapShowOtherNodes ||
_isBuildingPathTrace ||
settings.mapShowOverlaps)) {
addContact = true;
}
final hasOverlap = contacts
.where(
(c) =>
c.publicKeyHex != contact.publicKeyHex &&
c.publicKey.first == contact.publicKey.first &&
(c.type == advTypeRepeater || c.type == advTypeRoom) &&
(contact.type == advTypeRepeater ||
contact.type == advTypeRoom),
)
.firstOrNull;
if (hasOverlap == null &&
settings.mapShowOverlaps &&
!_isBuildingPathTrace) {
addContact = false;
}
if (addContact) {
filtered.add(contact);
}
}
return filtered;
}
List<Marker> _buildMarkers(
List<Contact> contacts,
settings, {
required bool showLabels,
}) {
final markers = <Marker>[];
final filteredContacts = _filterContactsBySettings(contacts, settings);
for (final contact in filteredContacts) {
for (final contact in contacts) {
if (!contact.hasLocation) continue;
// Apply node type filters
if (contact.type == advTypeRepeater &&
(!settings.mapShowRepeaters && !_isBuildingPathTrace)) {
continue;
}
if (contact.type == advTypeChat &&
!(settings.mapShowChatNodes && !_isBuildingPathTrace)) {
continue;
}
if (contact.type != advTypeChat &&
contact.type != advTypeRepeater &&
(!settings.mapShowOtherNodes && !_isBuildingPathTrace)) {
continue;
}
final marker = Marker(
point: LatLng(contact.latitude!, contact.longitude!),
width: 35,
@@ -917,9 +839,7 @@ class _MapScreenState extends State<MapScreen> {
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: settings.mapShowOverlaps && !_isBuildingPathTrace
? Colors.red
: _getNodeColor(contact.type),
color: _getNodeColor(contact.type),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
@@ -946,9 +866,7 @@ class _MapScreenState extends State<MapScreen> {
markers.add(
_buildNodeLabelMarker(
point: LatLng(contact.latitude!, contact.longitude!),
label: settings.mapShowOverlaps && !_isBuildingPathTrace
? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}"
: contact.name,
label: contact.name,
),
);
}
@@ -1023,25 +941,25 @@ class _MapScreenState extends State<MapScreen> {
}
Widget _buildLegend(
List<Contact> contacts,
List<Contact> contactsWithLocation,
settings,
int markerCount,
int guessedCount,
) {
final filteredContacts = _filterContactsBySettings(
contacts,
settings,
noLocations: false,
);
final filteredContactsAll = _filterContactsBySettings(
contacts,
settings,
noLocations: true,
);
final nodeCount = filteredContacts.length;
final nodeCountAll = filteredContactsAll.length;
int nodeCount = 0;
for (final contact in contactsWithLocation) {
// Apply node type filters
if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) {
continue;
}
if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue;
if (contact.type != advTypeChat &&
contact.type != advTypeRepeater &&
!settings.mapShowOtherNodes) {
continue;
}
nodeCount++;
}
return Positioned(
top: 16,
@@ -1077,54 +995,6 @@ class _MapScreenState extends State<MapScreen> {
fontSize: 14,
),
),
Row(
children: [
Icon(
Icons.location_on,
size: 16,
color: Colors.grey,
),
Text(
": $nodeCount",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
Row(
children: [
const Icon(
Icons.wrong_location,
size: 16,
color: Colors.grey,
),
Text(
": ${nodeCountAll - nodeCount}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
Row(
children: [
const Icon(
Icons.add_outlined,
size: 16,
color: Colors.grey,
),
Text(
": $nodeCountAll",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
Text(
context.l10n.map_pinsCount(markerCount),
style: const TextStyle(
@@ -1963,15 +1833,6 @@ class _MapScreenState extends State<MapScreen> {
},
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: Text(context.l10n.map_showOverlaps),
value: settings.mapShowOverlaps,
onChanged: (value) {
service.setMapShowOverlaps(value ?? true);
},
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
Text(
context.l10n.map_keyPrefix,
@@ -2130,13 +1991,12 @@ class _MapScreenState extends State<MapScreen> {
});
}
void _startPath(LatLng position) {
void _startPath() {
setState(() {
_isBuildingPathTrace = true;
_pathTrace.clear();
_points.clear();
_polylines.clear();
_points.add(position);
});
}
@@ -2182,14 +2042,14 @@ class _MapScreenState extends State<MapScreen> {
.join(','),
style: TextStyle(fontSize: 18),
),
// const SizedBox(height: 6),
const SizedBox(height: 6),
Wrap(
alignment: WrapAlignment.center,
spacing: 1,
runSpacing: 1,
spacing: 8,
runSpacing: 8,
children: [
if (_pathTrace.isNotEmpty)
IconButton(
ElevatedButton(
onPressed: () {
Navigator.push(
context,
@@ -2204,37 +2064,15 @@ class _MapScreenState extends State<MapScreen> {
_isBuildingPathTrace = false;
});
},
tooltip: l10n.map_runTrace,
icon: const Icon(Icons.arrow_forward_outlined),
child: Text(l10n.map_runTrace),
),
if (_pathTrace.isNotEmpty)
IconButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: l10n.contacts_pathTrace,
path: Uint8List.fromList(_pathTrace),
flipPathAround: true,
),
),
);
setState(() {
_isBuildingPathTrace = false;
});
},
tooltip: l10n.map_runTraceWithReturnPath,
icon: const Icon(Icons.replay),
),
if (_pathTrace.isNotEmpty)
IconButton(
ElevatedButton(
onPressed: _removePath,
tooltip: l10n.map_removeLast,
icon: const Icon(Icons.undo),
child: Text(l10n.map_removeLast),
),
if (_pathTrace.isEmpty)
IconButton(
ElevatedButton(
onPressed: () {
setState(() {
_isBuildingPathTrace = false;
@@ -2246,8 +2084,7 @@ class _MapScreenState extends State<MapScreen> {
SnackBar(content: Text(l10n.map_pathTraceCancelled)),
);
},
tooltip: l10n.common_cancel,
icon: const Icon(Icons.close),
child: Text(l10n.common_cancel),
),
],
),
+13 -7
View File
@@ -311,13 +311,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: Text("Delete All Paths"),
subtitle: Text(
"Clear all path data from contacts.",
style: TextStyle(color: Colors.red[700]),
),
onTap: () => connector.deleteAllPaths(),
leading: const Icon(Icons.cell_tower),
title: Text(l10n.settings_sendAdvertisement),
subtitle: Text(l10n.settings_sendAdvertisementSubtitle),
onTap: () => _sendAdvert(context, connector),
),
const Divider(height: 1),
ListTile(
@@ -660,6 +657,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.sendSelfAdvert(flood: true);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.settings_advertisementSent)));
}
void _syncTime(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.syncTime();
@@ -970,6 +975,7 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
value: advertLocPolicy,
onChanged: (value) {
setDialogState(() => advertLocPolicy = value);
advertLocPolicy = value;
},
),
const SizedBox(height: 8),
-28
View File
@@ -64,10 +64,6 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(mapShowOtherNodes: value));
}
Future<void> setMapShowOverlaps(bool value) async {
await updateSettings(_settings.copyWith(mapShowOverlaps: value));
}
Future<void> setMapTimeFilterHours(double value) async {
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
}
@@ -124,30 +120,6 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
}
Future<void> setMaxRouteWeight(double value) async {
await updateSettings(_settings.copyWith(maxRouteWeight: value));
}
Future<void> setInitialRouteWeight(double value) async {
await updateSettings(_settings.copyWith(initialRouteWeight: value));
}
Future<void> setRouteWeightSuccessIncrement(double value) async {
await updateSettings(
_settings.copyWith(routeWeightSuccessIncrement: value),
);
}
Future<void> setRouteWeightFailureDecrement(double value) async {
await updateSettings(
_settings.copyWith(routeWeightFailureDecrement: value),
);
}
Future<void> setMaxMessageRetries(int value) async {
await updateSettings(_settings.copyWith(maxMessageRetries: value));
}
Future<void> setThemeMode(String value) async {
await updateSettings(_settings.copyWith(themeMode: value));
}
+409 -243
View File
@@ -11,7 +11,7 @@ import 'app_debug_log_service.dart';
class _AckHistoryEntry {
final String messageId;
final List<int> ackHashes;
final List<Uint8List> ackHashes;
final DateTime timestamp;
_AckHistoryEntry({
@@ -21,84 +21,91 @@ class _AckHistoryEntry {
});
}
/// (messageId, timestamp, attemptIndex) stored per ACK hash for O(1) lookup.
typedef AckHashMapping = ({
String messageId,
DateTime timestamp,
int attemptIndex,
});
class _AckHashMapping {
final String messageId;
final DateTime timestamp;
class RetryServiceConfig {
final void Function(Contact, String, int, int) sendMessage;
final void Function(String, Message) addMessage;
final void Function(Message) updateMessage;
final Function(Contact)? clearContactPath;
final Function(Contact, Uint8List, int)? setContactPath;
final int Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeout;
final Uint8List? Function()? getSelfPublicKey;
final String Function(Contact, String)? prepareContactOutboundText;
final AppSettingsService? appSettingsService;
final AppDebugLogService? debugLogService;
final void Function(String, PathSelection, bool, int?)? recordPathResult;
final void Function(String, int, int, int)? onDeliveryObserved;
final PathSelection? Function(
String contactKey,
int attemptIndex,
int maxRetries,
List<PathSelection> recentSelections,
)?
selectRetryPath;
const RetryServiceConfig({
required this.sendMessage,
required this.addMessage,
required this.updateMessage,
this.clearContactPath,
this.setContactPath,
this.calculateTimeout,
this.getSelfPublicKey,
this.prepareContactOutboundText,
this.appSettingsService,
this.debugLogService,
this.recordPathResult,
this.onDeliveryObserved,
this.selectRetryPath,
});
_AckHashMapping({required this.messageId, required this.timestamp});
}
class MessageRetryService extends ChangeNotifier {
static const int maxRetries = 5;
static const int maxAckHistorySize = 100;
int _maxRetries = 5;
int get maxRetries => _maxRetries;
final Map<String, Timer> _timeoutTimers = {};
final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {};
final Map<String, List<PathSelection>> _attemptPathHistory = {};
final Map<String, AckHashMapping> _ackHashToMessageId = {};
final Map<String, List<int>> _expectedAckHashes = {};
final List<_AckHistoryEntry> _ackHistory = [];
final Map<String, List<String>> _sendQueue = {};
final Set<String> _activeMessages = {};
final Set<String> _resolvedMessages = {};
final Map<String, String> _expectedHashToMessageId = {};
final Map<String, PathSelection> _pendingPathSelections = {};
final Map<String, _AckHashMapping> _ackHashToMessageId =
{}; // ackHashHex messageId + timestamp for O(1) lookup
final Map<String, List<Uint8List>> _expectedAckHashes =
{}; // Track all expected ACKs for retries (for history)
final List<_AckHistoryEntry> _ackHistory =
[]; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, List<String>> _sendQueue =
{}; // contactPubKeyHex ordered list of messageIds awaiting send
final Set<String> _activeMessages =
{}; // messageIds currently in-flight (sent/retrying)
final Set<String> _resolvedMessages =
{}; // messageIds already resolved (prevents double _onMessageResolved)
final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
RetryServiceConfig? _config;
Function(Contact, String, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback;
Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback;
Function(Contact, Uint8List, int)? _setContactPathCallback;
Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
Uint8List? Function()? _getSelfPublicKeyCallback;
String Function(Contact, String)? _prepareContactOutboundTextCallback;
AppSettingsService? _appSettingsService;
AppDebugLogService? _debugLogService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
Function(String, int, int, int)? _onDeliveryObservedCallback;
MessageRetryService();
void initialize(RetryServiceConfig config) {
_config = config;
}
void setMaxRetries(int value) {
_maxRetries = value.clamp(2, 10);
void initialize({
required Function(Contact, String, int, int) sendMessageCallback,
required Function(String, Message) addMessageCallback,
required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeoutCallback,
Uint8List? Function()? getSelfPublicKeyCallback,
String Function(Contact, String)? prepareContactOutboundTextCallback,
AppSettingsService? appSettingsService,
AppDebugLogService? debugLogService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
Function(
String contactKey,
int pathLength,
int messageBytes,
int tripTimeMs,
)?
onDeliveryObservedCallback,
}) {
_sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback;
_updateMessageCallback = updateMessageCallback;
_clearContactPathCallback = clearContactPathCallback;
_setContactPathCallback = setContactPathCallback;
_calculateTimeoutCallback = calculateTimeoutCallback;
_getSelfPublicKeyCallback = getSelfPublicKeyCallback;
_prepareContactOutboundTextCallback = prepareContactOutboundTextCallback;
_appSettingsService = appSettingsService;
_debugLogService = debugLogService;
_recordPathResultCallback = recordPathResultCallback;
_onDeliveryObservedCallback = onDeliveryObservedCallback;
}
/// Compute expected ACK hash using same algorithm as firmware:
/// SHA256([timestamp(4)][attempt(1)][text][sender_pubkey(32)]) -> first 4 bytes
static int computeExpectedAckHash(
static Uint8List computeExpectedAckHash(
int timestampSeconds,
int attempt,
String text,
@@ -126,22 +133,23 @@ class MessageRetryService extends ChangeNotifier {
// Compute SHA256 and return first 4 bytes
final hash = sha256.convert(buffer);
final bytes = Uint8List.fromList(hash.bytes.sublist(0, 4));
return (bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0];
return Uint8List.fromList(hash.bytes.sublist(0, 4));
}
Future<void> sendMessageWithRetry({
required Contact contact,
required String text,
PathSelection? pathSelection,
Uint8List? pathBytes,
int? pathLength,
}) async {
final messageId = const Uuid().v4();
final resolved = resolvePathSelection(contact);
final useFlood = pathSelection?.useFlood ?? false;
final messagePathBytes =
pathBytes ?? Uint8List.fromList(resolved.pathBytes);
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
final messagePathLength =
pathLength ?? (resolved.useFlood ? -1 : resolved.hopCount);
pathLength ??
_resolveMessagePathLength(contact, useFlood, pathSelection);
final message = Message(
senderKey: contact.publicKey,
text: text,
@@ -156,8 +164,13 @@ class MessageRetryService extends ChangeNotifier {
_pendingMessages[messageId] = message;
_pendingContacts[messageId] = contact;
if (pathSelection != null) {
_pendingPathSelections[messageId] = pathSelection;
}
_config?.addMessage(contact.publicKeyHex, message);
if (_addMessageCallback != null) {
_addMessageCallback!(contact.publicKeyHex, message);
}
// Queue per contact only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
@@ -187,12 +200,13 @@ class MessageRetryService extends ChangeNotifier {
if (msg != null) {
final failed = msg.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failed;
_config?.updateMessage(failed);
_updateMessageCallback?.call(failed);
}
_onMessageResolved(messageId, contactKey);
});
return;
}
// Message was cancelled/cleaned up while queued try next
}
}
@@ -203,88 +217,33 @@ class MessageRetryService extends ChangeNotifier {
_sendNextForContact(contactKey);
}
PathSelection? _selectPathForAttempt(Message message, Contact contact) {
final config = _config;
if (config == null) return null;
final autoRotationEnabled =
config.appSettingsService?.settings.autoRouteRotationEnabled == true;
if (!autoRotationEnabled ||
contact.pathOverride != null ||
config.selectRetryPath == null) {
return null;
}
final recentSelections = List<PathSelection>.from(
_attemptPathHistory[message.messageId] ?? const <PathSelection>[],
);
return config.selectRetryPath!(
contact.publicKeyHex,
message.retryCount,
maxRetries,
recentSelections,
);
}
void _recordAttemptPathHistory(String messageId, PathSelection selection) {
if (selection.useFlood) return;
final history = _attemptPathHistory.putIfAbsent(messageId, () => []);
history.add(selection);
if (history.length > recentAttemptDiversityWindow) {
history.removeAt(0);
}
}
Future<void> _attemptSend(String messageId) async {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
final config = _config;
if (message == null || contact == null || config == null) return;
final currentSelection = _selectPathForAttempt(message, contact);
if (currentSelection != null) {
final updatedMessage = message.copyWith(
pathLength: currentSelection.useFlood ? -1 : currentSelection.hopCount,
pathBytes: currentSelection.useFlood
? Uint8List(0)
: Uint8List.fromList(currentSelection.pathBytes),
);
_pendingMessages[messageId] = updatedMessage;
} else if (message.retryCount > 0) {
// No schedule entry for this retry re-resolve path from current contact
// state so user's path override changes are picked up between retries.
final resolved = resolvePathSelection(contact);
final updatedMessage = message.copyWith(
pathLength: resolved.useFlood ? -1 : resolved.hopCount,
pathBytes: Uint8List.fromList(resolved.pathBytes),
);
_pendingMessages[messageId] = updatedMessage;
}
// Re-read after potential schedule update
final effectiveMessage = _pendingMessages[messageId] ?? message;
if (message == null || contact == null) return;
// Sync path settings with device before sending
if (config.setContactPath != null && config.clearContactPath != null) {
final bool useFlood = currentSelection != null
? currentSelection.useFlood
: (effectiveMessage.pathLength != null &&
effectiveMessage.pathLength! < 0);
final List<int> pathBytes = currentSelection != null
? currentSelection.pathBytes
: effectiveMessage.pathBytes;
final int hopCount = currentSelection != null
? currentSelection.hopCount
: (effectiveMessage.pathLength ?? 0);
if (useFlood) {
await config.clearContactPath!(contact);
} else if (effectiveMessage.pathLength != null) {
await config.setContactPath!(
// Use the path that was captured when the message was first sent
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) {
debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}',
);
await _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) {
final pathStr = message.pathBytes.isEmpty
? 'direct'
: message.pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(',');
debugPrint(
'Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}',
);
await _setContactPathCallback!(
contact,
Uint8List.fromList(pathBytes),
hopCount,
message.pathBytes,
message.pathLength!,
);
}
}
@@ -298,6 +257,8 @@ class MessageRetryService extends ChangeNotifier {
);
return;
}
// If the message was retried by a timer during our await, the retryCount
// will have advanced. Only proceed if it still matches the attempt we started.
if (currentMessage.retryCount != message.retryCount) {
debugPrint(
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
@@ -305,19 +266,15 @@ class MessageRetryService extends ChangeNotifier {
return;
}
if (currentSelection != null) {
_recordAttemptPathHistory(messageId, currentSelection);
}
final attempt = message.retryCount;
final attempt = message.retryCount.clamp(0, 3);
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
// Compute expected ACK hash that device will return in RESP_CODE_SENT
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
final selfPubKey = config.getSelfPublicKey?.call();
final selfPubKey = _getSelfPublicKeyCallback?.call();
if (selfPubKey != null) {
final outboundText =
config.prepareContactOutboundText?.call(contact, message.text) ??
_prepareContactOutboundTextCallback?.call(contact, message.text) ??
message.text;
final expectedHash = MessageRetryService.computeExpectedAckHash(
timestampSeconds,
@@ -325,28 +282,51 @@ class MessageRetryService extends ChangeNotifier {
outboundText,
selfPubKey,
);
final expectedHashHex = expectedHash.toRadixString(16).padLeft(8, '0');
final expectedHashHex = expectedHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
_expectedHashToMessageId[expectedHashHex] = messageId;
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
config.debugLogService?.info(
_debugLogService?.info(
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
tag: 'AckHash',
);
debugPrint(
'Computed expected ACK hash $expectedHashHex for message $messageId',
);
}
config.sendMessage(contact, message.text, attempt, timestampSeconds);
// DEPRECATED: Old queue-based matching (kept for fallback)
_pendingMessageQueuePerContact[contact.publicKeyHex] ??= [];
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
if (_sendMessageCallback != null) {
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
} else {
// No send callback message would be stuck forever. Fail it immediately.
debugPrint(
'_attemptSend: no sendMessageCallback, failing message $messageId',
);
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
_updateMessageCallback?.call(failedMessage);
_onMessageResolved(messageId, contact.publicKeyHex);
}
}
bool updateMessageFromSent(int ackHash, int timeoutMs) {
final config = _config;
if (config == null) return false;
bool updateMessageFromSent(
Uint8List ackHash,
int timeoutMs, {
bool allowQueueFallback = true,
}) {
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0');
// Try hash-based matching (fixes LoRa message drops causing mismatches)
// NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
Contact? contact;
@@ -358,50 +338,127 @@ class MessageRetryService extends ChangeNotifier {
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
config.debugLogService?.info(
_debugLogService?.info(
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
tag: 'AckHash',
);
debugPrint(
'Hash-based match: ACK hash $ackHashHex → message $messageId',
);
// Remove from old queue since we matched
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
} else {
config.debugLogService?.warn(
_debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex matched but message no longer pending',
tag: 'AckHash',
);
debugPrint('Hash matched $messageId but message no longer pending');
messageId = null;
contact = null;
}
}
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
// Only match within a single contact's queue to avoid cross-contact mismatches.
if (messageId == null && allowQueueFallback) {
_debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
tag: 'AckHash',
);
debugPrint(
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
);
// Search all contact queues so concurrent chats don't miss matches.
final queuesToSearch = _pendingMessageQueuePerContact;
for (var entry in queuesToSearch.entries) {
final contactKey = entry.key;
final queue = entry.value;
// Drain stale entries until we find a valid one or exhaust the queue.
while (queue.isNotEmpty) {
final candidateMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId];
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
);
break;
}
debugPrint('Dequeued stale message $candidateMessageId - skipping');
}
if (messageId != null) break;
}
}
if (messageId == null || contact == null) {
debugPrint('No pending message found for ACK hash: $ackHashHex');
return false;
}
final message = _pendingMessages[messageId]!;
_ackHashToMessageId[ackHashHex] = (
// Store the mapping for future lookups (e.g., when ACK arrives)
// Keep timestamp so we can clean up old mappings later
_ackHashToMessageId[ackHashHex] = _AckHashMapping(
messageId: messageId,
timestamp: DateTime.now(),
attemptIndex: message.retryCount,
);
debugPrint('Mapped ACK hash $ackHashHex to message $messageId');
final message = _pendingMessages[messageId];
final selection = _pendingPathSelections[messageId];
if (message == null) {
debugPrint(
'Message $messageId no longer pending for ACK hash: $ackHashHex',
);
_ackHashToMessageId.remove(ackHashHex);
return false;
}
// Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= [];
if (!_expectedAckHashes[messageId]!.any((hash) => hash == ackHash)) {
_expectedAckHashes[messageId]!.add(ackHash);
if (!_expectedAckHashes[messageId]!.any(
(hash) => listEquals(hash, ackHash),
)) {
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
debugPrint(
'Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})',
);
}
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
final pathLengthValue = message.pathLength ?? contact.pathLength;
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
int actualTimeout = timeoutMs;
if (config.calculateTimeout != null) {
final calculated = config.calculateTimeout!(
if (_calculateTimeoutCallback != null) {
final calculated = _calculateTimeoutCallback!(
pathLengthValue,
message.text.length,
contactKey: contact.publicKeyHex,
);
// calculateTimeout tries ML first, falls back to physics.
// Use calculated value if device didn't provide one, or if ML
// produced a tighter prediction than the device's estimate.
if (timeoutMs <= 0 || calculated < timeoutMs) {
actualTimeout = calculated;
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
}
}
@@ -413,26 +470,18 @@ class MessageRetryService extends ChangeNotifier {
);
_pendingMessages[messageId] = updatedMessage;
config.updateMessage(updatedMessage);
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
_startTimeoutTimer(messageId, actualTimeout);
debugPrint('Updated message $messageId with ACK hash: $ackHashHex');
return true;
}
bool get hasPendingMessages => _pendingMessages.isNotEmpty;
/// Update the stored contact snapshot for all pending messages to this contact.
/// Call this when the contact's pathOverride changes so retries use the new path.
void updatePendingContact(Contact contact) {
final keys = _pendingContacts.entries
.where((e) => e.value.publicKeyHex == contact.publicKeyHex)
.map((e) => e.key)
.toList();
for (final key in keys) {
_pendingContacts[key] = contact;
}
}
void _startTimeoutTimer(String messageId, int timeoutMs) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
@@ -440,24 +489,10 @@ class MessageRetryService extends ChangeNotifier {
});
}
void _cleanupMessage(String messageId) {
_moveAckHashesToHistory(messageId);
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_attemptPathHistory.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
}
void _handleTimeout(String messageId) {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
final config = _config;
final selection = message != null ? _selectionFromMessage(message) : null;
final selection = _pendingPathSelections[messageId];
if (message == null || contact == null) {
debugPrint(
@@ -469,40 +504,44 @@ class MessageRetryService extends ChangeNotifier {
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
config?.debugLogService?.warn(
_debugLogService?.warn(
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
tag: 'AckHash',
);
debugPrint(
'Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})',
);
if (message.retryCount < maxRetries - 1) {
final backoffMs = 1000 * (1 << message.retryCount);
if (selection != null) {
_recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
false,
null,
);
}
final updatedMessage = message.copyWith(
retryCount: message.retryCount + 1,
status: MessageStatus.pending,
// Keep expectedAckHash - it will be updated when the new attempt is sent
);
_pendingMessages[messageId] = updatedMessage;
config?.updateMessage(updatedMessage);
config?.debugLogService?.info(
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
_debugLogService?.info(
'Scheduling retry for "$shortText" to ${contact.name} after ${backoffMs}ms backoff',
tag: 'AckHash',
);
debugPrint('Scheduling retry after ${backoffMs}ms');
// Store the backoff timer so it can be canceled if new RESP_CODE_SENT arrives
_timeoutTimers[messageId] = Timer(Duration(milliseconds: backoffMs), () {
// Double-check message is still pending before retry
if (_pendingMessages.containsKey(messageId)) {
_attemptSend(messageId);
} else {
debugPrint(
'Retry cancelled: message $messageId was delivered while waiting',
);
}
});
} else {
@@ -510,9 +549,10 @@ class MessageRetryService extends ChangeNotifier {
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
if (config?.appSettingsService?.settings.clearPathOnMaxRetry == true &&
config?.clearContactPath != null) {
config!.clearContactPath!(contact);
// Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
_clearContactPathCallback != null) {
_clearContactPathCallback!(contact);
}
_recordPathResultFromMessage(
@@ -523,16 +563,34 @@ class MessageRetryService extends ChangeNotifier {
null,
);
config?.updateMessage(failedMessage);
if (_updateMessageCallback != null) {
_updateMessageCallback!(failedMessage);
}
notifyListeners();
// Message is done retrying send next queued message for this contact
_onMessageResolved(messageId, contact.publicKeyHex);
// Keep message in pending maps for 30s grace period so late ACKs
// can still match and update the message to delivered.
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
_cleanupMessage(messageId);
_moveAckHashesToHistory(messageId);
// Clean up ALL hash mappings for this message
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
final contactKey = contact.publicKeyHex;
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
_pendingMessageQueuePerContact.remove(contactKey);
}
});
}
}
@@ -548,16 +606,24 @@ class MessageRetryService extends ChangeNotifier {
),
);
// Trim history to max size (rolling buffer)
while (_ackHistory.length > maxAckHistorySize) {
_ackHistory.removeAt(0);
}
debugPrint(
'Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})',
);
}
}
bool _checkAckHistory(int ackHash) {
bool _checkAckHistory(Uint8List ackHash) {
for (final entry in _ackHistory) {
for (final expectedHash in entry.ackHashes) {
if (expectedHash == ackHash) {
if (listEquals(expectedHash, ackHash)) {
debugPrint(
'Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s',
);
return true;
}
}
@@ -565,13 +631,15 @@ class MessageRetryService extends ChangeNotifier {
return false;
}
void handleAckReceived(int ackHash, int tripTimeMs) {
final config = _config;
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
String? matchedMessageId;
int? matchedAttemptIndex;
final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0');
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// Clean up old ACK hash mappings (older than 15 minutes)
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
// First, clean up old ACK hash mappings (older than 15 minutes)
final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15));
final hashesToRemove = <String>[];
for (var entry in _ackHashToMessageId.entries) {
@@ -582,26 +650,34 @@ class MessageRetryService extends ChangeNotifier {
for (var hash in hashesToRemove) {
_ackHashToMessageId.remove(hash);
}
if (hashesToRemove.isNotEmpty) {
debugPrint('Cleaned up ${hashesToRemove.length} old ACK hash mappings');
}
// Use direct O(1) lookup via ACK hash mapping
final mapping = _ackHashToMessageId[ackHashHex];
if (mapping != null) {
matchedMessageId = mapping.messageId;
matchedAttemptIndex = mapping.attemptIndex;
debugPrint('Matched ACK to message via direct lookup: $matchedMessageId');
} else {
config?.debugLogService?.warn(
_debugLogService?.warn(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex not found in direct mapping, trying fallback',
tag: 'AckHash',
);
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
debugPrint(
'ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)',
);
for (var entry in _expectedAckHashes.entries) {
final messageId = entry.key;
final expectedHashes = entry.value;
for (final expectedHash in expectedHashes) {
if (expectedHash == ackHash) {
if (listEquals(expectedHash, ackHash)) {
matchedMessageId = messageId;
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
debugPrint(
'Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})',
);
break;
}
}
@@ -613,22 +689,27 @@ class MessageRetryService extends ChangeNotifier {
if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId];
if (message == null) {
// Message was already cleaned up (e.g. grace period expired)
_ackHashToMessageId.remove(ackHashHex);
debugPrint(
'ACK matched $matchedMessageId but message already cleaned up',
);
return;
}
final contact = _pendingContacts[matchedMessageId];
final ackedAttempt = matchedAttemptIndex ?? message.retryCount;
final selection = _selectionFromMessage(message);
final selection = _pendingPathSelections[matchedMessageId];
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
config?.debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} on retry ${ackedAttempt + 1} in ${tripTimeMs}ms',
_debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
tag: 'AckHash',
);
// Cancel any pending timeout or retry
_timeoutTimers[matchedMessageId]?.cancel();
_timeoutTimers.remove(matchedMessageId);
final deliveredMessage = message.copyWith(
status: MessageStatus.delivered,
@@ -636,9 +717,36 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs,
);
_cleanupMessage(matchedMessageId);
// Clean up ALL hash mappings for this message (from all retry attempts)
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == matchedMessageId,
);
_expectedHashToMessageId.removeWhere(
(_, msgId) => msgId == matchedMessageId,
);
config?.updateMessage(deliveredMessage);
// Move ACK hashes to history before removing
_moveAckHashesToHistory(matchedMessageId);
_pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId);
_resolvedMessages.remove(matchedMessageId);
// Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) {
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(
matchedMessageId,
);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
}
if (_updateMessageCallback != null) {
_updateMessageCallback!(deliveredMessage);
}
if (contact != null) {
_recordPathResultFromMessage(
@@ -648,10 +756,10 @@ class MessageRetryService extends ChangeNotifier {
true,
tripTimeMs,
);
if (config?.onDeliveryObserved != null &&
if (_onDeliveryObservedCallback != null &&
tripTimeMs > 0 &&
message.pathLength != null) {
config!.onDeliveryObserved!(
_onDeliveryObservedCallback!(
contact.publicKeyHex,
message.pathLength!,
message.text.length,
@@ -663,13 +771,15 @@ class MessageRetryService extends ChangeNotifier {
notifyListeners();
} else {
// Check ACK history for recently completed messages
if (_checkAckHistory(ackHash)) {
config?.debugLogService?.info(
_debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex matched a recently completed message (duplicate ACK)',
tag: 'AckHash',
);
debugPrint('ACK matched a recently completed message from history');
} else {
config?.debugLogService?.error(
_debugLogService?.error(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex has no matching message!',
tag: 'AckHash',
);
@@ -678,11 +788,62 @@ class MessageRetryService extends ChangeNotifier {
}
}
String? getContactKeyForAckHash(int ackHash) {
Uint8List _resolveMessagePathBytes(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
// Priority 1: Check user's path override
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) {
return Uint8List(0); // Force flood
}
return contact.pathOverrideBytes ?? Uint8List(0);
}
// Priority 2: Check forceFlood or device flood mode
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return Uint8List(0);
}
// Priority 3: Check PathSelection (auto-rotation)
if (selection != null && selection.pathBytes.isNotEmpty) {
return Uint8List.fromList(selection.pathBytes);
}
// Priority 4: Use device's discovered path
return contact.path;
}
int? _resolveMessagePathLength(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
// Priority 1: Check user's path override
if (contact.pathOverride != null) {
return contact.pathOverride;
}
// Priority 2: Check forceFlood or device flood mode
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return -1;
}
// Priority 3: Check PathSelection (auto-rotation)
if (selection != null && selection.pathBytes.isNotEmpty) {
return selection.hopCount;
}
// Priority 4: Use device's discovered path
return contact.pathLength;
}
String? getContactKeyForAckHash(Uint8List ackHash) {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.expectedAckHash != null &&
message.expectedAckHash == ackHash) {
listEquals(message.expectedAckHash, ackHash)) {
final contact = _pendingContacts[entry.key];
return contact?.publicKeyHex;
}
@@ -705,11 +866,15 @@ class MessageRetryService extends ChangeNotifier {
bool success,
int? tripTimeMs,
) {
final callback = _config?.recordPathResult;
if (callback == null) return;
if (_recordPathResultCallback == null) return;
final recordSelection = selection ?? _selectionFromMessage(message);
if (recordSelection == null) return;
callback(contactKey, recordSelection, success, tripTimeMs);
_recordPathResultCallback!(
contactKey,
recordSelection,
success,
tripTimeMs,
);
}
PathSelection? _selectionFromMessage(Message message) {
@@ -734,10 +899,11 @@ class MessageRetryService extends ChangeNotifier {
_timeoutTimers.clear();
_pendingMessages.clear();
_pendingContacts.clear();
_attemptPathHistory.clear();
_pendingPathSelections.clear();
_expectedAckHashes.clear();
_ackHistory.clear();
_ackHashToMessageId.clear();
_pendingMessageQueuePerContact.clear();
_sendQueue.clear();
_activeMessages.clear();
_resolvedMessages.clear();
+3 -18
View File
@@ -4,7 +4,6 @@ import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import '../helpers/reaction_helper.dart';
import '../l10n/app_localizations.dart';
import '../utils/platform_info.dart';
@@ -146,19 +145,6 @@ class NotificationService {
return true;
}
/// Format special message types for human-readable notifications.
static String formatNotificationText(String text) {
final trimmed = text.trim();
final reaction = ReactionHelper.parseReaction(trimmed);
if (reaction != null) {
return 'Reacted ${reaction.emoji}';
}
if (RegExp(r'^g:[A-Za-z0-9_-]+$').hasMatch(trimmed)) {
return 'Sent a GIF';
}
return text;
}
Future<void> _showMessageNotificationImpl({
required String contactName,
required String message,
@@ -201,7 +187,7 @@ class NotificationService {
await _notifications.show(
id: contactId?.hashCode ?? 0,
title: contactName,
body: formatNotificationText(message),
body: message,
notificationDetails: notificationDetails,
payload: 'message:$contactId',
);
@@ -297,7 +283,7 @@ class NotificationService {
macOS: macDetails,
);
final preview = formatNotificationText(message.trim());
final preview = message.trim();
final body = preview.isEmpty
? _l10n.notification_receivedNewMessage
: preview;
@@ -444,7 +430,6 @@ class NotificationService {
Future<void> showChannelMessageNotification({
required String channelName,
required String senderName,
required String message,
int? channelIndex,
int? badgeCount,
@@ -455,7 +440,7 @@ class NotificationService {
_PendingNotification(
type: _NotificationType.channelMessage,
title: channelName,
body: '$senderName: $message',
body: message,
id: channelIndex?.toString(),
badgeCount: badgeCount,
),
+60 -290
View File
@@ -9,8 +9,6 @@ class PathHistoryService extends ChangeNotifier {
final Map<String, ContactPathHistory> _cache = {};
final Map<String, int> _autoRotationIndex = {};
final Map<String, _FloodStats> _floodStats = {};
final Set<String> _pendingLoads = {};
final Map<String, List<_DeferredPathRecord>> _deferredRecords = {};
// LRU cache eviction tracking
static const int _maxCachedContacts = 50;
@@ -20,6 +18,7 @@ class PathHistoryService extends ChangeNotifier {
int _version = 0;
int get version => _version;
static const int _autoRotationTopCount = 3;
PathHistoryService(this._storage);
@@ -27,21 +26,17 @@ class PathHistoryService extends ChangeNotifier {
// Load cached path histories on startup if needed
}
void handlePathUpdated(Contact contact, {double initialWeight = 1.0}) {
if (contact.pathLength < 0 && contact.path.isEmpty) return;
final hopCount = contact.pathLength < 0
? contact.path.length
: contact.pathLength;
void handlePathUpdated(Contact contact) {
if (contact.pathLength < 0) return;
_addPathRecord(
contactPubKeyHex: contact.publicKeyHex,
hopCount: hopCount,
hopCount: contact.pathLength,
tripTimeMs: 0,
wasFloodDiscovery: true,
pathBytes: contact.path,
successCount: 0,
failureCount: 0,
routeWeight: initialWeight,
timestamp: null,
);
}
@@ -59,44 +54,6 @@ class PathHistoryService extends ChangeNotifier {
pathBytes: selection.pathBytes,
successCount: 0,
failureCount: 0,
timestamp: null,
);
}
/// When a flood message is delivered, credit the contact's current device
/// path so that the route the ACK traveled back through gets a weight boost.
void recordFloodPathAttribution({
required String contactPubKeyHex,
required List<int> pathBytes,
required int hopCount,
int? tripTimeMs,
double successIncrement = 0.5,
double maxWeight = 5.0,
}) {
if (pathBytes.isEmpty || hopCount < 0) return;
final existing = _findPathRecord(contactPubKeyHex, pathBytes);
final successCount = (existing?.successCount ?? 0) + 1;
final failureCount = existing?.failureCount ?? 0;
final currentWeight = existing?.routeWeight ?? 1.0;
final newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
debugPrint(
'Flood path attribution: crediting path [${pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',')}] '
'for $contactPubKeyHex (weight $currentWeight$newWeight)',
);
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: hopCount,
tripTimeMs: tripTimeMs ?? existing?.tripTimeMs ?? 0,
wasFloodDiscovery: true,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: newWeight,
timestamp: DateTime.now(),
);
}
@@ -105,9 +62,6 @@ class PathHistoryService extends ChangeNotifier {
PathSelection selection, {
required bool success,
int? tripTimeMs,
double successIncrement = 0.5,
double failureDecrement = 0.5,
double maxWeight = 5.0,
}) {
if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(
@@ -128,18 +82,6 @@ class PathHistoryService extends ChangeNotifier {
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
final currentWeight = existing?.routeWeight ?? 1.0;
double newWeight;
if (success) {
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
} else {
newWeight = currentWeight - failureDecrement;
if (newWeight <= 0) {
removePathRecord(contactPubKeyHex, selection.pathBytes);
return;
}
}
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: selection.hopCount,
@@ -148,68 +90,37 @@ class PathHistoryService extends ChangeNotifier {
pathBytes: selection.pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: newWeight,
timestamp: success ? DateTime.now() : existing?.timestamp,
);
}
PathSelection selectPathForAttempt(
String contactPubKeyHex, {
required int attemptIndex,
required int maxRetries,
List<PathSelection> recentSelections = const [],
}) {
if (maxRetries <= 0 || attemptIndex >= maxRetries - 1) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
final ranked = _getRankedPaths(contactPubKeyHex);
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(
contactPubKeyHex,
).take(_autoRotationTopCount).toList();
if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
_trackAccess(contactPubKeyHex);
final recentPaths = recentSelections
.where((selection) => !selection.useFlood)
.map((selection) => selection.pathBytes)
.toList();
final candidates = recentPaths.isEmpty
? ranked
: ranked
.where(
(path) => !recentPaths.any(
(recentPath) => _pathsEqual(path.pathBytes, recentPath),
),
)
.toList();
final selected = candidates.isNotEmpty
? (recentPaths.isEmpty
? _selectRotatedCandidate(contactPubKeyHex, candidates)
: candidates.first)
: ranked.first;
return PathSelection(
pathBytes: selected.pathBytes,
hopCount: selected.hopCount,
useFlood: false,
);
}
PathRecord _selectRotatedCandidate(
String contactPubKeyHex,
List<PathRecord> candidates,
) {
if (candidates.length <= 1) {
_autoRotationIndex[contactPubKeyHex] = 0;
return candidates.first;
}
final selections =
ranked
.map(
(path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
),
)
.toList()
..add(
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
);
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selectedIndex = currentIndex % candidates.length;
_autoRotationIndex[contactPubKeyHex] =
(selectedIndex + 1) % candidates.length;
return candidates[selectedIndex];
final selection = selections[currentIndex % selections.length];
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
return selection;
}
void _addPathRecord({
@@ -220,68 +131,37 @@ class PathHistoryService extends ChangeNotifier {
required List<int> pathBytes,
required int successCount,
required int failureCount,
double routeWeight = 1.0,
DateTime? timestamp,
}) {
var history = _cache[contactPubKeyHex];
if (history == null) {
// If a load is already in progress, defer this record
if (_pendingLoads.contains(contactPubKeyHex)) {
_deferredRecords.putIfAbsent(contactPubKeyHex, () => []);
_deferredRecords[contactPubKeyHex]!.add(
_DeferredPathRecord(
hopCount: hopCount,
tripTimeMs: tripTimeMs,
wasFloodDiscovery: wasFloodDiscovery,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: routeWeight,
timestamp: timestamp,
),
);
return;
}
_pendingLoads.add(contactPubKeyHex);
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
_cache[contactPubKeyHex] =
loaded ??
ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: [],
);
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
routeWeight,
timestamp,
);
// Apply any deferred records
final deferred = _deferredRecords.remove(contactPubKeyHex);
if (deferred != null) {
for (final record in deferred) {
_addPathRecordInternal(
contactPubKeyHex,
record.hopCount,
record.tripTimeMs,
record.wasFloodDiscovery,
record.pathBytes,
record.successCount,
record.failureCount,
record.routeWeight,
record.timestamp,
);
}
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
} else {
_cache[contactPubKeyHex] = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: [],
);
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
}
_pendingLoads.remove(contactPubKeyHex);
});
return;
}
@@ -294,8 +174,6 @@ class PathHistoryService extends ChangeNotifier {
pathBytes,
successCount,
failureCount,
routeWeight,
timestamp,
);
}
@@ -307,8 +185,6 @@ class PathHistoryService extends ChangeNotifier {
List<int> pathBytes,
int successCount,
int failureCount,
double routeWeight,
DateTime? timestamp,
) {
var history = _cache[contactPubKeyHex];
if (history == null) return;
@@ -322,18 +198,16 @@ class PathHistoryService extends ChangeNotifier {
tripTimeMs = existing.tripTimeMs;
}
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
timestamp ??= existing.timestamp;
}
final newRecord = PathRecord(
hopCount: hopCount,
tripTimeMs: tripTimeMs,
timestamp: timestamp,
timestamp: DateTime.now(),
wasFloodDiscovery: wasFloodDiscovery,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: routeWeight,
);
final updatedPaths = List<PathRecord>.from(history.recentPaths);
@@ -401,23 +275,6 @@ class PathHistoryService extends ChangeNotifier {
return history?.mostRecent;
}
({
int successCount,
int failureCount,
int lastTripTimeMs,
DateTime? lastUsed,
})?
getFloodStats(String contactPubKeyHex) {
final stats = _floodStats[contactPubKeyHex];
if (stats == null) return null;
return (
successCount: stats.successCount,
failureCount: stats.failureCount,
lastTripTimeMs: stats.lastTripTimeMs,
lastUsed: stats.lastUsed,
);
}
Future<void> clearPathHistory(String contactPubKeyHex) async {
_cache.remove(contactPubKeyHex);
_cacheAccessOrder.remove(contactPubKeyHex);
@@ -465,81 +322,26 @@ class PathHistoryService extends ChangeNotifier {
final ranked = List<PathRecord>.from(history.recentPaths)
..removeWhere((p) => p.pathBytes.isEmpty);
final fastestTripMs = _getFastestKnownTripMs(ranked);
final highestRouteWeight = _getHighestKnownRouteWeight(ranked);
ranked.sort((a, b) {
final scoreCompare =
_scorePathRecord(
b,
fastestTripMs: fastestTripMs,
highestRouteWeight: highestRouteWeight,
).compareTo(
_scorePathRecord(
a,
fastestTripMs: fastestTripMs,
highestRouteWeight: highestRouteWeight,
),
);
if (scoreCompare != 0) {
return scoreCompare;
}
if (a.routeWeight != b.routeWeight) {
return b.routeWeight.compareTo(a.routeWeight);
final aRate =
(a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate =
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
if (aRate != bRate) return bRate.compareTo(aRate);
if (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount);
}
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
final aTime = a.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
final bTime = b.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
return bTime.compareTo(aTime);
return b.timestamp.compareTo(a.timestamp);
});
return ranked;
}
int? _getFastestKnownTripMs(List<PathRecord> paths) {
final knownTrips = paths
.where((path) => path.tripTimeMs > 0)
.map((path) => path.tripTimeMs)
.toList();
if (knownTrips.isEmpty) return null;
return knownTrips.reduce((a, b) => a < b ? a : b);
}
double _getHighestKnownRouteWeight(List<PathRecord> paths) {
if (paths.isEmpty) return 1.0;
final highestWeight = paths
.map((path) => path.routeWeight)
.reduce((a, b) => a > b ? a : b);
return highestWeight <= 0 ? 1.0 : highestWeight;
}
double _scorePathRecord(
PathRecord path, {
required int? fastestTripMs,
required double highestRouteWeight,
}) {
final totalAttempts = path.successCount + path.failureCount;
final reliability = (path.successCount + 1) / (totalAttempts + 2);
final latency = fastestTripMs == null || path.tripTimeMs <= 0
? 0.6
: (fastestTripMs / path.tripTimeMs).clamp(0.0, 1.0);
final freshness = path.timestamp == null
? 0.0
: 1.0 /
(1.0 +
(DateTime.now().difference(path.timestamp!).inMinutes /
60.0 /
24.0));
final routeWeight = (path.routeWeight / highestRouteWeight).clamp(0.0, 1.0);
return (reliability * 0.45) +
(latency * 0.25) +
(freshness * 0.1) +
(routeWeight * 0.2);
}
bool _pathsEqual(List<int> a, List<int> b) {
return listEquals(a, b);
}
@@ -565,38 +367,6 @@ class PathHistoryService extends ChangeNotifier {
_floodStats.remove(oldest);
}
}
void clearAllHistories() {
_cache.clear();
_cacheAccessOrder.clear();
_autoRotationIndex.clear();
_floodStats.clear();
_storage.clearAllPathHistories();
_version = 0;
notifyListeners();
}
}
class _DeferredPathRecord {
final int hopCount;
final int tripTimeMs;
final bool wasFloodDiscovery;
final List<int> pathBytes;
final int successCount;
final int failureCount;
final double routeWeight;
final DateTime? timestamp;
_DeferredPathRecord({
required this.hopCount,
required this.tripTimeMs,
required this.wasFloodDiscovery,
required this.pathBytes,
required this.successCount,
required this.failureCount,
this.routeWeight = 1.0,
this.timestamp,
});
}
class _FloodStats {
-2
View File
@@ -108,7 +108,6 @@ class ChannelMessageStore {
'pathVariants': msg.pathVariants.map(base64Encode).toList(),
'repeats': msg.repeats.map(_repeatToJson).toList(),
'messageId': msg.messageId,
'packetHash': msg.packetHash,
'replyToMessageId': msg.replyToMessageId,
'replyToSenderName': msg.replyToSenderName,
'replyToText': msg.replyToText,
@@ -144,7 +143,6 @@ class ChannelMessageStore {
const [],
channelIndex: json['channelIndex'] as int?,
messageId: json['messageId'] as String?,
packetHash: json['packetHash'] as String?,
replyToMessageId: json['replyToMessageId'] as String?,
replyToSenderName: json['replyToSenderName'] as String?,
replyToText: json['replyToText'] as String?,
+6 -10
View File
@@ -85,7 +85,9 @@ class MessageStore {
'messageId': msg.messageId,
'retryCount': msg.retryCount,
'estimatedTimeoutMs': msg.estimatedTimeoutMs,
'expectedAckHash': msg.expectedAckHash,
'expectedAckHash': msg.expectedAckHash != null
? base64Encode(msg.expectedAckHash!)
: null,
'sentAt': msg.sentAt?.millisecondsSinceEpoch,
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
'tripTimeMs': msg.tripTimeMs,
@@ -94,9 +96,6 @@ class MessageStore {
? base64Encode(msg.pathBytes)
: null,
'reactions': msg.reactions,
'reactionStatuses': msg.reactionStatuses.map(
(key, value) => MapEntry(key, value.index),
),
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
};
}
@@ -117,7 +116,9 @@ class MessageStore {
messageId: json['messageId'] as String?,
retryCount: json['retryCount'] as int? ?? 0,
estimatedTimeoutMs: json['estimatedTimeoutMs'] as int?,
expectedAckHash: json['expectedAckHash'] as int? ?? 0,
expectedAckHash: json['expectedAckHash'] != null
? Uint8List.fromList(base64Decode(json['expectedAckHash'] as String))
: null,
sentAt: json['sentAt'] != null
? DateTime.fromMillisecondsSinceEpoch(json['sentAt'] as int)
: null,
@@ -134,11 +135,6 @@ class MessageStore {
(key, value) => MapEntry(key, value as int),
) ??
{},
reactionStatuses:
(json['reactionStatuses'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, MessageStatus.values[value as int]),
) ??
{},
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
? Uint8List.fromList(
base64Decode(json['fourByteRoomContactKey'] as String),
+8 -61
View File
@@ -9,7 +9,6 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../helpers/path_helper.dart';
import '../services/path_history_service.dart';
import 'path_selection_dialog.dart';
@@ -52,8 +51,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
return connector.contacts[_resolveContactIndex];
}
String _formatRelativeTime(BuildContext context, DateTime? time) {
if (time == null) return '';
String _formatRelativeTime(BuildContext context, DateTime time) {
final l10n = context.l10n;
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return l10n.time_justNow;
@@ -74,31 +72,15 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
return;
}
final connector = context.read<MeshCoreConnector>();
final allContacts = connector.allContacts;
final formattedPath = PathHelper.formatPathHex(pathBytes);
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
final formattedPath = pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.chat_fullPath),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(formattedPath),
const SizedBox(height: 8),
SelectableText(
resolvedNames,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
content: SelectableText(formattedPath),
actions: [
TextButton(
onPressed: () => Navigator.push(
@@ -291,17 +273,16 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
radius: 16,
backgroundColor: color,
child: Text(
path.routeWeight.toStringAsFixed(1),
style: const TextStyle(fontSize: 10),
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
),
),
title: Text(
l10n.chat_hopsCount(path.hopCount),
style: const TextStyle(fontSize: 14),
),
isThreeLine: true,
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}\n${path.successCount} ${l10n.chat_successes} • Score: ${path.routeWeight.toStringAsFixed(1)}',
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}${path.successCount} ${l10n.chat_successes}',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
@@ -376,40 +357,6 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
Text(l10n.chat_noPathHistoryYet),
const Divider(),
],
// Flood delivery stats
Builder(
builder: (context) {
final floodStats = pathService.getFloodStats(
currentContact.publicKeyHex,
);
if (floodStats == null ||
(floodStats.successCount == 0 &&
floodStats.failureCount == 0)) {
return const SizedBox.shrink();
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: const Text(
'Flood Mode',
style: TextStyle(fontSize: 14),
),
subtitle: Text(
'${floodStats.successCount} ${l10n.chat_successes} / ${floodStats.failureCount} failures'
'${floodStats.lastTripTimeMs > 0 ? '${(floodStats.lastTripTimeMs / 1000).toStringAsFixed(2)}s' : ''}'
'${floodStats.lastUsed != null ? '${_formatRelativeTime(context, floodStats.lastUsed!)}' : ''}',
style: const TextStyle(fontSize: 11),
),
),
);
},
),
const SizedBox(height: 8),
Text(
l10n.chat_pathActions,
@@ -9,7 +9,6 @@ import flutter_blue_plus_darwin
import flutter_local_notifications
import mobile_scanner
import package_info_plus
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import sqflite_darwin
@@ -21,7 +20,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
-36
View File
@@ -1,36 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/helpers/path_helper.dart';
import 'package:meshcore_open/models/contact.dart';
Contact _contact({
required int firstByte,
required String name,
required int type,
}) {
final key = Uint8List(32)..[0] = firstByte;
return Contact(
publicKey: key,
name: name,
type: type,
pathLength: 0,
path: Uint8List(0),
lastSeen: DateTime.now(),
);
}
void main() {
test('resolvePathNames ignores chat nodes and keeps repeater/room nodes', () {
final contacts = [
_contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat),
_contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater),
_contact(firstByte: 0xBA, name: 'USS Ronald Reagan', type: advTypeRoom),
];
final resolved = PathHelper.resolvePathNames([0xF2, 0x7E, 0xBA], contacts);
expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan'));
});
}
-361
View File
@@ -1,361 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/models/path_history.dart';
import 'package:meshcore_open/models/app_settings.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
// Builds a valid contact frame with the given pathLen and optional overrides.
// Frame layout: [respCode(1)][pubKey(32)][type(1)][flags(1)][pathLen(1)][path(64)][name(32)][timestamp(4)][lat(4)][lon(4)]
Uint8List _buildContactFrame({
int pathLen = 0,
Uint8List? pubKey,
String name = 'TestNode',
}) {
final writer = BytesBuilder();
writer.addByte(respCodeContact); // 3
writer.add(
pubKey ?? Uint8List.fromList(List.generate(32, (i) => i + 1)),
); // valid pubkey
writer.addByte(1); // type
writer.addByte(0); // flags
writer.addByte(pathLen);
writer.add(Uint8List(64)); // path bytes (zeros)
// name (32 bytes, null-padded)
final nameBytes = Uint8List(32);
final encoded = name.codeUnits;
for (var i = 0; i < encoded.length && i < 31; i++) {
nameBytes[i] = encoded[i];
}
writer.add(nameBytes);
// timestamp (4 bytes LE) - some nonzero value
writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00]));
// lat, lon (4 bytes each)
writer.add(Uint8List(4)); // lat
writer.add(Uint8List(4)); // lon
return Uint8List.fromList(writer.toBytes());
}
void main() {
group('Contact.fromFrame — pathLen mapping', () {
test('pathLen == 0 → pathLength == 0 (direct, NOT flood)', () {
final frame = _buildContactFrame(pathLen: 0);
final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
expect(contact!.pathLength, equals(0));
});
test('pathLen == 1 → pathLength == 1', () {
final frame = _buildContactFrame(pathLen: 1);
final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
expect(contact!.pathLength, equals(1));
});
test('pathLen == 64 (maxPathSize) → pathLength == 64', () {
final frame = _buildContactFrame(pathLen: maxPathSize);
final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
expect(contact!.pathLength, equals(maxPathSize));
});
test('pathLen == 0xFF → pathLength == -1 (flood)', () {
final frame = _buildContactFrame(pathLen: 0xFF);
final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
expect(contact!.pathLength, equals(-1));
});
test('pathLen == 65 (over maxPathSize) → pathLength == -1 (flood)', () {
final frame = _buildContactFrame(pathLen: 65);
final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
expect(contact!.pathLength, equals(-1));
});
});
group('Contact.fromFrame — corrupt contact guards', () {
test('all-zero public key → returns null', () {
final zeroPubKey = Uint8List(32); // all zeros
final frame = _buildContactFrame(pubKey: zeroPubKey);
final contact = Contact.fromFrame(frame);
expect(contact, isNull);
});
test('mostly-zero public key (>16 zeros out of 32) → returns null', () {
// 17 zeros out of 32 bytes exceeds pubKeySize ~/ 2 == 16
final pubKey = Uint8List(32);
pubKey[0] = 0xAB;
pubKey[1] = 0xCD;
pubKey[2] = 0xEF;
pubKey[3] = 0x12;
pubKey[4] = 0x34;
pubKey[5] = 0x56;
pubKey[6] = 0x78;
pubKey[7] = 0x9A;
pubKey[8] = 0xBC;
pubKey[9] = 0xDE;
pubKey[10] = 0xF0;
pubKey[11] = 0x11;
pubKey[12] = 0x22;
pubKey[13] = 0x33;
pubKey[14] = 0x44;
// bytes 1531 are zero: that is 17 zeros (indices 15..31 inclusive)
final frame = _buildContactFrame(pubKey: pubKey);
final contact = Contact.fromFrame(frame);
expect(contact, isNull);
});
test('valid public key (few zeros) → returns Contact', () {
// Only 1 zero well below the threshold
final pubKey = Uint8List.fromList(List.generate(32, (i) => i + 1));
pubKey[5] = 0; // one zero byte
final frame = _buildContactFrame(pubKey: pubKey);
final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
});
test('name with all non-printable characters → returns null', () {
// Build frame with a name composed entirely of control characters (< 0x20)
final nameBytes = Uint8List(32);
nameBytes[0] = 0x01;
nameBytes[1] = 0x02;
nameBytes[2] = 0x03;
// remaining are 0x00 (null terminator ends the string after index 2,
// so readCStringGreedy returns a 3-char string of non-printables)
final writer = BytesBuilder();
writer.addByte(respCodeContact);
writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1)));
writer.addByte(1); // type
writer.addByte(0); // flags
writer.addByte(0); // pathLen
writer.add(Uint8List(64)); // path
writer.add(nameBytes);
writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp
writer.add(Uint8List(4)); // lat
writer.add(Uint8List(4)); // lon
final frame = Uint8List.fromList(writer.toBytes());
final contact = Contact.fromFrame(frame);
expect(contact, isNull);
});
test('name with valid printable characters → returns Contact', () {
final frame = _buildContactFrame(name: 'Alice');
final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
expect(contact!.name, equals('Alice'));
});
test(
'name with mix of printable and replacement chars → returns Contact (not all bad)',
() {
// Build a name with mostly printable chars and one replacement char (0xFFFD in codeUnits).
// utf8 allowMalformed: true maps invalid sequences to U+FFFD.
// We embed one invalid UTF-8 byte (0x80) among valid ASCII bytes.
// The decoded string will be "Hi\uFFFDThere" not ALL bad, so should be accepted.
final nameBytes = Uint8List(32);
nameBytes[0] = 0x48; // 'H'
nameBytes[1] = 0x69; // 'i'
nameBytes[2] = 0x80; // invalid UTF-8 decoded as U+FFFD
nameBytes[3] = 0x54; // 'T'
nameBytes[4] = 0x68; // 'h'
nameBytes[5] = 0x65; // 'e'
nameBytes[6] = 0x72; // 'r'
nameBytes[7] = 0x65; // 'e'
// rest are 0x00 (null terminator)
final writer = BytesBuilder();
writer.addByte(respCodeContact);
writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1)));
writer.addByte(1); // type
writer.addByte(0); // flags
writer.addByte(0); // pathLen
writer.add(Uint8List(64)); // path
writer.add(nameBytes);
writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp
writer.add(Uint8List(4)); // lat
writer.add(Uint8List(4)); // lon
final frame = Uint8List.fromList(writer.toBytes());
final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
},
);
});
group('PathRecord — routeWeight field', () {
test('default routeWeight is 1.0', () {
final record = PathRecord(
hopCount: 2,
tripTimeMs: 500,
timestamp: DateTime(2024),
wasFloodDiscovery: false,
pathBytes: [0x01, 0x02],
successCount: 1,
failureCount: 0,
);
expect(record.routeWeight, equals(1.0));
});
test('custom routeWeight is preserved', () {
final record = PathRecord(
hopCount: 3,
tripTimeMs: 800,
timestamp: DateTime(2024),
wasFloodDiscovery: false,
pathBytes: [0x01],
successCount: 5,
failureCount: 2,
routeWeight: 3.5,
);
expect(record.routeWeight, equals(3.5));
});
test('toJson includes route_weight', () {
final record = PathRecord(
hopCount: 1,
tripTimeMs: 200,
timestamp: DateTime(2024),
wasFloodDiscovery: true,
pathBytes: [],
successCount: 0,
failureCount: 0,
routeWeight: 2.25,
);
final json = record.toJson();
expect(json.containsKey('route_weight'), isTrue);
expect(json['route_weight'], equals(2.25));
});
test('fromJson reads route_weight', () {
final json = {
'hop_count': 2,
'trip_time_ms': 400,
'timestamp': DateTime(2024).toIso8601String(),
'was_flood': false,
'path_bytes': [1, 2, 3],
'success_count': 3,
'failure_count': 1,
'route_weight': 4.0,
};
final record = PathRecord.fromJson(json);
expect(record.routeWeight, equals(4.0));
});
test(
'fromJson with missing route_weight defaults to 1.0 (backward compat)',
() {
final json = {
'hop_count': 1,
'trip_time_ms': 100,
'timestamp': DateTime(2024).toIso8601String(),
'was_flood': false,
'path_bytes': [],
'success_count': 0,
'failure_count': 0,
// 'route_weight' intentionally omitted
};
final record = PathRecord.fromJson(json);
expect(record.routeWeight, equals(1.0));
},
);
});
group('AppSettings — new fields', () {
test('default values are correct', () {
final settings = AppSettings();
expect(settings.maxRouteWeight, equals(5.0));
expect(settings.initialRouteWeight, equals(3.0));
expect(settings.routeWeightSuccessIncrement, equals(0.5));
expect(settings.routeWeightFailureDecrement, equals(0.2));
expect(settings.maxMessageRetries, equals(5));
});
test('toJson includes all new fields', () {
final settings = AppSettings();
final json = settings.toJson();
expect(json.containsKey('max_route_weight'), isTrue);
expect(json.containsKey('initial_route_weight'), isTrue);
expect(json.containsKey('route_weight_success_increment'), isTrue);
expect(json.containsKey('route_weight_failure_decrement'), isTrue);
expect(json.containsKey('max_message_retries'), isTrue);
expect(json['max_route_weight'], equals(5.0));
expect(json['initial_route_weight'], equals(3.0));
expect(json['route_weight_success_increment'], equals(0.5));
expect(json['route_weight_failure_decrement'], equals(0.2));
expect(json['max_message_retries'], equals(5));
});
test('fromJson reads all new fields', () {
final json = {
'max_route_weight': 10.0,
'initial_route_weight': 2.0,
'route_weight_success_increment': 1.0,
'route_weight_failure_decrement': 1.5,
'max_message_retries': 8,
};
final settings = AppSettings.fromJson(json);
expect(settings.maxRouteWeight, equals(10.0));
expect(settings.initialRouteWeight, equals(2.0));
expect(settings.routeWeightSuccessIncrement, equals(1.0));
expect(settings.routeWeightFailureDecrement, equals(1.5));
expect(settings.maxMessageRetries, equals(8));
});
test(
'fromJson with missing new fields uses defaults (backward compat)',
() {
// Simulate an old settings JSON with none of the new fields
final json = <String, dynamic>{};
final settings = AppSettings.fromJson(json);
expect(settings.maxRouteWeight, equals(5.0));
expect(settings.initialRouteWeight, equals(3.0));
expect(settings.routeWeightSuccessIncrement, equals(0.5));
expect(settings.routeWeightFailureDecrement, equals(0.2));
expect(settings.maxMessageRetries, equals(5));
},
);
test('copyWith works for maxRouteWeight', () {
final settings = AppSettings();
final updated = settings.copyWith(maxRouteWeight: 8.0);
expect(updated.maxRouteWeight, equals(8.0));
// Other fields should be unchanged
expect(updated.initialRouteWeight, equals(settings.initialRouteWeight));
expect(updated.maxMessageRetries, equals(settings.maxMessageRetries));
});
test('copyWith works for initialRouteWeight', () {
final settings = AppSettings();
final updated = settings.copyWith(initialRouteWeight: 3.0);
expect(updated.initialRouteWeight, equals(3.0));
expect(updated.maxRouteWeight, equals(settings.maxRouteWeight));
});
test('copyWith works for routeWeightSuccessIncrement', () {
final settings = AppSettings();
final updated = settings.copyWith(routeWeightSuccessIncrement: 0.25);
expect(updated.routeWeightSuccessIncrement, equals(0.25));
expect(
updated.routeWeightFailureDecrement,
equals(settings.routeWeightFailureDecrement),
);
});
test('copyWith works for routeWeightFailureDecrement', () {
final settings = AppSettings();
final updated = settings.copyWith(routeWeightFailureDecrement: 0.75);
expect(updated.routeWeightFailureDecrement, equals(0.75));
expect(
updated.routeWeightSuccessIncrement,
equals(settings.routeWeightSuccessIncrement),
);
});
test('copyWith works for maxMessageRetries', () {
final settings = AppSettings();
final updated = settings.copyWith(maxMessageRetries: 10);
expect(updated.maxMessageRetries, equals(10));
expect(updated.maxRouteWeight, equals(settings.maxRouteWeight));
});
});
}
@@ -1,823 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/models/path_history.dart';
import 'package:meshcore_open/models/path_selection.dart';
import 'package:meshcore_open/services/path_history_service.dart';
import 'package:meshcore_open/services/storage_service.dart';
// ---------------------------------------------------------------------------
// Fake storage no SharedPreferences dependency, all in-memory.
// ---------------------------------------------------------------------------
class FakeStorageService extends StorageService {
final Map<String, ContactPathHistory> _store = {};
@override
Future<void> savePathHistory(
String contactPubKeyHex,
ContactPathHistory history,
) async {
_store[contactPubKeyHex] = history;
}
@override
Future<ContactPathHistory?> loadPathHistory(String contactPubKeyHex) async {
return _store[contactPubKeyHex];
}
@override
Future<void> clearPathHistory(String contactPubKeyHex) async {
_store.remove(contactPubKeyHex);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Build a minimal Contact with the given pubKeyHex, pathLength, and path.
///
/// [publicKeyHex] must be exactly 64 hex characters (32 bytes).
Contact _makeContact({
required String publicKeyHex,
int pathLength = -1,
List<int> path = const [],
}) {
assert(publicKeyHex.length == 64, 'publicKeyHex must be 64 chars');
final bytes = Uint8List(32);
for (int i = 0; i < 32; i++) {
bytes[i] = int.parse(publicKeyHex.substring(i * 2, i * 2 + 2), radix: 16);
}
return Contact(
publicKey: bytes,
name: 'Test',
type: 1,
pathLength: pathLength,
path: Uint8List.fromList(path),
lastSeen: DateTime.now(),
);
}
/// A 64-char hex string derived from a short tag (padded with zeros).
String _hex(String tag) {
// Convert tag to hex-safe characters, then pad
final hexTag = tag.codeUnits
.map((c) => c.toRadixString(16).padLeft(2, '0'))
.join();
return hexTag.padLeft(64, '0');
}
/// Flush the microtask / async queue so that deferred storage loads complete.
Future<void> _flush() async {
await Future<void>.delayed(Duration.zero);
}
/// Seed the service's cache for [pubKeyHex] by adding one path record and
/// waiting for the async storage-load path to complete.
///
/// Call this before making synchronous assertions on a contact that has never
/// been seen by the service.
Future<void> _seed(
PathHistoryService svc,
String pubKeyHex, {
List<int> pathBytes = const [1],
int hopCount = 1,
double weight = 1.0,
}) async {
final contact = _makeContact(
publicKeyHex: pubKeyHex,
pathLength: hopCount,
path: pathBytes,
);
svc.handlePathUpdated(contact, initialWeight: weight);
await _flush();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
late FakeStorageService storage;
late PathHistoryService svc;
setUp(() {
storage = FakeStorageService();
svc = PathHistoryService(storage);
});
group('path selection', () {
test('empty path history returns flood', () {
const pubKey =
'0000000000000000000000000000000000000000000000000000000000000001';
final selection = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
expect(selection.useFlood, isTrue);
});
test('returns flood when maxRetries == 0', () {
const pubKey =
'0000000000000000000000000000000000000000000000000000000000000001';
final selection = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 0,
);
expect(selection.useFlood, isTrue);
});
test('single known path is used for non-final attempts', () async {
final pubKey = _hex('aabb');
await _seed(svc, pubKey, pathBytes: [0x01, 0x02], hopCount: 2);
for (int i = 0; i < 4; i++) {
final selection = svc.selectPathForAttempt(
pubKey,
attemptIndex: i,
maxRetries: 5,
);
expect(
selection.useFlood,
isFalse,
reason: 'attempt $i should be path',
);
expect(selection.pathBytes, equals([0x01, 0x02]));
}
});
test(
'retries avoid immediately repeating the same path when possible',
() async {
final pubKey = _hex('rot1');
await _seed(svc, pubKey, pathBytes: [0xAA], hopCount: 1, weight: 1.0);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0xBB], hopCount: 1, useFlood: false),
success: true,
successIncrement: 0.0,
);
await _flush();
final first = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
final second = svc.selectPathForAttempt(
pubKey,
attemptIndex: 1,
maxRetries: 5,
recentSelections: [first],
);
expect(first.useFlood, isFalse);
expect(second.useFlood, isFalse);
expect(second.pathBytes, isNot(equals(first.pathBytes)));
},
);
test(
'retries avoid the last two paths when a third option exists',
() async {
final pubKey = _hex('rot2');
await _seed(svc, pubKey, pathBytes: [0xA1], hopCount: 1, weight: 3.0);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0xB2], hopCount: 1, useFlood: false),
success: true,
successIncrement: 1.0,
);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0xC3], hopCount: 1, useFlood: false),
success: true,
successIncrement: 0.0,
);
await _flush();
final first = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
final second = svc.selectPathForAttempt(
pubKey,
attemptIndex: 1,
maxRetries: 5,
recentSelections: [first],
);
final third = svc.selectPathForAttempt(
pubKey,
attemptIndex: 2,
maxRetries: 5,
recentSelections: [first, second],
);
final chosenPaths = [
first.pathBytes,
second.pathBytes,
third.pathBytes,
];
expect(
chosenPaths
.map((path) => path.map((b) => b.toRadixString(16)).join(','))
.toSet()
.length,
equals(3),
);
expect(
chosenPaths,
everyElement(anyOf(equals([0xA1]), equals([0xB2]), equals([0xC3]))),
);
},
);
test('first-attempt selection rotates across ranked candidates', () async {
final pubKey = _hex('rot3');
await _seed(svc, pubKey, pathBytes: [0xA1], hopCount: 1, weight: 4.0);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0xB2], hopCount: 1, useFlood: false),
success: true,
successIncrement: 1.0,
);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0xC3], hopCount: 1, useFlood: false),
success: true,
successIncrement: 0.5,
);
await _flush();
final first = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
final second = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
final third = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
expect(first.pathBytes, isNot(equals(second.pathBytes)));
expect(second.pathBytes, isNot(equals(third.pathBytes)));
expect(
[first.pathBytes, second.pathBytes, third.pathBytes]
.map((path) => path.map((b) => b.toRadixString(16)).join(','))
.toSet()
.length,
equals(3),
);
});
test('final attempt is always flood regardless of known paths', () async {
final pubKey = _hex('ef01');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1);
for (final retries in [1, 2, 5, 10]) {
final lastAttempt = svc.selectPathForAttempt(
pubKey,
attemptIndex: retries - 1,
maxRetries: retries,
);
expect(
lastAttempt.useFlood,
isTrue,
reason: 'maxRetries=$retries: last attempt must be flood',
);
}
});
});
group('path scoring', () {
test('higher reliability beats higher route weight', () async {
final pubKey = _hex('rank1');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.5);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
success: false,
failureDecrement: 0.1,
);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
success: false,
failureDecrement: 0.1,
);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x02], hopCount: 1, useFlood: false),
success: true,
successIncrement: 0.0,
);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x02], hopCount: 1, useFlood: false),
success: true,
successIncrement: 0.0,
);
await _flush();
final first = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
expect(first.pathBytes, equals([0x02]));
});
test('lower latency wins when reliability is tied', () async {
final pubKey = _hex('rank2');
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x10], hopCount: 1, useFlood: false),
success: true,
tripTimeMs: 1200,
successIncrement: 0.0,
);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x20], hopCount: 1, useFlood: false),
success: true,
tripTimeMs: 400,
successIncrement: 0.0,
);
await _flush();
final first = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
expect(first.pathBytes, equals([0x20]));
});
test('fresher path wins when reliability and latency are tied', () async {
final pubKey = _hex('rank3');
final oldTimestamp = DateTime.now().subtract(const Duration(days: 10));
final newTimestamp = DateTime.now().subtract(const Duration(hours: 1));
storage._store[pubKey] = ContactPathHistory(
contactPubKeyHex: pubKey,
recentPaths: [
PathRecord(
hopCount: 1,
tripTimeMs: 900,
timestamp: oldTimestamp,
wasFloodDiscovery: false,
pathBytes: const [0x01],
successCount: 1,
failureCount: 0,
routeWeight: 1.0,
),
PathRecord(
hopCount: 1,
tripTimeMs: 900,
timestamp: newTimestamp,
wasFloodDiscovery: false,
pathBytes: const [0x02],
successCount: 1,
failureCount: 0,
routeWeight: 1.0,
),
],
);
svc.getRecentPaths(pubKey);
await _flush();
final first = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
expect(first.pathBytes, equals([0x02]));
});
test(
'higher route weight wins when other factors are effectively tied',
() async {
final pubKey = _hex('rank4');
final sharedTimestamp = DateTime.now().subtract(
const Duration(minutes: 30),
);
storage._store[pubKey] = ContactPathHistory(
contactPubKeyHex: pubKey,
recentPaths: [
PathRecord(
hopCount: 1,
tripTimeMs: 750,
timestamp: sharedTimestamp,
wasFloodDiscovery: false,
pathBytes: const [0x01],
successCount: 1,
failureCount: 0,
routeWeight: 4.0,
),
PathRecord(
hopCount: 1,
tripTimeMs: 750,
timestamp: sharedTimestamp,
wasFloodDiscovery: false,
pathBytes: const [0x02],
successCount: 1,
failureCount: 0,
routeWeight: 1.0,
),
],
);
svc.getRecentPaths(pubKey);
await _flush();
final first = svc.selectPathForAttempt(
pubKey,
attemptIndex: 0,
maxRetries: 5,
);
expect(first.pathBytes, equals([0x01]));
},
);
});
// -------------------------------------------------------------------------
// Group 3: recordPathResult weight adjustment
// -------------------------------------------------------------------------
group('recordPathResult weight adjustment', () {
test('success increments weight by successIncrement', () async {
final pubKey = _hex('w001');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
success: true,
successIncrement: 0.5,
);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(paths, isNotEmpty);
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
expect(paths.first.timestamp, isNotNull);
});
test('attempts do not set timestamp before first success', () async {
final pubKey = _hex('w000');
svc.recordPathAttempt(
pubKey,
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(paths, isNotEmpty);
expect(paths.first.successCount, equals(0));
expect(paths.first.timestamp, isNull);
});
test('failure preserves the last success timestamp', () async {
final pubKey = _hex('w006');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
success: true,
successIncrement: 0.0,
);
await _flush();
final successTimestamp = svc.getRecentPaths(pubKey).first.timestamp;
await Future<void>.delayed(const Duration(milliseconds: 5));
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
success: false,
failureDecrement: 0.1,
);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(paths.first.timestamp, equals(successTimestamp));
});
test('success clamps at maxWeight', () async {
final pubKey = _hex('w002');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.8);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
success: true,
successIncrement: 0.5,
maxWeight: 5.0,
);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(paths.first.routeWeight, closeTo(5.0, 0.001));
});
test('failure decrements weight', () async {
final pubKey = _hex('w003');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 2.0);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
success: false,
failureDecrement: 0.5,
);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
});
test('failure to 0 removes the path', () async {
final pubKey = _hex('w004');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 0.3);
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
success: false,
failureDecrement: 0.5, // 0.3 - 0.5 = -0.2 remove
);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(
paths.any((p) => p.pathBytes.length == 1 && p.pathBytes[0] == 0x01),
isFalse,
reason: 'path with weight <= 0 should have been removed',
);
});
test(
'flood result does not affect path records, updates floodStats',
() async {
final pubKey = _hex('w005');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
final pathsBefore = svc.getRecentPaths(pubKey);
final weightBefore = pathsBefore.first.routeWeight;
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
success: true,
tripTimeMs: 1234,
);
await _flush();
// Path records should be unchanged.
final pathsAfter = svc.getRecentPaths(pubKey);
expect(pathsAfter.first.routeWeight, equals(weightBefore));
// Flood stats should be updated.
final stats = svc.getFloodStats(pubKey);
expect(stats, isNotNull);
expect(stats!.successCount, equals(1));
expect(stats.lastTripTimeMs, equals(1234));
},
);
});
// -------------------------------------------------------------------------
// Group 4: handlePathUpdated
// -------------------------------------------------------------------------
group('handlePathUpdated', () {
test(
'pathLength >= 0 with path bytes → records path using pathLength',
() async {
final pubKey = _hex('h001');
final contact = _makeContact(
publicKeyHex: pubKey,
pathLength: 3,
path: [0x01, 0x02, 0x03],
);
svc.handlePathUpdated(contact);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(paths, isNotEmpty);
expect(paths.first.hopCount, equals(3));
expect(paths.first.pathBytes, equals([0x01, 0x02, 0x03]));
},
);
test(
'pathLength < 0 with path bytes → records path using path.length as hopCount',
() async {
final pubKey = _hex('h002');
final contact = _makeContact(
publicKeyHex: pubKey,
pathLength: -1, // flood indicator from firmware
path: [0xAA, 0xBB],
);
svc.handlePathUpdated(contact);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(paths, isNotEmpty);
// hopCount should equal path.length (2), not pathLength (-1).
expect(paths.first.hopCount, equals(2));
expect(paths.first.pathBytes, equals([0xAA, 0xBB]));
},
);
test('pathLength < 0 with empty path → skipped (returns early)', () async {
final pubKey = _hex('h003');
final contact = _makeContact(
publicKeyHex: pubKey,
pathLength: -1,
path: [],
);
svc.handlePathUpdated(contact);
await _flush();
// Nothing should have been recorded.
final paths = svc.getRecentPaths(pubKey);
expect(paths, isEmpty);
});
test('initialWeight is applied to the new record', () async {
final pubKey = _hex('h004');
final contact = _makeContact(
publicKeyHex: pubKey,
pathLength: 1,
path: [0x55],
);
svc.handlePathUpdated(contact, initialWeight: 2.5);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(paths.first.routeWeight, closeTo(2.5, 0.001));
});
});
// -------------------------------------------------------------------------
// Group 5: recordFloodPathAttribution
// -------------------------------------------------------------------------
group('recordFloodPathAttribution', () {
test('credits existing path with success increment', () async {
final pubKey = _hex('fa01');
await _seed(
svc,
pubKey,
pathBytes: [0x01, 0x02],
hopCount: 2,
weight: 1.0,
);
svc.recordFloodPathAttribution(
contactPubKeyHex: pubKey,
pathBytes: [0x01, 0x02],
hopCount: 2,
tripTimeMs: 3000,
successIncrement: 0.5,
maxWeight: 5.0,
);
await _flush();
final paths = svc.getRecentPaths(pubKey);
final credited = paths.firstWhere(
(p) => p.pathBytes.length == 2 && p.pathBytes[0] == 0x01,
);
expect(credited.routeWeight, closeTo(1.5, 0.001));
expect(credited.successCount, equals(1));
expect(credited.tripTimeMs, equals(3000));
});
test('creates new path record when path is unknown', () async {
final pubKey = _hex('fa02');
// Seed with a different path so the cache is warm.
await _seed(svc, pubKey, pathBytes: [0xAA], hopCount: 1, weight: 1.0);
svc.recordFloodPathAttribution(
contactPubKeyHex: pubKey,
pathBytes: [0xBB, 0xCC],
hopCount: 2,
tripTimeMs: 2000,
successIncrement: 0.5,
maxWeight: 5.0,
);
await _flush();
final paths = svc.getRecentPaths(pubKey);
final newPath = paths.firstWhere(
(p) => p.pathBytes.length == 2 && p.pathBytes[0] == 0xBB,
);
// New path: weight = 1.0 (default) + 0.5 = 1.5
expect(newPath.routeWeight, closeTo(1.5, 0.001));
expect(newPath.successCount, equals(1));
expect(newPath.wasFloodDiscovery, isTrue);
});
test('clamps weight at maxWeight', () async {
final pubKey = _hex('fa03');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.8);
svc.recordFloodPathAttribution(
contactPubKeyHex: pubKey,
pathBytes: [0x01],
hopCount: 1,
successIncrement: 0.5,
maxWeight: 5.0,
);
await _flush();
final paths = svc.getRecentPaths(pubKey);
expect(paths.first.routeWeight, closeTo(5.0, 0.001));
});
test('ignores empty pathBytes', () async {
final pubKey = _hex('fa04');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
final pathsBefore = svc.getRecentPaths(pubKey);
final weightBefore = pathsBefore.first.routeWeight;
svc.recordFloodPathAttribution(
contactPubKeyHex: pubKey,
pathBytes: [],
hopCount: 0,
successIncrement: 0.5,
maxWeight: 5.0,
);
await _flush();
// Existing path should be untouched.
final pathsAfter = svc.getRecentPaths(pubKey);
expect(pathsAfter.first.routeWeight, equals(weightBefore));
});
test('ignores negative hopCount (flood indicator)', () async {
final pubKey = _hex('fa05');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
final pathsBefore = svc.getRecentPaths(pubKey);
final weightBefore = pathsBefore.first.routeWeight;
svc.recordFloodPathAttribution(
contactPubKeyHex: pubKey,
pathBytes: [0x01],
hopCount: -1,
successIncrement: 0.5,
maxWeight: 5.0,
);
await _flush();
final pathsAfter = svc.getRecentPaths(pubKey);
expect(pathsAfter.first.routeWeight, equals(weightBefore));
});
test('flood stats still recorded independently', () async {
final pubKey = _hex('fa06');
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
// Record a flood success (this updates flood stats).
svc.recordPathResult(
pubKey,
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
success: true,
tripTimeMs: 5000,
);
// Then attribute the flood success to a path.
svc.recordFloodPathAttribution(
contactPubKeyHex: pubKey,
pathBytes: [0x01],
hopCount: 1,
tripTimeMs: 5000,
successIncrement: 0.5,
maxWeight: 5.0,
);
await _flush();
// Both flood stats and path attribution should exist.
final stats = svc.getFloodStats(pubKey);
expect(stats, isNotNull);
expect(stats!.successCount, equals(1));
final paths = svc.getRecentPaths(pubKey);
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
});
});
}
-619
View File
@@ -1,619 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/models/message.dart';
import 'package:meshcore_open/services/message_retry_service.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Replicates the SHA-256 computation from [MessageRetryService.computeExpectedAckHash]
/// so tests can cross-check without calling the real implementation twice.
int _manualAckHash(
int timestampSeconds,
int attemptMasked, // already masked to 0x03
String text,
Uint8List senderPubKey,
) {
final textBytes = utf8.encode(text);
final buffer = Uint8List(4 + 1 + textBytes.length + senderPubKey.length);
int offset = 0;
buffer[offset++] = timestampSeconds & 0xFF;
buffer[offset++] = (timestampSeconds >> 8) & 0xFF;
buffer[offset++] = (timestampSeconds >> 16) & 0xFF;
buffer[offset++] = (timestampSeconds >> 24) & 0xFF;
buffer[offset++] = attemptMasked & 0xFF;
buffer.setRange(offset, offset + textBytes.length, textBytes);
offset += textBytes.length;
buffer.setRange(offset, offset + senderPubKey.length, senderPubKey);
final hash = sha256.convert(buffer);
final bytes = Uint8List.fromList(hash.bytes.sublist(0, 4));
return (bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0];
}
Uint8List _makeKey(int seed) {
final key = Uint8List(32);
for (int i = 0; i < 32; i++) {
key[i] = (seed + i) & 0xFF;
}
return key;
}
Uint8List _makeRecipientKey() {
final key = Uint8List(32);
for (int i = 0; i < 32; i++) {
key[i] = (0xAA + i) & 0xFF;
}
return key;
}
Contact _makeContact({
required Uint8List publicKey,
int pathLength = -1,
List<int> path = const [],
}) {
return Contact(
publicKey: publicKey,
name: 'Test',
type: 1,
pathLength: pathLength,
path: Uint8List.fromList(path),
lastSeen: DateTime.now(),
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
// Fixed inputs reused across groups
const int fixedTs = 1700000000;
const String fixedText = 'Hello mesh';
final Uint8List fixedKey = _makeKey(0x11);
final Uint8List recipientKey = _makeRecipientKey();
// -------------------------------------------------------------------------
group('computeExpectedAckHash — attempt masking', () {
test('attempts 03 all produce different hashes', () {
final hashes = List.generate(
4,
(i) => MessageRetryService.computeExpectedAckHash(
fixedTs,
i,
fixedText,
fixedKey,
),
);
// All four must be pairwise distinct
for (int i = 0; i < hashes.length; i++) {
for (int j = i + 1; j < hashes.length; j++) {
expect(
hashes[i],
isNot(equals(hashes[j])),
reason: 'attempt $i and attempt $j should produce different hashes',
);
}
}
});
test('attempt 4 produces same hash as attempt 0 (4 & 0x03 == 0)', () {
final hash0 = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
fixedKey,
);
final hash4 = MessageRetryService.computeExpectedAckHash(
fixedTs,
4,
fixedText,
fixedKey,
);
expect(hash4, equals(hash0));
});
test('attempt 5 produces same hash as attempt 1 (5 & 0x03 == 1)', () {
final hash1 = MessageRetryService.computeExpectedAckHash(
fixedTs,
1,
fixedText,
fixedKey,
);
final hash5 = MessageRetryService.computeExpectedAckHash(
fixedTs,
5,
fixedText,
fixedKey,
);
expect(hash5, equals(hash1));
});
test('attempt 7 produces same hash as attempt 3 (7 & 0x03 == 3)', () {
final hash3 = MessageRetryService.computeExpectedAckHash(
fixedTs,
3,
fixedText,
fixedKey,
);
final hash7 = MessageRetryService.computeExpectedAckHash(
fixedTs,
7,
fixedText,
fixedKey,
);
expect(hash7, equals(hash3));
});
test('same inputs always produce the same hash (deterministic)', () {
final first = MessageRetryService.computeExpectedAckHash(
fixedTs,
2,
fixedText,
fixedKey,
);
final second = MessageRetryService.computeExpectedAckHash(
fixedTs,
2,
fixedText,
fixedKey,
);
expect(first, equals(second));
});
test('hash matches manual SHA-256 computation', () {
for (int attempt = 0; attempt < 4; attempt++) {
final actual = MessageRetryService.computeExpectedAckHash(
fixedTs,
attempt,
fixedText,
fixedKey,
);
final expected = _manualAckHash(fixedTs, attempt, fixedText, fixedKey);
expect(
actual,
equals(expected),
reason: 'mismatch at attempt $attempt',
);
}
});
test('different timestamps produce different hashes', () {
final hashA = MessageRetryService.computeExpectedAckHash(
1700000000,
0,
fixedText,
fixedKey,
);
final hashB = MessageRetryService.computeExpectedAckHash(
1700000001,
0,
fixedText,
fixedKey,
);
expect(hashA, isNot(equals(hashB)));
});
test('different texts produce different hashes', () {
final hashA = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
'Hello mesh',
fixedKey,
);
final hashB = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
'Hello mesh!',
fixedKey,
);
expect(hashA, isNot(equals(hashB)));
});
test('different sender keys produce different hashes', () {
final keyA = _makeKey(0x01);
final keyB = _makeKey(0x02);
final hashA = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
keyA,
);
final hashB = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
keyB,
);
expect(hashA, isNot(equals(hashB)));
});
});
// -------------------------------------------------------------------------
group('buildSendTextMsgFrame — attempt encoding', () {
// Frame layout: [cmd(1)][txtType(1)][attempt(1)][timestamp(4)][pubKeyPrefix(6)][text][null(1)]
// So byte index 2 carries the raw attempt & 0xFF.
test('attempt 0 → byte[2] is 0', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(0));
});
test('attempt 3 → byte[2] is 3', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 3,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(3));
});
test('attempt 4 → byte[2] is 4 (raw value, not clamped to 3)', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 4,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(4));
});
test('attempt 255 → byte[2] is 255', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 255,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(255));
});
test('attempt 256 → byte[2] is 255 (clamped, not wrapped)', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 256,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(255));
});
test('byte[0] is cmdSendTxtMsg (2)', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame[0], equals(cmdSendTxtMsg));
});
test('byte[1] is txtTypePlain (0)', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame[1], equals(txtTypePlain));
});
test('timestamp bytes[3..6] are little-endian encoded', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
final decoded =
frame[3] | (frame[4] << 8) | (frame[5] << 16) | (frame[6] << 24);
expect(decoded, equals(fixedTs));
});
test(
'pub key prefix (bytes 7..12) matches first 6 bytes of recipient key',
() {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame.sublist(7, 13), equals(recipientKey.sublist(0, 6)));
},
);
test('frame is null-terminated after text', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame.last, equals(0));
});
});
// -------------------------------------------------------------------------
group(
'ACK hash consistency between computeExpectedAckHash and firmware behavior',
() {
// The firmware reads the raw attempt byte from the frame, then masks it
// with & 3 when computing the ACK hash. Flutter does the same masking
// inside computeExpectedAckHash. So the two sides must agree.
test('attempt 4: flutter hash (4 & 3 = 0) equals hash for attempt 0', () {
// Flutter sends raw byte 4 in the frame, but computes hash with 4&3=0.
// Firmware reads 4, masks to 0, computes same hash they match.
final hashFor4 = MessageRetryService.computeExpectedAckHash(
fixedTs,
4,
fixedText,
fixedKey,
);
final hashFor0 = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
fixedKey,
);
expect(hashFor4, equals(hashFor0));
// Also confirm the frame byte is raw 4, not 0
final frame = buildSendTextMsgFrame(
recipientKey,
fixedText,
attempt: 4,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(4), reason: 'frame carries raw attempt byte');
});
test(
'attempt 3: flutter hash equals hash computed directly for attempt 3',
() {
// 3 & 3 == 3, so no wrapping both sides agree.
final hashFor3 = MessageRetryService.computeExpectedAckHash(
fixedTs,
3,
fixedText,
fixedKey,
);
final hashFor3Direct = _manualAckHash(
fixedTs,
3,
fixedText,
fixedKey,
);
expect(hashFor3, equals(hashFor3Direct));
final frame = buildSendTextMsgFrame(
recipientKey,
fixedText,
attempt: 3,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(3));
},
);
test(
'attempt 3 and attempt 4 produce DIFFERENT hashes (3&3=3 vs 4&3=0)',
() {
final hash3 = MessageRetryService.computeExpectedAckHash(
fixedTs,
3,
fixedText,
fixedKey,
);
final hash4 = MessageRetryService.computeExpectedAckHash(
fixedTs,
4,
fixedText,
fixedKey,
);
expect(hash3, isNot(equals(hash4)));
},
);
test('attempt 8 (8&3=0) produces the same hash as attempt 0', () {
final hash8 = MessageRetryService.computeExpectedAckHash(
fixedTs,
8,
fixedText,
fixedKey,
);
final hash0 = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
fixedKey,
);
expect(hash8, equals(hash0));
});
test(
'hash cycle repeats every 4 attempts (modular arithmetic holds)',
() {
for (int base = 0; base < 4; base++) {
final hashBase = MessageRetryService.computeExpectedAckHash(
fixedTs,
base,
fixedText,
fixedKey,
);
final hashPlus4 = MessageRetryService.computeExpectedAckHash(
fixedTs,
base + 4,
fixedText,
fixedKey,
);
final hashPlus8 = MessageRetryService.computeExpectedAckHash(
fixedTs,
base + 8,
fixedText,
fixedKey,
);
expect(
hashPlus4,
equals(hashBase),
reason: 'attempt ${base + 4} should match attempt $base',
);
expect(
hashPlus8,
equals(hashBase),
reason: 'attempt ${base + 8} should match attempt $base',
);
}
},
);
},
);
// -------------------------------------------------------------------------
group('_AckHashMapping.attemptIndex — indirect verification via public API', () {
// _AckHashMapping is private; we validate its purpose indirectly: that
// computeExpectedAckHash records the correct per-attempt hash so that the
// right hash is matched when an ACK arrives.
test('each attempt index 03 produces a distinct 4-byte hash', () {
final hashes = <String, int>{};
for (int attempt = 0; attempt < 4; attempt++) {
final hash = MessageRetryService.computeExpectedAckHash(
fixedTs,
attempt,
fixedText,
fixedKey,
);
final hex = hash.toRadixString(16).padLeft(8, '0');
expect(
hashes.containsKey(hex),
isFalse,
reason: 'attempt $attempt collides with attempt ${hashes[hex]}',
);
hashes[hex] = attempt;
}
expect(hashes.length, equals(4));
});
test(
'attempt index wraps: hash for attempt 4 matches stored hash for attempt 0',
() {
final storedHash = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
fixedKey,
);
// Simulates firmware reading raw attempt=4 and masking to 0 for hash.
final firmwareComputedHash = _manualAckHash(
fixedTs,
4 & 0x03, // firmware masks here
fixedText,
fixedKey,
);
expect(firmwareComputedHash, equals(storedHash));
},
);
test(
'attempt index 1 and 5 map to the same slot — ACK from either retry is matched',
() {
final hashForAttempt1 = MessageRetryService.computeExpectedAckHash(
fixedTs,
1,
fixedText,
fixedKey,
);
final hashForAttempt5 = MessageRetryService.computeExpectedAckHash(
fixedTs,
5,
fixedText,
fixedKey,
);
// Both should produce the identical bytes, confirming the service
// would record and match the correct attempt index.
expect(hashForAttempt5, equals(hashForAttempt1));
},
);
});
group('sendMessageWithRetry — auto path fallback', () {
test(
'preserves the contact path when auto-selection returns null',
() async {
final retryService = MessageRetryService();
Message? addedMessage;
final contact = _makeContact(
publicKey: recipientKey,
pathLength: 2,
path: const [0x10, 0x20],
);
retryService.initialize(
RetryServiceConfig(
sendMessage: (_, _, _, _) {},
addMessage: (_, message) => addedMessage = message,
updateMessage: (_) {},
clearContactPath: (_) {},
setContactPath: (_, _, _) {},
selectRetryPath: (_, _, _, _) => null,
),
);
await retryService.sendMessageWithRetry(
contact: contact,
text: 'hello',
);
expect(addedMessage, isNotNull);
expect(addedMessage!.pathLength, equals(2));
expect(
addedMessage!.pathBytes,
equals(Uint8List.fromList([0x10, 0x20])),
);
},
);
test('uses flood when contact is in flood mode', () async {
final retryService = MessageRetryService();
Message? addedMessage;
final contact = _makeContact(
publicKey: recipientKey,
pathLength: -1,
path: const [],
);
retryService.initialize(
RetryServiceConfig(
sendMessage: (_, _, _, _) {},
addMessage: (_, message) => addedMessage = message,
updateMessage: (_) {},
clearContactPath: (_) {},
setContactPath: (_, _, _) {},
),
);
await retryService.sendMessageWithRetry(contact: contact, text: 'hello');
expect(addedMessage, isNotNull);
expect(addedMessage!.pathLength, equals(-1));
expect(addedMessage!.pathBytes, isEmpty);
});
});
}