diff --git a/docs/BLE_PROTOCOL.md b/docs/BLE_PROTOCOL.md new file mode 100644 index 0000000..993c3ea --- /dev/null +++ b/docs/BLE_PROTOCOL.md @@ -0,0 +1,1292 @@ +# MeshCore BLE Protocol Documentation + +## Overview + +The MeshCore BLE protocol implements a binary frame-based communication system using Nordic UART Service (NUS) for low-level transport. The protocol supports mesh networking operations including contact management, text messaging, channel communication, and device configuration. + +## BLE Transport Layer + +### Nordic UART Service (NUS) + +**Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e` + +**Characteristics**: +- **RX Characteristic** (Write): `6e400002-b5a3-f393-e0a9-e50e24dcca9e` + - Used for sending commands/data TO the device + - Supports write with/without response + +- **TX Characteristic** (Notify): `6e400003-b5a3-f393-e0a9-e50e24dcca9e` + - Used for receiving responses/data FROM the device + - Notifications enabled during connection + +### Connection Flow + +1. **Scan** for devices with name prefix `MeshCore-` +2. **Connect** with 15-second timeout +3. **Request MTU** of 185 bytes (falls back to default if unsupported) +4. **Discover services** and locate NUS characteristics +5. **Enable notifications** on TX characteristic (with 3 retry attempts) +6. **Initialize device** by sending: + - `CMD_DEVICE_QUERY` - Get device capabilities + - `CMD_APP_START` - Register app with device + - `CMD_GET_BATT_AND_STORAGE` - Request battery status + - `CMD_GET_RADIO_SETTINGS` - Get LoRa radio parameters + +## Frame Structure + +### Command Frames (App → Device) + +All command frames start with a single-byte command code followed by command-specific data. + +**Format**: `[command_code][parameters...]` + +**Maximum Frame Size**: 172 bytes (`maxFrameSize`) + +### Response Frames (Device → App) + +Response frames start with a response code, followed by response-specific data. + +**Format**: `[response_code][data...]` + +### Push Frames (Device → App, Asynchronous) + +Push frames are unsolicited notifications from the device, using codes ≥ 0x80. + +**Format**: `[push_code][data...]` + +## Command Codes (0x01-0x39) + +Commands sent from the app to the device: + +| Code | Name | Description | +|------|------|-------------| +| 0x01 | `CMD_APP_START` | Register application with device | +| 0x02 | `CMD_SEND_TXT_MSG` | Send direct text message to contact | +| 0x03 | `CMD_SEND_CHANNEL_TXT_MSG` | Send text message to channel | +| 0x04 | `CMD_GET_CONTACTS` | Request contact list | +| 0x05 | `CMD_GET_DEVICE_TIME` | Get device's current time | +| 0x06 | `CMD_SET_DEVICE_TIME` | Sync device time | +| 0x07 | `CMD_SEND_SELF_ADVERT` | Broadcast self advertisement | +| 0x08 | `CMD_SET_ADVERT_NAME` | Set node display name | +| 0x09 | `CMD_ADD_UPDATE_CONTACT` | Add/update contact with custom path | +| 0x0A | `CMD_SYNC_NEXT_MESSAGE` | Request next queued message | +| 0x0B | `CMD_SET_RADIO_PARAMS` | Configure LoRa radio settings | +| 0x0C | `CMD_SET_RADIO_TX_POWER` | Set transmit power | +| 0x0D | `CMD_RESET_PATH` | Clear contact's routing path | +| 0x0E | `CMD_SET_ADVERT_LATLON` | Set node GPS coordinates | +| 0x0F | `CMD_REMOVE_CONTACT` | Delete contact from device | +| 0x10 | `CMD_SHARE_CONTACT` | Share contact via mesh | +| 0x11 | `CMD_EXPORT_CONTACT` | Export contact data | +| 0x12 | `CMD_IMPORT_CONTACT` | Import contact data | +| 0x13 | `CMD_REBOOT` | Reboot device | +| 0x14 | `CMD_GET_BATT_AND_STORAGE` | Request battery and storage info | +| 0x15 | `CMD_SET_TUNING_PARAMS` | Set device tuning parameters | +| 0x16 | `CMD_DEVICE_QUERY` | Query device capabilities | +| 0x17 | `CMD_EXPORT_PRIVATE_KEY` | Export device private key (secure) | +| 0x18 | `CMD_IMPORT_PRIVATE_KEY` | Import device private key (secure) | +| 0x19 | `CMD_SEND_RAW_DATA` | Send raw data to contact | +| 0x1A | `CMD_SEND_LOGIN` | Authenticate to repeater | +| 0x1B | `CMD_SEND_STATUS_REQ` | Request status from repeater | +| 0x1C | `CMD_HAS_CONNECTION` | Check if connection exists to contact | +| 0x1D | `CMD_LOGOUT` | Disconnect from repeater | +| 0x1E | `CMD_GET_CONTACT_BY_KEY` | Get specific contact by public key | +| 0x1F | `CMD_GET_CHANNEL` | Get channel configuration | +| 0x20 | `CMD_SET_CHANNEL` | Configure channel | +| 0x21 | `CMD_SIGN_START` | Start signing operation | +| 0x22 | `CMD_SIGN_DATA` | Add data to be signed | +| 0x23 | `CMD_SIGN_FINISH` | Finish signing and get signature | +| 0x24 | `CMD_SEND_TRACE_PATH` | Send path trace request | +| 0x25 | `CMD_SET_DEVICE_PIN` | Set device PIN for pairing | +| 0x26 | `CMD_SET_OTHER_PARAMS` | Set miscellaneous parameters | +| 0x27 | `CMD_SEND_TELEMETRY_REQ` | Request telemetry data (deprecated) | +| 0x28 | `CMD_GET_CUSTOM_VARS` | Get custom variables | +| 0x29 | `CMD_SET_CUSTOM_VAR` | Set custom variable | +| 0x2A | `CMD_GET_ADVERT_PATH` | Get advertisement path for contact | +| 0x2B | `CMD_GET_TUNING_PARAMS` | Get device tuning parameters | +| 0x32 | `CMD_SEND_BINARY_REQ` | Send binary request to contact | +| 0x33 | `CMD_FACTORY_RESET` | Factory reset device | +| 0x34 | `CMD_SEND_PATH_DISCOVERY_REQ` | Request path discovery | +| 0x36 | `CMD_SET_FLOOD_SCOPE` | Set flood routing scope (v8+) | +| 0x37 | `CMD_SEND_CONTROL_DATA` | Send control data (v8+) | +| 0x38 | `CMD_GET_STATS` | Get statistics (v8+, sub-types: core/radio/packets) | +| 0x39 | `CMD_GET_RADIO_SETTINGS` | Get current radio parameters | + +## Response Codes (0x00-0x19) + +Responses from device to app: + +| Code | Name | Description | +|------|------|-------------| +| 0x00 | `RESP_CODE_OK` | Generic success | +| 0x01 | `RESP_CODE_ERR` | Generic error | +| 0x02 | `RESP_CODE_CONTACTS_START` | Beginning of contact list | +| 0x03 | `RESP_CODE_CONTACT` | Contact entry | +| 0x04 | `RESP_CODE_END_OF_CONTACTS` | End of contact list | +| 0x05 | `RESP_CODE_SELF_INFO` | Device identity and settings | +| 0x06 | `RESP_CODE_SENT` | Message sent (includes ACK hash) | +| 0x07 | `RESP_CODE_CONTACT_MSG_RECV` | Received direct message (v1/v2) | +| 0x08 | `RESP_CODE_CHANNEL_MSG_RECV` | Received channel message (v1/v2) | +| 0x09 | `RESP_CODE_CURR_TIME` | Current device time | +| 0x0A | `RESP_CODE_NO_MORE_MESSAGES` | Queue empty | +| 0x0B | `RESP_CODE_EXPORT_CONTACT` | Exported contact data | +| 0x0C | `RESP_CODE_BATT_AND_STORAGE` | Battery and storage status | +| 0x0D | `RESP_CODE_DEVICE_INFO` | Device capabilities | +| 0x0E | `RESP_CODE_PRIVATE_KEY` | Exported private key | +| 0x0F | `RESP_CODE_DISABLED` | Feature disabled | +| 0x10 | `RESP_CODE_CONTACT_MSG_RECV_V3` | Received direct message (v3) | +| 0x11 | `RESP_CODE_CHANNEL_MSG_RECV_V3` | Received channel message (v3) | +| 0x12 | `RESP_CODE_CHANNEL_INFO` | Channel configuration | +| 0x13 | `RESP_CODE_SIGN_START` | Signing operation started | +| 0x14 | `RESP_CODE_SIGNATURE` | Digital signature result | +| 0x15 | `RESP_CODE_CUSTOM_VARS` | Custom variables data | +| 0x16 | `RESP_CODE_ADVERT_PATH` | Advertisement path data | +| 0x17 | `RESP_CODE_TUNING_PARAMS` | Tuning parameters | +| 0x18 | `RESP_CODE_STATS` | Statistics data (v8+) | +| 0x19 | `RESP_CODE_RADIO_SETTINGS` | Radio parameters | + +## Push Codes (0x80-0x8E) + +Asynchronous notifications from device: + +| Code | Name | Description | +|------|------|-------------| +| 0x80 | `PUSH_CODE_ADVERT` | Advertisement received | +| 0x81 | `PUSH_CODE_PATH_UPDATED` | Contact path changed | +| 0x82 | `PUSH_CODE_SEND_CONFIRMED` | Message ACK received | +| 0x83 | `PUSH_CODE_MSG_WAITING` | New messages in queue | +| 0x84 | `PUSH_CODE_RAW_DATA` | Raw data received from contact | +| 0x85 | `PUSH_CODE_LOGIN_SUCCESS` | Repeater login succeeded | +| 0x86 | `PUSH_CODE_LOGIN_FAIL` | Repeater login failed | +| 0x87 | `PUSH_CODE_STATUS_RESPONSE` | Repeater status response | +| 0x88 | `PUSH_CODE_LOG_RX_DATA` | Raw LoRa packet log | +| 0x89 | `PUSH_CODE_TRACE_DATA` | Path trace response | +| 0x8A | `PUSH_CODE_NEW_ADVERT` | New contact advertisement | +| 0x8B | `PUSH_CODE_TELEMETRY_RESPONSE` | Telemetry data response | +| 0x8C | `PUSH_CODE_BINARY_RESPONSE` | Binary request response | +| 0x8D | `PUSH_CODE_PATH_DISCOVERY_RESPONSE` | Path discovery response | +| 0x8E | `PUSH_CODE_CONTROL_DATA` | Control data received (v8+) | + +## Key Frame Formats + +### CMD_APP_START (0x01) + +Registers the application with the device. + +**Format**: +``` +[0x01][app_ver][reserved x6][app_name...]\0 +``` + +**Fields**: +- `app_ver` (1 byte): Application version number +- `reserved` (6 bytes): Reserved for future use (zeros) +- `app_name` (variable): Null-terminated UTF-8 app name + +**Example**: +```dart +buildAppStartFrame(appName: 'MeshCoreOpen', appVersion: 1) +// [0x01][0x01][0x00 x6]["MeshCoreOpen"][0x00] +``` + +### CMD_SEND_TXT_MSG (0x02) + +Sends a direct message to a contact. + +**Format**: +``` +[0x02][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]\0 +``` + +**Fields**: +- `txt_type` (1 byte): Message type (0=plain, 1=CLI data) +- `attempt` (1 byte): Retry attempt number (0-3) +- `timestamp` (4 bytes LE): Unix timestamp in seconds +- `pub_key_prefix` (6 bytes): First 6 bytes of recipient's public key +- `text` (variable): UTF-8 message text, null-terminated + +**Max text length**: 160 bytes after overhead (matching firmware `MAX_TEXT_LEN`) + +**Example**: +```dart +buildSendTextMsgFrame(recipientPubKey, "Hello mesh!", attempt: 0) +``` + +### CMD_SEND_CHANNEL_TXT_MSG (0x03) + +Sends a message to a channel (broadcast group). + +**Format**: +``` +[0x03][txt_type][channel_idx][timestamp x4][text...]\0 +``` + +**Fields**: +- `txt_type` (1 byte): Message type (0=plain) +- `channel_idx` (1 byte): Channel index (0-7 typically) +- `timestamp` (4 bytes LE): Unix timestamp in seconds +- `text` (variable): UTF-8 message text, null-terminated + +**Max text length**: Depends on sender name prefix (see `maxChannelMessageBytes()`) + +### CMD_GET_CONTACTS (0x04) + +Requests contact list from device. + +**Format**: +``` +[0x04] # Get all contacts +[0x04][since x4] # Get contacts modified after timestamp +``` + +**Fields**: +- `since` (4 bytes LE, optional): Unix timestamp filter + +**Response**: +- `RESP_CODE_CONTACTS_START` (0x02) +- Multiple `RESP_CODE_CONTACT` (0x03) frames +- `RESP_CODE_END_OF_CONTACTS` (0x04) + +### CMD_GET_CONTACT_BY_KEY (0x1E) + +Fetches a specific contact by their public key. + +**Format**: +``` +[0x1E][pub_key x32] +``` + +**Fields**: +- `pub_key` (32 bytes): Contact's Ed25519 public key + +**Response**: +- `RESP_CODE_CONTACT` (0x03) if found +- `RESP_CODE_ERR` (0x01) with `ERR_CODE_NOT_FOUND` (2) if not found + +**Use case**: Efficiently check if a specific contact exists without fetching entire contact list. + +**Example**: +```dart +// Fetch specific contact +final pubKey = hexToPubKey('a1b2c3d4...'); +await connector.getContactByKey(pubKey); + +// Response handled in _handleContact() as usual +``` + +### CMD_SET_DEVICE_TIME (0x06) + +Synchronizes device clock with app. + +**Format**: +``` +[0x06][timestamp x4] +``` + +**Fields**: +- `timestamp` (4 bytes LE): Current Unix timestamp in seconds + +### CMD_SET_ADVERT_NAME (0x08) + +Sets the device's display name for advertisements. + +**Format**: +``` +[0x08][name...] +``` + +**Fields**: +- `name` (variable): UTF-8 name, max 31 bytes (truncated if longer) + +### CMD_SET_ADVERT_LATLON (0x0E) + +Sets the device's GPS coordinates. + +**Format**: +``` +[0x0E][lat x4][lon x4] +``` + +**Fields**: +- `lat` (4 bytes LE): Latitude × 1,000,000 (signed int32) +- `lon` (4 bytes LE): Longitude × 1,000,000 (signed int32) + +**Example**: +```dart +// 37.7749° N, -122.4194° W (San Francisco) +buildSetAdvertLatLonFrame(37.7749, -122.4194) +// lat_int = 37774900, lon_int = -122419400 +``` + +### CMD_ADD_UPDATE_CONTACT (0x09) + +Adds a new contact or updates an existing contact's routing path. + +**Format**: +``` +[0x09][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4] +``` + +**Fields**: +- `pub_key` (32 bytes): Contact's public key +- `type` (1 byte): Advertisement type (1=chat, 2=repeater, 3=room, 4=sensor) +- `flags` (1 byte): Contact flags +- `path_len` (1 byte): Number of path bytes used +- `path` (64 bytes): Custom routing path (padded with zeros) +- `name` (32 bytes): Contact name, null-padded UTF-8 +- `timestamp` (4 bytes LE): Unix timestamp + +**Total size**: 136 bytes + +### CMD_RESET_PATH (0x0D) + +Clears a contact's custom path, reverting to flood mode. + +**Format**: +``` +[0x0D][pub_key x32] +``` + +**Fields**: +- `pub_key` (32 bytes): Contact's public key + +### CMD_SET_RADIO_PARAMS (0x0B) + +Configures LoRa radio parameters. + +**Format**: +``` +[0x0B][freq x4][bw x4][sf][cr] +``` + +**Fields**: +- `freq` (4 bytes LE): Frequency in Hz (300,000 - 2,500,000) +- `bw` (4 bytes LE): Bandwidth in Hz (7,000 - 500,000) +- `sf` (1 byte): Spreading factor (5-12) +- `cr` (1 byte): Coding rate (5-8) + +**Example**: +```dart +// 915 MHz, 125 kHz BW, SF7, CR 4/5 +buildSetRadioParamsFrame(915000000, 125000, 7, 5) +``` + +### CMD_SET_CHANNEL (0x20) + +Creates or updates a channel configuration. + +**Format**: +``` +[0x20][idx][name x32][psk x16] +``` + +**Fields**: +- `idx` (1 byte): Channel index (0-7) +- `name` (32 bytes): Channel name, null-padded UTF-8 +- `psk` (16 bytes): Pre-shared key for encryption + +**To delete a channel**: Send empty name and zero PSK + +### RESP_CODE_SELF_INFO (0x05) + +Device identity and current settings. + +**Format**: +``` +[0x05][adv_type][tx_pwr][max_pwr][pub_key x32][lat x4][lon x4][multi_acks] +[adv_loc_policy][telemetry][manual_add][freq x4][bw x4][sf][cr][name...] +``` + +**Fields**: +- `adv_type` (1 byte): Advertisement type (1=chat, 2=repeater) +- `tx_pwr` (1 byte): Current TX power in dBm +- `max_pwr` (1 byte): Maximum TX power in dBm +- `pub_key` (32 bytes): Device's public key +- `lat` (4 bytes LE): Latitude × 1,000,000 +- `lon` (4 bytes LE): Longitude × 1,000,000 +- `multi_acks` (1 byte): Multi-ACK mode flag +- `adv_loc_policy` (1 byte): Location advertisement policy +- `telemetry` (1 byte): Telemetry mode flags +- `manual_add` (1 byte): Manual contact addition mode +- `freq` (4 bytes LE): Radio frequency in Hz +- `bw` (4 bytes LE): Radio bandwidth in Hz +- `sf` (1 byte): Spreading factor +- `cr` (1 byte): Coding rate +- `name` (variable): Node name, null-terminated UTF-8 + +**Minimum size**: 58 bytes (without name) + +### RESP_CODE_CONTACT (0x03) + +Contact entry from device. + +**Format**: +``` +[0x03][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4] +[lat x4][lon x4][lastmod x4] +``` + +**Fields**: +- `pub_key` (32 bytes): Contact's public key +- `type` (1 byte): Contact type (1=chat, 2=repeater, 3=room, 4=sensor) +- `flags` (1 byte): Contact flags +- `path_len` (1 byte): Path length (0xFF = flood mode) +- `path` (64 bytes): Routing path data +- `name` (32 bytes): Contact name, null-terminated UTF-8 +- `timestamp` (4 bytes LE): Last seen timestamp +- `lat` (4 bytes LE): Latitude × 1,000,000 +- `lon` (4 bytes LE): Longitude × 1,000,000 +- `lastmod` (4 bytes LE): Last modification timestamp + +**Total size**: 148 bytes + +**Path length interpretation**: +- `-1` (0xFF): Flood mode (no direct path) +- `≥0`: Direct path with N hops + +### RESP_CODE_CONTACT_MSG_RECV_V3 (0x10) + +Received direct message (protocol version 3). + +**Format**: +``` +[0x10][snr][res x2][prefix x6][path_len][txt_type][timestamp x4][extra? x4][text...] +``` + +**Fields**: +- `snr` (1 byte): Signal-to-noise ratio +- `res` (2 bytes): Reserved +- `prefix` (6 bytes): Sender's public key prefix +- `path_len` (1 byte): Path length (0xFF = direct) +- `txt_type` (1 byte): Text type (bits 7-2: type, bits 1-0: flags) + - Type 0: Plain text + - Type 1: CLI data +- `timestamp` (4 bytes LE): Message timestamp (Unix seconds) +- `extra` (4 bytes, optional): Extra data for signed/plain variants +- `text` (variable): Message text, null-terminated + +**Text decoding**: +1. Try reading at base offset (timestamp + 4) +2. If empty and room for extra bytes, try offset + 4 +3. Check for SMAZ compression prefix +4. Decode as UTF-8 + +### RESP_CODE_CHANNEL_MSG_RECV_V3 (0x11) + +Received channel message (protocol version 3). + +**Format**: +``` +[0x11][snr][res x2][channel_idx][path_len][txt_type][timestamp x4][sender_name...]: [text...] +``` + +**Fields**: +- `snr` (1 byte): Signal-to-noise ratio +- `res` (2 bytes): Reserved +- `channel_idx` (1 byte): Channel index +- `path_len` (1 byte): Path length +- `txt_type` (1 byte): Text type +- `timestamp` (4 bytes LE): Message timestamp +- Combined text format: `"[sender_name]: [message_text]"` + +### RESP_CODE_SENT (0x06) + +Confirmation that message was transmitted to LoRa radio. + +**Format**: +``` +[0x06][is_flood][ack_hash x4][timeout_ms x4] +``` + +**Fields**: +- `is_flood` (1 byte): 1 if flood mode, 0 if direct path +- `ack_hash` (4 bytes): Hash for matching future ACK +- `timeout_ms` (4 bytes LE): Expected ACK timeout in milliseconds + +### PUSH_CODE_SEND_CONFIRMED (0x82) + +ACK received for a sent message. + +**Format**: +``` +[0x82][ack_hash x4][trip_time_ms x4] +``` + +**Fields**: +- `ack_hash` (4 bytes): Hash matching RESP_CODE_SENT +- `trip_time_ms` (4 bytes LE): Round-trip time in milliseconds + +### PUSH_CODE_PATH_UPDATED (0x81) + +Notification that a contact's path has been updated by the device. + +**Format**: +``` +[0x81][pub_key x32] +``` + +**Fields**: +- `pub_key` (32 bytes): Contact whose path changed + +**Handler action**: Request updated contact list + +### RESP_CODE_BATT_AND_STORAGE (0x0C) + +Battery and storage status. + +**Format**: +``` +[0x0C][battery_mv x2][storage_used_kb x4][storage_total_kb x4] +``` + +**Fields**: +- `battery_mv` (2 bytes LE): Battery voltage in millivolts +- `storage_used_kb` (4 bytes LE): Used storage in kilobytes +- `storage_total_kb` (4 bytes LE): Total storage in kilobytes + +**Battery percentage calculation**: +```dart +// Chemistry-specific voltage ranges: +// LiFePO4: 2600-3650 mV +// LiPo/NMC: 3000-4200 mV +int percent = ((mv - minMv) * 100) / (maxMv - minMv); +``` + +### RESP_CODE_DEVICE_INFO (0x0D) + +Device capabilities and limits. + +**Format**: +``` +[0x0D][protocol_ver][max_contacts_div2][max_channels] +``` + +**Fields**: +- `protocol_ver` (1 byte): Protocol version +- `max_contacts_div2` (1 byte): Max contacts ÷ 2 (actual = value × 2) +- `max_channels` (1 byte): Max supported channels + +**Example**: `[0x0D][0x03][0x10][0x08]` = v3, 32 contacts, 8 channels + +### RESP_CODE_RADIO_SETTINGS (0x19) + +Current LoRa radio parameters. + +**Format**: +``` +[0x19][freq x4][bw x4][sf][cr] +``` + +**Fields**: +- `freq` (4 bytes LE): Frequency in Hz +- `bw` (4 bytes LE): Bandwidth in Hz +- `sf` (1 byte): Spreading factor (5-12) +- `cr` (1 byte): Coding rate (5-8, subtract 4 for actual CR) + +## Advanced Commands (Not Yet Implemented in Flutter) + +The following commands are supported by the firmware but not yet implemented in the Flutter app: + +### CMD_GET_ADVERT_PATH (0x2A) + +Get the recently heard advertisement path for a contact. + +**Purpose**: Retrieves the inbound path that was used when the contact's advertisement was last received. Useful for discovering optimal paths. + +**Format**: +``` +[0x2A][pub_key_prefix x6] +``` + +**Response**: `RESP_CODE_ADVERT_PATH` (0x16) with path data + +### CMD_GET_STATS (0x38) + +Get device statistics (protocol version 8+). + +**Format**: +``` +[0x38][stats_type] +``` + +**Stats types**: +- `0x00`: Core stats (packet counts, air time) +- `0x01`: Radio stats (RSSI, SNR, noise floor) +- `0x02`: Packet stats (detailed packet analysis) + +**Response**: `RESP_CODE_STATS` (0x18) with statistics data + +### CMD_SEND_TELEMETRY_REQ (0x27) + +Request telemetry data from a contact (deprecated in favor of binary requests). + +**Purpose**: Request sensor data, battery status, or environmental data from remote nodes. + +**Response**: `PUSH_CODE_TELEMETRY_RESPONSE` (0x8B) + +### CMD_SEND_PATH_DISCOVERY_REQ (0x34) + +Request path discovery to a contact. + +**Purpose**: Actively discover available paths to a contact by broadcasting a discovery request. + +**Response**: `PUSH_CODE_PATH_DISCOVERY_RESPONSE` (0x8D) with discovered paths + +### CMD_SET_FLOOD_SCOPE (0x36) + +Set flood routing scope (v8+). + +**Purpose**: Configure geographic or logical boundaries for flood routing to reduce mesh congestion. + +**Format**: +``` +[0x36][scope_data...] +``` + +### CMD_FACTORY_RESET (0x33) + +Factory reset the device. + +**Purpose**: Erase all stored data (contacts, keys, settings) and return to factory defaults. + +**Format**: +``` +[0x33] +``` + +**Response**: `RESP_CODE_OK` or `RESP_CODE_ERR` + +**WARNING**: This erases the device's identity! Use with caution. + +### CMD_EXPORT_PRIVATE_KEY (0x17) / CMD_IMPORT_PRIVATE_KEY (0x18) + +Export or import the device's Ed25519 private key. + +**Security**: These commands should be protected by device PIN or other authentication. + +**Export format**: +``` +[0x17] +``` + +**Import format**: +``` +[0x18][private_key x32] +``` + +**Response**: `RESP_CODE_PRIVATE_KEY` (0x0E) or `RESP_CODE_OK` + +### CMD_GET_CUSTOM_VARS (0x28) / CMD_SET_CUSTOM_VAR (0x29) + +Get or set custom device variables for application-specific configuration. + +**Get format**: +``` +[0x28] +``` + +**Set format**: +``` +[0x29][var_id][value...] +``` + +**Response**: `RESP_CODE_CUSTOM_VARS` (0x15) + +### CMD_SET_OTHER_PARAMS (0x26) + +Set miscellaneous device parameters (battery chemistry, telemetry mode, etc.). + +**Format**: +``` +[0x26][param_data...] +``` + +### CMD_SIGN_START/DATA/FINISH (0x21-0x23) + +Multi-step digital signing operation. + +**Purpose**: Sign arbitrary data using the device's private key. + +**Flow**: +1. `CMD_SIGN_START` - Initialize signing session +2. `CMD_SIGN_DATA` (multiple) - Add data chunks (max 8KB total) +3. `CMD_SIGN_FINISH` - Get Ed25519 signature + +**Response**: `RESP_CODE_SIGNATURE` (0x14) with 64-byte signature + +### PUSH_CODE_LOG_RX_DATA (0x88) + +Raw LoRa packet for debugging/decryption. + +**Format**: +``` +[0x88][flags][snr][raw_packet...] +``` + +**Used for**: Decrypting channel messages when device doesn't have the channel key. + +**Raw packet structure**: +``` +[header][transport_id? x4][path_len][path...][payload...] +``` + +**Channel message decryption flow**: +1. Parse header to get route type +2. Extract path and payload +3. For group text payload (type 0x05): + - First payload byte is channel hash + - Verify hash against known channels + - Decrypt payload using channel PSK + - Parse decrypted data as channel message + +## Message Features + +### Text Compression (SMAZ) + +The protocol supports optional SMAZ compression for text messages: + +**Encoding**: +```dart +// Only compress if it saves space +String outbound = Smaz.encodeIfSmaller(text); +``` + +**Decoding**: +```dart +// Detect and decode SMAZ prefix +String decoded = Smaz.tryDecodePrefixed(received) ?? received; +``` + +**Enable per-contact/channel**: +- Contact: Stored in `ContactSettingsStore` +- Channel: Stored in `ChannelSettingsStore` + +**Exclusions**: Structured payloads starting with `g:`, `m:`, or `V1|` are never compressed. + +### Message Reactions + +**Format**: `"m:[message_id]:[emoji]"` + +**Example**: `"m:abc123:👍"` + +**Processing**: +1. Parse reaction from incoming message +2. Find target message by `messageId` +3. Increment emoji counter in target message's `reactions` map +4. Don't display reaction as a separate message + +### Message Replies + +**Format**: `"@[node_name] [actual_message]"` + +**Example**: `"@Alice Hello there!"` + +**Processing**: +1. Parse reply mention from message text +2. Find most recent message from mentioned sender +3. Attach reply metadata to new message: + - `replyToMessageId` + - `replyToSenderName` + - `replyToText` + +### Message Retry and ACK Tracking + +The app implements automatic retry for failed messages: + +**Flow**: +1. Send message → Receive `RESP_CODE_SENT` with `ack_hash` and `timeout_ms` +2. Start timeout timer +3. On timeout: Retry with incremented attempt counter +4. On `PUSH_CODE_SEND_CONFIRMED`: Mark delivered, record trip time + +**Retry strategy**: Exponential backoff with path rotation (if auto-rotation enabled). + +## LoRa Timing Calculations + +### Airtime Calculation + +Based on Semtech SX127x datasheet formula: + +```dart +double symbolDuration = (1 << sf) / (bw / 1000.0); // ms +double preambleTime = (preambleSymbols + 4.25) * symbolDuration; + +int numerator = 8*payloadBytes - 4*sf + 28 + 16*crc - headerBytes; +int denominator = 4*(sf - 2*de); +int payloadSymbols = 8 + ceil(numerator/denominator) * (cr + 4); + +double payloadTime = payloadSymbols * symbolDuration; +int airtime = ceil(preambleTime + payloadTime); +``` + +**Variables**: +- `sf`: Spreading factor (5-12) +- `bw`: Bandwidth in Hz +- `cr`: Coding rate (5-8) +- `de`: Low data rate optimization (1 if sf≥11, else 0) +- `crc`: CRC enabled (always 1) + +### Message Timeout Calculation + +**Flood mode** (path_len = -1): +``` +timeout = 500 + (16 × airtime) ms +``` + +**Direct path** (path_len ≥ 0): +``` +timeout = 500 + ((airtime×6 + 250) × (hops+1)) ms +``` + +**Example** (SF7, BW125, 100 bytes, 2 hops): +``` +airtime ≈ 50 ms +timeout = 500 + ((50×6 + 250) × 3) = 500 + (550 × 3) = 2150 ms +``` + +## Path Routing + +### Path Format + +Paths are sequences of 1-byte public key prefixes: + +**Example path** (3 hops): +``` +[0xAB][0xCD][0xEF] // Route through nodes AB... → CD... → EF... +``` + +**Max path size**: 64 bytes = 64 hops maximum + +### Path Modes + +**Flood mode** (`path_len = -1`): +- Message floods through all nodes +- Higher latency, more reliable +- Used when no direct path known + +**Direct mode** (`path_len ≥ 0`): +- Message follows specific path +- Lower latency, less reliable +- Requires path discovery/maintenance + +### Auto Path Rotation + +When enabled, the app cycles through known paths: + +**Implementation**: +1. `PathHistoryService` tracks success/failure per path +2. On message send, select next path variant +3. Record attempt and outcome +4. Rotate to next path on retry +5. Update contact's path in-memory via `CMD_ADD_UPDATE_CONTACT` + +## Channel Encryption + +Channels use symmetric encryption with pre-shared keys (PSK). + +### Encryption Scheme + +**MAC**: HMAC-SHA256 (first 2 bytes) +**Cipher**: AES-128-ECB + +**Process**: +1. Compute channel hash: `sha256(psk)[0]` +2. Encrypt payload with AES-128-ECB using first 16 bytes of PSK +3. Compute HMAC-SHA256 of ciphertext using 32-byte padded PSK +4. Prepend 2-byte MAC to ciphertext +5. Prepend channel hash byte + +**Format**: +``` +[channel_hash][mac x2][ciphertext...] +``` + +### Decryption (from PUSH_CODE_LOG_RX_DATA) + +1. Extract channel hash from payload +2. Try each known channel's PSK +3. Verify 2-byte HMAC prefix +4. Decrypt with AES-128-ECB +5. Parse decrypted payload as channel message + +## Data Types + +### Binary Encoding + +**Little-endian** for all multi-byte integers: + +```dart +// Read uint32 +int val = data[offset] | (data[offset+1] << 8) | + (data[offset+2] << 16) | (data[offset+3] << 24); + +// Write uint32 +data[offset] = val & 0xFF; +data[offset+1] = (val >> 8) & 0xFF; +data[offset+2] = (val >> 16) & 0xFF; +data[offset+3] = (val >> 24) & 0xFF; +``` + +### String Encoding + +**Format**: Null-terminated UTF-8 + +```dart +// Read C-string +String readCString(Uint8List data, int offset, int maxLen) { + int end = offset; + while (end < offset + maxLen && end < data.length && data[end] != 0) { + end++; + } + return utf8.decode(data.sublist(offset, end), allowMalformed: true); +} +``` + +**Fallback**: If UTF-8 decoding fails, use Latin-1 (byte-to-char mapping). + +### Public Keys + +**Format**: 32-byte Ed25519 public keys + +**Hex representation**: +```dart +String hex = pubKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +// Example: "a1b2c3d4e5f6..." +``` + +**Prefix matching**: First 6 bytes used for message routing. + +## Connection Management + +### Connection States + +```dart +enum MeshCoreConnectionState { + disconnected, // Not connected + scanning, // BLE scan in progress + connecting, // Connection attempt in progress + connected, // Fully connected and initialized + disconnecting, // Disconnect in progress +} +``` + +### Auto-Reconnection + +When connection is lost (not manual disconnect): + +**Strategy**: Exponential backoff +```dart +int delayMs = 1000 * (1 << attempt); // 1s, 2s, 4s, 8s, 16s, 32s +delayMs = min(delayMs, 30000); // Cap at 30 seconds +``` + +**Attempts**: Unlimited until manual disconnect or successful reconnect. + +### Message Queue Syncing + +On connect, the app syncs queued messages: + +**Flow**: +1. Wait for `RESP_CODE_SELF_INFO` (device ready) +2. Wait for `RESP_CODE_END_OF_CONTACTS` (contacts loaded) +3. Send `CMD_SYNC_NEXT_MESSAGE` +4. Process each message/response +5. Send next `CMD_SYNC_NEXT_MESSAGE` +6. Continue until `RESP_CODE_NO_MORE_MESSAGES` + +**Trigger**: Also triggered by `PUSH_CODE_MSG_WAITING` notification. + +## Error Handling + +### Frame Validation + +All frame handlers validate: +1. Minimum frame length +2. Expected data offsets +3. Null-termination of strings +4. Public key prefix matching (for messages) + +**Invalid frames**: Silently ignored, logged to debug. + +### Send Failures + +**Exceptions**: +- Not connected: `throw Exception("Not connected to a MeshCore device")` +- No write support: `throw Exception("RX characteristic does not support write")` + +**Retries**: Write operations use platform-level retries (BLE stack). + +### Disconnection Recovery + +**Actions on disconnect**: +1. Cancel all subscriptions +2. Clear device references (but preserve ID/name for reconnection) +3. Clear in-memory contacts and conversations +4. Reset sync state flags +5. Schedule reconnection (if not manual) + +## CLI Commands + +The device supports text-based CLI commands for advanced configuration: + +**Format**: +``` +[0x01][command_string...][0x00] +``` + +**Examples**: +```dart +sendCliCommand('set privacy on') +sendCliCommand('radio sf 7') +sendCliCommand('channel add "TestChannel"') +``` + +**Note**: CLI commands don't use the frame-based protocol and are sent as UTF-8 text with 0x01 prefix. + +## Protocol Constants + +```dart +const int pubKeySize = 32; // Ed25519 public key +const int maxPathSize = 64; // Max routing path +const int pathHashSize = 1; // Path prefix size +const int maxNameSize = 32; // Max name length +const int maxFrameSize = 172; // BLE MTU constraint +const int maxTextPayloadBytes = 160; // Firmware limit (10 cipher blocks) +const int appProtocolVersion = 3; // Current protocol version +``` + +## Implementation Notes + +### Thread Safety + +`MeshCoreConnector` uses Flutter's `ChangeNotifier`: +- All state changes trigger `notifyListeners()` +- BLE callbacks run on main isolate +- No explicit locking required (single-threaded) + +### Storage + +Persistent storage uses separate stores: +- `ContactStore`: Contact list cache +- `MessageStore`: Per-contact message history +- `ChannelMessageStore`: Per-channel message history +- `ContactSettingsStore`: Per-contact settings (SMAZ, etc.) +- `ChannelSettingsStore`: Per-channel settings +- `UnreadStore`: Unread message tracking + +**Windowing**: Only most recent 200 messages kept in memory per conversation. + +### Deduplication + +**Contact messages**: Compare timestamp + text in last 10 messages. + +**Channel messages**: +- Same text + timestamp within 5 seconds = duplicate +- Self-messages: Match sender name + path contains own public key prefix + +### Notifications + +The app shows system notifications for: +- New advertisements (if enabled) +- New direct messages (if enabled) +- New channel messages (if enabled) + +**Filtering**: No notifications for outgoing messages or CLI data. + +## Firmware Implementation Details + +### C++ Firmware Side (companion_radio) + +The firmware implements the protocol through the `MyMesh` class which extends `BaseChatMesh`. Key implementation notes: + +#### Frame Constants + +```cpp +#define MAX_FRAME_SIZE 172 // BLE MTU constraint +#define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // 160 bytes +``` + +#### Timeout Calculations + +**Base constants**: +```cpp +#define SEND_TIMEOUT_BASE_MILLIS 500 +#define FLOOD_SEND_TIMEOUT_FACTOR 16.0f +#define DIRECT_SEND_PERHOP_FACTOR 6.0f +#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 +``` + +**Flood timeout**: `500 + (airtime × 16) ms` + +**Direct timeout**: `500 + ((airtime × 6 + 250) × (hops + 1)) ms` + +These match the Dart implementation. + +#### Offline Message Queue + +The firmware maintains an offline queue for when the BLE client is disconnected: + +```cpp +#define OFFLINE_QUEUE_SIZE 16 +Frame offline_queue[OFFLINE_QUEUE_SIZE]; +``` + +**Features**: +- Queues messages when app not connected +- On connection, sends `PUSH_CODE_MSG_WAITING` to trigger sync +- Channel messages can be evicted if queue full (oldest first) +- Contact messages are preserved over channel messages + +**Sync flow**: +1. App sends `CMD_SYNC_NEXT_MESSAGE` +2. Firmware sends oldest queued frame +3. App sends another `CMD_SYNC_NEXT_MESSAGE` +4. Repeat until firmware sends `RESP_CODE_NO_MORE_MESSAGES` + +#### Contact Storage + +**Lazy write strategy**: +```cpp +#define LAZY_CONTACTS_WRITE_DELAY 5000 // 5 seconds +``` + +Contact list changes trigger a delayed write (5s after last change) to reduce wear on flash storage. + +#### Advertisement Path Cache + +The firmware caches recently heard advertisement paths in volatile memory: + +```cpp +#define ADVERT_PATH_TABLE_SIZE 16 +struct AdvertPath { + uint8_t pubkey_prefix[6]; + char name[32]; + uint32_t recv_timestamp; + uint8_t path_len; + uint8_t path[MAX_PATH_SIZE]; +}; +``` + +**Purpose**: Allows `CMD_GET_ADVERT_PATH` to retrieve inbound paths for discovered nodes. + +#### Expected ACK Table + +```cpp +#define EXPECTED_ACK_TABLE_SIZE 8 +struct { + uint32_t ack; // Expected ACK hash + uint32_t msg_sent; // Timestamp when sent + ContactInfo* contact; // Recipient contact +} expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; +``` + +When message is sent, firmware: +1. Computes `expected_ack` hash from message +2. Stores in table with send timestamp +3. On ACK receipt, computes trip time +4. Sends `PUSH_CODE_SEND_CONFIRMED` to app + +#### Protocol Version Negotiation + +```cpp +uint8_t app_target_ver = 0; // Set by CMD_APP_START +``` + +The firmware adapts response formats based on app version: +- Version < 3: Uses `RESP_CODE_CONTACT_MSG_RECV` (no SNR) +- Version ≥ 3: Uses `RESP_CODE_CONTACT_MSG_RECV_V3` (includes SNR + reserved bytes) + +#### Error Codes + +```cpp +#define ERR_CODE_UNSUPPORTED_CMD 1 +#define ERR_CODE_NOT_FOUND 2 +#define ERR_CODE_TABLE_FULL 3 +#define ERR_CODE_BAD_STATE 4 +#define ERR_CODE_FILE_IO_ERROR 5 +#define ERR_CODE_ILLEGAL_ARG 6 +``` + +Returned in `RESP_CODE_ERR` frames: `[0x01][err_code]` + +### Flutter Implementation Details + +#### Message Windowing + +```dart +static const int _messageWindowSize = 200; +``` + +Only most recent 200 messages per contact/channel kept in memory. Older messages stored on disk but must be explicitly loaded via `loadOlderMessages()`. + +#### Frame Processing + +All received frames processed in `_handleFrame()`: +```dart +void _handleFrame(List data) { + final frame = Uint8List.fromList(data); + final code = frame[0]; + switch (code) { + case respCodeSelfInfo: _handleSelfInfo(frame); + case respCodeContact: _handleContact(frame); + // ... etc + } +} +``` + +**Validations**: +- Minimum frame length checks +- Null-termination validation for strings +- Public key prefix matching for messages +- Contact existence checks before processing messages + +#### Auto-Reconnection + +```dart +int _nextReconnectDelayMs() { + final attempt = _reconnectAttempts.clamp(0, 6); + final delayMs = 1000 * (1 << attempt); // Exponential backoff + return delayMs > 30000 ? 30000 : delayMs; +} +``` + +**Strategy**: 1s, 2s, 4s, 8s, 16s, 32s, then capped at 30s. + +#### Self-Info Retry + +```dart +Timer.periodic(const Duration(milliseconds: 3500), (timer) { + if (!_awaitingSelfInfo) timer.cancel(); + sendFrame(buildAppStartFrame()); +}); +``` + +On connect, if `RESP_CODE_SELF_INFO` not received within 3s, retry `CMD_APP_START` every 3.5s until received. + +#### Message Deduplication + +**Contact messages**: Compare last 10 messages for same timestamp + text. + +**Channel messages**: +- Same text within 5 seconds = duplicate +- Self-message detection: Match sender name with self name + path contains own public key prefix + +#### Reaction Processing + +```dart +final reactionInfo = Message.parseReaction(message.text); +if (reactionInfo != null) { + _processContactReaction(messages, reactionInfo); + return; // Don't add as visible message +} +``` + +Reactions are parsed, processed to update target message's reaction counts, but never displayed as standalone messages. + +## References + +- **Firmware Repository**: https://github.com/nonik0/meshcore +- **LoRa Airtime Calculator**: Based on Semtech AN1200.22 +- **SMAZ Compression**: https://github.com/antirez/smaz +- **Ed25519**: https://ed25519.cr.yp.to/ +- **AES-128-ECB**: FIPS 197 +- **Nordic UART Service (NUS)**: Nordic Semiconductor BLE UART specification diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 634a1d1..40f9b11 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1,13 +1,11 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import 'package:pointycastle/export.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:uuid/uuid.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; @@ -66,6 +64,8 @@ class MeshCoreConnector extends ChangeNotifier { final Map> _conversations = {}; final Map> _channelMessages = {}; final Set _loadedConversationKeys = {}; + final Map> _processedChannelReactions = {}; // channelIndex -> Set of "reactionKey_emoji" + final Map> _processedContactReactions = {}; // contactPubKeyHex -> Set of "reactionKey_emoji" StreamSubscription>? _scanSubscription; StreamSubscription? _connectionSubscription; @@ -101,6 +101,10 @@ class MeshCoreConnector extends ChangeNotifier { bool _queuedMessageSyncInFlight = false; bool _didInitialQueueSync = false; bool _pendingQueueSync = false; + Timer? _queueSyncTimeout; + int _queueSyncRetries = 0; + static const int _maxQueueSyncRetries = 3; + static const int _queueSyncTimeoutMs = 5000; // 5 second timeout // Services MessageRetryService? _retryService; @@ -316,6 +320,19 @@ class MeshCoreConnector extends ChangeNotifier { return count; } + int getTotalUnreadCount() { + var total = 0; + // Count unread contact messages + for (final contact in _contacts) { + total += getUnreadCountForContact(contact); + } + // Count unread channel messages + for (final channelIndex in _channelMessages.keys) { + total += getUnreadCountForChannelIndex(channelIndex); + } + return total; + } + bool isChannelSmazEnabled(int channelIndex) { return _channelSmazEnabled[channelIndex] ?? false; } @@ -675,6 +692,9 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); + // Enable wake lock to prevent BLE disconnection when screen turns off + await WakelockPlus.enable(); + await _requestDeviceInfo(); final gotSelfInfo = await _waitForSelfInfo( timeout: const Duration(seconds: 3), @@ -778,6 +798,9 @@ class MeshCoreConnector extends ChangeNotifier { } _setState(MeshCoreConnectionState.disconnecting); + // Disable wake lock when disconnecting + await WakelockPlus.disable(); + await _notifySubscription?.cancel(); _notifySubscription = null; @@ -785,6 +808,9 @@ class MeshCoreConnector extends ChangeNotifier { _connectionSubscription = null; _selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer = null; + _queueSyncTimeout?.cancel(); + _queueSyncTimeout = null; + _queueSyncRetries = 0; try { // Skip queued BLE operations so disconnect doesn't get stuck behind them. @@ -912,16 +938,20 @@ class MeshCoreConnector extends ChangeNotifier { ); } + Future getContactByKey(Uint8List pubKey) async { + if (!isConnected) return; + await sendFrame(buildGetContactByKeyFrame(pubKey)); + } + Future sendMessage( Contact contact, - String text, { - bool clearPath = false, - }) async { + String text, + ) async { if (!isConnected || text.isEmpty) return; // Handle auto-rotation if enabled PathSelection? autoSelection; - if (_appSettingsService?.settings.autoRouteRotationEnabled == true && !clearPath) { + if (_appSettingsService?.settings.autoRouteRotationEnabled == true) { autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex); if (autoSelection != null) { _pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection); @@ -936,21 +966,20 @@ class MeshCoreConnector extends ChangeNotifier { } if (_retryService != null) { - final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection); - final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection); + final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection); + final pathLength = _resolveOutgoingPathLength(contact, autoSelection); final selectedContact = _applyAutoSelection(contact, autoSelection); await _retryService!.sendMessageWithRetry( contact: selectedContact, text: text, - clearPath: clearPath, pathSelection: autoSelection, pathBytes: pathBytes, pathLength: pathLength, ); } else { // Fallback to old behavior if retry service not initialized - final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection); - final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection); + final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection); + final pathLength = _resolveOutgoingPathLength(contact, autoSelection); final message = Message.outgoing( contact.publicKey, text, @@ -985,9 +1014,72 @@ class MeshCoreConnector extends ChangeNotifier { )); } + /// Set path override for a contact (persists across contact refreshes) + /// pathLen: -1 = force flood, null = auto (use device path), >= 0 = specific path + Future setPathOverride( + Contact contact, { + int? pathLen, + Uint8List? pathBytes, + }) async { + // Find contact in list + final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex); + if (index == -1) return; + + // Update contact with new path override + _contacts[index] = _contacts[index].copyWith( + pathOverride: pathLen, + pathOverrideBytes: pathBytes, + clearPathOverride: pathLen == null, // Clear if pathLen is null + ); + + // Save to storage + await _contactStore.saveContacts(_contacts); + + // If setting a specific path (not flood, not auto), also sync with device + if (pathLen != null && pathLen >= 0 && pathBytes != null) { + await setContactPath(contact, pathBytes, pathLen); + } + + debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}'); + notifyListeners(); + } + Future sendChannelMessage(Channel channel, String text) async{ if (!isConnected || text.isEmpty) return; + // Check if this is a reaction - if so, process it immediately instead of adding as a message + final reactionInfo = ReactionHelper.parseReaction(text); + if (reactionInfo != null) { + // Check if we've already processed this reaction + _processedChannelReactions.putIfAbsent(channel.index, () => {}); + final reactionKey = reactionInfo.reactionKey; + final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null; + + if (reactionIdentifier != null && _processedChannelReactions[channel.index]!.contains(reactionIdentifier)) { + // Already processed, don't process again + return; + } + + // Get the in-memory messages list (same as _addChannelMessage uses) + _channelMessages.putIfAbsent(channel.index, () => []); + final messages = _channelMessages[channel.index]!; + + // Process reaction locally to update the UI immediately + _processReaction(messages, reactionInfo); + await _channelMessageStore.saveChannelMessages(channel.index, messages); + + // Mark this reaction as processed + if (reactionIdentifier != null) { + _processedChannelReactions[channel.index]!.add(reactionIdentifier); + } + + notifyListeners(); + + // Send the reaction to the device (don't add as a visible message) + await sendFrame(buildSendChannelTextMsgFrame(channel.index, text)); + return; + } + final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index); _addChannelMessage(channel.index, message); notifyListeners(); @@ -1025,16 +1117,10 @@ class MeshCoreConnector extends ChangeNotifier { _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex); if (existingIndex >= 0) { final existing = _contacts[existingIndex]; - _contacts[existingIndex] = Contact( - publicKey: existing.publicKey, - name: existing.name, - type: existing.type, + // Use copyWith to preserve pathOverride and pathOverrideBytes + _contacts[existingIndex] = existing.copyWith( pathLength: -1, path: Uint8List(0), - latitude: existing.latitude, - longitude: existing.longitude, - lastSeen: existing.lastSeen, - lastMessageAt: existing.lastMessageAt, ); notifyListeners(); unawaited(_persistContacts()); @@ -1082,15 +1168,47 @@ class MeshCoreConnector extends ChangeNotifier { if (!isConnected) { _isSyncingQueuedMessages = false; _queuedMessageSyncInFlight = false; + _queueSyncRetries = 0; return; } if (_queuedMessageSyncInFlight) return; _queuedMessageSyncInFlight = true; + + // Cancel any existing timeout + _queueSyncTimeout?.cancel(); + + // Set up timeout for this request + _queueSyncTimeout = Timer(Duration(milliseconds: _queueSyncTimeoutMs), () { + _handleQueueSyncTimeout(); + }); + + debugPrint('[QueueSync] Requesting next message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)'); + try { await sendFrame(buildSyncNextMessageFrame()); } catch (e) { + debugPrint('[QueueSync] Error sending sync request: $e'); _queuedMessageSyncInFlight = false; _isSyncingQueuedMessages = false; + _queueSyncTimeout?.cancel(); + _queueSyncRetries = 0; + } + } + + void _handleQueueSyncTimeout() { + debugPrint('[QueueSync] Timeout waiting for message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)'); + + if (_queueSyncRetries < _maxQueueSyncRetries) { + // Retry + _queueSyncRetries++; + _queuedMessageSyncInFlight = false; + _requestNextQueuedMessage(); + } else { + // Max retries reached, give up + debugPrint('[QueueSync] Max retries reached, stopping sync'); + _queuedMessageSyncInFlight = false; + _isSyncingQueuedMessages = false; + _queueSyncRetries = 0; } } @@ -1128,22 +1246,51 @@ class MeshCoreConnector extends ChangeNotifier { await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}'); } + final Set _expectedChannelIndices = {}; + Future getChannels({int? maxChannels}) async { if (!isConnected) return; _isLoadingChannels = true; + final previousChannels = List.from(_channels); _channels.clear(); + _expectedChannelIndices.clear(); notifyListeners(); - // Request each channel index + // Request each channel index (send all requests in parallel) final channelCount = maxChannels ?? _maxChannels; for (int i = 0; i < channelCount; i++) { - await sendFrame(buildGetChannelFrame(i)); + _expectedChannelIndices.add(i); + sendFrame(buildGetChannelFrame(i)); // No await - send all at once } - // Wait a bit for responses to arrive, then apply final sort - await Future.delayed(const Duration(seconds: 2)); + // Wait for responses with timeout + final stopwatch = Stopwatch()..start(); + const maxWaitTime = Duration(seconds: 5); + const checkInterval = Duration(milliseconds: 100); + + while (_expectedChannelIndices.isNotEmpty && stopwatch.elapsed < maxWaitTime) { + await Future.delayed(checkInterval); + } + + stopwatch.stop(); + + // If timeout expired and we're still missing channels, restore them from previous load + if (_expectedChannelIndices.isNotEmpty) { + debugPrint('Channel loading timeout - missing ${_expectedChannelIndices.length} channels, restoring from cache'); + for (final prevChannel in previousChannels) { + if (_expectedChannelIndices.contains(prevChannel.index) && + !_channels.any((c) => c.index == prevChannel.index)) { + _channels.add(prevChannel); + debugPrint('Restored channel ${prevChannel.index} (${prevChannel.name}) from cache'); + } + } + } + + debugPrint('Channel loading completed: received ${_channels.length}/$channelCount channels in ${stopwatch.elapsedMilliseconds}ms'); + _isLoadingChannels = false; + _expectedChannelIndices.clear(); _applyChannelOrder(); notifyListeners(); } @@ -1266,7 +1413,10 @@ class MeshCoreConnector extends ChangeNotifier { if (contact != null) { _pathHistoryService!.handlePathUpdated(contact); - refreshContactsSinceLastmod(); + // Refresh just this specific contact instead of all contacts. + // This avoids race conditions with _preserveContactsOnRefresh flag + // that can occur when using refreshContactsSinceLastmod(). + getContactByKey(pubKey); } } } @@ -1341,13 +1491,19 @@ class MeshCoreConnector extends ChangeNotifier { } void _handleNoMoreMessages() { + debugPrint('[QueueSync] No more messages, sync complete'); + _queueSyncTimeout?.cancel(); _isSyncingQueuedMessages = false; _queuedMessageSyncInFlight = false; + _queueSyncRetries = 0; // Reset retry counter on successful completion } void _handleQueuedMessageReceived() { if (!_isSyncingQueuedMessages) return; + debugPrint('[QueueSync] Message received, requesting next'); + _queueSyncTimeout?.cancel(); // Cancel timeout - message arrived _queuedMessageSyncInFlight = false; + _queueSyncRetries = 0; // Reset retry counter on successful message unawaited(_requestNextQueuedMessage()); } @@ -1432,8 +1588,11 @@ class MeshCoreConnector extends ChangeNotifier { final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt) ? existing.lastMessageAt : contact.lastMessageAt; + // CRITICAL: Preserve user's path override when contact is refreshed from device _contacts[existingIndex] = contact.copyWith( lastMessageAt: mergedLastMessageAt, + pathOverride: existing.pathOverride, // Preserve user's path choice + pathOverrideBytes: existing.pathOverrideBytes, ); } else { _contacts.add(contact); @@ -1559,10 +1718,28 @@ class MeshCoreConnector extends ChangeNotifier { return false; } - void _handleIncomingMessage(Uint8List frame) { + void _handleIncomingMessage(Uint8List frame) async { if (_selfPublicKey == null) return; var message = _parseContactMessage(frame); + + // If message parsing failed due to unknown contact, refresh contacts and retry + if (message == null && !_isLoadingContacts) { + final senderPrefix = _extractSenderPrefix(frame); + if (senderPrefix != null) { + final hasContact = _contacts.any((c) => _matchesPrefix(c.publicKey, senderPrefix)); + if (!hasContact) { + debugPrint('Received message from unknown contact, refreshing contacts...'); + await refreshContactsSinceLastmod(); + // Retry parsing after refresh + message = _parseContactMessage(frame); + if (message != null) { + debugPrint('Successfully parsed message after contact refresh'); + } + } + } + } + if (message != null) { final contact = _contacts.cast().firstWhere( (c) => c?.publicKeyHex == message!.senderKeyHex, @@ -1605,6 +1782,7 @@ class MeshCoreConnector extends ChangeNotifier { contactName: contact?.name ?? 'Unknown', message: message.text, contactId: message.senderKeyHex, + badgeCount: getTotalUnreadCount(), ); } } @@ -1680,6 +1858,21 @@ class MeshCoreConnector extends ChangeNotifier { return true; } + Uint8List? _extractSenderPrefix(Uint8List frame) { + if (frame.isEmpty) return null; + final code = frame[0]; + if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) { + return null; + } + + final prefixOffset = code == respCodeContactMsgRecvV3 ? 4 : 1; + const prefixLen = 6; + + if (frame.length < prefixOffset + prefixLen) return null; + + return frame.sublist(prefixOffset, prefixOffset + prefixLen); + } + void _ensureContactSmazSettingLoaded(String contactKeyHex) { if (_contactSmazEnabled.containsKey(contactKeyHex)) return; _contactSettingsStore.loadSmazEnabled(contactKeyHex).then((enabled) { @@ -1729,6 +1922,7 @@ class MeshCoreConnector extends ChangeNotifier { channelName: label, message: message.text, channelIndex: channelIndex, + badgeCount: getTotalUnreadCount(), ); } @@ -1884,14 +2078,21 @@ class MeshCoreConnector extends ChangeNotifier { void _handleChannelInfo(Uint8List frame) { final channel = Channel.fromFrame(frame); - if (channel != null && !channel.isEmpty) { - _channels.add(channel); + if (channel != null) { + // Mark this channel index as received + _expectedChannelIndices.remove(channel.index); + + // Only add non-empty channels to the list + if (!channel.isEmpty) { + _channels.add(channel); + } + // Only sort and notify if we're not currently loading channels // This prevents the list from jumping around as channels arrive during refresh if (!_isLoadingChannels) { _applyChannelOrder(); + notifyListeners(); } - notifyListeners(); } } @@ -1999,17 +2200,24 @@ class MeshCoreConnector extends ChangeNotifier { // Parse reaction info final reactionInfo = Message.parseReaction(message.text); if (reactionInfo != null) { - // Check if we've already processed this exact reaction - final isDuplicate = messages.any((m) => - m.text == message.text && - m.senderKey == message.senderKey && - m.timestamp.millisecondsSinceEpoch == message.timestamp.millisecondsSinceEpoch - ); + // Check if we've already processed this exact reaction using lightweight key + _processedContactReactions.putIfAbsent(pubKeyHex, () => {}); + final reactionKey = reactionInfo.reactionKey; + final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null; + + final isDuplicate = reactionIdentifier != null && + _processedContactReactions[pubKeyHex]!.contains(reactionIdentifier); if (!isDuplicate) { // New reaction - process it _processContactReaction(messages, reactionInfo); _messageStore.saveMessages(pubKeyHex, messages); + + // Mark as processed + if (reactionIdentifier != null) { + _processedContactReactions[pubKeyHex]!.add(reactionIdentifier); + } + notifyListeners(); } return; // Don't add reaction as a visible message @@ -2115,29 +2323,50 @@ class MeshCoreConnector extends ChangeNotifier { Uint8List _resolveOutgoingPathBytes( Contact contact, - bool clearPath, PathSelection? selection, ) { - if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) { + // 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 device flood mode or PathSelection flood + if (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? _resolveOutgoingPathLength( Contact contact, - bool clearPath, PathSelection? selection, ) { - if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) { + // Priority 1: Check user's path override + if (contact.pathOverride != null) { + return contact.pathOverride; + } + + // Priority 2: Check device flood mode or PathSelection flood + if (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; } @@ -2148,19 +2377,24 @@ class MeshCoreConnector extends ChangeNotifier { // Parse reaction info final reactionInfo = ChannelMessage.parseReaction(message.text); if (reactionInfo != null) { - // Check if we've already processed this exact reaction by looking for duplicate in messages - // Reaction messages are kept in the list but won't be displayed (filtered in UI or here) - final isDuplicate = messages.any((m) => - m.text == message.text && - m.senderName == message.senderName && - m.timestamp.millisecondsSinceEpoch == message.timestamp.millisecondsSinceEpoch - ); + // Check if we've already processed this exact reaction using lightweight key + _processedChannelReactions.putIfAbsent(channelIndex, () => {}); + final reactionKey = reactionInfo.reactionKey; + final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null; + + final isDuplicate = reactionIdentifier != null && + _processedChannelReactions[channelIndex]!.contains(reactionIdentifier); if (!isDuplicate) { // New reaction - process it _processReaction(messages, reactionInfo); // Save updated messages _channelMessageStore.saveChannelMessages(channelIndex, messages); + + // Mark as processed + if (reactionIdentifier != null) { + _processedChannelReactions[channelIndex]!.add(reactionIdentifier); + } } return false; // Don't add reaction as a visible message } @@ -2208,11 +2442,16 @@ class MeshCoreConnector extends ChangeNotifier { processedMessage.pathLength, mergedPathBytes.length, ); + final newRepeatCount = existing.repeatCount + 1; messages[existingIndex] = existing.copyWith( - repeatCount: existing.repeatCount + 1, + repeatCount: newRepeatCount, pathLength: mergedPathLength, pathBytes: mergedPathBytes, pathVariants: mergedPathVariants, + // Mark as sent when first repeat is heard + status: newRepeatCount == 1 && existing.status == ChannelMessageStatus.pending + ? ChannelMessageStatus.sent + : existing.status, ); } else { messages.add(processedMessage); @@ -2357,6 +2596,9 @@ class MeshCoreConnector extends ChangeNotifier { } void _handleDisconnection() { + // Disable wake lock when connection is lost + WakelockPlus.disable(); + _notifySubscription?.cancel(); _notifySubscription = null; _connectionSubscription?.cancel(); diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 2ba2431..2c57d1d 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -25,6 +25,7 @@ const int cmdGetBattAndStorage = 20; const int cmdDeviceQuery = 22; const int cmdSendLogin = 26; const int cmdSendStatusReq = 27; +const int cmdGetContactByKey = 30; const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdGetRadioSettings = 57; @@ -507,6 +508,15 @@ Uint8List buildUpdateContactPathFrame( return frame; } +// Build CMD_GET_CONTACT_BY_KEY frame +// Format: [cmd][pub_key x32] +Uint8List buildGetContactByKeyFrame(Uint8List pubKey) { + final frame = Uint8List(1 + pubKeySize); + frame[0] = cmdGetContactByKey; + frame.setRange(1, 1 + pubKeySize, pubKey); + return frame; +} + // Build CMD_GET_RADIO_SETTINGS frame Uint8List buildGetRadioSettingsFrame() { return Uint8List.fromList([cmdGetRadioSettings]); diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 99fb74c..004904b 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -1,22 +1,53 @@ class ReactionInfo { final String targetMessageId; final String emoji; + final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix ReactionInfo({ required this.targetMessageId, required this.emoji, + this.reactionKey, }); } class ReactionHelper { /// Parse reaction format: r:[messageId]:[emoji] + /// Supports both old format (full messageId) and new format (timestamp_senderPrefix) static ReactionInfo? parseReaction(String text) { final regex = RegExp(r'^r:([^:]+):(.+)$'); final match = regex.firstMatch(text); if (match == null) return null; + + final targetId = match.group(1)!; + final emoji = match.group(2)!; + + // Extract reaction key for deduplication + // If targetId is in new format (timestamp_senderPrefix), use it directly + // Otherwise, extract timestamp from old format (timestamp_nameHash_textHash) + String? reactionKey; + if (targetId.contains('_')) { + final parts = targetId.split('_'); + if (parts.length >= 2) { + // New format: timestamp_senderPrefix, or old format with at least timestamp + reactionKey = '${parts[0]}_${parts[1]}'; + } + } + return ReactionInfo( - targetMessageId: match.group(1)!, - emoji: match.group(2)!, + targetMessageId: targetId, + emoji: emoji, + reactionKey: reactionKey, ); } + + /// Generate a lightweight reaction key for a message + /// Format: r:[timestamp]_[senderPrefix]:[emoji] + static String buildReactionText(String timestamp, String senderPrefix, String emoji) { + return 'r:${timestamp}_$senderPrefix:$emoji'; + } + + /// Extract sender prefix from public key hex (first 8 chars) + static String getSenderPrefix(String senderKeyHex) { + return senderKeyHex.substring(0, 8); + } } diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 1f5003d..389a829 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -5,8 +5,10 @@ class Contact { final Uint8List publicKey; final String name; final int type; - final int pathLength; // -1 = flood, 0+ = direct hops - final Uint8List path; + final int pathLength; // -1 = flood, 0+ = direct hops (from device) + final Uint8List path; // Path bytes from device + final int? pathOverride; // User's path override: -1 = force flood, null = auto + final Uint8List? pathOverrideBytes; // User's path override bytes final double? latitude; final double? longitude; final DateTime lastSeen; @@ -18,6 +20,8 @@ class Contact { required this.type, required this.pathLength, required this.path, + this.pathOverride, + this.pathOverrideBytes, this.latitude, this.longitude, required this.lastSeen, @@ -55,6 +59,9 @@ class Contact { int? type, int? pathLength, Uint8List? path, + int? pathOverride, + Uint8List? pathOverrideBytes, + bool clearPathOverride = false, double? latitude, double? longitude, DateTime? lastSeen, @@ -66,6 +73,8 @@ class Contact { type: type ?? this.type, pathLength: pathLength ?? this.pathLength, path: path ?? this.path, + pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride), + pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes), latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, lastSeen: lastSeen ?? this.lastSeen, diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index e2dac21..9244dd8 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -42,6 +41,11 @@ class _ChannelChatScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; context.read().setActiveChannel(widget.channel.index); + + // Scroll to bottom when opening channel chat + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } }); } @@ -352,12 +356,15 @@ class _ChannelChatScreenState extends State { Widget contentPreview; if (gifId != null) { - contentPreview = Row( - children: [ - Icon(Icons.gif_box, size: 14, color: previewTextColor), - const SizedBox(width: 4), - Text('GIF', style: TextStyle(fontSize: 12, color: previewTextColor)), - ], + contentPreview = ClipRRect( + borderRadius: BorderRadius.circular(4), + child: GifMessage( + url: 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: colorScheme.surfaceContainerHighest, + fallbackTextColor: previewTextColor, + width: 120, + height: 80, + ), ); } else if (poi != null) { contentPreview = Row( @@ -843,6 +850,8 @@ class _ChannelChatScreenState extends State { void _sendReaction(ChannelMessage message, String emoji) { final connector = context.read(); + // Send reaction with full messageId to find target, but parser will extract + // lightweight reactionKey (timestamp_senderPrefix) for deduplication final reactionText = 'r:${message.messageId}:$emoji'; connector.sendChannelMessage(widget.channel, reactionText); } diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 56b1400..debd1cf 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -78,48 +78,46 @@ class _ChannelsScreenState extends State ), ], ), - body: Consumer( - builder: (context, connector, child) { - if (connector.isLoadingChannels) { - return const Center(child: CircularProgressIndicator()); - } + body: () { + if (connector.isLoadingChannels) { + return const Center(child: CircularProgressIndicator()); + } - final channels = connector.channels; + final channels = connector.channels; - if (channels.isEmpty) { - return EmptyState( - icon: Icons.tag, - title: 'No channels configured', - action: FilledButton.icon( - onPressed: () => _addPublicChannel(context, connector), - icon: const Icon(Icons.public), - label: const Text('Add Public Channel'), + if (channels.isEmpty) { + return EmptyState( + icon: Icons.tag, + title: 'No channels configured', + action: FilledButton.icon( + onPressed: () => _addPublicChannel(context, connector), + icon: const Icon(Icons.public), + label: const Text('Add Public Channel'), + ), + ); + } + + return ReorderableListView.builder( + padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 88), + buildDefaultDragHandles: false, + itemCount: channels.length, + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) newIndex -= 1; + final reordered = List.from(channels); + final item = reordered.removeAt(oldIndex); + reordered.insert(newIndex, item); + unawaited( + connector.setChannelOrder( + reordered.map((c) => c.index).toList(), ), ); - } - - return ReorderableListView.builder( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 88), - buildDefaultDragHandles: false, - itemCount: channels.length, - onReorder: (oldIndex, newIndex) { - if (newIndex > oldIndex) newIndex -= 1; - final reordered = List.from(channels); - final item = reordered.removeAt(oldIndex); - reordered.insert(newIndex, item); - unawaited( - connector.setChannelOrder( - reordered.map((c) => c.index).toList(), - ), - ); - }, - itemBuilder: (context, index) { - final channel = channels[index]; - return _buildChannelTile(context, connector, channel, index); - }, - ); - }, - ), + }, + itemBuilder: (context, index) { + final channel = channels[index]; + return _buildChannelTile(context, connector, channel, index); + }, + ); + }(), floatingActionButton: FloatingActionButton( onPressed: () => _showAddChannelDialog(context), child: const Icon(Icons.add), @@ -144,7 +142,7 @@ class _ChannelsScreenState extends State final unreadCount = connector.getUnreadCountForChannel(channel); return Card( key: ValueKey('channel_${channel.index}'), - margin: const EdgeInsets.symmetric(vertical: 4), + margin: const EdgeInsets.only(bottom: 12), child: ListTile( dense: true, minVerticalPadding: 0, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 1b734e5..c48d7b8 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -32,7 +32,6 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { final _textController = TextEditingController(); final _scrollController = ScrollController(); - bool _clearPath = false; @override void initState() { @@ -40,6 +39,11 @@ class _ChatScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; context.read().setActiveContact(widget.contact.publicKeyHex); + + // Scroll to bottom when opening chat + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } }); } @@ -60,75 +64,86 @@ class _ChatScreenState extends State { final contact = _resolveContact(connector); final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); final unreadLabel = 'Unread: $unreadCount'; - final pathLabel = _clearPath ? 'Flood (forced)' : _currentPathLabel(contact); - final canShowPathDetails = !_clearPath && contact.path.isNotEmpty; + final pathLabel = _currentPathLabel(contact); + + // Show path details if we have path data (from device or override) + final hasPathData = contact.path.isNotEmpty || contact.pathOverrideBytes != null; + final effectivePath = contact.pathOverrideBytes ?? contact.path; + return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(contact.name), - if (canShowPathDetails) - GestureDetector( - behavior: HitTestBehavior.opaque, - onLongPress: () => _showFullPathDialog(context, contact.path), - child: Text( - '$pathLabel • $unreadLabel', - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal), - ), - ) - else - Text( + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null, + child: Text( '$pathLabel • $unreadLabel', overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.normal, + decoration: hasPathData ? TextDecoration.underline : null, + decorationStyle: TextDecorationStyle.dotted, + ), ), + ), ], ); }, ), centerTitle: false, actions: [ - PopupMenuButton( - icon: Icon(_clearPath ? Icons.waves : Icons.route), - tooltip: 'Routing mode', - onSelected: (mode) { - setState(() { - _clearPath = (mode == 'flood'); - }); + Consumer( + builder: (context, connector, _) { + final contact = _resolveContact(connector); + final isFloodMode = contact.pathOverride == -1; + + return PopupMenuButton( + icon: Icon(isFloodMode ? Icons.waves : Icons.route), + tooltip: 'Routing mode', + onSelected: (mode) async { + if (mode == 'flood') { + await connector.setPathOverride(contact, pathLen: -1); + } else { + await connector.setPathOverride(contact, pathLen: null); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'auto', + child: Row( + children: [ + Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), + const SizedBox(width: 8), + Text( + 'Auto (use saved path)', + style: TextStyle( + fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + PopupMenuItem( + value: 'flood', + child: Row( + children: [ + Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), + const SizedBox(width: 8), + Text( + 'Force Flood Mode', + style: TextStyle( + fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + ], + ); }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'auto', - child: Row( - children: [ - Icon(Icons.auto_mode, size: 20, color: !_clearPath ? Theme.of(context).primaryColor : null), - const SizedBox(width: 8), - Text( - 'Auto (use saved path)', - style: TextStyle( - fontWeight: !_clearPath ? FontWeight.bold : FontWeight.normal, - ), - ), - ], - ), - ), - PopupMenuItem( - value: 'flood', - child: Row( - children: [ - Icon(Icons.waves, size: 20, color: _clearPath ? Theme.of(context).primaryColor : null), - const SizedBox(width: 8), - Text( - 'Force Flood Mode', - style: TextStyle( - fontWeight: _clearPath ? FontWeight.bold : FontWeight.normal, - ), - ), - ], - ), - ), - ], ), IconButton( icon: const Icon(Icons.timeline), @@ -304,7 +319,6 @@ class _ChatScreenState extends State { connector.sendMessage( widget.contact, text, - clearPath: _clearPath, ); _textController.clear(); @@ -416,23 +430,14 @@ class _ChatScreenState extends State { final pathBytes = Uint8List.fromList(path.pathBytes); final pathLength = path.pathBytes.length; - await connector.setContactPath( + // Set the path override to persist user's choice + await connector.setPathOverride( widget.contact, - pathBytes, - pathLength, - ); - - // Update contact in memory directly for immediate UI feedback - connector.updateContactInMemory( - widget.contact.publicKeyHex, + pathLen: pathLength, pathBytes: pathBytes, - pathLength: pathLength, ); if (!context.mounted) return; - setState(() { - _clearPath = false; - }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'), @@ -499,10 +504,9 @@ class _ChatScreenState extends State { ), title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)), subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)), - onTap: () { - setState(() { - _clearPath = true; - }); + onTap: () async { + await connector.setPathOverride(widget.contact, pathLen: -1); + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'), @@ -573,9 +577,16 @@ class _ChatScreenState extends State { } String _currentPathLabel(Contact contact) { + // Check if user has set a path override + if (contact.pathOverride != null) { + if (contact.pathOverride! < 0) return 'Flood (forced)'; + if (contact.pathOverride == 0) return 'Direct (forced)'; + return '${contact.pathOverride} hops (forced)'; + } + + // Use device's path if (contact.pathLength < 0) return 'Flood (auto)'; if (contact.pathLength == 0) return 'Direct'; - if (contact.pathIdList.isNotEmpty) return contact.pathIdList; return '${contact.pathLength} hops'; } @@ -604,7 +615,7 @@ class _ChatScreenState extends State { 'Location', '${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}', ), - _buildInfoRow('Public Key', contact.publicKeyHex.substring(0, 16) + '...'), + _buildInfoRow('Public Key', '${contact.publicKeyHex.substring(0, 16)}...'), const Divider(), SwitchListTile( contentPadding: EdgeInsets.zero, @@ -1125,12 +1136,10 @@ class _ChatScreenState extends State { void _retryMessage(Message message) { final connector = Provider.of(context, listen: false); - // Retry with clearPath if the message has no path or pathLength is -1 (indicating flood was used) - final shouldClearPath = message.pathLength != null && message.pathLength! < 0; + // Retry using the contact's current path override setting connector.sendMessage( widget.contact, message.text, - clearPath: shouldClearPath, ); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Retrying message')), @@ -1151,7 +1160,11 @@ class _ChatScreenState extends State { void _sendReaction(Message message, String emoji) { final connector = context.read(); - final reactionText = 'r:${message.messageId}:$emoji'; + // Send reaction with messageId if available, otherwise use lightweight format + // Parser will extract reactionKey (timestamp_senderPrefix) for deduplication + final messageId = message.messageId ?? + '${message.timestamp.millisecondsSinceEpoch}_${message.senderKeyHex.substring(0, 8)}'; + final reactionText = 'r:$messageId:$emoji'; connector.sendMessage(widget.contact, reactionText); } } @@ -1176,7 +1189,6 @@ class _MessageBubble extends StatelessWidget { final gifId = _parseGifId(message.text); final poi = _parsePoiMessage(message.text); final isFailed = message.status == MessageStatus.failed; - final attempts = message.retryCount + 1; final bubbleColor = isFailed ? colorScheme.errorContainer : (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest); @@ -1240,13 +1252,14 @@ class _MessageBubble extends StatelessWidget { color: textColor, ), ), - if (isOutgoing) ...[ + if (isOutgoing && message.retryCount > 0) ...[ const SizedBox(height: 4), Text( - 'Attempts: $attempts', + 'Retry ${message.retryCount}/4', style: TextStyle( fontSize: 10, color: metaColor, + fontWeight: FontWeight.w500, ), ), ], diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 31382a5..e4a29f3 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -682,7 +682,7 @@ class _ContactsScreenState extends State return; } final exists = _groups.any((g) { - if (isEditing && g.name == group!.name) return false; + if (isEditing && g.name == group.name) return false; return g.name.toLowerCase() == name.toLowerCase(); }); if (exists) { @@ -693,7 +693,7 @@ class _ContactsScreenState extends State } setState(() { if (isEditing) { - final index = _groups.indexWhere((g) => g.name == group!.name); + final index = _groups.indexWhere((g) => g.name == group.name); if (index != -1) { _groups[index] = ContactGroup( name: name, diff --git a/lib/screens/device_screen.dart b/lib/screens/device_screen.dart index ad81a47..f844f84 100644 --- a/lib/screens/device_screen.dart +++ b/lib/screens/device_screen.dart @@ -123,7 +123,7 @@ class _DeviceScreenState extends State return Card( elevation: 0, - color: colorScheme.surfaceVariant, + color: colorScheme.surfaceContainerHighest, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index dafd69c..248c035 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -142,7 +142,9 @@ class _MapCacheScreenState extends State { ), ); - if (confirmed != true) return; + if (confirmed != true || !mounted) return; + + final cacheService = context.read(); setState(() { _isDownloading = true; @@ -150,7 +152,6 @@ class _MapCacheScreenState extends State { _failedTiles = 0; }); - final cacheService = context.read(); final result = await cacheService.downloadRegion( bounds: bounds, minZoom: _minZoom, @@ -198,7 +199,7 @@ class _MapCacheScreenState extends State { ], ), ); - if (confirmed != true) return; + if (confirmed != true || !mounted) return; final cacheService = context.read(); await cacheService.clearCache(); diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index b8699a0..b15fc88 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -785,10 +785,13 @@ class _MapScreenState extends State { } final label = await _promptForLabel(context, defaultLabel); - if (label == null) return; + if (label == null || !mounted) return; final markerText = _formatMarkerMessage(position, label, flags); + if (!mounted) return; + await _showRecipientSheet( + // ignore: use_build_context_synchronously context: context, connector: connector, markerText: markerText, diff --git a/lib/screens/repeater_cli_screen.dart b/lib/screens/repeater_cli_screen.dart index 4f3025e..297cca1 100644 --- a/lib/screens/repeater_cli_screen.dart +++ b/lib/screens/repeater_cli_screen.dart @@ -767,7 +767,7 @@ class _RepeaterCliScreenState extends State { return Card( elevation: 0, margin: const EdgeInsets.only(bottom: 8), - color: colorScheme.surfaceVariant, + color: colorScheme.surfaceContainerHighest, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: BorderSide(color: colorScheme.outlineVariant), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 77a64d3..f9ae066 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -4,7 +4,6 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../models/radio_settings.dart'; -import '../services/app_settings_service.dart'; import 'app_settings_screen.dart'; import 'ble_debug_log_screen.dart'; @@ -63,6 +62,8 @@ class SettingsScreen extends StatelessWidget { _buildInfoRow('Node Name', connector.selfName!), if (connector.selfPublicKey != null) _buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'), + _buildInfoRow('Contacts Count', '${connector.contacts.length}'), + _buildInfoRow('Channel Count', '${connector.channels.length}'), ], ), ), @@ -619,7 +620,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _bandwidth, + initialValue: _bandwidth, decoration: const InputDecoration( labelText: 'Bandwidth', border: OutlineInputBorder(), @@ -636,7 +637,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _spreadingFactor, + initialValue: _spreadingFactor, decoration: const InputDecoration( labelText: 'Spreading Factor', border: OutlineInputBorder(), @@ -653,7 +654,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _codingRate, + initialValue: _codingRate, decoration: const InputDecoration( labelText: 'Coding Rate', border: OutlineInputBorder(), diff --git a/lib/services/codec2_ffi.dart b/lib/services/codec2_ffi.dart deleted file mode 100644 index 4f3bce7..0000000 --- a/lib/services/codec2_ffi.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'dart:ffi'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:ffi/ffi.dart'; - -const int _codec2Mode1300 = 4; - -class Codec2Ffi { - Codec2Ffi._(this._lib) - : _codec2Create = _lib - .lookupFunction<_codec2_create_c, _codec2_create_d>('codec2_create'), - _codec2Destroy = _lib - .lookupFunction<_codec2_destroy_c, _codec2_destroy_d>('codec2_destroy'), - _codec2Encode = _lib - .lookupFunction<_codec2_encode_c, _codec2_encode_d>('codec2_encode'), - _codec2Decode = _lib - .lookupFunction<_codec2_decode_c, _codec2_decode_d>('codec2_decode'), - _codec2SamplesPerFrame = _lib.lookupFunction<_codec2_samples_per_frame_c, - _codec2_samples_per_frame_d>('codec2_samples_per_frame'), - _codec2BytesPerFrame = _lib.lookupFunction<_codec2_bytes_per_frame_c, - _codec2_bytes_per_frame_d>('codec2_bytes_per_frame'); - - static final Codec2Ffi instance = Codec2Ffi._(_openLibrary()); - - final DynamicLibrary _lib; - final _codec2_create_d _codec2Create; - final _codec2_destroy_d _codec2Destroy; - final _codec2_encode_d _codec2Encode; - final _codec2_decode_d _codec2Decode; - final _codec2_samples_per_frame_d _codec2SamplesPerFrame; - final _codec2_bytes_per_frame_d _codec2BytesPerFrame; - - Codec2Session createSession() { - final handle = _codec2Create(_codec2Mode1300); - if (handle == nullptr) { - throw StateError('codec2_create returned null'); - } - return Codec2Session._( - handle: handle, - destroy: _codec2Destroy, - encode: _codec2Encode, - decode: _codec2Decode, - samplesPerFrame: _codec2SamplesPerFrame, - bytesPerFrame: _codec2BytesPerFrame, - ); - } - - static DynamicLibrary _openLibrary() { - if (Platform.isAndroid) { - return DynamicLibrary.open('libcodec2.so'); - } - if (Platform.isIOS || Platform.isMacOS) { - return DynamicLibrary.process(); - } - throw UnsupportedError('Codec2 is only supported on Android and iOS.'); - } -} - -class Codec2Session { - Codec2Session._({ - required this.handle, - required this.destroy, - required this.encode, - required this.decode, - required this.samplesPerFrame, - required this.bytesPerFrame, - }); - - final Pointer handle; - final _codec2_destroy_d destroy; - final _codec2_encode_d encode; - final _codec2_decode_d decode; - final _codec2_samples_per_frame_d samplesPerFrame; - final _codec2_bytes_per_frame_d bytesPerFrame; - - int get samplesPerFrameValue => samplesPerFrame(handle); - int get bytesPerFrameValue => bytesPerFrame(handle); - - Uint8List encodePcmFrame(Int16List pcmFrame) { - final bytesOut = calloc(bytesPerFrameValue); - final pcmIn = calloc(samplesPerFrameValue); - try { - final sampleCount = samplesPerFrameValue; - final pcmBuffer = pcmIn.asTypedList(sampleCount); - final copyLen = pcmFrame.length < sampleCount ? pcmFrame.length : sampleCount; - pcmBuffer.setRange(0, copyLen, pcmFrame); - if (copyLen < sampleCount) { - for (var i = copyLen; i < sampleCount; i++) { - pcmBuffer[i] = 0; - } - } - encode(handle, bytesOut, pcmIn); - return Uint8List.fromList(bytesOut.asTypedList(bytesPerFrameValue)); - } finally { - calloc.free(bytesOut); - calloc.free(pcmIn); - } - } - - Int16List decodeCodecFrame(Uint8List codecFrame) { - final pcmOut = calloc(samplesPerFrameValue); - final bytesIn = calloc(bytesPerFrameValue); - try { - final codecBuffer = bytesIn.asTypedList(bytesPerFrameValue); - codecBuffer.setRange(0, bytesPerFrameValue, codecFrame); - decode(handle, pcmOut, bytesIn); - return Int16List.fromList(pcmOut.asTypedList(samplesPerFrameValue)); - } finally { - calloc.free(bytesIn); - calloc.free(pcmOut); - } - } - - void dispose() { - destroy(handle); - } -} - -typedef _codec2_create_c = Pointer Function(Int32 mode); -typedef _codec2_create_d = Pointer Function(int mode); - -typedef _codec2_destroy_c = Void Function(Pointer codec2State); -typedef _codec2_destroy_d = void Function(Pointer codec2State); - -typedef _codec2_encode_c = Void Function( - Pointer codec2State, - Pointer bytes, - Pointer speechIn, -); -typedef _codec2_encode_d = void Function( - Pointer codec2State, - Pointer bytes, - Pointer speechIn, -); - -typedef _codec2_decode_c = Void Function( - Pointer codec2State, - Pointer speechOut, - Pointer bytes, -); -typedef _codec2_decode_d = void Function( - Pointer codec2State, - Pointer speechOut, - Pointer bytes, -); - -typedef _codec2_samples_per_frame_c = Int32 Function(Pointer codec2State); -typedef _codec2_samples_per_frame_d = int Function(Pointer codec2State); - -typedef _codec2_bytes_per_frame_c = Int32 Function(Pointer codec2State); -typedef _codec2_bytes_per_frame_d = int Function(Pointer codec2State); diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index 47910f3..234481d 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -183,7 +183,7 @@ class MapTileCacheService { int _lonToTileX(double lon, int zoom, int maxIndex) { final n = 1 << zoom; final value = ((lon + 180.0) / 360.0 * n).floor(); - return value.clamp(0, maxIndex) as int; + return value.clamp(0, maxIndex); } int _latToTileY(double lat, int zoom, int maxIndex) { @@ -194,12 +194,12 @@ class MapTileCacheService { 2 * n) .floor(); - return value.clamp(0, maxIndex) as int; + return value.clamp(0, maxIndex); } double _clampLatitude(double lat) { const maxLat = 85.05112878; - return lat.clamp(-maxLat, maxLat) as double; + return lat.clamp(-maxLat, maxLat); } String _buildTileUrl(int x, int y, int zoom) { diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 696a332..e2e66a6 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -19,6 +19,16 @@ class _AckHistoryEntry { }); } +class _AckHashMapping { + final String messageId; + final DateTime timestamp; + + _AckHashMapping({ + required this.messageId, + required this.timestamp, + }); +} + class MessageRetryService extends ChangeNotifier { static const int maxRetries = 5; static const int maxAckHistorySize = 100; @@ -28,8 +38,10 @@ class MessageRetryService extends ChangeNotifier { final Map _pendingMessages = {}; final Map _pendingContacts = {}; final Map _pendingPathSelections = {}; - final Map> _expectedAckHashes = {}; // Track all expected ACKs for retries + final Map _ackHashToMessageId = {}; // ackHashHex → messageId + timestamp for O(1) lookup + final Map> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history) final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes + final Map> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds Function(Contact, String, int, int)? _sendMessageCallback; Function(String, Message)? _addMessageCallback; @@ -65,17 +77,16 @@ class MessageRetryService extends ChangeNotifier { Future sendMessageWithRetry({ required Contact contact, required String text, - bool clearPath = false, PathSelection? pathSelection, Uint8List? pathBytes, int? pathLength, }) async { final messageId = const Uuid().v4(); - final useClearPath = clearPath || (pathSelection?.useFlood ?? false); + final useFlood = pathSelection?.useFlood ?? false; final messagePathBytes = - pathBytes ?? _resolveMessagePathBytes(contact, useClearPath, pathSelection); + pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection); final messagePathLength = - pathLength ?? _resolveMessagePathLength(contact, useClearPath, pathSelection); + pathLength ?? _resolveMessagePathLength(contact, useFlood, pathSelection); final message = Message( senderKey: contact.publicKey, text: text, @@ -126,6 +137,11 @@ class MessageRetryService extends ChangeNotifier { final attempt = message.retryCount.clamp(0, 3); + // Enqueue this message to track send order for ACK hash mapping (FIFO) + _pendingMessageQueuePerContact[contact.publicKeyHex] ??= []; + _pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId); + debugPrint('Enqueued message $messageId for ${contact.name} (queue size: ${_pendingMessageQueuePerContact[contact.publicKeyHex]!.length})'); + if (_sendMessageCallback != null) { final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; _sendMessageCallback!( @@ -138,58 +154,103 @@ class MessageRetryService extends ChangeNotifier { } void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { - for (var entry in _pendingMessages.entries) { - final message = entry.value; - // Only update if pending (waiting to send) or already sent with matching ACK - if (message.status == MessageStatus.pending || - (message.status == MessageStatus.sent && - message.expectedAckHash != null && - listEquals(message.expectedAckHash, ackHash))) { - final contact = _pendingContacts[entry.key]; - final selection = _pendingPathSelections[entry.key]; + final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - // Add this ACK hash to the list of expected ACKs for this message - _expectedAckHashes[entry.key] ??= []; - if (!_expectedAckHashes[entry.key]!.any((hash) => listEquals(hash, ackHash))) { - _expectedAckHashes[entry.key]!.add(Uint8List.fromList(ackHash)); - debugPrint('Added ACK hash ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()} to message ${entry.key} (total: ${_expectedAckHashes[entry.key]!.length})'); - } + // Dequeue the next message from the FIFO queue to match with this RESP_CODE_SENT + // We iterate through contacts to find which one has a pending message in their queue + String? messageId; + Contact? contact; - // Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid - int actualTimeout = timeoutMs; - if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) { - 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; + for (var entry in _pendingMessageQueuePerContact.entries) { + final contactKey = entry.key; + final queue = entry.value; + + if (queue.isNotEmpty) { + // Dequeue the first (oldest) message from this contact's queue + final candidateMessageId = queue.removeAt(0); + + // Verify this message is still pending + if (_pendingMessages.containsKey(candidateMessageId)) { + messageId = candidateMessageId; + contact = _pendingContacts[candidateMessageId]; + debugPrint('Dequeued message $messageId for $contactKey (remaining in queue: ${queue.length})'); + break; + } else { + debugPrint('Dequeued stale message $candidateMessageId - skipping'); + // Continue to next message in queue + if (queue.isNotEmpty) { + final nextMessageId = queue.removeAt(0); + if (_pendingMessages.containsKey(nextMessageId)) { + messageId = nextMessageId; + contact = _pendingContacts[nextMessageId]; + debugPrint('Dequeued next message $messageId for $contactKey (remaining: ${queue.length})'); + break; + } } - actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length); - debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops'); } - - final updatedMessage = message.copyWith( - status: MessageStatus.sent, - expectedAckHash: ackHash, // Keep the most recent one for display - estimatedTimeoutMs: actualTimeout, - sentAt: DateTime.now(), - ); - - _pendingMessages[entry.key] = updatedMessage; - - if (_updateMessageCallback != null) { - _updateMessageCallback!(updatedMessage); - } - - _startTimeoutTimer(entry.key, actualTimeout); - debugPrint('Updated message ${entry.key} with ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'); - return; } } - debugPrint('No pending message found for ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'); + + if (messageId == null || contact == null) { + debugPrint('No pending message found for ACK hash: $ackHashHex (all queues empty or stale)'); + return; + } + + // 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(), + ); + 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; + } + + // Add this ACK hash to the list of expected ACKs for this message (for history) + _expectedAckHashes[messageId] ??= []; + 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})'); + } + + // Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid + int actualTimeout = timeoutMs; + if (timeoutMs <= 0 && _calculateTimeoutCallback != null) { + 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; + } + actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length); + debugPrint('Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue'); + } + + final updatedMessage = message.copyWith( + status: MessageStatus.sent, + expectedAckHash: ackHash, + estimatedTimeoutMs: actualTimeout, + sentAt: DateTime.now(), + ); + + _pendingMessages[messageId] = updatedMessage; + + if (_updateMessageCallback != null) { + _updateMessageCallback!(updatedMessage); + } + + _startTimeoutTimer(messageId, actualTimeout); + debugPrint('Updated message $messageId with ACK hash: $ackHashHex'); } void _startTimeoutTimer(String messageId, int timeoutMs) { @@ -204,7 +265,10 @@ class MessageRetryService extends ChangeNotifier { final contact = _pendingContacts[messageId]; final selection = _pendingPathSelections[messageId]; - if (message == null || contact == null) return; + if (message == null || contact == null) { + debugPrint('Timeout fired but message $messageId no longer pending (likely already delivered)'); + return; + } debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})'); @@ -225,7 +289,12 @@ class MessageRetryService extends ChangeNotifier { debugPrint('Scheduling retry after ${backoffMs}ms'); Timer(Duration(milliseconds: backoffMs), () { - _attemptSend(messageId); + // 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 { // Max retries reached - mark as failed @@ -240,6 +309,12 @@ class MessageRetryService extends ChangeNotifier { _timeoutTimers[messageId]?.cancel(); _timeoutTimers.remove(messageId); + // Clean up the queue entry for this contact + _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId); + if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) { + _pendingMessageQueuePerContact.remove(contact.publicKeyHex); + } + // Check if we should clear the path on max retry if (_appSettingsService?.settings.clearPathOnMaxRetry == true && _clearContactPathCallback != null) { @@ -291,28 +366,44 @@ class MessageRetryService extends ChangeNotifier { final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms'); - debugPrint('Pending messages:'); - for (var entry in _pendingMessages.entries) { - final message = entry.value; - final expectedHex = message.expectedAckHash?.map((b) => b.toRadixString(16).padLeft(2, '0')).join() ?? 'none'; - final allExpectedHashes = _expectedAckHashes[entry.key]?.map((h) => h.map((b) => b.toRadixString(16).padLeft(2, '0')).join()).join(', ') ?? 'none'; - debugPrint(' ${entry.key}: status=${message.status}, latestAck=$expectedHex, allAcks=[$allExpectedHashes], retry=${message.retryCount}'); + + // First, clean up old ACK hash mappings (older than 15 minutes) + final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15)); + final hashesToRemove = []; + for (var entry in _ackHashToMessageId.entries) { + if (entry.value.timestamp.isBefore(cutoffTime)) { + hashesToRemove.add(entry.key); + } + } + for (var hash in hashesToRemove) { + _ackHashToMessageId.remove(hash); + } + if (hashesToRemove.isNotEmpty) { + debugPrint('Cleaned up ${hashesToRemove.length} old ACK hash mappings'); } - // Check against ALL expected ACK hashes (from all retry attempts) - for (var entry in _expectedAckHashes.entries) { - final messageId = entry.key; - final expectedHashes = entry.value; + // Use direct O(1) lookup via ACK hash mapping + final mapping = _ackHashToMessageId[ackHashHex]; + if (mapping != null) { + matchedMessageId = mapping.messageId; + debugPrint('Matched ACK to message via direct lookup: $matchedMessageId'); + } else { + // 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 (listEquals(expectedHash, ackHash)) { - matchedMessageId = messageId; - debugPrint('Matched ACK to message: $matchedMessageId (matched hash from attempt ${expectedHashes.indexOf(expectedHash)})'); - break; + for (final expectedHash in expectedHashes) { + if (listEquals(expectedHash, ackHash)) { + matchedMessageId = messageId; + debugPrint('Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})'); + break; + } } - } - if (matchedMessageId != null) break; + if (matchedMessageId != null) break; + } } if (matchedMessageId != null) { @@ -337,6 +428,14 @@ class MessageRetryService extends ChangeNotifier { _pendingContacts.remove(matchedMessageId); _pendingPathSelections.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); } @@ -361,12 +460,25 @@ class MessageRetryService extends ChangeNotifier { 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; } @@ -375,12 +487,22 @@ class MessageRetryService extends ChangeNotifier { 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; } @@ -442,6 +564,8 @@ class MessageRetryService extends ChangeNotifier { _pendingPathSelections.clear(); _expectedAckHashes.clear(); _ackHistory.clear(); + _ackHashToMessageId.clear(); + _pendingMessageQueuePerContact.clear(); super.dispose(); } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index cc13ccb..6db316d 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -67,27 +67,30 @@ class NotificationService { required String contactName, required String message, String? contactId, + int? badgeCount, }) async { if (!_isInitialized) { await initialize(); } - const androidDetails = AndroidNotificationDetails( + final androidDetails = AndroidNotificationDetails( 'messages', 'Messages', channelDescription: 'New message notifications', importance: Importance.high, priority: Priority.high, icon: '@mipmap/ic_launcher', + number: badgeCount, ); - const iosDetails = DarwinNotificationDetails( + final iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, + badgeNumber: badgeCount, ); - const notificationDetails = NotificationDetails( + final notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, ); @@ -143,27 +146,30 @@ class NotificationService { required String channelName, required String message, int? channelIndex, + int? badgeCount, }) async { if (!_isInitialized) { await initialize(); } - const androidDetails = AndroidNotificationDetails( + final androidDetails = AndroidNotificationDetails( 'channel_messages', 'Channel Messages', channelDescription: 'New channel message notifications', importance: Importance.high, priority: Priority.high, icon: '@mipmap/ic_launcher', + number: badgeCount, ); - const iosDetails = DarwinNotificationDetails( + final iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, + badgeNumber: badgeCount, ); - const notificationDetails = NotificationDetails( + final notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, ); diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 3759283..6a18b2a 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -33,6 +33,10 @@ class ContactStore { 'type': contact.type, 'pathLength': contact.pathLength, 'path': base64Encode(contact.path), + 'pathOverride': contact.pathOverride, + 'pathOverrideBytes': contact.pathOverrideBytes != null + ? base64Encode(contact.pathOverrideBytes!) + : null, 'latitude': contact.latitude, 'longitude': contact.longitude, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch, @@ -51,6 +55,10 @@ class ContactStore { path: json['path'] != null ? Uint8List.fromList(base64Decode(json['path'] as String)) : Uint8List(0), + pathOverride: json['pathOverride'] as int?, + pathOverrideBytes: json['pathOverrideBytes'] != null + ? Uint8List.fromList(base64Decode(json['pathOverrideBytes'] as String)) + : null, latitude: (json['latitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index d612f90..4f13a2f 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -37,8 +37,8 @@ class QuickSwitchBar extends StatelessWidget { surfaceTintColor: Colors.transparent, shadowColor: Colors.transparent, indicatorColor: colorScheme.primaryContainer, - labelTextStyle: MaterialStateProperty.resolveWith((states) { - final isSelected = states.contains(MaterialState.selected); + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final isSelected = states.contains(WidgetState.selected); return labelStyle.copyWith( fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, color: isSelected @@ -46,8 +46,8 @@ class QuickSwitchBar extends StatelessWidget { : colorScheme.onSurfaceVariant, ); }), - iconTheme: MaterialStateProperty.resolveWith((states) { - final isSelected = states.contains(MaterialState.selected); + iconTheme: WidgetStateProperty.resolveWith((states) { + final isSelected = states.contains(WidgetState.selected); return IconThemeData( color: isSelected ? colorScheme.onPrimaryContainer diff --git a/lib/widgets/repeater_login_dialog.dart b/lib/widgets/repeater_login_dialog.dart index 157d1e7..2a54b47 100644 --- a/lib/widgets/repeater_login_dialog.dart +++ b/lib/widgets/repeater_login_dialog.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; @@ -101,16 +100,16 @@ class _RepeaterLoginDialogState extends State { } // If we got a response, login succeeded - if (mounted) { - // Save password if requested - if (_savePassword) { - await _storage.saveRepeaterPassword( - widget.repeater.publicKeyHex, password); - } else { - // Remove saved password if user unchecked the box - await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex); - } + // Save password if requested + if (_savePassword) { + await _storage.saveRepeaterPassword( + widget.repeater.publicKeyHex, password); + } else { + // Remove saved password if user unchecked the box + await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex); + } + if (mounted) { Navigator.pop(context, password); Future.microtask(() => widget.onLogin(password)); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3abb27a..7deb1ef 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,14 +7,18 @@ import Foundation import flutter_blue_plus_darwin import flutter_local_notifications +import package_info_plus import path_provider_foundation import shared_preferences_foundation import sqflite_darwin +import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index be2c039..66c8391 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -66,7 +66,7 @@ packages: source: hosted version: "1.3.1" characters: - dependency: transitive + dependency: "direct main" description: name: characters sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 @@ -234,10 +234,10 @@ packages: dependency: transitive description: name: flutter_blue_plus_winrt - sha256: "5cfa5960ac8723771cbc59586588b100f38494390154b8a3268c95db37e21617" + sha256: "0c87ca5bdf1a110d42847edeca8fbb11a9701738dc8526aefbb2a115bea29aef" url: "https://pub.dev" source: hosted - version: "0.0.7" + version: "0.0.10" flutter_cache_manager: dependency: "direct main" description: @@ -436,10 +436,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mgrs_dart: dependency: transitive description: @@ -464,6 +464,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -745,10 +761,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timezone: dependency: transitive description: @@ -797,6 +813,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" web: dependency: transitive description: @@ -805,6 +837,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 92a9042..41b4d24 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,8 @@ dependencies: cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 flutter_foreground_task: ^6.1.2 + wakelock_plus: ^1.2.8 + characters: ^1.4.0 dev_dependencies: flutter_test: