diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..05c82de
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,78 @@
+name: Build
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ android:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: "temurin"
+ java-version: "17"
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: "stable"
+ cache: true
+ - name: Cache Gradle
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/build.gradle', 'android/settings.gradle', 'android/app/build.gradle', 'pubspec.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+ - run: flutter pub get
+ - run: flutter build apk --release --no-pub
+
+ ios:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: "stable"
+ cache: true
+ - run: flutter pub get
+ - run: flutter build ios --release --no-codesign --no-pub
+
+ linux:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: "stable"
+ cache: true
+ - name: Install Linux build deps
+ run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
+ - run: flutter pub get
+ - run: flutter build linux --release --no-pub
+
+ macos:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: "stable"
+ cache: true
+ - run: flutter pub get
+ - run: flutter build macos --release --no-pub
+
+ web:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: "stable"
+ cache: true
+ - run: flutter pub get
+ - run: flutter build web --release --no-pub
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..c3926c9
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,38 @@
+name: Deploy to Cloudflare Workers
+
+on:
+ push:
+ tags:
+ - '*'
+ workflow_dispatch:
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: 'stable'
+ # Match local development version which provides Dart 3.11.0
+ flutter-version: '3.41.2'
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Get dependencies
+ run: flutter pub get
+
+ - name: Build Web
+ run: bun run build
+
+ - name: Deploy to Cloudflare
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ command: deploy
diff --git a/.github/workflows/flutter_dart.yml b/.github/workflows/flutter_dart.yml
new file mode 100644
index 0000000..117eb4f
--- /dev/null
+++ b/.github/workflows/flutter_dart.yml
@@ -0,0 +1,31 @@
+name: Flutter and Dart
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ analyze:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ - name: Analyze code
+ run: flutter analyze --fatal-infos --fatal-warnings
+
+ - name: Verify formatting
+ run: dart format --output=none --set-exit-if-changed .
+
+ - name: Run tests
+ run: flutter test -r github
diff --git a/.gitignore b/.gitignore
index 97cafdb..88295e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,8 +30,12 @@ migrate_working_dir/
.flutter-plugins-dependencies
.pub-cache/
.pub/
+pubspec.lock
/build/
/coverage/
+# fvm project files
+.fvm/
+.fvmrc
# Symbolication related
app.*.symbols
@@ -57,6 +61,7 @@ secrets.dart
.DS_Store
.AppleDouble
.LSOverride
+macos/Flutter/GeneratedPluginRegistrant.swift
# iOS
**/ios/Pods/
@@ -65,11 +70,13 @@ secrets.dart
**/ios/Flutter/Flutter.podspec
# Android
+.gradle/
**/android/.gradle/
**/android/captures/
**/android/local.properties
**/android/.externalNativeBuild/
*.jks
+key.properties
keystore.properties
# Generated files
@@ -80,3 +87,6 @@ keystore.properties
# IDE
.vscode/launch.json
.vscode/settings.json
+
+# Cloudflare Wrangler
+.wrangler
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..e69de29
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..fcdb2e1
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+4.0.0
diff --git a/.swift-version b/.swift-version
new file mode 100644
index 0000000..31b44b0
--- /dev/null
+++ b/.swift-version
@@ -0,0 +1 @@
+6.2.4
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index bac981d..273bb96 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,7 +6,7 @@
## BLE Frames & Protocol Notes
- Nordic UART Service (NUS) UUIDs: Service `6e400001-b5a3-f393-e0a9-e50e24dcca9e`, RX `6e400002-b5a3-f393-e0a9-e50e24dcca9e`, TX `6e400003-b5a3-f393-e0a9-e50e24dcca9e`.
-- Discovery: scans for device name prefix `MeshCore-` and filters by `platformName`/`advertisementData.advName`.
+- Discovery: scans for device names matching known prefixes and filters by `platformName`/`advertisementData.advName`.
- Frames are capped at `maxFrameSize = 172` bytes; byte 0 is the command/response/push code. I/O is `MeshCoreConnector.sendFrame` and `MeshCoreConnector.receivedFrames`.
- Command codes (to device): `cmdAppStart`=1, `cmdSendTxtMsg`=2, `cmdSendChannelTxtMsg`=3, `cmdGetContacts`=4, `cmdGetDeviceTime`=5, `cmdSetDeviceTime`=6, `cmdSendSelfAdvert`=7, `cmdSetAdvertName`=8, `cmdAddUpdateContact`=9, `cmdSyncNextMessage`=10, `cmdSetRadioParams`=11, `cmdSetRadioTxPower`=12, `cmdResetPath`=13, `cmdSetAdvertLatLon`=14, `cmdRemoveContact`=15, `cmdShareContact`=16, `cmdExportContact`=17, `cmdImportContact`=18, `cmdReboot`=19, `cmdSendLogin`=26, `cmdGetChannel`=31, `cmdSetChannel`=32, `cmdGetRadioSettings`=57.
- Response codes (from device): `respCodeOk`=0, `respCodeErr`=1, `respCodeContactsStart`=2, `respCodeContact`=3, `respCodeEndOfContacts`=4, `respCodeSelfInfo`=5, `respCodeSent`=6, `respCodeContactMsgRecv`=7, `respCodeChannelMsgRecv`=8, `respCodeCurrTime`=9, `respCodeNoMoreMessages`=10, `respCodeContactMsgRecvV3`=16, `respCodeChannelMsgRecvV3`=17, `respCodeChannelInfo`=18, `respCodeRadioSettings`=25.
diff --git a/CLAUDE.md b/CLAUDE.md
index 08ef342..55af890 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,7 +61,7 @@ lib/
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
### Device Discovery
-- Scans for devices with name prefix `MeshCore-`
+- Scans for devices with known name prefixes
- Filters by `platformName` or `advertisementData.advName`
### Connection States
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..ac727ba
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,71 @@
+# How to contribute to Meshcore Open
+
+Before submitting any pull requests (PR), please review the following information.
+
+Unsolicited PRs without previous discussion or open issues may be
+rejected. As may changes that are too broad (i.e. 100 files changed) or that
+cover too many separate changes. If the changes are clearly AI generated they
+may also be rejected. [See more](#ai-use)
+
+## First Step Checklist
+
+### **Did you find a bug?**
+
+* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/zjs81/meshcore-open/issues).
+
+* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/zjs81/meshcore-open/issues/new).
+Be sure to include a **title and clear description**, as much relevant
+information as possible, and a **code sample** or an **executable test case**
+demonstrating the expected behavior that is not occurring. You can also include
+screenshots or video.
+
+* DO NOT start work and submit a PR at this time, please discuss the issue and
+your implementation plan first.
+
+### **Did you fix whitespace, format code, or make a purely cosmetic patch?**
+
+Changes that are cosmetic in nature and do not add anything substantial to the
+stability, functionality, or testability of the application will generally not
+be accepted.
+
+### **Do you intend to add a new feature or change an existing one?**
+
+* Suggest your change in a new issue as a feature request.
+
+* DO NOT start work and submit a PR at this time, please discuss the change and
+your implementation plan first.
+
+* After it is generally decided that the feature or change fits the goals of the
+project you can start work or open a PR if you have already started.
+
+## Submitting your patch
+
+* All changes should be based on the `dev` branch. When creating your PR please
+be sure to change the target to merge into dev, and when starting work on a new
+branch be sure to start on latest `dev`.
+
+* Ensure the PR description clearly describes the problem and solution. Include
+the relevant issue number if applicable.
+
+* The PR should contain **one commit** only, the commit message should have a
+clear title followed by a new line and then brief description if needed. PR with
+multiple commits will be squashed into one before merging if required. See
+[Git Mastery](https://git-mastery.org/lessons/commitMessage/) for more
+information on good commit messages.
+
+* **Before committing changes** on your branch, be sure to run both
+`dart format .` and `flutter analyze`. The continuous development checks will
+fail if issues here are not addressed before hand.
+
+## AI-use
+
+Everyone loves some help, AI agents are a tool in many of our belts. The project
+is not anti-AI.
+
+There are some limits to acceptable use however. Generally:
+
+* All code generated by AI should be thoroughly reviewed by the contributor.
+* The changes should be tightly controlled to not change anything out of scope
+for the patch, bug fix, etc.
+* The contributor should have a good understanding of what the code does and how
+the application works in order to effectively be able to manage the agent.
diff --git a/README.md b/README.md
index 2acb390..ac188f6 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,10 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
+
+
+
+
## Screenshots
@@ -21,6 +25,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
## Features
### Core Functionality
+
- **Direct Messaging**: Private encrypted conversations with individual contacts
- **Public Channels**: Broadcast messages to channel subscribers on the mesh network
- **Contact Management**: Organize contacts, track last seen times, and manage conversation history
@@ -29,6 +34,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- **Message Replies**: Thread conversations with inline reply functionality
### Mesh Network
+
- **Path Visualization**: View routing paths and signal quality for each contact
- **Route Management**: Manual path overriding and automatic route rotation
- **Signal Metrics**: Real-time SNR (Signal-to-Noise Ratio) tracking
@@ -36,6 +42,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- **Repeater Support**: Connect to and manage repeater nodes for extended range
### Map & Location
+
- **Live Map View**: Real-time visualization of mesh network nodes on an interactive map
- **Node Filtering**: Filter by node type (chat, repeater, sensor) and time range
- **Location Sharing**: Share GPS coordinates and custom markers with contacts
@@ -43,12 +50,14 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- **MGRS Coordinates**: Support for Military Grid Reference System coordinate format
### Device Management
-- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth
+
+- **BLE, USB, TCP Connection**: Scan and connect to MeshCore devices via Bluetooth, USB or TCP
- **Device Settings**: Configure radio parameters, power settings, and network options
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
- **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon)
### Repeater Hub
+
- **CLI Access**: Full command-line interface to repeater nodes
- **Settings Management**: Configure repeater behavior, power limits, and network settings
- **Statistics Dashboard**: View repeater traffic, connected clients, and system health
@@ -57,6 +66,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
## Technical Details
### Architecture
+
- **Framework**: Flutter 3.38.5 / Dart 3.10.4
- **State Management**: Provider pattern with ChangeNotifier
- **BLE Protocol**: Nordic UART Service (NUS) over Bluetooth Low Energy
@@ -64,11 +74,20 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- **Encryption**: End-to-end encryption for private messages using the MeshCore protocol
### Platform Support
-- ✅ **Android**: Full support (API 21+)
-- ✅ **iOS**: Full support (iOS 12+)
-- 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
+
+| Feature | Android (API 21+) | iOS (12+) | Linux | Windows | macOS | Web |
+|--------------------|:-----------------:|:---------:|:-----:|:-------:|:-----:|:---------------------------------:|
+| BLE companion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| USB companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ |
+| TCP companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ❌ (requires websocket bridge) |
+| Core Functionality | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Mesh Network | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Map & Location | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Device Management | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Repeater Hub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
### Dependencies
+
| Package | Purpose |
|---------|---------|
| flutter_blue_plus | Bluetooth Low Energy communication |
@@ -84,6 +103,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
## Getting Started
### Prerequisites
+
- Flutter SDK 3.38.5 or later
- Android Studio / Xcode (for mobile development)
- A MeshCore-compatible LoRa device
@@ -91,17 +111,20 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
### Installation
1. **Clone the repository**
+
```bash
git clone https://github.com/zjs81/meshcore-open.git
cd meshcore-open
```
2. **Install dependencies**
+
```bash
flutter pub get
```
3. **Run the app**
+
```bash
flutter run
```
@@ -109,11 +132,13 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
### Building for Release
**Android APK:**
+
```bash
flutter build apk --release
```
**iOS:**
+
```bash
flutter build ios --release
```
@@ -125,7 +150,8 @@ lib/
├── main.dart # App entry point
├── connector/
│ ├── meshcore_connector.dart # BLE communication & state management
-│ └── meshcore_protocol.dart # Protocol definitions & frame parsing
+│ ├── meshcore_protocol.dart # Protocol definitions & frame parsing
+│ └── meshcore_uuids.dart # Device names and IDs (add prefixes here!)
├── screens/
│ ├── scanner_screen.dart # Device scanning (home screen)
│ ├── contacts_screen.dart # Contact list
@@ -152,25 +178,39 @@ lib/
## BLE Protocol
### Nordic UART Service (NUS)
+
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
- **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device)
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
### Device Discovery
-Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-`
+
+Devices are discovered by scanning for BLE advertisements with known MeshCore device name prefixes. These are currently:
+ - `MeshCore-`
+ - `Whisper-`
+ - `WisCore-`
+ - `HT-`
+ - `LowMesh_MC_`
+
+New device prefixes can be added in `lib/connector/meshcore_uuids.dart`.
+
### Message Format
+
Messages are transmitted as binary frames using a custom protocol optimized for LoRa transmission. See `meshcore_protocol.dart` for frame structure definitions.
## Configuration
### App Settings
+
- **Theme**: System default, light, or dark mode
+- **Language**: Use one of 15 languages (English, Chinese, French, Spanish, Portuguese, German, Dutch, Polish, Swedish, Italian, Slovak, Slovene, Bulgarian, Russian, Ukrainian)
- **Notifications**: Configurable for messages, channels, and node advertisements
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
- **Message Retry**: Automatic retry with configurable path clearing
### Device Settings
+
- **Radio Power**: Transmit power adjustment (10-30 dBm)
- **Frequency**: LoRa frequency configuration
- **Bandwidth**: Channel bandwidth selection
@@ -182,22 +222,24 @@ Messages are transmitted as binary frames using a custom protocol optimized for
This is an open-source project. Contributions are welcome!
### Development Guidelines
+
- Follow the Flutter style guide
- Use Material 3 design components
- Write clear commit messages
- Test on both Android and iOS before submitting PRs
### Code Style
+
- Prefer `StatelessWidget` with `Consumer` for reactive UI
- Use `const` constructors where possible
- Keep functions small and focused
- Avoid premature abstractions
-
+- Run dart format on all changes before submitting
## Support
For issues, questions, or feature requests, please open an issue on GitHub:
-https://github.com/zjs81/meshcore-open/issues
+
## Donate
@@ -205,6 +247,11 @@ If you find MeshCore Open useful and would like to support development, you can
**Solana Address:** `F15YanjZj96YTBtKJYgNa8RLQLCZkx5CEwogPWkqXeoQ`
+
+**Monero Address:** `453TxnpUqjkJtXxzdjMsrgERNkBRXEGamPbpC45ENrvKAk9tH7kZbxWF82Hz66etgDZyXFPEBU2JUEqhLeJyWt9kBvTVy5m`
+
+**Bitcoin Address:** `bc1qh45x28v8dslcg4v4upmqd9g0mvc3lnyffmyzr5`
+
Your support helps maintain and improve this open-source project!
## Acknowledgments
diff --git a/TESTFLIGHT_GUIDE.md b/TESTFLIGHT_GUIDE.md
new file mode 100644
index 0000000..b092678
--- /dev/null
+++ b/TESTFLIGHT_GUIDE.md
@@ -0,0 +1,244 @@
+# TestFlight and App Store Deployment Guide
+
+## Prerequisites
+
+- [x] Apple Developer Account ($99/year) - [developer.apple.com](https://developer.apple.com)
+- [x] Xcode installed
+- [x] Apple Transporter app installed
+- [x] App icons ready (1024x1024px)
+- [x] Bundle ID configured: `com.monitormx.meshcoreopen`
+
+## Step 1: Register Bundle Identifier
+
+1. Go to [Apple Developer - Identifiers](https://developer.apple.com/account/resources/identifiers/list)
+2. Click the **"+"** button
+3. Select **"App IDs"** → Continue
+4. Select **"App"** → Continue
+5. Fill in:
+ - **Description**: Meshcore Open
+ - **Bundle ID**: Explicit - `com.monitormx.meshcoreopen`
+ - **Capabilities**: Leave defaults (or add as needed)
+6. Click **Continue** → **Register**
+
+## Step 2: Create App in App Store Connect
+
+1. Go to [App Store Connect](https://appstoreconnect.apple.com)
+2. Sign in with your Apple ID
+3. Click **"My Apps"**
+4. Click the **"+"** button → **"New App"**
+5. Fill in the form:
+ - **Platforms**: iOS
+ - **Name**: Meshcore Open
+ - **Primary Language**: English (U.S.)
+ - **Bundle ID**: Select `com.monitormx.meshcoreopen` from dropdown
+ - **SKU**: `meshcore-open-001` (or any unique identifier)
+ - **User Access**: Full Access
+6. Click **"Create"**
+
+## Step 3: Build the IPA
+
+Run these commands from the project directory:
+
+```bash
+# Add CocoaPods to PATH
+export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
+
+# Clean previous builds
+../flutter/bin/flutter clean
+
+# Build IPA for App Store
+../flutter/bin/flutter build ipa
+```
+
+The IPA will be created at: `build/ios/ipa/meshcore_open.ipa`
+
+## Step 4: Upload to App Store Connect via Transporter
+
+1. **Open Apple Transporter**
+ - Launch from Applications folder
+ - Sign in with your Apple ID
+
+2. **Upload the IPA**
+ - Drag and drop `build/ios/ipa/meshcore_open.ipa` into Transporter
+ - Click **"Deliver"**
+ - Wait for upload to complete (usually 1-5 minutes)
+
+3. **Processing**
+ - Apple will process your build (10-30 minutes)
+ - You'll receive an email when processing is complete
+
+## Step 5: Configure App Store Connect Metadata
+
+### App Information
+1. In App Store Connect, go to your app
+2. Fill in required information:
+ - **Subtitle**: Short description (30 chars max)
+ - **Privacy Policy URL**: Required for Bluetooth apps
+ - **Category**: Utilities or Productivity
+ - **Age Rating**: Complete questionnaire
+
+### App Store Listing
+1. Go to **App Store** tab
+2. Upload **Screenshots** (required):
+ - iPhone 6.7" display (1290 x 2796 pixels) - At least 1 screenshot
+ - iPhone 6.5" display (1242 x 2688 pixels) - At least 1 screenshot
+ - Optional: iPad screenshots
+
+3. Fill in **Description**:
+ ```
+ Meshcore Open is a Flutter client for MeshCore LoRa mesh networking devices.
+
+ Features:
+ - BLE connectivity to MeshCore devices
+ - Real-time mesh network communication
+ - Map visualization with OpenStreetMap
+ - Community management with QR code scanning
+ - Message tracking and retry system
+
+ Connect to your MeshCore LoRa device and start communicating over the mesh network.
+ ```
+
+4. **Keywords**: `lora,mesh,networking,bluetooth,communication`
+5. **Support URL**: Your GitHub or website URL
+6. **Marketing URL**: (Optional)
+
+### Version Information
+1. **What's New in This Version**:
+ ```
+ Initial release of Meshcore Open
+
+ - BLE device connectivity
+ - Mesh network messaging
+ - Map integration
+ - Community features
+ ```
+
+2. **Build**: Select the uploaded build once processing completes
+
+## Step 6: TestFlight Setup
+
+### Internal Testing (No Review Required)
+1. Go to **TestFlight** tab in App Store Connect
+2. Click **Internal Testing** → **"+"** to create a group
+3. Name your group (e.g., "Internal Testers")
+4. Add yourself as a tester using your email
+5. Select the build you uploaded
+6. Testers will receive an email with TestFlight invitation
+
+### External Testing (Requires Beta Review)
+1. Click **External Testing** → **"+"** to create a group
+2. Add build and testers
+3. Fill in **Test Information**:
+ - **What to Test**: Brief description of features
+ - **Feedback Email**: Your email address
+4. Click **Submit for Review**
+5. Beta review typically takes 24-48 hours
+
+## Step 7: App Store Submission
+
+Once you're ready for public release:
+
+1. Go to **App Store** tab
+2. Complete all required metadata (if not done)
+3. Select your build
+4. Fill in **App Review Information**:
+ - **Contact Information**: Your name, phone, email
+ - **Demo Account**: If app requires login
+ - **Notes**: Any special instructions for reviewers
+5. Answer **Export Compliance** questions:
+ - Does your app use encryption? **Yes** (uses TLS/HTTPS)
+ - Is encryption registration required? **No** (standard encryption)
+6. Click **Add for Review**
+7. Review summary and click **Submit to App Review**
+
+## Step 8: After Submission
+
+- **App Review**: Typically 24-48 hours
+- **Common Rejection Reasons**:
+ - Missing privacy policy
+ - Incomplete app information
+ - Crashes or bugs
+ - Misleading app description
+
+- **If Approved**: You can release immediately or schedule a release date
+- **If Rejected**: Address issues and resubmit
+
+## Updating the App
+
+When you need to release an update:
+
+1. **Update version** in `pubspec.yaml`:
+ ```yaml
+ version: 0.5.0+6 # Increment version (0.5.0) and build number (+6)
+ ```
+
+2. **Build new IPA**:
+ ```bash
+ export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
+ ../flutter/bin/flutter clean
+ ../flutter/bin/flutter build ipa
+ ```
+
+3. **Upload via Transporter** (same process as above)
+
+4. **Create new version** in App Store Connect:
+ - Click **"+"** next to versions
+ - Select version number
+ - Update "What's New" text
+ - Select new build
+ - Submit for review
+
+## macOS Build (Bonus)
+
+To build for macOS:
+
+```bash
+export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
+../flutter/bin/flutter build macos --release
+cd build/macos/Build/Products/Release
+zip -r meshcore_open-macos.zip meshcore_open.app
+```
+
+Distribution:
+- Share the zip file directly
+- Users unzip and drag to Applications
+- First run: Right-click → Open (to bypass Gatekeeper)
+
+## Troubleshooting
+
+### Build Errors
+- **CocoaPods not found**: Ensure PATH includes `/opt/homebrew/lib/ruby/gems/4.0.0/bin`
+- **No signing certificate**: Configure Team in Xcode (Signing & Capabilities)
+- **Bundle ID mismatch**: Check `ios/Runner.xcodeproj/project.pbxproj`
+
+### Upload Errors
+- **No profiles found**: Create app in App Store Connect first
+- **Bundle ID not registered**: Register in Apple Developer portal
+- **Authentication failed**: Use Transporter app instead of CLI
+
+### TestFlight Issues
+- **Build not appearing**: Wait 10-30 minutes for processing
+- **Can't add testers**: Check you have available slots (100 internal, 10,000 external)
+- **TestFlight crashes**: Check device logs in Xcode → Devices & Simulators
+
+## Important Files
+
+- **iOS IPA**: `build/ios/ipa/meshcore_open.ipa`
+- **macOS App**: `build/macos/Build/Products/Release/meshcore_open.app`
+- **Bundle ID Config**: `ios/Runner.xcodeproj/project.pbxproj`
+- **Version Info**: `pubspec.yaml`
+
+## Useful Links
+
+- [App Store Connect](https://appstoreconnect.apple.com)
+- [Apple Developer Portal](https://developer.apple.com/account)
+- [TestFlight Documentation](https://developer.apple.com/testflight/)
+- [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
+- [Flutter iOS Deployment](https://docs.flutter.dev/deployment/ios)
+
+## Support
+
+For issues with:
+- **App Store Process**: [Apple Developer Support](https://developer.apple.com/contact/)
+- **Flutter Build Issues**: [Flutter GitHub](https://github.com/flutter/flutter/issues)
+- **Meshcore Open App**: [GitHub Issues](https://github.com/wel97459/meshcore-open/issues)
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 7c6e251..c8028e0 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,3 +1,5 @@
+import java.util.Properties
+
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,19 +7,25 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
+val keystoreProperties = Properties()
+val keystorePropertiesFile = rootProject.file("key.properties")
+if (keystorePropertiesFile.exists()) {
+ keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
+}
+
android {
namespace = "com.meshcore.meshcore_open"
compileSdk = flutter.compileSdkVersion
- ndkVersion = flutter.ndkVersion
+ ndkVersion = "29.0.14206865"
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
- jvmTarget = JavaVersion.VERSION_11.toString()
+ jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
@@ -40,11 +48,25 @@ android {
// }
}
+ signingConfigs {
+ create("release") {
+ val storeFilePath = keystoreProperties["storeFile"] as String?
+ if (storeFilePath != null) {
+ storeFile = file(storeFilePath)
+ storePassword = keystoreProperties["storePassword"] as String?
+ keyAlias = keystoreProperties["keyAlias"] as String?
+ keyPassword = keystoreProperties["keyPassword"] as String?
+ }
+ }
+ }
+
buildTypes {
release {
- // TODO: Add your own signing config for the release build.
- // Signing with the debug keys for now, so `flutter run --release` works.
- signingConfig = signingConfigs.getByName("debug")
+ signingConfig = if (keystorePropertiesFile.exists()) {
+ signingConfigs.getByName("release")
+ } else {
+ signingConfigs.getByName("debug")
+ }
}
}
@@ -61,5 +83,5 @@ flutter {
}
dependencies {
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a4d9039..4ff626f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -16,6 +16,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
index 4350b1e..9022c8b 100644
--- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
@@ -1,5 +1,18 @@
package com.meshcore.meshcore_open
import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
-class MainActivity : FlutterActivity()
+class MainActivity : FlutterActivity() {
+ private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ usbFunctions.configureFlutterEngine(flutterEngine)
+ }
+
+ override fun onDestroy() {
+ usbFunctions.dispose()
+ super.onDestroy()
+ }
+}
diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MeshcoreUsbFunctions.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MeshcoreUsbFunctions.kt
new file mode 100644
index 0000000..279ba8a
--- /dev/null
+++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MeshcoreUsbFunctions.kt
@@ -0,0 +1,582 @@
+package com.meshcore.meshcore_open
+
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.hardware.usb.UsbConstants
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbDeviceConnection
+import android.hardware.usb.UsbEndpoint
+import android.hardware.usb.UsbInterface
+import android.hardware.usb.UsbManager
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import java.util.Locale
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+class MeshcoreUsbFunctions(
+ private val activity: FlutterActivity,
+) {
+ private companion object {
+ const val usbRecipientInterface = 0x01
+ }
+
+ private val usbMethodChannelName = "meshcore_open/android_usb_serial"
+ private val usbEventChannelName = "meshcore_open/android_usb_serial_events"
+ private val usbPermissionAction = "com.meshcore.meshcore_open.USB_PERMISSION"
+
+ private val usbManager by lazy {
+ activity.getSystemService(Context.USB_SERVICE) as UsbManager
+ }
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor()
+
+ @Volatile private var eventSink: EventChannel.EventSink? = null
+ @Volatile private var usbConnection: UsbDeviceConnection? = null
+ @Volatile private var usbInEndpoint: UsbEndpoint? = null
+ @Volatile private var usbOutEndpoint: UsbEndpoint? = null
+ @Volatile private var controlInterface: UsbInterface? = null
+ @Volatile private var dataInterface: UsbInterface? = null
+ private var readThread: Thread? = null
+ @Volatile private var isReading = false
+ @Volatile private var connectedDeviceName: String? = null
+
+ private var pendingConnectResult: MethodChannel.Result? = null
+ private var pendingConnectPortName: String? = null
+ private var pendingConnectBaudRate: Int = 115200
+
+ private data class PortConfig(
+ val controlInterface: UsbInterface?,
+ val dataInterface: UsbInterface,
+ val inEndpoint: UsbEndpoint,
+ val outEndpoint: UsbEndpoint,
+ )
+
+ private val permissionReceiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ when (intent?.action) {
+ UsbManager.ACTION_USB_DEVICE_DETACHED -> {
+ handleUsbDetached(intent)
+ return
+ }
+ usbPermissionAction -> Unit
+ else -> return
+ }
+
+ val result = pendingConnectResult
+ val portName = pendingConnectPortName
+ pendingConnectResult = null
+ pendingConnectPortName = null
+
+ if (result == null || portName == null) {
+ return
+ }
+
+ val device = findUsbDevice(portName)
+ if (device == null) {
+ result.error(
+ "usb_device_missing",
+ null,
+ null,
+ )
+ return
+ }
+
+ val granted =
+ intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
+ if (!granted || !usbManager.hasPermission(device)) {
+ result.error("usb_permission_denied", null, null)
+ return
+ }
+
+ openUsbDevice(device, pendingConnectBaudRate, result)
+ }
+ }
+
+ fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ registerUsbPermissionReceiver()
+
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, usbMethodChannelName)
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "listPorts" -> result.success(listUsbPorts())
+ "connect" -> handleUsbConnect(call, result)
+ "write" -> handleUsbWrite(call, result)
+ "disconnect" -> {
+ scheduleCloseUsbConnection {
+ result.success(null)
+ }
+ }
+ else -> result.notImplemented()
+ }
+ }
+
+ EventChannel(flutterEngine.dartExecutor.binaryMessenger, usbEventChannelName)
+ .setStreamHandler(
+ object : EventChannel.StreamHandler {
+ override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
+ eventSink = events
+ }
+
+ override fun onCancel(arguments: Any?) {
+ eventSink = null
+ }
+ },
+ )
+ }
+
+ fun dispose() {
+ closeUsbConnection()
+ usbIoExecutor.shutdownNow()
+ try {
+ activity.unregisterReceiver(permissionReceiver)
+ } catch (_: IllegalArgumentException) {
+ }
+ }
+
+ private fun registerUsbPermissionReceiver() {
+ val filter =
+ IntentFilter().apply {
+ addAction(usbPermissionAction)
+ addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
+ } else {
+ @Suppress("DEPRECATION")
+ activity.registerReceiver(permissionReceiver, filter)
+ }
+ }
+
+ private fun listUsbPorts(): List {
+ return usbManager.deviceList.values.map { device ->
+ val productName = device.productName ?: "USB Serial Device"
+ val vendorProduct =
+ String.format(
+ Locale.US,
+ "VID:%04X PID:%04X",
+ device.vendorId,
+ device.productId,
+ )
+ "${device.deviceName} - $productName - $vendorProduct"
+ }
+ }
+
+ private fun handleUsbConnect(call: MethodCall, result: MethodChannel.Result) {
+ val portName = call.argument("portName")
+ val baudRate = call.argument("baudRate") ?: 115200
+ if (portName.isNullOrBlank()) {
+ result.error("usb_invalid_port", null, null)
+ return
+ }
+
+ val device = findUsbDevice(portName)
+ if (device == null) {
+ result.error("usb_device_missing", null, null)
+ return
+ }
+
+ if (usbManager.hasPermission(device)) {
+ openUsbDevice(device, baudRate, result)
+ return
+ }
+
+ if (pendingConnectResult != null) {
+ result.error("usb_busy", null, null)
+ return
+ }
+
+ pendingConnectResult = result
+ pendingConnectPortName = portName
+ pendingConnectBaudRate = baudRate
+
+ val permissionIntent = PendingIntent.getBroadcast(
+ activity,
+ 0,
+ Intent(usbPermissionAction).setPackage(activity.packageName),
+ pendingIntentFlags(),
+ )
+ usbManager.requestPermission(device, permissionIntent)
+ }
+
+ private fun handleUsbWrite(call: MethodCall, result: MethodChannel.Result) {
+ val data = call.argument("data")
+ val connection = usbConnection
+ val endpoint = usbOutEndpoint
+ if (data == null) {
+ result.error("usb_invalid_data", null, null)
+ return
+ }
+ if (connection == null || endpoint == null) {
+ result.error("usb_not_connected", null, null)
+ return
+ }
+
+ usbIoExecutor.execute {
+ try {
+ writeToDevice(data)
+ mainHandler.post { result.success(null) }
+ } catch (error: Exception) {
+ mainHandler.post {
+ result.error("usb_write_failed", error.message, null)
+ }
+ }
+ }
+ }
+
+ private fun findUsbDevice(portName: String): UsbDevice? {
+ val devices = usbManager.deviceList.values
+ val exactMatch = devices.firstOrNull { it.deviceName == portName }
+ if (exactMatch != null) {
+ return exactMatch
+ }
+
+ val normalizedName = portName.substringBefore(" - ").trim()
+ return devices.firstOrNull { it.deviceName == normalizedName }
+ }
+
+ private fun openUsbDevice(
+ device: UsbDevice,
+ baudRate: Int,
+ result: MethodChannel.Result,
+ ) {
+ usbIoExecutor.execute {
+ try {
+ closeUsbConnection()
+
+ val config = resolvePortConfig(device)
+ if (config == null) {
+ mainHandler.post {
+ result.error(
+ "usb_driver_missing",
+ null,
+ null,
+ )
+ }
+ return@execute
+ }
+
+ val connection = usbManager.openDevice(device)
+ if (connection == null) {
+ mainHandler.post {
+ result.error(
+ "usb_open_failed",
+ null,
+ null,
+ )
+ }
+ return@execute
+ }
+
+ if (!connection.claimInterface(config.dataInterface, true)) {
+ connection.close()
+ mainHandler.post {
+ result.error(
+ "usb_open_failed",
+ null,
+ null,
+ )
+ }
+ return@execute
+ }
+
+ if (config.controlInterface != null &&
+ config.controlInterface.id != config.dataInterface.id &&
+ !connection.claimInterface(config.controlInterface, true)
+ ) {
+ connection.releaseInterface(config.dataInterface)
+ connection.close()
+ mainHandler.post {
+ result.error(
+ "usb_open_failed",
+ null,
+ null,
+ )
+ }
+ return@execute
+ }
+
+ usbConnection = connection
+ usbInEndpoint = config.inEndpoint
+ usbOutEndpoint = config.outEndpoint
+ controlInterface = config.controlInterface
+ dataInterface = config.dataInterface
+
+ configureDevice(connection, config, baudRate)
+
+ connectedDeviceName = device.deviceName
+ startReadLoop()
+
+ mainHandler.post {
+ result.success(null)
+ }
+ } catch (error: Exception) {
+ closeUsbConnection()
+ mainHandler.post {
+ result.error("usb_connect_failed", error.message, null)
+ }
+ }
+ }
+ }
+
+ private fun resolvePortConfig(device: UsbDevice): PortConfig? {
+ var preferredDataInterface: UsbInterface? = null
+ var preferredInEndpoint: UsbEndpoint? = null
+ var preferredOutEndpoint: UsbEndpoint? = null
+ var fallbackDataInterface: UsbInterface? = null
+ var fallbackInEndpoint: UsbEndpoint? = null
+ var fallbackOutEndpoint: UsbEndpoint? = null
+ var preferredControlInterface: UsbInterface? = null
+
+ for (interfaceIndex in 0 until device.interfaceCount) {
+ val usbInterface = device.getInterface(interfaceIndex)
+ var inEndpoint: UsbEndpoint? = null
+ var outEndpoint: UsbEndpoint? = null
+
+ for (endpointIndex in 0 until usbInterface.endpointCount) {
+ val endpoint = usbInterface.getEndpoint(endpointIndex)
+ if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
+ continue
+ }
+ when (endpoint.direction) {
+ UsbConstants.USB_DIR_IN -> if (inEndpoint == null) inEndpoint = endpoint
+ UsbConstants.USB_DIR_OUT -> if (outEndpoint == null) outEndpoint = endpoint
+ }
+ }
+
+ val hasDataPair = inEndpoint != null && outEndpoint != null
+ when {
+ usbInterface.interfaceClass == UsbConstants.USB_CLASS_COMM &&
+ preferredControlInterface == null -> {
+ preferredControlInterface = usbInterface
+ }
+ hasDataPair &&
+ usbInterface.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA -> {
+ preferredDataInterface = usbInterface
+ preferredInEndpoint = inEndpoint
+ preferredOutEndpoint = outEndpoint
+ }
+ hasDataPair && fallbackDataInterface == null -> {
+ fallbackDataInterface = usbInterface
+ fallbackInEndpoint = inEndpoint
+ fallbackOutEndpoint = outEndpoint
+ }
+ }
+ }
+
+ val dataInterface = preferredDataInterface ?: fallbackDataInterface ?: return null
+ val inEndpoint = preferredInEndpoint ?: fallbackInEndpoint ?: return null
+ val outEndpoint = preferredOutEndpoint ?: fallbackOutEndpoint ?: return null
+ return PortConfig(preferredControlInterface, dataInterface, inEndpoint, outEndpoint)
+ }
+
+ private fun configureDevice(
+ connection: UsbDeviceConnection,
+ config: PortConfig,
+ baudRate: Int,
+ ) {
+ val control = config.controlInterface ?: return
+ val lineCoding =
+ byteArrayOf(
+ (baudRate and 0xFF).toByte(),
+ ((baudRate shr 8) and 0xFF).toByte(),
+ ((baudRate shr 16) and 0xFF).toByte(),
+ ((baudRate shr 24) and 0xFF).toByte(),
+ 0, // stop bits: 1
+ 0, // parity: none
+ 8, // data bits
+ )
+
+ val lineCodingResult =
+ connection.controlTransfer(
+ UsbConstants.USB_DIR_OUT or
+ UsbConstants.USB_TYPE_CLASS or
+ usbRecipientInterface,
+ 0x20,
+ 0,
+ control.id,
+ lineCoding,
+ lineCoding.size,
+ 1000,
+ )
+ if (lineCodingResult < 0) {
+ throw IllegalStateException("Failed to configure USB line coding")
+ }
+
+ val controlLineResult =
+ connection.controlTransfer(
+ UsbConstants.USB_DIR_OUT or
+ UsbConstants.USB_TYPE_CLASS or
+ usbRecipientInterface,
+ 0x22,
+ 0x0001, // DTR on, RTS off
+ control.id,
+ null,
+ 0,
+ 1000,
+ )
+ if (controlLineResult < 0) {
+ throw IllegalStateException("Failed to configure USB control line state")
+ }
+ }
+
+ private fun startReadLoop() {
+ val connection = usbConnection ?: return
+ val endpoint = usbInEndpoint ?: return
+
+ isReading = true
+ readThread =
+ Thread({
+ val packetSize = endpoint.maxPacketSize.coerceAtLeast(64)
+ val buffer = ByteArray(packetSize * 4)
+ try {
+ while (isReading) {
+ val bytesRead = connection.bulkTransfer(endpoint, buffer, buffer.size, 250)
+ if (!isReading) {
+ break
+ }
+ if (bytesRead <= 0) {
+ continue
+ }
+ val packet = buffer.copyOf(bytesRead)
+ mainHandler.post {
+ eventSink?.success(packet)
+ }
+ }
+ } catch (error: Exception) {
+ if (isReading) {
+ mainHandler.post {
+ eventSink?.error(
+ "usb_io_error",
+ error.message ?: "USB serial I/O error",
+ null,
+ )
+ }
+ scheduleCloseUsbConnection()
+ }
+ }
+ }, "MeshCoreUsbRead").also { thread ->
+ thread.isDaemon = true
+ thread.start()
+ }
+ }
+
+ private fun writeToDevice(data: ByteArray) {
+ val connection = usbConnection ?: throw IllegalStateException("USB connection missing")
+ val endpoint = usbOutEndpoint ?: throw IllegalStateException("USB output endpoint missing")
+ var offset = 0
+ val maxPacketSize = endpoint.maxPacketSize.coerceAtLeast(64)
+ while (offset < data.size) {
+ val chunkSize = minOf(maxPacketSize, data.size - offset)
+ val chunk = data.copyOfRange(offset, offset + chunkSize)
+ val bytesWritten = connection.bulkTransfer(endpoint, chunk, chunkSize, 1000)
+ if (bytesWritten != chunkSize) {
+ throw IllegalStateException("Short USB write: wrote $bytesWritten of $chunkSize bytes")
+ }
+ offset += chunkSize
+ }
+ }
+
+ private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) {
+ usbIoExecutor.execute {
+ closeUsbConnection()
+ if (onComplete != null) {
+ mainHandler.post(onComplete)
+ }
+ }
+ }
+
+ @Synchronized
+ private fun closeUsbConnection() {
+ isReading = false
+ readThread?.interrupt()
+ if (readThread != null && readThread !== Thread.currentThread()) {
+ try {
+ readThread?.join(300)
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }
+ readThread = null
+
+ val connection = usbConnection
+ val claimedControl = controlInterface
+ val claimedData = dataInterface
+
+ usbInEndpoint = null
+ usbOutEndpoint = null
+ controlInterface = null
+ dataInterface = null
+ usbConnection = null
+
+ if (connection != null) {
+ if (claimedControl != null) {
+ try {
+ connection.releaseInterface(claimedControl)
+ } catch (_: Exception) {
+ }
+ }
+ if (claimedData != null && claimedData.id != claimedControl?.id) {
+ try {
+ connection.releaseInterface(claimedData)
+ } catch (_: Exception) {
+ }
+ }
+ try {
+ connection.close()
+ } catch (_: Exception) {
+ }
+ }
+ connectedDeviceName = null
+ }
+
+ private fun handleUsbDetached(intent: Intent) {
+ val detachedDevice =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
+ }
+
+ val detachedName = detachedDevice?.deviceName ?: return
+
+ if (pendingConnectPortName == detachedName) {
+ pendingConnectResult?.error(
+ "usb_device_detached",
+ "USB device was removed before the connection completed",
+ null,
+ )
+ pendingConnectResult = null
+ pendingConnectPortName = null
+ }
+
+ if (connectedDeviceName == detachedName) {
+ scheduleCloseUsbConnection {
+ eventSink?.error(
+ "usb_device_detached",
+ "USB device was disconnected",
+ null,
+ )
+ }
+ }
+ }
+
+ private fun pendingIntentFlags(): Int {
+ var flags = PendingIntent.FLAG_UPDATE_CURRENT
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ flags = flags or PendingIntent.FLAG_MUTABLE
+ }
+ return flags
+ }
+}
diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
new file mode 100644
index 0000000..2220133
--- /dev/null
+++ b/android/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
diff --git a/assets/badges/badge_obtainium.png b/assets/badges/badge_obtainium.png
new file mode 100644
index 0000000..cc3a0ed
Binary files /dev/null and b/assets/badges/badge_obtainium.png differ
diff --git a/assets/icons/done_all.svg b/assets/icons/done_all.svg
new file mode 100644
index 0000000..bfeeec0
--- /dev/null
+++ b/assets/icons/done_all.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/mesh-icon.png b/assets/images/mesh-icon.png
new file mode 100644
index 0000000..f7cf267
Binary files /dev/null and b/assets/images/mesh-icon.png differ
diff --git a/docs/BLE_PROTOCOL.md b/docs/BLE_PROTOCOL.md
index 993c3ea..c17c3e7 100644
--- a/docs/BLE_PROTOCOL.md
+++ b/docs/BLE_PROTOCOL.md
@@ -21,7 +21,12 @@ The MeshCore BLE protocol implements a binary frame-based communication system u
### Connection Flow
-1. **Scan** for devices with name prefix `MeshCore-`
+1. **Scan** for devices with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`):
+ - `MeshCore-`
+ - `Whisper-`
+ - `WisCore-`
+ - `HT-`
+ - `LowMesh_MC_`
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
diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md
new file mode 100644
index 0000000..bdbe89d
--- /dev/null
+++ b/docs/PRIVACY_POLICY.md
@@ -0,0 +1,104 @@
+# Privacy Policy for MeshCore Open
+
+**Last Updated:** January 11, 2026
+
+## Introduction
+
+MeshCore Open ("the App") is an open-source Flutter application for communicating with MeshCore LoRa mesh networking devices. This Privacy Policy explains how the App handles your information.
+
+## Data Collection
+
+### Data We Do NOT Collect
+
+MeshCore Open does **not**:
+- Collect personal information
+- Send data to external servers (except map tile requests)
+- Track your usage or behavior
+- Use analytics services
+- Require account creation
+- Share any data with third parties
+
+### Data Stored Locally on Your Device
+
+The App stores the following data **locally on your device only**:
+
+- **Messages**: Chat messages sent and received through the mesh network
+- **Contacts**: Names and identifiers of mesh network contacts
+- **App Settings**: Your preferences (theme, language, notification settings)
+- **Channel Settings**: Configuration for mesh network channels
+- **Message History**: Path history for message routing
+- **Debug Logs**: Optional BLE and app debug logs (if enabled by user)
+- **Cached Map Tiles**: Offline map data for the mapping feature
+
+All locally stored data remains on your device and is never transmitted to us or any third party.
+
+## Permissions
+
+The App requires certain device permissions to function:
+
+### Bluetooth Permissions
+- **BLUETOOTH, BLUETOOTH_ADMIN** (Android 11 and below)
+- **BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE** (Android 12+)
+
+These permissions are used solely to discover and communicate with MeshCore hardware devices via Bluetooth Low Energy (BLE).
+
+### Location Permission
+- **ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION**
+
+Required by Android for BLE scanning on Android 11 and below. The App does not track or store your location. Location data may be optionally shared over the mesh network if you choose to enable location sharing features.
+
+### Internet Permission
+- **INTERNET**
+
+Used only for downloading map tiles from OpenStreetMap tile servers when using the map feature. No personal data is transmitted.
+
+### Notification Permission
+- **POST_NOTIFICATIONS** (Android 13+)
+
+Used to display notifications for incoming messages when the app is in the background.
+
+### Background Service Permissions
+- **FOREGROUND_SERVICE, FOREGROUND_SERVICE_CONNECTED_DEVICE, WAKE_LOCK**
+
+Used to maintain BLE connection with your MeshCore device while the app is in the background.
+
+## Third-Party Services
+
+### Map Tiles
+The App uses OpenStreetMap tile servers to display maps. When viewing maps, your device's IP address may be visible to the tile server. No other data is shared. See [OpenStreetMap's Privacy Policy](https://wiki.osmfoundation.org/wiki/Privacy_Policy) for more information.
+
+### GIF Search (Giphy)
+The App includes a GIF picker feature powered by Giphy. When you use the GIF search feature:
+- Your search queries are sent to Giphy's API servers
+- Your device's IP address is visible to Giphy
+- Giphy may collect usage data according to their privacy policy
+
+GIF search is optional and only activated when you choose to use it. See [Giphy's Privacy Policy](https://support.giphy.com/hc/en-us/articles/360032872931-GIPHY-Privacy-Policy) for more information about how they handle data.
+
+## Mesh Network Communications
+
+Messages sent through the MeshCore mesh network are transmitted over radio frequencies to other mesh devices. The App itself does not control or monitor these communications beyond facilitating the connection between your mobile device and your MeshCore hardware.
+
+## Data Security
+
+All data is stored locally on your device using standard Flutter/Android storage mechanisms. The App does not implement additional encryption for locally stored data beyond what the operating system provides.
+
+## Children's Privacy
+
+The App does not knowingly collect any personal information from children under 13 years of age.
+
+## Open Source
+
+MeshCore Open is open-source software. You can review the complete source code to verify these privacy practices at [the project repository].
+
+## Changes to This Policy
+
+We may update this Privacy Policy from time to time. Any changes will be reflected in the "Last Updated" date at the top of this policy.
+
+## Contact
+
+If you have questions about this Privacy Policy or the App's privacy practices, please open an issue on the project's GitHub repository.
+
+---
+
+**Summary**: MeshCore Open is a privacy-respecting app that stores all data locally on your device. We do not collect, track, or share your personal information.
diff --git a/docs/screenshots/signal-ui-consistency.png b/docs/screenshots/signal-ui-consistency.png
new file mode 100644
index 0000000..2575945
Binary files /dev/null and b/docs/screenshots/signal-ui-consistency.png differ
diff --git a/documentation/README.md b/documentation/README.md
new file mode 100644
index 0000000..1367013
--- /dev/null
+++ b/documentation/README.md
@@ -0,0 +1,30 @@
+# MeshCore Open - Feature Documentation
+
+MeshCore Open is an open-source Flutter client for MeshCore LoRa mesh networking devices. This documentation covers every user-facing feature, how to access it, and what it does.
+
+## Table of Contents
+
+1. [Scanner & Connection](scanner-and-connection.md) - BLE scanning, USB serial, and TCP connection
+2. [Navigation](navigation.md) - App flow, device screen, and quick-switch navigation
+3. [Contacts](contacts.md) - Contact management, groups, discovery, and sharing
+4. [Chat & Messaging](chat-and-messaging.md) - Direct messages, message status, reactions, and retries
+5. [Channels](channels.md) - Broadcast channels, communities, and channel chat
+6. [Map & Location](map-and-location.md) - Node map, path tracing, line-of-sight, and offline caching
+7. [Settings](settings.md) - Device settings, app settings, radio configuration, and exports
+8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences
+9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors
+10. [Additional Features](additional-features.md) - GIF picker, localization, debug logs, SMAZ compression, and more
+11. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture
+
+## App Overview
+
+MeshCore Open connects to MeshCore LoRa mesh radios over BLE, USB, or TCP. Once connected, users can:
+
+- **Chat** with other mesh nodes via encrypted direct messages
+- **Broadcast** on shared channels (public, hashtag, private, or community-scoped)
+- **View nodes on a map** with GPS locations, predicted positions, and path traces
+- **Manage repeaters** with CLI access, telemetry, neighbor info, and settings
+- **Share contacts** via `meshcore://` URIs and QR codes
+- **Configure radio settings** including frequency, power, bandwidth, and spreading factor
+- **Cache offline maps** for use without internet connectivity
+- **Analyze line-of-sight** between nodes with terrain elevation profiles
diff --git a/documentation/additional-features.md b/documentation/additional-features.md
new file mode 100644
index 0000000..f7b8319
--- /dev/null
+++ b/documentation/additional-features.md
@@ -0,0 +1,187 @@
+# Additional Features
+
+## GIF Picker (Giphy Integration)
+
+### How to Access
+In any chat screen (direct or channel), tap the GIF button in the message input bar.
+
+### What the User Sees
+A bottom sheet with a search field and a grid of GIF thumbnails.
+
+### Key Interactions
+- On open, loads trending GIFs (G-rated, 25 results)
+- Type to search and press the keyboard submit button (search triggers on submit, not on each keystroke). Clearing the search field reloads trending GIFs
+- On network/API errors, a "Retry" button is shown in-place
+- Tap a GIF to select it — the chat input shows an inline preview with an X button to dismiss
+- Send the message to transmit the GIF reference (`g:`)
+- Recipients see the GIF rendered inline via Giphy CDN
+- "Powered by Giphy" attribution is always shown at the bottom of the picker
+- The bottom sheet occupies 70% of screen height
+
+---
+
+## Localization / Multi-Language Support
+
+### How to Access
+App Settings → Appearance → Language
+
+### Supported Languages (15)
+English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian
+
+### How It Works
+- All UI strings go through Flutter's ARB localization system
+- Language can follow the system locale or be explicitly overridden
+- Changes take effect immediately
+
+---
+
+## Discovered Contacts Screen
+
+### How to Access
+From Contacts screen → overflow menu → "Discovered Contacts"
+
+### What the User Sees
+A list of nodes heard passively over the air but not yet added as contacts. Each shows:
+- Color-coded avatar (by type)
+- Name
+- Short public key
+- Last-seen time
+
+### Key Interactions
+- Search bar with debounced filtering
+- Sort by last seen or name; filter by type
+- **Tap**: Import the contact (adds to your contact list)
+- **Long-press**: Add Contact, Copy `meshcore://` URI to clipboard, or Delete
+- Overflow menu → "Delete All" (with confirmation)
+- Already-known contacts and your own node are filtered out
+
+---
+
+## SMAZ Compression
+
+### What It Is
+An optional per-contact and per-channel text compression feature using the SMAZ algorithm (optimized for short English text).
+
+### How to Enable
+- **Per contact**: Chat screen → info button → toggle "SMAZ compression"
+- **Per channel**: Long-press channel → Edit → toggle "SMAZ compression"
+
+### How It Works
+- When enabled, compression is applied using a "compress only if smaller" strategy — the message is only transmitted compressed if the encoded result is actually shorter than the original. Otherwise, the original text is sent uncompressed
+- Compressed messages are transmitted with a `s:` prefix followed by base64-encoded data
+- Recipients using MeshCore Open will decompress automatically. **Recipients using other software** that is not SMAZ-aware will see garbled `s:...` text
+- The codec operates on ASCII. Non-ASCII / non-English text generally does not benefit from compression and may even expand. Best suited for short English messages
+- Disabled by default
+
+---
+
+## Community QR Scanner
+
+### How to Access
+From Channels screen → "+" FAB → "Scan Community QR"
+
+### What the User Sees
+A live QR scanner view with instruction text overlay.
+
+### Key Interactions
+- Scan a community QR code shared by another member
+- On valid scan: confirmation dialog showing community name and ID
+- Option to "Add public channel to device" on join
+- If already a member: shows an "Already a member" dialog
+- Invalid QR: shows an orange error snackbar
+
+---
+
+## Channel Message Path Viewing
+
+### How to Access
+In a channel chat, tap a message bubble (mobile) or use the "Path" action (desktop).
+
+### What the User Sees
+- Summary card: sender, time, repeat count, path type, observed hops
+- "Other Observed Paths" section (if multiple paths detected)
+- "Repeater Hops" section listing each hop with hex prefix, resolved name, and GPS coordinates
+
+### Actions
+- **Radar icon**: Opens path trace map for live trace
+- **Map icon**: Opens a map with hop markers and polyline
+- **Path dropdown**: Switch between observed path variants (if multiple)
+
+---
+
+## Debug Logging
+
+### BLE Debug Log
+**Access**: Settings → BLE Debug Log
+
+Two views:
+- **Frames**: Each BLE frame with direction, description, hex preview, timestamp. Long-press to copy hex.
+- **Raw Log RX**: Decoded LoRa packets with route type, payload type, path bytes, and summary.
+
+### App Debug Log
+**Access**: Settings → App Debug Log (must be enabled first in App Settings → Debug)
+
+Structured log entries with level (Info/Warning/Error), tag, message, and timestamp.
+
+Both logs support copy-all and clear operations.
+
+---
+
+## Chrome Required Screen
+
+### When It Appears
+Automatically shown on web platforms when a non-Chromium browser is detected.
+
+### What the User Sees
+A full-screen informational page explaining that Web Bluetooth requires a Chromium-based browser. No interactive elements — purely informational.
+
+---
+
+## Path History Service
+
+### What It Does (Background Service)
+Maintains an in-memory LRU cache of up to 50 contacts, each with up to 100 route history entries, tracking:
+- Hop count and trip time
+- Success/failure counts and route weights
+- Flood vs. direct discovery
+
+### Path Scoring
+Paths are scored using a weighted formula: reliability (45%), route weight (20%), latency (25%), and freshness (10%). These weights are internal and not user-configurable. Paths whose weight drops to zero or below are automatically deleted. Flood deliveries that receive an ACK give a weight boost (+0.5) to the specific return path.
+
+Used internally for:
+- **Auto route rotation**: Cycles through known paths using configurable weights on retries, with a diversity window to avoid re-using recently tried paths
+- **Path selection**: Picks the best-scored path for each retry attempt
+- **Flood statistics**: Tracks flood vs. direct discovery ratios
+
+---
+
+## Message Retry Service
+
+### What It Does (Background Service)
+Handles reliable delivery of outgoing direct messages:
+1. Assigns a UUID and sends immediately. Only one message per contact can be in-flight at a time (avoids overflowing the firmware's 8-entry ACK table); subsequent messages are queued
+2. Listens for ACK frames matched via SHA-256 hash of `[timestamp][attempt][text][sender_pubkey]`
+3. On timeout, retries with exponential backoff: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s...)
+4. Each retry may use a different path (via path history diversity window)
+5. After max retries: marks failed but keeps a **30-second grace window** during which a late ACK can still resolve the message to "delivered". Optionally clears the contact's path
+6. Reports RTT and path data for quality learning
+7. Maintains an ACK hash history (last 50 entries) to handle duplicate ACKs
+
+### Configurable Settings (App Settings → Messaging)
+- Max retries (2–10, default 5)
+- Clear path on max retry (on/off)
+- Auto route rotation with weight parameters
+
+---
+
+## Timeout Prediction (ML)
+
+### What It Does (Background Service)
+An ML-based service that predicts expected delivery timeouts:
+- Collects delivery observations (path length, message size, time since last RX, delivery time) in a sliding window of up to 100 observations (oldest evicted first)
+- Requires **10 minimum observations** before first training. After that, retrains every 5 new observations
+- Applies a **1.5x safety margin** to raw predictions (the actual timeout issued is 1.5× the model's predicted delivery time)
+- Features with zero variance are automatically excluded from training
+- Blends per-contact statistics with ML predictions
+- Falls back to `3000 + 3000 × pathLength` ms when insufficient data
+- Observations are persisted to storage via a 2-second debounced timer (observations within 2s of app termination may be lost)
diff --git a/documentation/ble-protocol.md b/documentation/ble-protocol.md
new file mode 100644
index 0000000..ec24094
--- /dev/null
+++ b/documentation/ble-protocol.md
@@ -0,0 +1,254 @@
+# BLE Protocol & Data Layer
+
+This is a technical reference for the communication protocol and data architecture.
+
+## Transport Layer
+
+The app supports three transports, all sharing the same command/response protocol:
+
+| Transport | Method | Implementation |
+|---|---|---|
+| Bluetooth LE | Nordic UART Service (NUS) GATT | `flutter_blue_plus` |
+| USB Serial | Packet-framed serial | `MeshCoreUsbManager` |
+| TCP | Packet-framed socket | `MeshCoreTcpConnector` |
+
+### BLE (Nordic UART Service)
+
+- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
+- **RX Characteristic** (write to device): `6e400002-b5a3-f393-e0a9-e50e24dcca9e`
+- **TX Characteristic** (notify from device): `6e400003-b5a3-f393-e0a9-e50e24dcca9e`
+
+Raw `Uint8List` payloads are written directly to the RX characteristic. Writes use "write without response" if supported, falling back to "write with response".
+
+### USB and TCP Framing
+
+Both use a lightweight packet framing codec:
+
+```
+TX (host → device): [0x3C][len_lo][len_hi][payload...]
+RX (device → host): [0x3E][len_lo][len_hi][payload...]
+```
+
+- Frame start: `0x3C` (`<`) for outgoing, `0x3E` (`>`) for incoming
+- Length: 2-byte little-endian, payload only
+- Max payload: 172 bytes
+- TCP: `tcpNoDelay: true` (Nagle disabled), writes serialized to prevent interleaving
+- USB: 10ms post-write delay between frames
+
+## Connection State Machine
+
+```
+enum MeshCoreConnectionState {
+ disconnected,
+ scanning,
+ connecting,
+ connected,
+ disconnecting,
+}
+```
+
+## BLE Connection Lifecycle
+
+1. **Scan** with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`):
+ - `MeshCore-`
+ - `Whisper-`
+ - `WisCore-`
+ - `HT-`
+ - `LowMesh_MC_`
+2. **Connect** with 15-second timeout
+3. **Request MTU** 185 bytes (non-web only)
+4. **Discover services** and locate NUS
+5. **Enable TX notifications** (up to 3 attempts on native)
+6. **Subscribe** to TX characteristic for incoming frames
+7. **Initial sync**: device info query, time sync, channel sync
+
+## Auto-Reconnect (BLE Only)
+
+On unexpected disconnection, auto-reconnect with exponential backoff:
+- Delays: 1s, 2s, 4s, 8s, 16s, 30s, 30s...
+- Resets on successful connection
+- Disabled for manual disconnects
+- Not available for USB or TCP
+
+## Protocol Constants
+
+| Constant | Value | Description |
+|---|---|---|
+| Max frame size | 172 bytes | BLE/USB/TCP payload limit |
+| Public key size | 32 bytes | Ed25519 public key |
+| Max path size | 64 bytes | Maximum path data |
+| Max name size | 32 bytes | Maximum node name |
+| Max text payload | 160 bytes | Firmware `MAX_TEXT_LEN` |
+| App protocol version | 3 | Sent in device query |
+| Contact frame size | 148 bytes | Fixed-size contact record |
+
+## Command Codes (App → Device)
+
+| Code | Name | Description |
+|------|------|-------------|
+| 1 | CMD_APP_START | Announce app connection |
+| 2 | CMD_SEND_TXT_MSG | Send direct text message |
+| 3 | CMD_SEND_CHANNEL_TXT_MSG | Send channel text message |
+| 4 | CMD_GET_CONTACTS | Request contact list |
+| 5 | CMD_GET_DEVICE_TIME | Query device clock |
+| 6 | CMD_SET_DEVICE_TIME | Set device clock |
+| 7 | CMD_SEND_SELF_ADVERT | Broadcast own advertisement |
+| 8 | CMD_SET_ADVERT_NAME | Set node name |
+| 9 | CMD_ADD_UPDATE_CONTACT | Add or update a contact |
+| 10 | CMD_SYNC_NEXT_MESSAGE | Request next queued message |
+| 11 | CMD_SET_RADIO_PARAMS | Set radio parameters |
+| 12 | CMD_SET_RADIO_TX_POWER | Set TX power |
+| 13 | CMD_RESET_PATH | Reset contact path |
+| 14 | CMD_SET_ADVERT_LATLON | Set advertised location |
+| 15 | CMD_REMOVE_CONTACT | Remove a contact |
+| 16 | CMD_SHARE_CONTACT | Share contact to mesh |
+| 17 | CMD_EXPORT_CONTACT | Export contact as bytes |
+| 18 | CMD_IMPORT_CONTACT | Import contact from bytes |
+| 19 | CMD_REBOOT | Reboot device |
+| 20 | CMD_GET_BATT_AND_STORAGE | Query battery and storage |
+| 22 | CMD_DEVICE_QUERY | Query device info |
+| 26 | CMD_SEND_LOGIN | Login to repeater/room |
+| 27 | CMD_SEND_STATUS_REQ | Request repeater status |
+| 30 | CMD_GET_CONTACT_BY_KEY | Get contact by public key |
+| 31 | CMD_GET_CHANNEL | Get channel definition |
+| 32 | CMD_SET_CHANNEL | Set channel name and PSK |
+| 36 | CMD_SEND_TRACE_PATH | Request path trace |
+| 38 | CMD_SET_OTHER_PARAMS | Set misc parameters |
+| 39 | CMD_GET_TELEMETRY_REQ | Request sensor telemetry |
+| 40 | CMD_GET_CUSTOM_VAR | Get custom variables |
+| 41 | CMD_SET_CUSTOM_VAR | Set a custom variable |
+| 50 | CMD_SEND_BINARY_REQ | Send binary request |
+| 57 | CMD_SEND_ANON_REQ | Send anonymous request |
+| 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration |
+| 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration |
+
+## Response / Push Codes (Device → App)
+
+| Code | Name | Description |
+|------|------|-------------|
+| 0 | RESP_CODE_OK | Generic success |
+| 1 | RESP_CODE_ERR | Generic error |
+| 2 | RESP_CODE_CONTACTS_START | Contact list begins |
+| 3 | RESP_CODE_CONTACT | Single contact data |
+| 4 | RESP_CODE_END_OF_CONTACTS | Contact list complete |
+| 5 | RESP_CODE_SELF_INFO | Device self-info response |
+| 6 | RESP_CODE_SENT | Message transmitted; carries `[1]=is_flood, [2–5]=ack_hash, [6–9]=estimated_timeout_ms` |
+| 7 | RESP_CODE_CONTACT_MSG_RECV | Incoming direct message (v2) |
+| 8 | RESP_CODE_CHANNEL_MSG_RECV | Incoming channel message (v2) |
+| 10 | RESP_CODE_NO_MORE_MESSAGES | No more queued messages |
+| 11 | RESP_CODE_EXPORT_CONTACT | Exported contact data |
+| 9 | RESP_CODE_CURR_TIME | Current device time |
+| 12 | RESP_CODE_BATT_AND_STORAGE | Battery mV (uint16 LE) + storage used/total (uint32 LE each) |
+| 13 | RESP_CODE_DEVICE_INFO | Firmware info |
+| 16 | RESP_CODE_CONTACT_MSG_RECV_V3 | Incoming direct message (v3) |
+| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) |
+| 18 | RESP_CODE_CHANNEL_INFO | Channel definition |
+| 21 | RESP_CODE_CUSTOM_VARS | Custom variables |
+| 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags |
+| 0x80 | PUSH_CODE_ADVERT | Known contact re-seen |
+| 0x81 | PUSH_CODE_PATH_UPDATED | Better path found; carries the 32-byte public key of the updated contact |
+| 0x82 | PUSH_CODE_SEND_CONFIRMED | Delivery ACK from remote; carries ACK hash (4 bytes) + trip time (4 bytes) |
+| 0x83 | PUSH_CODE_MSG_WAITING | Offline messages queued |
+| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Repeater/room login succeeded |
+| 0x86 | PUSH_CODE_LOGIN_FAIL | Repeater/room login failed |
+| 0x87 | PUSH_CODE_STATUS_RESPONSE | Repeater status response |
+| 0x88 | PUSH_CODE_LOG_RX_DATA | Radio RX data with SNR (int8, units 1/4 dB), RSSI, and raw radio packet |
+| 0x89 | PUSH_CODE_TRACE_DATA | Path trace result |
+| 0x8A | PUSH_CODE_NEW_ADVERT | New node discovered |
+| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Sensor telemetry data |
+| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary data response |
+
+## Data Models
+
+### Contact
+32-byte public key (primary identity), name, type (chat/repeater/room/sensor), flags, path data, GPS coordinates, last-seen timestamp. Parsed from 148-byte firmware frames with this layout:
+
+```
+[0] = resp_code
+[1–32] = public key (32 bytes)
+[33] = type (1=chat, 2=repeater, 3=room, 4=sensor)
+[34] = flags (bit 0 = favorite)
+[35] = path_length
+[36–99] = path (64 bytes)
+[100–131] = name (32 bytes, null-padded)
+[132–135] = timestamp (uint32 LE)
+[136–139] = latitude (int32 LE, × 1e-6 degrees)
+[140–143] = longitude (int32 LE, × 1e-6 degrees)
+[144–147] = last_modified (uint32 LE)
+```
+
+### Message (Direct)
+Sender key, text, timestamp, outgoing flag, status (pending/sent/delivered/failed), message ID (UUID), retry count, ACK hash, trip time, path data, reactions.
+
+### Channel Message
+Sender name, text, timestamp, status (pending/sent/failed), repeater hops, path variants, channel index, reactions, reply threading fields.
+
+### Channel
+Index (0–7), name, 16-byte PSK, unread count. PSK derivation methods for hashtag (SHA-256) and community (HMAC-SHA256) channels.
+
+### Community
+UUID, name, 32-byte secret, hashtag channel list. Shared via QR code.
+
+## Persistence
+
+All data is stored via `SharedPreferences` (JSON-serialized). No SQLite or other database.
+
+| Data | Storage Key Pattern | Scope |
+|---|---|---|
+| Contacts | `contacts` | Per device identity |
+| Messages | `messages_` | Per device + contact |
+| Channel Messages | `channel_messages_` | Per device + channel |
+| Channels | `channels` | Per device identity |
+| Channel Order | `channel_order_` | Per device identity |
+| Contact Groups | `contact_groups` | Per device identity |
+| Communities | `communities_v1` | Per device identity |
+| Unread Counts | `contact_unread_count` | Per device identity |
+| Discovered Contacts | `discovered_contacts` | Global |
+| App Settings | `app_settings` | Global |
+| Path History | `path_history_` | Per contact |
+
+## Auto-Add Configuration Bitmask
+
+Used by `CMD_SET_AUTO_ADD_CONFIG` (58) and `RESP_CODE_AUTO_ADD_CONFIG` (25):
+
+| Bit | Flag | Description |
+|-----|------|-------------|
+| 0 | 0x01 | Overwrite oldest contact when list is full |
+| 1 | 0x02 | Auto-add chat users |
+| 2 | 0x04 | Auto-add repeaters |
+| 3 | 0x08 | Auto-add room servers |
+| 4 | 0x10 | Auto-add sensors |
+
+## Radio Packet Payload Types
+
+Seen inside `PUSH_CODE_LOG_RX_DATA` raw packets:
+
+| Code | Type |
+|------|------|
+| 0x00 | REQ (request) |
+| 0x01 | RESPONSE |
+| 0x02 | TXTMSG (text message) |
+| 0x03 | ACK |
+| 0x04 | ADVERT |
+| 0x05 | GRPTXT (group/channel text) |
+| 0x06 | GRPDATA (group data) |
+| 0x07 | ANONREQ (anonymous request) |
+| 0x08 | PATH |
+| 0x09 | TRACE |
+| 0x0A | MULTIPART |
+| 0x0B | CONTROL |
+| 0x0F | RAW_CUSTOM |
+
+## State Management
+
+Uses Flutter `Provider` with `ChangeNotifier`. The central state holder is `MeshCoreConnector`, which owns all in-memory collections and fires debounced (50ms) `notifyListeners()` to update the UI. In-memory conversations are windowed to 200 messages per contact; older messages remain on disk and are loaded on demand.
+
+### Data Flow
+
+1. Raw frames arrive over BLE/USB/TCP
+2. First byte is parsed as response/push code
+3. Appropriate model factory (`fromFrame()`) parses the data
+4. In-memory collections are updated
+5. Storage stores are persisted (async)
+6. `notifyListeners()` triggers UI rebuilds
+7. Screens read current state via getters
diff --git a/documentation/channels.md b/documentation/channels.md
new file mode 100644
index 0000000..21fb52e
--- /dev/null
+++ b/documentation/channels.md
@@ -0,0 +1,164 @@
+# Channels
+
+## Overview
+
+Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh.
+
+Up to 8 channels (indices 0–7) can be active simultaneously on one device.
+
+## How to Access
+
+QuickSwitchBar tab 1 (middle) from any main screen.
+
+## Channel Types
+
+| Type | Icon | Color | Description |
+|---|---|---|---|
+| Public | Globe | Green | Fixed well-known PSK; any device can join |
+| Hashtag | Hash tag | Blue | PSK derived from the hashtag name via SHA-256; discoverable by convention |
+| Private | Lock | Blue | Random PSK; requires out-of-band sharing of the 32-hex key |
+| Community | Groups/Tag | Purple | PSK derived via HMAC-SHA256 from a community's shared secret |
+
+## Channels List Screen
+
+### What the User Sees
+
+- **Search bar** with live text filtering (300ms debounce)
+- **Sort/filter button**
+- **Scrollable list of channel cards**, each showing:
+ - Type icon with color coding (purple badge overlay for community channels)
+ - Channel name (or "Channel N" if unnamed)
+ - Subtitle: "Public channel", "Hashtag channel", "Private channel", or "Community channel - {name}"
+ - Unread badge (if messages are unread)
+ - Drag handle (when manual sort is active)
+- **"+" FAB** to add a new channel
+- **Overflow menu**: Disconnect, Manage Communities (only shown when at least one community exists), Settings
+
+If no channels exist, an empty state with an "Add Public Channel" shortcut is shown. If a search produces no results, a separate "no results" empty state with a search-off icon is shown.
+
+Pull-to-refresh (swipe down) forces a re-fetch of channels from the device firmware.
+
+### Sorting Options
+
+- **Manual** (default): Drag-and-drop reordering, persisted (drag handles are hidden when a search query is active)
+- **A–Z**: Alphabetical
+- **Latest messages**: Most recent first
+- **Unread**: Most unread first
+
+## Adding a Channel
+
+Tap the "+" FAB to open a dialog with six options:
+
+1. **Create Private Channel** — Enter a name (max 31 characters); a random PSK is generated
+2. **Join Private Channel** — Enter a name and a 32-hex PSK (non-hex characters like spaces and dashes are silently stripped, so pasted keys with formatting are accepted)
+3. **Join Public Channel** — One tap; uses the well-known public PSK (only shown if no public channel exists)
+4. **Join Hashtag Channel** — Enter a hashtag name; PSK is derived from the name. If communities exist, choose between regular hashtag (SHA-256) or community hashtag (HMAC)
+5. **Scan Community QR** — Opens QR scanner to join a community
+6. **Create Community** — Enter a name; generates a random 32-byte secret; optionally adds a community public channel; shows QR code for sharing
+
+## Channel Actions (Long-Press / Right-Click)
+
+| Action | Description |
+|---|---|
+| Edit | Change name, PSK (with a dice icon to generate a random PSK), or SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit) |
+| Mute / Unmute | Toggle push notification suppression for this channel |
+| Delete | Remove the channel from the device (confirmation required) |
+
+## Channel Chat
+
+Tap a channel card to open the channel chat screen.
+
+### App Bar
+
+- Type icon (public/private/hashtag)
+- Channel name
+- Subtitle: "{type} - {N} unread"
+
+### Message Display
+
+- Reverse-scrolling list (newest at bottom)
+- **Incoming messages**: Colored avatar with sender's initial (or first emoji if name starts with one; color is deterministic from sender name hash), sender name in primary color, message bubble
+- **Outgoing messages**: Primary container color bubble with a small status icon: pending (clock), sent (checkmark), or failed (red error circle)
+- Automatic older-message loading on scroll-to-top
+- Jump-to-bottom button when scrolled up
+- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset text size
+- **Message tracing mode** (when enabled in App Settings): Each bubble additionally shows path prefix bytes (`via XX,YY,...`), a timestamp, and a repeat count icon
+
+### Message Types in Chat
+
+- **Plain text** with linkified URLs
+- **GIFs** (`g:{gifId}`) rendered inline via Giphy CDN
+- **Location pins** (`m:{lat},{lon}|{label}|`) shown as tappable location cards
+- **Reactions** displayed as emoji pills below target messages
+
+### Replies (Channel Chat Only)
+
+- **Mobile**: Swipe an **incoming** message left to trigger reply (with haptic feedback). You cannot swipe your own outgoing messages. Swipe reply is not available on desktop.
+- **All platforms**: Long-press → "Reply"
+- Reply banner appears above the input bar with the quoted message (tap X to cancel)
+- Sent replies are prefixed `@[{senderName}] {text}`
+- Received replies show a bordered quote block inside the bubble; tapping scrolls to the original. Reply previews render GIF thumbnails and location pin icons, not just text.
+
+### Message Path Viewing
+
+- **Mobile**: Tap a message bubble to view its routing path
+- **Desktop**: Long-press/right-click → "Path" (tapping the bubble does nothing on desktop)
+- Opens the Channel Message Path Screen (see [Additional Features](additional-features.md))
+
+### Context Actions (Long-Press / Right-Click)
+
+| Action | Availability | Description |
+|---|---|---|
+| Reply | All messages | Triggers reply mode |
+| Path | Desktop only | Opens message path view |
+| Add Reaction | Incoming messages only | Opens emoji picker (cannot react to your own messages) |
+| Copy | All messages | Copies text to clipboard |
+| Delete | All messages | Removes locally (not from mesh) |
+
+### Message Path Viewing
+
+Tap a message bubble to open the Channel Message Path Screen, which shows:
+- Each hop in the path as a visual chain
+- Known contacts identified by name at each hop
+- Observed vs. declared hop counts
+- Alternative path variants (if received via multiple paths)
+- Map view buttons for geographic path visualization
+
+## Communities
+
+Communities are a layer above channels that provide a private namespace.
+
+### What is a Community?
+
+A community has a name and a 32-byte random secret. Channel PSKs are derived from this secret:
+- **Public channel**: `HMAC-SHA256(secret, "channel:v1:__public__")[:16]`
+- **Hashtag channel**: `HMAC-SHA256(secret, "channel:v1:{hashtag}")[:16]`
+
+Outsiders who don't know the secret cannot discover or join community channels.
+
+### Sharing a Community
+
+Communities are shared via QR codes containing a JSON payload:
+```json
+{"v": 1, "type": "meshcore_community", "name": "...", "k": ""}
+```
+
+### Managing Communities
+
+From the channels screen overflow menu → "Manage Communities". Opens a draggable scrollable sheet (resizable 30–90% of screen height):
+
+- Each community shows its name and a short community ID (first 8 hex characters)
+- **Tap a community** to directly show its QR code for sharing
+- **Popup menu** per community:
+ - **Show QR** — displays the QR code for sharing with new members
+ - **Delete** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed)
+
+## How Channels Differ from Direct Messages
+
+| Aspect | Channels | Direct Messages |
+|---|---|---|
+| Addressing | Broadcast to all nodes with matching PSK | Point-to-point to a specific contact |
+| Encryption | Shared PSK (symmetric) | Contact's public key (asymmetric) |
+| Sender identity | Plain text prefix in payload | Verified via public key |
+| Replies | Supported (swipe or long-press) | Not supported |
+| Retry mechanism | No automatic retry | Exponential backoff with path rotation |
diff --git a/documentation/chat-and-messaging.md b/documentation/chat-and-messaging.md
new file mode 100644
index 0000000..22030d5
--- /dev/null
+++ b/documentation/chat-and-messaging.md
@@ -0,0 +1,120 @@
+# Chat & Messaging
+
+## Overview
+
+The app supports two chat modes:
+- **Direct messages**: Encrypted point-to-point messages to individual contacts
+- **Channel messages**: Broadcast messages to shared channels (see [Channels](channels.md))
+
+This page covers direct messaging. For channel chat, see the Channels documentation.
+
+## How to Access
+
+From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
+
+## Chat Screen Layout
+
+### App Bar
+
+- **Title**: Contact name
+- **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details.
+- **Action buttons**:
+ - **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing
+ - **Path management** (timeline icon): View recent paths with hop count, round-trip time, age, and success count. Paths are color-coded by direct repeater (green/yellow/red/blue for ranked repeaters, grey for unknown). Tap a path to activate it (the device verifies and confirms via snackbar), long-press to view full path details, set custom paths, or force flood mode. A warning banner appears when history reaches 100 entries.
+ - **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle
+
+### Message List
+
+- Scrollable list with newest messages at the bottom
+- **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background
+- **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name)
+- Bubble width capped at 65% of screen width
+- Hyperlinks rendered as tappable green underlined text
+- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset
+- **Jump to bottom**: Floating button appears when scrolled away from the bottom
+- **Lazy loading**: Scrolling to top loads older messages from storage
+
+### Input Bar
+
+- **GIF button** (left): Opens GIF picker bottom sheet
+- **Text field** (center): Auto-capitalization, enforces UTF-8 byte limit in real-time
+- **Send button** (right): Submits the message
+- On desktop: Enter/Numpad Enter also submits
+- When a GIF is selected, the text field shows an inline GIF preview with a dismiss button
+
+## Message Types
+
+| Type | Wire Format | Display |
+|---|---|---|
+| Plain text | Raw UTF-8 string | Inline text with link detection |
+| GIF | `g:` | Inline GIF image from Giphy CDN |
+| Location pin | `m:,\|\|...` | Location icon + label; tap to open map |
+| Reaction | `r::` | Applied to target message as emoji pill |
+
+## Message Status
+
+Outgoing messages display a status indicator:
+
+| Status | Icon | Meaning |
+|---|---|---|
+| Pending | Grey double-check | Queued, waiting for device to transmit (visually identical to Sent) |
+| Sent | Grey double-check | Device confirmed transmission (visually identical to Pending) |
+| Delivered | Green double-check | Remote node acknowledged receipt |
+| Failed | Red X | All retries exhausted |
+
+### Message Tracing Mode
+
+When enabled in App Settings, additional metadata appears inside each bubble:
+- Timestamp (HH:MM)
+- Retry count (e.g., "Retry 2 of 4")
+- Status icon
+- Round-trip time in seconds (if delivered)
+
+## Message Length Limits
+
+- **Direct messages**: 156 bytes (UTF-8) — enforced in real-time by the input formatter
+- **Channel messages**: 160 minus sender name length minus 2 bytes for the `": "` prefix
+- Over-length paste shows a snackbar error
+
+## Send Queue
+
+Only one message per contact can be in-flight at a time (to avoid overflowing the firmware's 8-entry ACK table). If you send multiple messages rapidly, they are queued and sent sequentially — each waits for the previous one to be delivered, fail, or exhaust retries before transmitting.
+
+## Retry Mechanism
+
+When a direct message is sent:
+
+1. The app computes an expected ACK hash: `SHA256([timestamp][attempt][text][selfPubKey])[0:4]` — matching the firmware's hash calculation. If SMAZ compression is enabled, the compressed text (not the original) is hashed
+2. On device acknowledgment (`RESP_CODE_SENT`), the message transitions to "sent" and a timeout timer starts
+3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise `3000 + 3000 × path_length` milliseconds (15000ms for flood)
+4. On timeout, the message is retried with **exponential backoff**: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s, 16s...)
+5. **Max retries**: Configurable (default 5, range 2–10)
+6. After max retries, the message is marked "failed" — but a **30-second grace window** remains during which a late ACK can still resolve the message to "delivered"
+7. If **Clear Path on Max Retry** is enabled (App Settings), the contact's stored routing path is automatically cleared when max retries are exhausted
+8. **Auto route rotation**: When enabled (and no manual path override is set), the retry service uses a diversity window to avoid re-using recently tried paths, cycling through known routes on each attempt
+
+### Manual Retry
+
+Long-press a failed message → "Retry" to re-send using the current routing settings.
+
+## Reactions
+
+Add emoji reactions to incoming messages (not your own):
+
+1. Long-press (or right-click on desktop) a message
+2. Select "Add reaction" from the context menu
+3. Choose from quick emojis (thumbs up, heart, laugh, party, clap, fire) or browse the full emoji picker
+4. Reactions appear as pills below the message bubble with emoji and count
+5. Pending reactions show at 50% opacity with a spinner
+6. Failed reactions show a red retry icon (tap to retry)
+
+## Context Actions (Long-Press / Right-Click)
+
+| Action | Availability | Description |
+|---|---|---|
+| Add reaction | Incoming messages only | Opens emoji picker |
+| View path | Mobile: tap bubble directly; Desktop: long-press/right-click menu | Shows message routing path |
+| Copy | All messages | Copies text to clipboard |
+| Delete | All messages | Removes locally (not from mesh) |
+| Retry | Failed outgoing messages | Re-sends the message |
+| Open chat with sender | Room server chats | Opens 1:1 chat with the message sender |
diff --git a/documentation/contacts.md b/documentation/contacts.md
new file mode 100644
index 0000000..1a94ba2
--- /dev/null
+++ b/documentation/contacts.md
@@ -0,0 +1,118 @@
+# Contacts
+
+## Overview
+
+The Contacts screen is the primary hub for managing mesh nodes your radio has a relationship with. A "contact" is any node whose cryptographic advertisement has been received — it can be a chat user, repeater, room server, or sensor.
+
+## How to Access
+
+- Automatically shown after connecting to a device
+- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens
+- Back navigation from Chat or Settings screens
+
+## Contact Types
+
+| Type | Avatar Color | Icon | Description |
+|---|---|---|---|
+| Chat | Blue | Chat bubble | Another user's mesh radio |
+| Repeater | Orange | Cell tower | A mesh repeater/relay node |
+| Room | Purple | Group | A room server for group chat |
+| Sensor | Green | Sensors | A sensor device |
+
+## Contact List
+
+Each contact is displayed as a list tile showing:
+
+- **Avatar**: Color-coded circle with type icon (or first emoji of the contact's name if it starts with one)
+- **Name**: Contact name (single line)
+- **Path label**: "Direct", "N hops", or "Flood" (with forced variants if a path override is active)
+- **Public key**: Shortened hex format ``
+- **Unread badge**: Red pill with count (if unread messages exist)
+- **Last seen**: Relative timestamp ("Now", "5 mins ago", "2 hours ago", "3 days ago"). For chat contacts, this shows whichever is more recent: the last advertisement time or the last message time
+- **Favorite star**: Amber star icon if favorited
+- **Location pin**: Grey pin icon if the contact has GPS coordinates
+
+Pull-to-refresh re-fetches the full contact list from the device.
+
+## Search and Filter
+
+A toolbar at the top provides:
+
+**Search**: Matches contact name (case-insensitive) or public key hex prefix. Debounced at 300ms.
+
+**Sort options**:
+- Latest Messages (by most recent message)
+- Heard Recently (by last seen / last message)
+- A–Z (alphabetical)
+
+**Filter options**:
+- All, Favorites, Users, Repeaters, Room Servers, Unread Only
+
+## Contact Groups
+
+Groups are a client-side organizational feature for grouping contacts.
+
+- **Create a group**: Tap the group dropdown → "+" icon → enter name → select members → Save
+- **Edit a group**: Group dropdown → pencil icon next to the group
+- **Delete a group**: Group dropdown → trash icon next to the group
+- **Filter by group**: Select a group from the dropdown to show only its members
+
+Groups are stored per radio identity (scoped by public key).
+
+**Validation rules**: Group names cannot be empty, cannot be "all" (reserved, case-insensitive), and must be unique (case-insensitive). The group creation dialog includes a built-in search field to filter contacts when selecting members. Creating a new group automatically selects it as the active filter.
+
+## Tap Actions
+
+| Contact Type | Action on Tap |
+|---|---|
+| Chat / Sensor | Opens ChatScreen for direct messaging |
+| Repeater | Shows password login dialog → opens RepeaterHubScreen |
+| Room | Shows password login dialog → opens ChatScreen for room chat |
+
+## Long-Press / Right-Click Menu
+
+| Action | Availability | Description |
+|---|---|---|
+| Path Trace / Ping | Repeaters, Rooms (always); Chat if `pathLength > 0` | Opens PathTraceMapScreen. Label shows "Ping" when no path bytes are known, "Path Trace" otherwise |
+| Manage Repeater | Repeaters only | Login dialog → RepeaterHubScreen |
+| Room Login | Rooms only | Login dialog → ChatScreen |
+| Room Management | Rooms only | Login dialog → RepeaterHubScreen (management mode) |
+| Open Chat | Chat/Sensor | Same as single tap |
+| Add/Remove Favorite | All types | Toggles the favorite flag |
+| Share Contact | All types | Copies `meshcore://` URI to clipboard |
+| Share Contact Zero-Hop | All types | Broadcasts the contact's advertisement one hop |
+| Delete Contact | All types | Confirmation dialog → removes from device and clears messages |
+
+## App Bar Menus
+
+The Contacts screen has **two separate popup menus** in the app bar:
+
+**Antenna icon menu** (contact sharing):
+- Zero-Hop Advert — broadcasts your advertisement to immediately adjacent nodes
+- Flood Advert — broadcasts across the full mesh network
+- Copy Advert to Clipboard — copies your `meshcore://` URI for sharing externally
+- Add Contact from Clipboard — reads a `meshcore://` URI from clipboard and imports it
+
+**Three-dot overflow menu**:
+- Disconnect — disconnects from the device
+- Discovered Contacts — opens the DiscoveryScreen
+- Settings — opens the Settings screen
+
+## Adding Contacts
+
+### Automatic (Passive)
+When the radio hears an advertisement, the contact appears automatically if auto-add is enabled for that type (configurable in Settings → Contact Settings).
+
+### Import from Clipboard
+Antenna menu → "Add Contact from Clipboard". Reads a `meshcore://` URI from clipboard and imports it to the device.
+
+### Import from Discovered Contacts
+Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Add, Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms, Favorites), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts.
+
+## Contact Sharing Format
+
+Contacts are shared using the `meshcore://` URI scheme:
+```
+meshcore://
+```
+This contains the node's public key and metadata. Paste it into another MeshCore app to import.
diff --git a/documentation/map-and-location.md b/documentation/map-and-location.md
new file mode 100644
index 0000000..f293abe
--- /dev/null
+++ b/documentation/map-and-location.md
@@ -0,0 +1,186 @@
+# Map & Location
+
+## Overview
+
+The Map feature is a full-featured node-location visualization and radio-planning tool built on OpenStreetMap tiles. It is one of the three primary views accessible from the QuickSwitchBar.
+
+## How to Access
+
+- **QuickSwitchBar tab 2** (rightmost) from Contacts or Channels
+- **Deep-link from a chat message**: Tapping a shared location pin in a chat opens the map centered on that pin
+- **Settings → Offline Map Cache**: Opens the tile cache management screen
+
+## What the Map Displays
+
+### Self Location (Teal Circle)
+Your own node's position, obtained from the device firmware. Displayed as a teal `person_pin_circle` icon. Only appears if the device has GPS data or a manually-set location.
+
+### Contact / Node Markers (Color-Coded)
+All contacts with known GPS coordinates are plotted:
+
+| Type | Color | Icon |
+|---|---|---|
+| Chat user | Blue | Person |
+| Repeater | Green | Router |
+| Room | Purple | Meeting room |
+| Sensor | Orange | Sensors |
+
+Node name labels appear automatically at zoom level 12 and above.
+
+### Shared Map Pins (Flag Icons)
+Location pins shared in chat messages are displayed as flags:
+- **Blue flag**: From a direct message
+- **Purple flag**: From a private channel
+- **Orange flag**: From a public channel
+
+Tap a pin to see its info. Options to "Hide" (session only) or "Remove" (persistent).
+
+### Predicted / Guessed Locations (Semi-Transparent)
+
+Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as semi-transparent markers with a `not_listed_location` icon, visually distinct from confirmed-location markers.
+
+#### Why guessed locations exist
+
+In a mesh network, every message hops through one or more repeaters on its way to the destination. Each repeater in the path is identified by the first byte of its public key. If any of those repeaters have a known GPS location (because they advertise it), then a contact that routes through those repeaters must be somewhere within radio range of them. By combining the positions of multiple repeaters a contact is known to use, the app can triangulate a rough area where the contact is likely located.
+
+#### How the algorithm works
+
+1. **Build a repeater index**: The app collects all known contacts of type Repeater that have a valid GPS position and indexes them by the first byte of their public key.
+
+2. **Collect anchor points**: For each contact that lacks GPS, the app looks at the **last-hop byte** of the contact's current path and also searches the `PathHistoryService` for recent paths. Each last-hop byte that matches a located repeater becomes an "anchor point" — a GPS coordinate the contact is likely near.
+
+3. **Resolve ambiguity**: If multiple repeaters share the same first public-key byte (a hash collision), that byte is discarded as ambiguous. Only unambiguous one-to-one matches are kept.
+
+4. **Filter geometric inconsistencies**: Two anchor points separated by more than `2 × maxRangeKm` (the estimated LoRa radio range, computed from the current frequency, bandwidth, spreading factor, and TX power using a free-space path loss model) cannot both be in range of the same node. Outlier anchors are removed to keep only a geometrically consistent set.
+
+5. **Compute the estimated position**:
+ - **Single anchor**: The contact is placed on a small circle (330m radius) around the repeater. The angle on the circle is deterministic — derived from an FNV-1a hash of the contact's public key — so the same contact always appears at the same offset, preventing markers from stacking on top of each other.
+ - **Two or more anchors**: The position is the average (centroid) of all anchor coordinates, with a smaller offset radius (80–120m) applied for visual separation.
+
+6. **Assign confidence level**:
+ - **High confidence** (2+ anchors): Displayed at 55% opacity.
+ - **Low confidence** (1 anchor): Displayed at 30% opacity.
+
+7. **Cache the result**: The computation is cached using a key derived from the contact's paths, anchor positions, path-history version, and radio parameters. The cache is only invalidated when any of these inputs change, avoiding recomputation on every UI rebuild.
+
+#### How to read guessed locations on the map
+
+- **Semi-transparent marker** with a `not_listed_location` icon: This is a guessed position, not a confirmed GPS fix.
+- **More opaque** (55%): Higher confidence — the contact was seen through 2 or more repeaters with known positions.
+- **More transparent** (30%): Lower confidence — based on a single repeater anchor only.
+- Coordinates shown in the marker info dialog are prefixed with `~` to indicate they are estimated.
+- Guessed locations can be toggled on/off in the map filter dialog (FAB → "Guessed locations" toggle).
+
+## Map Interactions
+
+### Zoom and Pan
+Standard pinch-to-zoom (range 2–18). Initial camera position is calculated from the statistical spread of all plotted points.
+
+### Tap on a Node Marker
+Opens a dialog showing: type, path (hop chain), coordinates, last-seen time, and public key. Action buttons vary by type:
+- **Chat nodes**: "Open Chat"
+- **Repeaters**: "Manage Repeater"
+- **Rooms**: "Join Room"
+
+### Long-Press on Empty Map Area
+Shows a bottom sheet with:
+- **Share marker here**: Prompts for a label, then pick a DM contact or channel to send the location to. Wire format: `m:,||poi`
+- **Set as my location**: Updates your device's advertised location
+
+### Filter Dialog (FAB)
+Toggle visibility of: chat nodes, repeaters, other nodes, guessed locations, discovery contacts.
+Additional filters:
+- **Key prefix filter**: Show only contacts whose public key starts with a given prefix
+- **Last-seen time slider**: From 1 hour to "all time"
+
+### Legend Card (Top-Right)
+Shows node count and pin count. Tappable to expand a legend of all marker types.
+
+---
+
+## Path Trace Map
+
+### How to Access
+- From the main map's radar icon
+- From a contact's long-press menu → "Path Trace / Ping"
+- From a message's path view → radar icon
+
+### What the User Sees
+A map with a polyline showing the route from your node through repeater hops to the target:
+- **Green circles**: Hops with known GPS coordinates
+- **Orange circles** (`~HH`): Inferred positions (no GPS but deducible from contacts)
+- **Red endpoint**: Target contact with known GPS
+- **Purple semi-transparent endpoint**: Target with guessed position
+
+A legend card at the bottom lists each hop pair with SNR quality icons and total path distance.
+
+### How It Works
+Sends a trace request frame over the mesh. The repeater network traces the path hop-by-hop and returns per-hop SNR data. For hops without GPS, positions are inferred by averaging GPS coordinates of contacts sharing that last-hop byte.
+
+---
+
+## Line-of-Sight (LOS) Analysis
+
+### How to Access
+From the main map, tap the terrain/antenna icon.
+
+### What the User Sees
+A full-screen map with a collapsible control panel containing:
+- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow)
+- **Status**: Clear (green) or blocked (red) with distance and minimum clearance
+- **Options panel**: Node toggles, endpoint dropdowns, antenna height sliders (0–400 ft), Run LOS button
+
+### Key Interactions
+- **Long-press the map** to add custom endpoints (orange pushpin markers, renameable/deleteable)
+- **Tap a marker** to select it as Point A or B; LOS runs automatically when both are set
+- **Antenna heights** are adjustable for both endpoints
+- **Map line** between endpoints is colored green (clear) or red (blocked)
+- Terrain elevation is fetched from the Open-Meteo API (21–81 sample points, cached 24 hours)
+- K-factor is adjusted per radio frequency from a baseline of 4/3 at 915 MHz
+
+---
+
+## Offline Map Cache
+
+### How to Access
+Settings → App Settings → Map Display → Offline Map Cache
+
+### What the User Sees
+- Map with a blue polygon overlay showing previously selected cache bounds
+- Bounding box coordinates card
+- **Cache Area** controls: "Use Current View" and Clear buttons
+- **Zoom Range** slider (3–18) with estimated tile count
+- **Download progress** bar (when downloading)
+- **Download Tiles** and **Clear Cache** buttons
+
+### Key Interactions
+1. Pan/zoom the map to the desired area
+2. Tap "Use Current View" to capture the viewport as cache bounds
+3. Adjust the zoom range slider
+4. Tap "Download Tiles" (confirmation dialog shows estimated count)
+5. Tiles are downloaded with up to 8 concurrent connections
+6. Once cached, tiles are served from disk without internet (365-day stale period)
+
+---
+
+## GPX Export
+
+### How to Access
+Settings → Export section
+
+### What It Does
+Exports contacts with GPS coordinates to a `.gpx` file via the OS share sheet. Three export options:
+- **Export Repeaters**: Repeater and Room contacts with locations
+- **Export Contacts**: Chat contacts with locations
+- **Export All**: All contacts with locations
+
+Each waypoint includes: name, lat/lon, type label, and public key hex.
+
+---
+
+## Location Data Sources
+
+The phone's own GPS is **never used**. All location data comes from the mesh:
+
+1. **Device self-location**: Read from firmware device-info response. Set manually in Settings → Location, or updated automatically if the device has a GPS module.
+2. **Remote node locations**: Extracted from advertisement packets received over the mesh. Encoded as integer lat/lon × 1,000,000.
diff --git a/documentation/navigation.md b/documentation/navigation.md
new file mode 100644
index 0000000..9003122
--- /dev/null
+++ b/documentation/navigation.md
@@ -0,0 +1,87 @@
+# Navigation
+
+## App Flow
+
+The app follows this general flow:
+
+```
+Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Contacts Screen
+```
+
+After connecting, the three main screens (Contacts, Channels, Map) are accessible via a persistent bottom navigation bar called the **QuickSwitchBar**.
+
+## Quick Switch Bar
+
+The QuickSwitchBar is a Material 3 `NavigationBar` with a frosted-glass visual treatment (blur backdrop, transparent theme, rounded corners). It appears at the bottom of all three main screens.
+
+| Index | Icon | Label | Screen |
+|---|---|---|---|
+| 0 | People | Contacts | ContactsScreen |
+| 1 | Tag | Channels | ChannelsScreen |
+| 2 | Map | Map | MapScreen |
+
+Tapping a tab replaces the current screen with a subtle fade + slight horizontal nudge transition (220ms forward, 200ms reverse). The back button is suppressed on all three main screens — navigation between them is flat, not stacked. All icons use outline variants (`people_outline`, `tag`, `map_outlined`) following Material 3 conventions.
+
+## Device Screen
+
+The Device Screen is a transitional hub that shows after connection. In practice, the app navigates directly to Contacts after connecting, but the Device Screen is reachable via the QuickSwitchBar.
+
+### What the User Sees
+
+**App Bar**:
+- Left: Battery indicator chip (tappable — toggles between percentage and voltage display). Icon changes based on level: `battery_unknown` when data unavailable, `battery_alert` (orange) at 15% or below, `battery_full` otherwise
+- Left-aligned title (`centerTitle: false`): Two-line layout — small grey "MeshCore" label above the device name in bold
+- Right: Disconnect button (`bluetooth_disabled` crossed-out icon) and Settings button (tune icon)
+
+**Body**:
+- **Connection Card**: Device avatar, device name, device ID, "Connected" chip, and battery chip
+- **Quick Switch** section: The QuickSwitchBar widget for navigating to Contacts/Channels/Map
+
+### Disconnection
+
+- The disconnect button shows a confirmation dialog before disconnecting
+- If the device disconnects unexpectedly, the app automatically navigates back to the Scanner screen (fires after the current frame completes via a post-frame callback)
+- This auto-navigation behavior (`DisconnectNavigationMixin`) is shared across all main screens
+
+## Theme and Locale
+
+- **Theme mode** is user-configurable in App Settings (System / Light / Dark) — not locked to system
+- **Language** can be overridden to one of 15 supported languages, or follow the system locale
+- On web, if a non-Chromium browser is detected, the app shows a `ChromeRequiredScreen` instead of the Scanner (Web Bluetooth requires Chromium)
+
+## Full Navigation Graph
+
+```
+ScannerScreen (root, always on stack)
+ ├─ [BLE connect] → push → ContactsScreen
+ ├─ [TCP FAB] → push → TcpScreen
+ │ └─ [TCP connected] → pushReplacement → ContactsScreen
+ └─ [USB FAB] → push → UsbScreen
+ └─ [USB connected] → pushReplacement → ContactsScreen
+
+ContactsScreen (selected=0)
+ ├─ [quick-switch 1] → pushReplacement → ChannelsScreen
+ ├─ [quick-switch 2] → pushReplacement → MapScreen
+ ├─ [tap contact] → push → ChatScreen
+ ├─ [overflow > Settings] → push → SettingsScreen
+ └─ [overflow > Discovered] → push → DiscoveryScreen
+
+ChannelsScreen (selected=1)
+ ├─ [quick-switch 0] → pushReplacement → ContactsScreen
+ ├─ [quick-switch 2] → pushReplacement → MapScreen
+ ├─ [tap channel] → push → ChannelChatScreen
+ └─ [overflow > Settings] → push → SettingsScreen
+
+MapScreen (selected=2)
+ ├─ [quick-switch 0] → pushReplacement → ContactsScreen
+ ├─ [quick-switch 1] → pushReplacement → ChannelsScreen
+ ├─ [radar button] → push → PathTraceMapScreen
+ ├─ [terrain button] → push → LineOfSightMapScreen
+ └─ [long-press] → share marker / set location
+
+Settings (push from any main screen)
+ └─ [App Settings] → push → AppSettingsScreen
+ └─ [Offline Map Cache] → push → MapCacheScreen
+```
+
+Any disconnection from any screen triggers `popUntil(route.isFirst)`, returning to the Scanner.
diff --git a/documentation/notifications.md b/documentation/notifications.md
new file mode 100644
index 0000000..0eb2574
--- /dev/null
+++ b/documentation/notifications.md
@@ -0,0 +1,92 @@
+# Notifications
+
+## Overview
+
+MeshCore Open provides both **system notifications** (push-style OS alerts) and **in-app unread badges** to inform users of new activity.
+
+## Notification Types
+
+### 1. Direct Message Notifications
+- **Triggered when**: A new incoming message arrives from a Chat or Room contact
+- **Title**: Contact's name
+- **Body**: Message text (reactions show "Reacted [emoji]", GIFs show "Sent a GIF")
+- **Priority**: High
+- **Android channel**: `messages`
+
+### 2. Channel Message Notifications
+- **Triggered when**: A new message arrives on a non-muted channel
+- **Title**: Channel name (or "Channel N" if unnamed)
+- **Body**: `": "`
+- **Priority**: High
+- **Android channel**: `channel_messages`
+
+### 3. Advertisement Notifications
+- **Triggered when**: A new node is discovered on the mesh for the first time
+- **Title**: "New [type] discovered" (e.g., "New chat node discovered")
+- **Body**: Contact's name
+- **Priority**: Default
+- **Android channel**: `adverts`
+
+### 4. Background Service Notification (Android Only)
+- A persistent low-priority notification: "MeshCore running — Keeping BLE connected"
+- Required by Android for foreground services to keep BLE alive in the background
+- Tap to re-launch the app
+- **Does not auto-start on reboot** — the user must re-open the app manually after a phone restart
+
+### Notification Tap Behavior
+
+Tapping a notification currently re-launches the app at the root route. It does **not** navigate directly to the relevant chat or channel.
+
+## In-App Unread Badges
+
+Red numeric badges appear throughout the UI:
+- **Contacts list**: Each contact row shows a red pill badge (e.g., "3") for unread messages
+- **Channels list**: Each channel row shows an unread badge
+- **Chat screen subtitle**: Shows unread count inline
+- Badges cap at "99+" for display
+
+### How Unread Counts Work
+
+- Stored per contact (by public key) and per channel, **scoped to the connected device's identity** (first 10 hex characters of its public key). Switching between different radios gives each its own independent unread state
+- **Suppressed when viewing**: Opening a chat resets the count to 0 and cancels the OS notification
+- **Ignored for**: Outgoing messages, CLI messages, and repeater contacts
+- Debounced writes (500ms) to avoid excessive storage I/O during message bursts
+
+## Notification Settings
+
+Access via **App Settings → Notifications**:
+
+| Setting | Default | Description |
+|---|---|---|
+| Enable Notifications | On | Master toggle; requests OS permission when turned on |
+| Message Notifications | On | DM alerts (greyed out if master is off) |
+| Channel Message Notifications | On | Channel alerts (greyed out if master is off) |
+| Advertisement Notifications | On | New node alerts (greyed out if master is off) |
+
+### Per-Channel Muting
+
+Long-press a channel in the channels list → "Mute channel" / "Unmute channel". Muted channels do not generate OS notifications.
+
+There is no per-contact muting.
+
+## Rate Limiting
+
+The notification system prevents notification storms:
+- **Minimum interval**: 3 seconds between individual notifications
+- **Batch window**: If multiple notifications arrive within 5 seconds, they are combined into a single summary notification on a fourth Android channel (`batch_summary`): "MeshCore Activity — 2 messages, 1 channel message, 3 new nodes". Note: batch summaries are Android-only; on Apple platforms individual notifications are shown
+
+## Notification Clearing
+
+- **Opening a contact chat**: Cancels the OS notification and resets unread count
+- **Opening a channel**: Cancels the channel notification and resets unread count
+- **Opening Contacts screen**: Cancels all advertisement notifications
+
+## Platform Support
+
+| Platform | Message Notifs | Badge | Background Service |
+|---|---|---|---|
+| Android | Yes | Via notification number | Yes (foreground service) |
+| iOS | Yes | Yes (app badge) | No |
+| macOS | Yes | Yes | No |
+| Windows | Yes | No | No |
+| Linux | Yes (if D-Bus available) | No | No |
diff --git a/documentation/repeater-management.md b/documentation/repeater-management.md
new file mode 100644
index 0000000..be5015f
--- /dev/null
+++ b/documentation/repeater-management.md
@@ -0,0 +1,186 @@
+# Repeater Management
+
+## Overview
+
+Repeater Management provides tools for administering MeshCore repeater and room server nodes. It includes device status monitoring, CLI access, telemetry reading, neighbor discovery, and remote configuration.
+
+## How to Access
+
+From the Contacts screen:
+1. Long-press a **Repeater** or **Room** contact
+2. Select "Manage Repeater" or "Room Management"
+3. Enter the admin password in the login dialog
+4. Navigate to the Repeater Hub Screen
+
+### Login Dialog
+
+- Password field with show/hide toggle
+- "Save password" checkbox (persists for future logins). If a saved password exists, it is pre-filled and the checkbox is pre-checked, making login one-tap
+- Routing mode selector and "Manage Paths" link are available directly in the dialog (configure routing before login)
+- Auto-retries up to 5 times on timeout, showing progress ("Attempt 2 of 5"). A wrong password stops immediately after the first attempt — only timeouts trigger retries
+- After 5 failed attempts, further login attempts are blocked
+
+---
+
+## Repeater Hub Screen
+
+The central management screen showing:
+
+- **Header card**: Repeater name, short public key, path label, GPS coordinates (if known)
+- **Battery chemistry selector**: NMC / LiFePO4 / LiPo (saved per repeater)
+- **Management tool cards** (full-width cards with chevron arrows, not a grid). Title dynamically shows "Repeater Management" or "Room Management" based on contact type:
+
+| Card | Destination |
+|---|---|
+| Status | Repeater Status Screen |
+| Telemetry | Telemetry Screen |
+| CLI | Repeater CLI Screen |
+| Neighbors | Neighbors Screen |
+| Settings | Repeater Settings Screen |
+
+---
+
+## Repeater Status
+
+### What the User Sees
+
+Three information cards:
+
+**System Information**:
+- Battery percentage
+- Uptime
+- Queue length
+- Error flags
+- Clock at login time
+
+**Radio Statistics**:
+- Last RSSI and SNR
+- Noise floor
+- TX and RX airtime
+
+**Packet Statistics**:
+- Packets sent, received, and duplicates
+- Broken down by flood vs. direct
+
+### Key Interactions
+- Auto-queries the repeater on open; shows a loading spinner until data arrives
+- On timeout: red snackbar error. On success: data appears with a green snackbar confirmation
+- Pull-to-refresh or refresh button to re-query
+- Routing mode popup and path management dialog in app bar (these controls appear on **all** management sub-screens, not just Status)
+
+---
+
+## Repeater CLI
+
+A terminal-style interface for sending commands directly to the repeater.
+
+### What the User Sees
+
+- **Quick-command bar** (horizontal scroll): Shortcut buttons for common commands (get name, get radio, get tx, neighbors, ver, advert, clock)
+- **Command history list**: Sent commands in primary color, responses in secondary color
+- **Input bar**: Up/down history arrows, monospace text field with `> ` prefix, send button
+
+### Key Interactions
+
+- Type a command and press send (or Enter on desktop)
+- Up/down arrows navigate through command history
+- Quick-command buttons populate and send common commands
+- Bug report icon: Shows raw frame debug info for the next typed command (shows error snackbar if input field is empty)
+- Help icon: Opens a scrollable reference of all known CLI commands. Tapping any command populates the input field immediately
+- Clear icon: Wipes the command/response history
+- Failed/timed-out commands are automatically retried once
+
+### Available CLI Commands
+
+**General**: `advert`, `reboot`, `clock`, `password`, `ver`, `clear stats`
+
+**Settings**: `set name`, `set af`, `set tx`, `set repeat`, `set allow.read.only`, `set flood.max`, `set int.thresh`, `set agc.reset.interval`, `set multi.acks`, `set advert.interval`, `set flood.advert.interval`, `set guest.password`, `set lat`, `set lon`, `set radio`, `set rxdelay`, `set txdelay`, `set direct.txdelay`, `set bridge.*`, `set adc.multiplier`, `tempradio`, `setperm`
+
+**Bridge**: `get bridge.type`
+
+**Logging**: `log start`, `log stop`, `log erase`
+
+**Neighbors**: `neighbors`, `neighbor.remove`
+
+**Region Management**: `region`, `region load/get/put/remove/allowf/denyf/home/save`
+
+**GPS**: `gps`, `gps on/off/sync/setloc/advert`
+
+---
+
+## Telemetry
+
+### What the User Sees
+
+A list of Cayenne LPP sensor channel cards:
+
+- **Channel 1** (special): Battery voltage (shown as percentage or raw mV) and MCU temperature
+- **Other channels**: Raw sensor values with appropriate labels
+
+Shows "No data" until a response arrives from the repeater.
+
+### Key Interactions
+- Auto-queries on open
+- Pull-to-refresh
+- Temperature respects metric/imperial setting
+- Battery readings are stored for the repeater's battery snapshot
+
+---
+
+## Neighbors
+
+### What the User Sees
+
+A card titled "Repeater's Neighbors - N" listing each neighbor as:
+- Repeater name (or hex key prefix if unknown)
+- Time since last heard
+- SNR quality icon with color coding and label
+
+### Key Interactions
+- Auto-queries up to 15 neighbors on open
+- Matches public key prefixes against known contacts to show names
+- Pull-to-refresh
+
+---
+
+## Repeater Settings
+
+### What the User Sees
+
+Five configuration cards:
+
+**1. Basic Settings**
+- Name field
+- Admin password field
+- Guest password field
+
+**2. Radio Settings**
+- Frequency (MHz)
+- TX Power (dBm)
+- Bandwidth dropdown (kHz)
+- Spreading Factor (SF5–SF12)
+- Coding Rate (4/5–4/8)
+
+**3. Location Settings**
+- Latitude and longitude fields
+
+**4. Features**
+- Packet forwarding toggle
+- Guest access toggle
+
+**5. Advertisement Settings**
+- Local advert interval slider (60–240 minutes) with enable/disable toggle
+- Flood advert interval slider (3–168 hours) with enable/disable toggle
+
+**6. Danger Zone** (red-styled card)
+- Reboot repeater
+- Erase filesystem (serial-only warning)
+
+### Key Interactions
+- **Settings are NOT auto-fetched on open**. Only name and location are pre-filled from locally cached contact data. You must tap each section's refresh button to fetch live values from the repeater
+- TX Power has its own separate refresh button, independent from the main Radio Settings refresh
+- Save button appears when changes are detected
+- Settings are sent sequentially with 200ms delays between commands (fire-and-forget, no per-command acknowledgment wait)
+- Validation prevents invalid values (e.g., frequency range, LoRa parameter compatibility)
+- Advertisement interval sliders reset to defaults when re-enabled (local: 60 min, flood: 3 hours)
+- **Erase Filesystem** does NOT send any command over the air — tapping it only shows a snackbar explaining the operation requires physical serial access. It is effectively non-functional when connected wirelessly
diff --git a/documentation/scanner-and-connection.md b/documentation/scanner-and-connection.md
new file mode 100644
index 0000000..2c5dbae
--- /dev/null
+++ b/documentation/scanner-and-connection.md
@@ -0,0 +1,124 @@
+# Scanner & Connection
+
+## BLE Scanner (Home Screen)
+
+The BLE Scanner is the app's home screen, displayed immediately on launch.
+
+### How to Access
+
+- Opens automatically when the app starts
+- Returns here when disconnecting from any device
+- Accessible by navigating back from a connected session
+
+### What the User Sees
+
+**App Bar**: Centered title "Scanner".
+
+**Bluetooth-Off Warning Banner** (conditional): Appears when the Bluetooth adapter is off, showing a `bluetooth_disabled` icon, a warning message, and on Android, an "Enable Bluetooth" button.
+
+**Status Bar**: A full-width colored strip reflecting the current connection state:
+
+| State | Text | Color |
+|---|---|---|
+| Disconnected | "Not connected" | Grey |
+| Scanning | "Scanning..." | Blue |
+| Connecting | "Connecting..." | Orange |
+| Connected | "Connected to \" | Green |
+| Disconnecting | "Disconnecting..." | Orange |
+
+**Device List**: When no devices are found, shows a large Bluetooth icon with a prompt. The prompt text is dynamic: "Searching for devices..." while actively scanning, or "Tap Scan to search" when idle. When devices are found, shows a scrollable list of `DeviceTile` widgets.
+
+**Bottom FAB Row**: Up to three floating action buttons:
+- **USB** button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
+- **TCP/IP** button - Opens TCP connection screen (all non-web platforms)
+- **BLE Scan** button - Toggles BLE scanning on/off; shows a spinner when scanning. **Disabled** (greyed out, not tappable) when Bluetooth is off
+
+### Device Tile
+
+Each discovered device is displayed as a list tile showing:
+- **Signal strength icon** (color-coded by RSSI):
+ - Green: >= -60 dBm (excellent)
+ - Light green: -60 to -70 dBm (good)
+ - Amber: -70 to -80 dBm (fair)
+ - Orange: -80 to -90 dBm (weak)
+ - Red: < -90 dBm (poor)
+- **RSSI value** in dBm (e.g., "-72 dBm")
+- **Device name** (falls back to "Unknown Device")
+- **Device ID** (BLE MAC address on Android; a system-assigned UUID on iOS/macOS)
+- **Connect button** (the entire tile row is also tappable — both trigger connection)
+
+Note: The weak (-80 to -90 dBm) and poor (< -90 dBm) tiers share the same icon shape and are only differentiated by color (orange vs. red).
+
+### How Scanning Works
+
+- Filters for devices with names starting with `MeshCore-` or `Whisper-`
+- Uses low-latency scan mode on Android
+- Scans for 10 seconds then auto-stops
+- On iOS/macOS, waits for BLE adapter initialization before starting
+- If Bluetooth is turned off during a scan, scanning stops immediately
+
+### Connecting to a Device
+
+Tap a device tile or its Connect button:
+1. The connector stops scanning and transitions to "connecting"
+2. Connects to the device with a 15-second timeout
+3. Requests MTU 185 bytes for optimal throughput
+4. Discovers BLE services and locates the Nordic UART Service
+5. Subscribes to TX notifications for receiving data
+6. On success, automatically navigates to the Contacts screen
+7. On failure, shows a red error snackbar
+
+---
+
+## USB Connection
+
+### How to Access
+
+From the Scanner screen, tap the **USB** FAB button.
+
+### What the User Sees
+
+- A colored status bar at the top (same color scheme as BLE scanner)
+- A list of detected USB serial ports, each showing:
+ - Friendly display name
+ - Raw port name (subtitle, only shown when it differs from the display name)
+ - "Connect" button
+- FABs at the bottom to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
+
+### Key Interactions
+
+- On desktop (Windows, Linux, macOS): ports are polled every 2 seconds for hot-plug detection (polling pauses while connecting/connected)
+- On mobile: tap the "Scan" FAB to manually refresh
+- Tap a port or its Connect button to connect
+- On successful connection, navigates to Contacts screen
+- On connection failure, the port list automatically refreshes
+- Platform-specific error messages for common USB failures (permission denied, device missing, device detached, device busy, driver missing, port invalid, timeout, and more)
+
+---
+
+## TCP Connection
+
+### How to Access
+
+From the Scanner screen, tap the **TCP/IP** FAB button.
+
+### What the User Sees
+
+- A colored status bar at the top
+- **Host address** text field
+- **Port number** text field
+- **Connect** button
+- FABs at the bottom to switch to USB or BLE
+
+### Key Interactions
+
+- Last-used host and port are pre-populated from saved settings
+- Tap Connect to validate inputs and connect
+ - Host must not be empty
+ - Port must be a number between 1 and 65535
+ - Validation errors are shown as red snackbars
+- The Connect button shows a spinner and "Connecting..." label while in progress
+- The status bar shows the specific host:port being connected to (e.g., "Connecting to 192.168.1.1:5000")
+- On success, navigates to Contacts screen and saves the host/port to settings
+- On connection, the status bar shows the active TCP endpoint (e.g., "Connected to 192.168.1.1:5000")
+- Error messages for timeout, unsupported platform, and connection failures
diff --git a/documentation/settings.md b/documentation/settings.md
new file mode 100644
index 0000000..70e39e2
--- /dev/null
+++ b/documentation/settings.md
@@ -0,0 +1,169 @@
+# Settings
+
+## How to Access
+
+- From the Device Screen: tap the tune/sliders icon in the app bar
+- From Contacts or Channels: overflow menu (three-dot) → Settings
+
+Settings are only accessible while a device is connected.
+
+## Settings Screen Layout
+
+The settings screen is a scrollable list of cards:
+
+1. [Device Info](#device-info)
+2. [App Settings](#app-settings) (link to sub-screen)
+3. [Node Settings](#node-settings)
+4. [Actions](#actions)
+5. [Debug](#debug)
+6. [Export](#export)
+7. [About](#about)
+
+---
+
+## Device Info
+
+A collapsible card showing read-only device information. **Collapsed by default** — tap the header to expand with an animated chevron indicator:
+
+| Field | Description |
+|---|---|
+| Name | Connected device's display name |
+| ID | Device identifier |
+| Status | Connected / Disconnected |
+| Battery | Percentage or voltage (tap to toggle) |
+| Node Name | The node's mesh identity name |
+| Public Key | First 16 hex characters + "..." |
+| Contacts Count | Number of known contacts |
+| Channel Count | Number of configured channels |
+
+Battery shows an alert icon and orange text when at 15% or below. The toggle only works when millivolt data is available from the firmware.
+
+---
+
+## App Settings
+
+A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
+
+### Appearance
+- **Theme**: System / Light / Dark
+- **Language**: System default or one of 15 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian)
+- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
+
+### Notifications
+- **Master enable/disable**: Requests OS permission when enabling
+- **Message notifications**: New direct message alerts
+- **Channel message notifications**: New channel message alerts
+- **Advertisement notifications**: New node discovery alerts
+
+### Messaging
+- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
+- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
+ - Max Route Weight (1–10, default 5, integer steps)
+ - Initial Route Weight (0.5–5.0, default 3.0)
+ - Success Increment (0.1–2.0, default 0.5, 0.1 steps)
+ - Failure Decrement (0.1–2.0, default 0.2, 0.1 steps)
+ - Max Message Retries (2–10, default 5)
+
+### Battery
+- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
+
+### Map Display
+- **Show Repeaters**: Toggle repeater markers on map
+- **Show Chat Nodes**: Toggle chat node markers
+- **Show Other Nodes**: Toggle room/sensor markers
+- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
+- **Units**: Metric / Imperial
+- **Offline Map Cache**: Navigate to tile download screen
+
+### Debug
+- **App Debug Logging**: Enable the in-app debug log
+
+---
+
+## Node Settings
+
+These settings are sent directly to the connected device firmware.
+
+### Node Name
+- Opens a dialog with a text field (max 31 characters)
+- Sends the new name to the device
+- Confirmed via snackbar
+
+### Radio Settings
+Opens a dialog pre-populated with the device's current radio settings. Contains:
+- **Preset dropdown**: 19 regional presets — selecting a preset immediately fills all fields below. Full list: Australia, Australia (Narrow), Australia SA/WA/QLD, Czech Republic, EU 433MHz, EU/UK (Long Range), EU/UK (Medium Range), EU/UK (Narrow), New Zealand, New Zealand (Narrow), Portugal 433, Portugal 869, Switzerland, USA Arizona, USA/Canada, Vietnam, Off-Grid 433, Off-Grid 869, Off-Grid 918
+- **Frequency** (MHz): Free text, validated 300–2500 MHz
+- **Bandwidth**: Dropdown (7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500 kHz)
+- **Spreading Factor**: SF5–SF12
+- **Coding Rate**: 4/5, 4/6, 4/7, 4/8
+- **TX Power** (dBm): Validated 0 to device max (typically 22 dBm)
+- **Client Repeat** toggle: Only shown on firmware v9+; requires frequency to be exactly 433.000, 869.000, or 918.000 MHz (the Off-Grid presets). Save is blocked with a warning if enabled on other frequencies
+
+### Location
+Opens a dialog pre-populated with the device's current coordinates (if known):
+- Latitude and longitude fields (decimal, 6 decimal places). If only one field is provided, the other uses the device's current value
+- If GPS-capable hardware (detected via `gps` custom variable):
+ - GPS Update Interval (seconds, 60–86399, default 900 = 15 minutes). Validated and sent separately before lat/lon
+ - Enable GPS toggle (takes effect immediately, not deferred to Save)
+- Validation: lat ±90, lon ±180
+
+### Contact Settings
+Five toggles controlling which node types are auto-added when heard:
+- Auto-add Chat Users
+- Auto-add Repeaters
+- Auto-add Room Servers
+- Auto-add Sensors
+- Overwrite Oldest (when contact list is full)
+
+### Privacy Mode
+Opens a confirmation dialog with three buttons: Cancel, Enable, and Disable. Both states can be set from the same dialog regardless of current state. A snackbar confirms which state was applied. When on, the node stops broadcasting its location in advertisements.
+
+---
+
+## Actions
+
+One-tap device operations:
+
+| Action | Description |
+|---|---|
+| Send Advertisement | Floods the mesh with your node's advertisement |
+| Sync Time | Sends current Unix timestamp to the device |
+| Refresh Contacts | Re-requests the full contact list |
+| Reboot Device | Confirmation dialog → reboots the device (shown in orange) |
+
+---
+
+## Debug
+
+Two log viewers accessible via list tiles:
+
+### BLE Debug Log
+Two views (togglable via segmented button):
+- **Frames view**: Direction icon, description, hex preview, timestamp per frame. Long-press to copy hex.
+- **Raw Log RX view**: Decoded LoRa packets with route type, payload type, path, and summary.
+- Copy-all and Clear buttons in the app bar.
+
+### App Debug Log
+Structured log entries (Info / Warning / Error), with tag, message, and timestamp.
+- Must be enabled first in App Settings → Debug
+- Copy-all and Clear buttons
+
+---
+
+## Export
+
+Three GPX export options (not available on web):
+
+| Option | Exports |
+|---|---|
+| Export Repeaters | Repeaters and Rooms with GPS coordinates |
+| Export Contacts | Chat contacts with GPS coordinates |
+| Export All | All contacts with GPS coordinates |
+
+Each creates a `.gpx` file and opens the OS share sheet. Feedback via snackbar for four outcomes: success, no contacts with coordinates, feature not available (web), or error.
+
+---
+
+## About
+
+Shows the standard Flutter about dialog with app name, version, and legal notice.
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..4d0355b
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1770562336,
+ "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..1671145
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,86 @@
+{
+ description = "MeshCore Flutter Application";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
+ flake-utils.url = "github:numtide/flake-utils";
+ };
+
+ outputs = { self, nixpkgs, flake-utils }:
+ flake-utils.lib.eachDefaultSystem (system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+ in
+ {
+ devShells.default = pkgs.mkShell {
+ buildInputs = with pkgs; [
+ # Flutter and Dart
+ flutter
+ dart
+
+ # Java (required for Android development)
+ jdk17
+
+ # Android development tools
+ android-tools
+ gradle
+
+ # For the shell hook to set up the environment for Flutter development
+ gtk3
+ glib
+ sysprof
+ libclang
+ cmake
+ ninja
+ pkg-config
+ libdatrie
+
+ # Additional tools for installing Android SDK if not present
+ curl
+ unzip
+ ];
+
+ shellHook = ''
+ echo "MeshCore Flutter Development Environment"
+ export PKG_CONFIG_PATH="${pkgs.gtk3}/lib/pkgconfig:${pkgs.glib}/lib/pkgconfig:${pkgs.sysprof}/lib/pkgconfig:$PKG_CONFIG_PATH"
+ export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [pkgs.gtk3 pkgs.glib pkgs.sysprof pkgs.libdatrie]}:$LD_LIBRARY_PATH"
+ export CMAKE_INSTALL_PREFIX="$PWD/build/bundle"
+
+ # Setup Android SDK in home directory (standard location)
+ export ANDROID_HOME="$HOME/Android/Sdk"
+ export ANDROID_SDK_ROOT="$ANDROID_HOME"
+ export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$PATH"
+
+ echo "Android SDK: $ANDROID_HOME"
+ echo ""
+
+ # Check if Android SDK exists and offer to download if not
+ if [ ! -d "$ANDROID_HOME" ]; then
+ echo "WARNING: Android SDK not found at $ANDROID_HOME"
+ echo ""
+ echo "To download and set up the Android SDK, run this command:"
+ echo ""
+ cat << 'EOF'
+mkdir -p ~/Android/Sdk && cd ~/Android/Sdk && \
+curl -o cmdline-tools.zip ${if pkgs.stdenv.isDarwin then "https://dl.google.com/android/repository/commandlinetools-mac-10406996_latest.zip" else "https://dl.google.com/android/repository/commandlinetools-linux-10406996_latest.zip"} && \
+unzip -q cmdline-tools.zip && \
+mkdir -p cmdline-tools/latest && \
+mv cmdline-tools/* cmdline-tools/latest/ 2>/dev/null || echo "Warning: failed to move Android cmdline-tools into 'latest' directory; please check your SDK layout." >&2 && \
+rm cmdline-tools.zip && \
+cd cmdline-tools/latest/bin && \
+yes | ./sdkmanager --sdk_root=~/Android/Sdk 'platform-tools' && \
+echo "Android SDK setup complete!"
+EOF
+ echo ""
+ echo "Then run 'flutter doctor' again to verify."
+ echo ""
+ else
+ echo "Android SDK found at $ANDROID_HOME"
+ fi
+
+ echo "To check that everything is set up correctly, run 'flutter doctor' and ensure there are no issues."
+ '';
+ };
+ }
+ );
+}
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
index 1dc6cf7..391a902 100644
--- a/ios/Flutter/AppFrameworkInfo.plist
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -20,7 +20,5 @@
????
CFBundleVersion
1.0
- MinimumOSVersion
- 13.0
diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
index 592ceee..ec97fc6 100644
--- a/ios/Flutter/Debug.xcconfig
+++ b/ios/Flutter/Debug.xcconfig
@@ -1 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
index 592ceee..c4855bf 100644
--- a/ios/Flutter/Release.xcconfig
+++ b/ios/Flutter/Release.xcconfig
@@ -1 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
diff --git a/ios/Podfile b/ios/Podfile
index 69ed111..24a17c4 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -1,4 +1,4 @@
-platform :ios, '12.0'
+platform :ios, '16.4'
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -32,5 +32,8 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
+ target.build_configurations.each do |config|
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.4'
+ end
end
end
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
new file mode 100644
index 0000000..b0e98ca
--- /dev/null
+++ b/ios/Podfile.lock
@@ -0,0 +1,74 @@
+PODS:
+ - Flutter (1.0.0)
+ - flutter_blue_plus_darwin (0.0.2):
+ - Flutter
+ - FlutterMacOS
+ - flutter_foreground_task (0.0.1):
+ - Flutter
+ - flutter_local_notifications (0.0.1):
+ - Flutter
+ - mobile_scanner (7.0.0):
+ - Flutter
+ - FlutterMacOS
+ - package_info_plus (0.4.5):
+ - Flutter
+ - share_plus (0.0.1):
+ - Flutter
+ - shared_preferences_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - sqflite_darwin (0.0.4):
+ - Flutter
+ - FlutterMacOS
+ - url_launcher_ios (0.0.1):
+ - Flutter
+
+DEPENDENCIES:
+ - Flutter (from `Flutter`)
+ - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
+ - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
+ - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
+ - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
+ - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
+ - share_plus (from `.symlinks/plugins/share_plus/ios`)
+ - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+ - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
+ - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
+
+EXTERNAL SOURCES:
+ Flutter:
+ :path: Flutter
+ flutter_blue_plus_darwin:
+ :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
+ flutter_foreground_task:
+ :path: ".symlinks/plugins/flutter_foreground_task/ios"
+ flutter_local_notifications:
+ :path: ".symlinks/plugins/flutter_local_notifications/ios"
+ mobile_scanner:
+ :path: ".symlinks/plugins/mobile_scanner/darwin"
+ package_info_plus:
+ :path: ".symlinks/plugins/package_info_plus/ios"
+ share_plus:
+ :path: ".symlinks/plugins/share_plus/ios"
+ shared_preferences_foundation:
+ :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+ sqflite_darwin:
+ :path: ".symlinks/plugins/sqflite_darwin/darwin"
+ url_launcher_ios:
+ :path: ".symlinks/plugins/url_launcher_ios/ios"
+
+SPEC CHECKSUMS:
+ Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+ flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
+ flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
+ flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
+ mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
+ package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
+ share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
+ shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+ sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+ url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
+
+PODFILE CHECKSUM: e42b502c78c33aa1ed9d42eaea8960ce2139504b
+
+COCOAPODS: 1.16.2
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 09c8350..7bf9b4c 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -14,6 +14,7 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+ 9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4268181FCF3E12817B700E9C /* libPods-Runner.a */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -42,9 +43,13 @@
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 24A76623340E493BD4C25C5C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 40AC50CE3E1D4278E82498CF /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ 4268181FCF3E12817B700E9C /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 718BC7DCCFC5C370705C12E5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
@@ -62,6 +67,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -94,6 +100,8 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
+ DEE6F094D3B70E76087722E1 /* Pods */,
+ DAE613E34DF694C2E33B64C7 /* Frameworks */,
);
sourceTree = "";
};
@@ -121,6 +129,25 @@
path = Runner;
sourceTree = "";
};
+ DAE613E34DF694C2E33B64C7 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 4268181FCF3E12817B700E9C /* libPods-Runner.a */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ DEE6F094D3B70E76087722E1 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 40AC50CE3E1D4278E82498CF /* Pods-Runner.debug.xcconfig */,
+ 24A76623340E493BD4C25C5C /* Pods-Runner.release.xcconfig */,
+ 718BC7DCCFC5C370705C12E5 /* Pods-Runner.profile.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -145,12 +172,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
+ DE3B2E091393835C0B38492E /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ F0D7F2413C6E4B7A9B1C2D3E /* Fix Native Asset Minimum OS */,
+ B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -253,6 +283,61 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
+ B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ F0D7F2413C6E4B7A9B1C2D3E /* Fix Native Asset Minimum OS */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}",
+ );
+ name = "Fix Native Asset Minimum OS";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "set -e\nFRAMEWORKS_DIR=\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}\"\nMIN_OS=\"${IPHONEOS_DEPLOYMENT_TARGET}\"\nif [ ! -d \"$FRAMEWORKS_DIR\" ] || [ -z \"$MIN_OS\" ]; then\n exit 0\nfi\nfind \"$FRAMEWORKS_DIR\" -maxdepth 2 -name Info.plist | while read -r plist; do\n bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \"$plist\" 2>/dev/null || true)\n case \"$bundle_id\" in\n io.flutter.flutter.native-assets.*)\n /usr/libexec/PlistBuddy -c \"Set :MinimumOSVersion $MIN_OS\" \"$plist\" 2>/dev/null || \\\n /usr/libexec/PlistBuddy -c \"Add :MinimumOSVersion string $MIN_OS\" \"$plist\"\n ;;\n esac\ndone\n";
+ };
+ DE3B2E091393835C0B38492E /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -346,7 +431,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.4;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -368,7 +453,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
+ PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -384,7 +469,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
+ PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -401,7 +486,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
+ PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -416,7 +501,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
+ PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -472,7 +557,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.4;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -523,7 +608,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.4;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -547,7 +632,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
+ PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -569,7 +654,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
+ PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata
index 1d526a1..21a3cc1 100644
--- a/ios/Runner.xcworkspace/contents.xcworkspacedata
+++ b/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -4,4 +4,7 @@
+
+
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 6266644..c30b367 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -2,12 +2,15 @@ import Flutter
import UIKit
@main
-@objc class AppDelegate: FlutterAppDelegate {
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
- GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
}
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 460b4dd..00d9efa 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -2,6 +2,8 @@
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -22,8 +24,46 @@
????
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
+ LSApplicationQueriesSchemes
+
+ http
+ https
+
LSRequiresIPhoneOS
+ NSBluetoothAlwaysUsageDescription
+ This app uses Bluetooth to communicate with MeshCore devices.
+ NSBluetoothPeripheralUsageDescription
+ This app uses Bluetooth to communicate with MeshCore devices.
+ NSCameraUsageDescription
+ This app uses the camera to scan QR codes for joining communities.
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ FlutterSceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UIBackgroundModes
+
+ bluetooth-central
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -41,17 +81,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
- UIBackgroundModes
-
- bluetooth-central
-
- NSBluetoothAlwaysUsageDescription
- This app uses Bluetooth to communicate with MeshCore devices.
- NSBluetoothPeripheralUsageDescription
- This app uses Bluetooth to communicate with MeshCore devices.
diff --git a/l10n.yaml b/l10n.yaml
new file mode 100644
index 0000000..b89567e
--- /dev/null
+++ b/l10n.yaml
@@ -0,0 +1,6 @@
+arb-dir: lib/l10n
+template-arb-file: app_en.arb
+output-localization-file: app_localizations.dart
+output-class: AppLocalizations
+nullable-getter: false
+untranslated-messages-file: untranslated.json
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 166c98f..b432277 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -1,40 +1,85 @@
import 'dart:async';
import 'dart:convert';
+import 'dart:math' as math;
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:wakelock_plus/wakelock_plus.dart';
+import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
+import '../models/companion_radio_stats.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/path_selection.dart';
+import '../models/translation_support.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/smaz.dart';
import '../services/app_debug_log_service.dart';
import '../services/ble_debug_log_service.dart';
+import '../services/linux_ble_error_classifier.dart';
+import '../services/linux_ble_pairing_service_stub.dart'
+ if (dart.library.io) '../services/linux_ble_pairing_service.dart';
import '../services/message_retry_service.dart';
import '../services/path_history_service.dart';
import '../services/app_settings_service.dart';
import '../services/background_service.dart';
+import '../services/timeout_prediction_service.dart';
+import '../services/translation_service.dart';
import '../services/notification_service.dart';
+import 'meshcore_connector_usb.dart';
+import 'meshcore_connector_tcp.dart';
import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart';
+import '../storage/channel_store.dart';
+import '../storage/contact_discovery_store.dart';
import '../storage/contact_settings_store.dart';
import '../storage/contact_store.dart';
import '../storage/message_store.dart';
import '../storage/unread_store.dart';
import '../utils/app_logger.dart';
+import '../utils/battery_utils.dart';
+import '../utils/platform_info.dart';
+import 'meshcore_uuids.dart';
import 'meshcore_protocol.dart';
-class MeshCoreUuids {
- static const String service = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
- static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
- static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
+class DirectRepeater {
+ static const int maxAgeMinutes = 30; // Max age for direct repeater info
+ final int pubkeyFirstByte;
+ double snr;
+ DateTime lastUpdated;
+
+ DirectRepeater({
+ required this.pubkeyFirstByte,
+ required this.snr,
+ DateTime? lastUpdated,
+ }) : lastUpdated = lastUpdated ?? DateTime.now();
+
+ void update(double newSNR) {
+ snr = newSNR;
+ lastUpdated = DateTime.now();
+ }
+
+ int get ranking {
+ if (isStale()) {
+ return -1; // Stale repeaters get lowest rank
+ }
+ // Higher SNR gets higher rank and recency within maxAgeMinutes breaks ties.
+ final ageMs =
+ DateTime.now().millisecondsSinceEpoch -
+ lastUpdated.millisecondsSinceEpoch;
+ final maxAgeMs = maxAgeMinutes * 60 * 1000;
+ final recencyScore = (maxAgeMs - ageMs).clamp(0, maxAgeMs);
+ return ((snr - 31.75) * 1000).round() + recencyScore;
+ }
+
+ bool isStale() {
+ return DateTime.now().difference(lastUpdated) >
+ const Duration(minutes: maxAgeMinutes);
+ }
}
enum MeshCoreConnectionState {
@@ -45,6 +90,36 @@ enum MeshCoreConnectionState {
disconnecting,
}
+enum MeshCoreTransportType { bluetooth, usb, tcp }
+
+class RepeaterBatterySnapshot {
+ final int millivolts;
+ final DateTime updatedAt;
+ final String source;
+
+ const RepeaterBatterySnapshot({
+ required this.millivolts,
+ required this.updatedAt,
+ required this.source,
+ });
+}
+
+class MeshCoreRadioStateSnapshot {
+ final int freqHz;
+ final int bwHz;
+ final int sf;
+ final int cr;
+ final int txPowerDbm;
+
+ const MeshCoreRadioStateSnapshot({
+ required this.freqHz,
+ required this.bwHz,
+ required this.sf,
+ required this.cr,
+ required this.txPowerDbm,
+ });
+}
+
class MeshCoreConnector extends ChangeNotifier {
// Message windowing to limit memory usage
static const int _messageWindowSize = 200;
@@ -59,22 +134,44 @@ class MeshCoreConnector extends ChangeNotifier {
String? _lastDeviceId;
String? _lastDeviceDisplayName;
bool _manualDisconnect = false;
+ final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
+ final LinuxBlePairingService _linuxBlePairingService =
+ LinuxBlePairingService();
+ StreamSubscription? _usbFrameSubscription;
+ final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector();
+ MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
final List _scanResults = [];
+ final List _linuxSystemScanResults = [];
final List _contacts = [];
+ final List _discoveredContacts = [];
final List _channels = [];
final Map> _conversations = {};
final Map> _channelMessages = {};
+ final List _pendingChannelSentQueue = [];
+ final List<_PendingCommandAck> _pendingGenericAckQueue = [];
+ static const String _reactionSendQueuePrefix = '__reaction_send__';
+ int _reactionSendQueueSequence = 0;
final Set _loadedConversationKeys = {};
- final Map> _processedChannelReactions = {}; // channelIndex -> Set of "reactionKey_emoji"
- final Map> _processedContactReactions = {}; // contactPubKeyHex -> Set of "reactionKey_emoji"
+ final Map> _processedChannelReactions =
+ {}; // channelIndex -> Set of "targetHash_emoji"
+ final Map> _processedContactReactions =
+ {}; // contactPubKeyHex -> Set of "targetHash_emoji"
StreamSubscription>? _scanSubscription;
StreamSubscription? _connectionSubscription;
StreamSubscription>? _notifySubscription;
+ Timer? _notifyListenersTimer;
Timer? _selfInfoRetryTimer;
Timer? _reconnectTimer;
+ Timer? _batteryPollTimer;
+ Timer? _radioStatsPollTimer;
+ int _radioStatsPollRefCount = 0;
+ final ValueNotifier radioStatsNotifier =
+ ValueNotifier(null);
int _reconnectAttempts = 0;
+ bool _notifyListenersDirty = false;
+ static const Duration _notifyListenersDebounce = Duration(milliseconds: 50);
final StreamController _receivedFramesController =
StreamController.broadcast();
@@ -87,14 +184,57 @@ class MeshCoreConnector extends ChangeNotifier {
int? _currentBwHz;
int? _currentSf;
int? _currentCr;
+ bool? _clientRepeat;
+ MeshCoreRadioStateSnapshot? _rememberedNonRepeatRadioState;
+ int? _firmwareVerCode;
+ int _pathHashByteWidth = 1;
+ CompanionRadioStats? _latestRadioStats;
+ Stopwatch? _airtimeBumpStopwatch;
+ int _prevTotalAirSecs = 0;
int? _batteryMillivolts;
double? _selfLatitude;
double? _selfLongitude;
+ final List _directRepeaters = List.empty(growable: true);
bool _isLoadingContacts = false;
bool _isLoadingChannels = false;
+ bool _hasLoadedChannels = false;
+ TimeoutPredictionService? _timeoutPredictionService;
+ TranslationService? _translationService;
+ // Intentionally global (not per-contact): tracks overall network activity.
+ // Frequent RX from any source indicates a busy network with more collisions.
+ DateTime _lastRxTime = DateTime.now();
+ DateTime _lastRadioRxTime = DateTime.fromMillisecondsSinceEpoch(0);
+ DateTime _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
+ DateTime _lastChannelMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
+ static const int _radioQuietMs = 3000;
+ static const int _radioQuietMaxWaitMs = 3000;
+
+ /// When companion radio stats are unavailable, keep the legacy fixed backoff.
+ static const int _contactMsgBackoffFallbackMs = 5000;
+ static const int _contactMsgBackoffMinMs = 500;
+ static const int _contactMsgBackoffMaxMs = 15000;
+ int _pollingInterval = 30;
bool _batteryRequested = false;
bool _awaitingSelfInfo = false;
+ bool _hasReceivedDeviceInfo = false;
+ bool _pendingInitialChannelSync = false;
+ bool _pendingInitialContactsSync = false;
+ bool _bleInitialSyncStarted = false;
+ bool _pendingDeferredChannelSyncAfterContacts = false;
+ bool _webInitialHandshakeRequestSent = false;
bool _preserveContactsOnRefresh = false;
+ bool _autoAddUsers = false;
+ bool _autoAddRepeaters = false;
+ bool _autoAddRoomServers = false;
+ bool _autoAddSensors = false;
+ bool _overwriteOldest = false;
+ bool _manualAddContacts = false;
+ int _telemetryModeBase = 0;
+ int _telemetryModeLoc = 0;
+ int _telemetryModeEnv = 0;
+ int _advertLocPolicy = 0;
+ int _multiAcks = 0;
+
static const int _defaultMaxContacts = 32;
static const int _defaultMaxChannels = 8;
int _maxContacts = _defaultMaxContacts;
@@ -107,6 +247,10 @@ class MeshCoreConnector extends ChangeNotifier {
int _queueSyncRetries = 0;
static const int _maxQueueSyncRetries = 3;
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
+ // Serializes path operations (setContactPath/clearContactPath) to prevent
+ // interleaved async calls from leaving in-memory state inconsistent with device.
+ Future _pathOpLock = Future.value();
+ Map? _currentCustomVars;
// Channel syncing state (sequential pattern)
bool _isSyncingChannels = false;
@@ -118,6 +262,7 @@ class MeshCoreConnector extends ChangeNotifier {
List _previousChannelsCache = [];
static const int _maxChannelSyncRetries = 3;
static const int _channelSyncTimeoutMs = 2000; // 2 second timeout per channel
+ static const Duration _batteryPollInterval = Duration(seconds: 120);
// Services
MessageRetryService? _retryService;
@@ -133,24 +278,45 @@ class MeshCoreConnector extends ChangeNotifier {
final ChannelSettingsStore _channelSettingsStore = ChannelSettingsStore();
final ContactSettingsStore _contactSettingsStore = ContactSettingsStore();
final ContactStore _contactStore = ContactStore();
+ final ContactDiscoveryStore _discoveryContactStore = ContactDiscoveryStore();
+ final ChannelStore _channelStore = ChannelStore();
final UnreadStore _unreadStore = UnreadStore();
+ List _cachedChannels = [];
final Map _channelSmazEnabled = {};
- bool _lastSentWasCliCommand = false; // Track if last sent message was a CLI command
+ bool _lastSentWasCliCommand =
+ false; // Track if last sent message was a CLI command
final Map _contactSmazEnabled = {};
final Set _knownContactKeys = {};
- final Map _contactLastReadMs = {};
- final Map _channelLastReadMs = {};
+ final Map _contactUnreadCount = {};
+ final Map _repeaterBatterySnapshots = {};
+ bool _unreadStateLoaded = false;
final Map _pendingRepeaterAcks = {};
String? _activeContactKey;
int? _activeChannelIndex;
List _channelOrder = [];
+ int _storageUsedKb = -1;
+ int _storageTotalKb = -1;
+
// Getters
MeshCoreConnectionState get state => _state;
BluetoothDevice? get device => _device;
String? get deviceId => _deviceId;
String get deviceIdLabel => _deviceId ?? 'Unknown';
+ MeshCoreTransportType get activeTransport => _activeTransport;
+ String? get activeUsbPort => _usbManager.activePortKey;
+ String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel;
+ bool get isUsbTransportConnected =>
+ _state == MeshCoreConnectionState.connected &&
+ _activeTransport == MeshCoreTransportType.usb;
+ bool get isAutoReconnectScheduled =>
+ _shouldAutoReconnect && (_reconnectTimer?.isActive ?? false);
+ String? get activeTcpEndpoint => _tcpConnector.activeEndpoint;
+ bool get isTcpTransportConnected =>
+ _state == MeshCoreConnectionState.connected &&
+ _activeTransport == MeshCoreTransportType.tcp;
+
String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) {
return _selfName!;
@@ -164,6 +330,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
return 'Unknown Device';
}
+
List get scanResults => List.unmodifiable(_scanResults);
List get contacts {
final selfKey = _selfPublicKey;
@@ -174,35 +341,110 @@ class MeshCoreConnector extends ChangeNotifier {
_contacts.where((contact) => !listEquals(contact.publicKey, selfKey)),
);
}
+
+ List get allContacts => List.unmodifiable([
+ ..._contacts,
+ ..._discoveredContacts.where(
+ (c) => !c.isActive && c.publicKeyHex != selfPublicKeyHex,
+ ),
+ ]);
+
+ List get allContactsUnfiltered =>
+ List.unmodifiable([..._contacts, ..._discoveredContacts]);
+
+ List get discoveredContacts {
+ return List.unmodifiable(_discoveredContacts);
+ }
+
List get channels => List.unmodifiable(_channels);
bool get isConnected => _state == MeshCoreConnectionState.connected;
bool get isLoadingContacts => _isLoadingContacts;
bool get isLoadingChannels => _isLoadingChannels;
Stream get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey;
+ String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
String? get selfName => _selfName;
double? get selfLatitude => _selfLatitude;
double? get selfLongitude => _selfLongitude;
+ List get directRepeaters => _directRepeaters;
int? get currentTxPower => _currentTxPower;
int? get maxTxPower => _maxTxPower;
+
+ int get pathHashByteWidth => _pathHashByteWidth;
+
+ CompanionRadioStats? get latestRadioStats => _latestRadioStats;
+
+ bool get supportsCompanionRadioStats => (_firmwareVerCode ?? 0) >= 8;
+
+ bool get radioStatsAirActivityPulse {
+ final sw = _airtimeBumpStopwatch;
+ if (sw == null || !sw.isRunning) return false;
+ return sw.elapsed < const Duration(seconds: 2);
+ }
+
int? get currentFreqHz => _currentFreqHz;
int? get currentBwHz => _currentBwHz;
int? get currentSf => _currentSf;
int? get currentCr => _currentCr;
+ MeshCoreRadioStateSnapshot? get rememberedNonRepeatRadioState =>
+ _rememberedNonRepeatRadioState;
+ bool? get autoAddUsers => _autoAddUsers;
+ bool? get autoAddRepeaters => _autoAddRepeaters;
+ bool? get autoAddRoomServers => _autoAddRoomServers;
+ bool? get autoAddSensors => _autoAddSensors;
+ bool? get autoAddOverwriteOldest => _overwriteOldest;
+ int get telemetryModeBase => _telemetryModeBase;
+ int get telemetryModeLoc => _telemetryModeLoc;
+ int get telemetryModeEnv => _telemetryModeEnv;
+ int get advertLocationPolicy => _advertLocPolicy;
+ int get multiAcks => _multiAcks;
+ bool? get clientRepeat => _clientRepeat;
+ void rememberNonRepeatRadioState(MeshCoreRadioStateSnapshot snapshot) {
+ _rememberedNonRepeatRadioState = snapshot;
+ }
+
+ int? get firmwareVerCode => _firmwareVerCode;
+ Map? get currentCustomVars => _currentCustomVars;
int? get batteryMillivolts => _batteryMillivolts;
+ int? get storageUsedKb => _storageUsedKb;
+ int? get storageTotalKb => _storageTotalKb;
int get maxContacts => _maxContacts;
int get maxChannels => _maxChannels;
+ Set get knownContactKeys => Set.unmodifiable(_knownContactKeys);
bool get isSyncingQueuedMessages => _isSyncingQueuedMessages;
bool get isSyncingChannels => _isSyncingChannels;
- int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0
+ int get channelSyncProgress =>
+ _isSyncingChannels && _totalChannelsToRequest > 0
? ((_nextChannelIndexToRequest / _totalChannelsToRequest) * 100).round()
: 0;
int? get batteryPercent => _batteryMillivolts == null
? null
- : _estimateBatteryPercent(
+ : estimateBatteryPercentFromMillivolts(
_batteryMillivolts!,
_batteryChemistryForDevice(),
);
+ RepeaterBatterySnapshot? getRepeaterBatterySnapshot(String contactKeyHex) =>
+ _repeaterBatterySnapshots[contactKeyHex];
+ int? getRepeaterBatteryMillivolts(String contactKeyHex) =>
+ _repeaterBatterySnapshots[contactKeyHex]?.millivolts;
+
+ void updateRepeaterBatterySnapshot(
+ String contactKeyHex,
+ int millivolts, {
+ String source = 'unknown',
+ }) {
+ if (contactKeyHex.isEmpty || millivolts <= 0) return;
+ final previous = _repeaterBatterySnapshots[contactKeyHex];
+ final snapshot = RepeaterBatterySnapshot(
+ millivolts: millivolts,
+ updatedAt: DateTime.now(),
+ source: source,
+ );
+ _repeaterBatterySnapshots[contactKeyHex] = snapshot;
+ if (previous?.millivolts != millivolts) {
+ notifyListeners();
+ }
+ }
String _batteryChemistryForDevice() {
final deviceId = _device?.remoteId.toString();
@@ -210,27 +452,6 @@ class MeshCoreConnector extends ChangeNotifier {
return _appSettingsService!.batteryChemistryForDevice(deviceId);
}
- int _estimateBatteryPercent(int millivolts, String chemistry) {
- final range = _batteryVoltageRange(chemistry);
- final minMv = range.$1;
- final maxMv = range.$2;
- if (millivolts <= minMv) return 0;
- if (millivolts >= maxMv) return 100;
- return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
- }
-
- (int, int) _batteryVoltageRange(String chemistry) {
- switch (chemistry) {
- case 'lifepo4':
- return (2600, 3650);
- case 'lipo':
- return (3000, 4200);
- case 'nmc':
- default:
- return (3000, 4200);
- }
- }
-
List getMessages(Contact contact) {
return _conversations[contact.publicKeyHex] ?? [];
}
@@ -256,11 +477,50 @@ class MeshCoreConnector extends ChangeNotifier {
? allMessages.sublist(allMessages.length - _messageWindowSize)
: allMessages;
- _conversations[contactKeyHex] = windowedMessages;
+ final currentMessages =
+ _conversations[contactKeyHex] ?? const [];
+ final mergedMessages = [...windowedMessages];
+ final persistedKeyCounts = {};
+ for (final message in windowedMessages) {
+ final key = _messageMergeKey(message);
+ persistedKeyCounts[key] = (persistedKeyCounts[key] ?? 0) + 1;
+ }
+ final currentKeyCounts = {};
+
+ for (final message in currentMessages) {
+ final key = _messageMergeKey(message);
+ final currentCount = (currentKeyCounts[key] ?? 0) + 1;
+ currentKeyCounts[key] = currentCount;
+ final persistedCount = persistedKeyCounts[key] ?? 0;
+
+ // Preserve distinct duplicates without IDs (for example same text
+ // received multiple times in the same second) by only skipping the
+ // overlapping occurrences that already exist in persisted storage.
+ if (currentCount > persistedCount) {
+ mergedMessages.add(message);
+ }
+ }
+
+ // Re-sort after merging persisted and in-memory messages so the
+ // conversation window remains stable after optimistic inserts.
+ mergedMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
+ final windowedMergedMessages = mergedMessages.length > _messageWindowSize
+ ? mergedMessages.sublist(mergedMessages.length - _messageWindowSize)
+ : mergedMessages;
+
+ _conversations[contactKeyHex] = windowedMergedMessages;
notifyListeners();
}
}
+ String _messageMergeKey(Message message) {
+ final messageId = message.messageId;
+ if (messageId.isNotEmpty) {
+ return 'id:$messageId';
+ }
+ return 'fallback:${message.senderKeyHex}:${message.isOutgoing}:${message.isCli}:${message.timestamp.millisecondsSinceEpoch}:${message.text}';
+ }
+
/// Load older messages for a contact (pagination)
Future> loadOlderMessages(
String contactKeyHex, {
@@ -307,18 +567,9 @@ class MeshCoreConnector extends ChangeNotifier {
}
int getUnreadCountForContactKey(String contactKeyHex) {
+ if (!_unreadStateLoaded) return 0;
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0;
- final messages = _conversations[contactKeyHex];
- if (messages == null || messages.isEmpty) return 0;
- final lastReadMs = _contactLastReadMs[contactKeyHex] ?? 0;
- var count = 0;
- for (final message in messages) {
- if (message.isOutgoing || message.isCli) continue;
- if (message.timestamp.millisecondsSinceEpoch > lastReadMs) {
- count++;
- }
- }
- return count;
+ return _contactUnreadCount[contactKeyHex] ?? 0;
}
int getUnreadCountForChannel(Channel channel) {
@@ -326,20 +577,12 @@ class MeshCoreConnector extends ChangeNotifier {
}
int getUnreadCountForChannelIndex(int channelIndex) {
- final messages = _channelMessages[channelIndex];
- if (messages == null || messages.isEmpty) return 0;
- final lastReadMs = _channelLastReadMs[channelIndex] ?? 0;
- var count = 0;
- for (final message in messages) {
- if (message.isOutgoing) continue;
- if (message.timestamp.millisecondsSinceEpoch > lastReadMs) {
- count++;
- }
- }
- return count;
+ if (!_unreadStateLoaded) return 0;
+ return _findChannelByIndex(channelIndex)?.unreadCount ?? 0;
}
int getTotalUnreadCount() {
+ if (!_unreadStateLoaded) return 0;
var total = 0;
// Count unread contact messages
for (final contact in _contacts) {
@@ -365,17 +608,20 @@ class MeshCoreConnector extends ChangeNotifier {
}
Future loadUnreadState() async {
- _contactLastReadMs
+ _contactUnreadCount
..clear()
- ..addAll(await _unreadStore.loadContactLastRead());
- _channelLastReadMs
- ..clear()
- ..addAll(await _unreadStore.loadChannelLastRead());
+ ..addAll(await _unreadStore.loadContactUnreadCount());
+ _unreadStateLoaded = true;
notifyListeners();
}
+ Future loadCachedChannels() async {
+ _cachedChannels = await _channelStore.loadChannels();
+ }
+
void setActiveContact(String? contactKeyHex) {
- if (contactKeyHex != null && !_shouldTrackUnreadForContactKey(contactKeyHex)) {
+ if (contactKeyHex != null &&
+ !_shouldTrackUnreadForContactKey(contactKeyHex)) {
_activeContactKey = null;
return;
}
@@ -394,17 +640,44 @@ class MeshCoreConnector extends ChangeNotifier {
void markContactRead(String contactKeyHex) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return;
- final markMs = _calculateReadTimestampMs(
- _conversations[contactKeyHex]?.map((m) => m.timestamp),
- );
- _setContactLastReadMs(contactKeyHex, markMs);
+ final previousCount = _contactUnreadCount[contactKeyHex] ?? 0;
+ if (previousCount > 0) {
+ _contactUnreadCount[contactKeyHex] = 0;
+ _appDebugLogService?.info(
+ 'Contact $contactKeyHex marked as read (was $previousCount unread)',
+ tag: 'Unread',
+ );
+ _unreadStore.saveContactUnreadCount(
+ Map.from(_contactUnreadCount),
+ );
+ _notificationService.clearContactNotification(
+ contactKeyHex,
+ getTotalUnreadCount(),
+ );
+ notifyListeners();
+ }
}
void markChannelRead(int channelIndex) {
- final markMs = _calculateReadTimestampMs(
- _channelMessages[channelIndex]?.map((m) => m.timestamp),
- );
- _setChannelLastReadMs(channelIndex, markMs);
+ final channel = _findChannelByIndex(channelIndex);
+ if (channel != null && channel.unreadCount > 0) {
+ final previousCount = channel.unreadCount;
+ channel.unreadCount = 0;
+ _appDebugLogService?.info(
+ 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)',
+ tag: 'Unread',
+ );
+ unawaited(
+ _channelStore.saveChannels(
+ _channels.isNotEmpty ? _channels : _cachedChannels,
+ ),
+ );
+ _notificationService.clearChannelNotification(
+ channelIndex,
+ getTotalUnreadCount(),
+ );
+ notifyListeners();
+ }
}
Future setChannelSmazEnabled(int channelIndex, bool enabled) async {
@@ -427,7 +700,9 @@ class MeshCoreConnector extends ChangeNotifier {
/// Load persisted channel messages for a specific channel
Future _loadChannelMessages(int channelIndex) async {
- final allMessages = await _channelMessageStore.loadChannelMessages(channelIndex);
+ final allMessages = await _channelMessageStore.loadChannelMessages(
+ channelIndex,
+ );
if (allMessages.isNotEmpty) {
// Keep only the most recent N messages in memory to bound memory usage
final windowedMessages = allMessages.length > _messageWindowSize
@@ -444,7 +719,9 @@ class MeshCoreConnector extends ChangeNotifier {
int channelIndex, {
int count = 50,
}) async {
- final allMessages = await _channelMessageStore.loadChannelMessages(channelIndex);
+ final allMessages = await _channelMessageStore.loadChannelMessages(
+ channelIndex,
+ );
final currentMessages = _channelMessages[channelIndex] ?? [];
if (allMessages.length <= currentMessages.length) {
@@ -477,16 +754,22 @@ class MeshCoreConnector extends ChangeNotifier {
required MessageRetryService retryService,
required PathHistoryService pathHistoryService,
AppSettingsService? appSettingsService,
+ TranslationService? translationService,
BleDebugLogService? bleDebugLogService,
AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService,
+ TimeoutPredictionService? timeoutPredictionService,
}) {
_retryService = retryService;
_pathHistoryService = pathHistoryService;
_appSettingsService = appSettingsService;
+ _translationService = translationService;
_bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
+ _timeoutPredictionService = timeoutPredictionService;
+ _usbManager.setDebugLogService(_appDebugLogService);
+ _tcpConnector.setDebugLogService(_appDebugLogService);
// Initialize notification service
_notificationService.initialize();
@@ -494,19 +777,45 @@ class MeshCoreConnector extends ChangeNotifier {
// Initialize retry service callbacks
_retryService?.initialize(
- sendMessageCallback: _sendMessageDirect,
- addMessageCallback: _addMessage,
- updateMessageCallback: _updateMessage,
- clearContactPathCallback: clearContactPath,
- setContactPathCallback: setContactPath,
- calculateTimeoutCallback: (pathLength, messageBytes) =>
- calculateTimeout(pathLength: pathLength, messageBytes: messageBytes),
- getSelfPublicKeyCallback: () => _selfPublicKey,
- prepareContactOutboundTextCallback: prepareContactOutboundText,
- appSettingsService: appSettingsService,
- debugLogService: _appDebugLogService,
- recordPathResultCallback: _recordPathResult,
+ RetryServiceConfig(
+ sendMessage: _sendMessageDirect,
+ addMessage: _addMessage,
+ updateMessage: _updateMessage,
+ clearContactPath: clearContactPath,
+ setContactPath: setContactPath,
+ calculateTimeout: (pathLength, messageBytes, {String? contactKey}) =>
+ calculateTimeout(
+ pathLength: pathLength,
+ messageBytes: messageBytes,
+ contactKey: contactKey,
+ ),
+ getSelfPublicKey: () => _selfPublicKey,
+ prepareContactOutboundText: prepareContactOutboundText,
+ appSettingsService: appSettingsService,
+ debugLogService: _appDebugLogService,
+ recordPathResult: _recordPathResult,
+ selectRetryPath:
+ (contactKey, attemptIndex, maxRetries, recentSelections) =>
+ _selectAutoPathForAttempt(
+ contactKey,
+ attemptIndex: attemptIndex,
+ maxRetries: maxRetries,
+ recentSelections: recentSelections,
+ ),
+ onDeliveryObserved: (contactKey, pathLength, messageBytes, tripTimeMs) {
+ final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
+ _timeoutPredictionService?.recordObservation(
+ contactKey: contactKey,
+ pathLength: pathLength,
+ messageBytes: messageBytes,
+ tripTimeMs: tripTimeMs,
+ secondsSinceLastRx: secSinceRx,
+ );
+ },
+ ),
);
+ final maxRetries = _appSettingsService?.settings.maxMessageRetries ?? 5;
+ _retryService?.setMaxRetries(maxRetries);
}
Future loadContactCache() async {
@@ -514,11 +823,21 @@ class MeshCoreConnector extends ChangeNotifier {
_knownContactKeys
..clear()
..addAll(cached.map((c) => c.publicKeyHex));
+ _contacts
+ ..clear()
+ ..addAll(cached);
for (final contact in cached) {
_ensureContactSmazSettingLoaded(contact.publicKeyHex);
}
}
+ Future _loadDiscoveredContactCache() async {
+ final cached = await _discoveryContactStore.loadContacts();
+ _discoveredContacts
+ ..clear()
+ ..addAll(cached);
+ }
+
Future loadChannelSettings({int? maxChannels}) async {
_channelSmazEnabled.clear();
final channelCount = maxChannels ?? _maxChannels;
@@ -527,35 +846,265 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
- void _sendMessageDirect(
+ /// After an incoming DM or channel message, wait before TX so we do not
+ /// collide with mesh propagation. With companion stats, scale wait by RF
+ /// conditions (up to [_contactMsgBackoffMaxMs]); otherwise use
+ /// [_contactMsgBackoffFallbackMs].
+ int _contactMessageBackoffTargetMs() {
+ if (!supportsCompanionRadioStats || _latestRadioStats == null) {
+ return _contactMsgBackoffFallbackMs;
+ }
+ final stats = _latestRadioStats!;
+ final nf = stats.noiseFloorDbm.toDouble();
+ // Quieter (more negative) → lower score; noisier → higher.
+ const noiseQuietDbm = -118.0;
+ const noiseNoisyDbm = -88.0;
+ final noiseT = ((nf - noiseQuietDbm) / (noiseNoisyDbm - noiseQuietDbm))
+ .clamp(0.0, 1.0);
+
+ final snr = stats.lastSnrDb;
+ const snrGood = 12.0;
+ const snrBad = -2.0;
+ final snrT = (1.0 - ((snr - snrBad) / (snrGood - snrBad))).clamp(0.0, 1.0);
+
+ final airBusy = _recentAirtimeBusyFraction();
+ final severity = (math.max(noiseT, snrT) * 0.82 + airBusy * 0.18).clamp(
+ 0.0,
+ 1.0,
+ );
+
+ return (_contactMsgBackoffMinMs +
+ severity * (_contactMsgBackoffMaxMs - _contactMsgBackoffMinMs))
+ .round();
+ }
+
+ /// 1.0 shortly after TX/RX airtime counters increase, decaying to 0 over ~8s.
+ double _recentAirtimeBusyFraction() {
+ final sw = _airtimeBumpStopwatch;
+ if (sw == null || !sw.isRunning) return 0;
+ final ms = sw.elapsedMilliseconds;
+ const windowMs = 8000;
+ if (ms >= windowMs) return 0;
+ return 1.0 - (ms / windowMs);
+ }
+
+ /// Start of the post-inbound cool-down: the later of BLE message RX time and
+ /// companion airtime bump ([_airtimeBumpStopwatch], same as the activity dot).
+ DateTime _postTxBackoffAnchor(DateTime lastInboundRxTime) {
+ if (!supportsCompanionRadioStats) return lastInboundRxTime;
+ final sw = _airtimeBumpStopwatch;
+ if (sw == null || !sw.isRunning) return lastInboundRxTime;
+ final bumpAt = DateTime.now().subtract(sw.elapsed);
+ return bumpAt.isAfter(lastInboundRxTime) ? bumpAt : lastInboundRxTime;
+ }
+
+ Future _waitForRadioQuiet({required DateTime lastInboundRxTime}) async {
+ // Wait for backoff after inbound traffic / RF airtime (avoid collision with
+ // mesh propagation). Elapsed time uses the dot's airtime bump when newer.
+ final backoffTargetMs = _contactMessageBackoffTargetMs();
+ final anchor = _postTxBackoffAnchor(lastInboundRxTime);
+ final msSinceAnchor = DateTime.now().difference(anchor).inMilliseconds;
+ if (msSinceAnchor < backoffTargetMs) {
+ final waitMs = backoffTargetMs - msSinceAnchor;
+ debugPrint(
+ 'Post-inbound backoff: waiting ${waitMs}ms '
+ '(target=${backoffTargetMs}ms, anchorAge=${msSinceAnchor}ms)',
+ );
+ await Future.delayed(Duration(milliseconds: waitMs));
+ }
+
+ // Then wait for radio silence (no RF activity for 3s)
+ final msSinceRx = DateTime.now()
+ .difference(_lastRadioRxTime)
+ .inMilliseconds;
+ if (msSinceRx >= _radioQuietMs) return;
+
+ final deadline = DateTime.now().add(
+ const Duration(milliseconds: _radioQuietMaxWaitMs),
+ );
+ while (DateTime.now().isBefore(deadline)) {
+ final quiet = DateTime.now().difference(_lastRadioRxTime).inMilliseconds;
+ if (quiet >= _radioQuietMs) {
+ debugPrint('Radio quiet for ${quiet}ms, proceeding with send');
+ return;
+ }
+ await Future.delayed(const Duration(milliseconds: 200));
+ }
+ debugPrint(
+ 'Radio quiet wait exceeded ${_radioQuietMaxWaitMs}ms, sending anyway',
+ );
+ }
+
+ Future _sendMessageDirect(
Contact contact,
String text,
int attempt,
int timestampSeconds,
) async {
if (!isConnected || text.isEmpty) return;
- final outboundText = prepareContactOutboundText(contact, text);
- await sendFrame(
- buildSendTextMsgFrame(
- contact.publicKey,
- outboundText,
- attempt: attempt,
- timestampSeconds: timestampSeconds,
- ),
- );
+ try {
+ await _waitForRadioQuiet(lastInboundRxTime: _lastContactMsgRxTime);
+ final outboundText = prepareContactOutboundText(contact, text);
+ await sendFrame(
+ buildSendTextMsgFrame(
+ contact.publicKey,
+ outboundText,
+ attempt: attempt,
+ timestampSeconds: timestampSeconds,
+ ),
+ );
+ } catch (e) {
+ appLogger.error('Failed to send message: $e', tag: 'Connector');
+ }
}
void _updateMessage(Message message) {
final contactKey = pubKeyToHex(message.senderKey);
final messages = _conversations[contactKey];
if (messages != null) {
- final index = messages.indexWhere((m) => m.messageId == message.messageId);
+ final index = messages.indexWhere(
+ (m) => m.messageId == message.messageId,
+ );
if (index != -1) {
messages[index] = message;
_messageStore.saveMessages(contactKey, messages);
notifyListeners();
}
}
+
+ // If this is a reaction message, update the target message's reaction status
+ final reactionInfo = ReactionHelper.parseReaction(message.text);
+ if (reactionInfo != null &&
+ (message.status == MessageStatus.delivered ||
+ message.status == MessageStatus.failed)) {
+ final contactKey2 = pubKeyToHex(message.senderKey);
+ _setReactionStatus(contactKey2, reactionInfo, message.status);
+ _messageStore.saveMessages(
+ contactKey2,
+ _conversations[contactKey2] ?? [],
+ );
+ notifyListeners();
+ }
+ }
+
+ Future _translateIncomingContactMessage(
+ String contactKeyHex,
+ Message message,
+ ) async {
+ try {
+ final service = _translationService;
+ if (service == null ||
+ !service.shouldTranslateIncoming(
+ text: message.text,
+ isCli: message.isCli,
+ isOutgoing: message.isOutgoing,
+ )) {
+ return;
+ }
+ final targetLanguageCode = service.resolvedIncomingLanguageCode(
+ _appSettingsService?.settings.languageOverride,
+ );
+ final result = await service.translateIncomingText(
+ text: message.text,
+ targetLanguageCode: targetLanguageCode,
+ );
+ if (result == null) {
+ return;
+ }
+ final translated = result.status == MessageTranslationStatus.completed
+ ? result.translatedText
+ : null;
+ _updateStoredContactMessage(
+ contactKeyHex,
+ message.messageId,
+ (current) => current.copyWith(
+ translatedText: translated,
+ translatedLanguageCode: result.detectedLanguageCode,
+ translationStatus: result.status,
+ translationModelId: result.modelId,
+ ),
+ );
+ } catch (error) {
+ appLogger.warn('Translation failed for contact message: $error');
+ }
+ }
+
+ Future _translateIncomingChannelMessage(
+ int channelIndex,
+ ChannelMessage message,
+ ) async {
+ try {
+ final service = _translationService;
+ if (service == null ||
+ !service.shouldTranslateIncoming(
+ text: message.text,
+ isCli: false,
+ isOutgoing: message.isOutgoing,
+ )) {
+ return;
+ }
+ final targetLanguageCode = service.resolvedIncomingLanguageCode(
+ _appSettingsService?.settings.languageOverride,
+ );
+ final result = await service.translateIncomingText(
+ text: message.text,
+ targetLanguageCode: targetLanguageCode,
+ );
+ if (result == null) {
+ return;
+ }
+ final translated = result.status == MessageTranslationStatus.completed
+ ? result.translatedText
+ : null;
+ _updateStoredChannelMessage(
+ channelIndex,
+ message.messageId,
+ (current) => current.copyWith(
+ translatedText: translated,
+ translatedLanguageCode: result.detectedLanguageCode,
+ translationStatus: result.status,
+ translationModelId: result.modelId,
+ ),
+ );
+ } catch (error) {
+ appLogger.warn('Translation failed for channel message: $error');
+ }
+ }
+
+ void _updateStoredContactMessage(
+ String contactKeyHex,
+ String messageId,
+ Message Function(Message current) update,
+ ) {
+ final messages = _conversations[contactKeyHex];
+ if (messages == null) {
+ return;
+ }
+ final index = messages.indexWhere((entry) => entry.messageId == messageId);
+ if (index < 0) {
+ return;
+ }
+ messages[index] = update(messages[index]);
+ _messageStore.saveMessages(contactKeyHex, messages);
+ notifyListeners();
+ }
+
+ void _updateStoredChannelMessage(
+ int channelIndex,
+ String messageId,
+ ChannelMessage Function(ChannelMessage current) update,
+ ) {
+ final messages = _channelMessages[channelIndex];
+ if (messages == null) {
+ return;
+ }
+ final index = messages.indexWhere((entry) => entry.messageId == messageId);
+ if (index < 0) {
+ return;
+ }
+ messages[index] = update(messages[index]);
+ _channelMessageStore.saveChannelMessages(channelIndex, messages);
+ notifyListeners();
}
void _recordPathResult(
@@ -565,60 +1114,217 @@ class MeshCoreConnector extends ChangeNotifier {
int? tripTimeMs,
) {
if (_pathHistoryService == null) return;
+ final settings = _appSettingsService?.settings;
_pathHistoryService!.recordPathResult(
contactPubKeyHex,
selection,
success: success,
tripTimeMs: tripTimeMs,
+ successIncrement: settings?.routeWeightSuccessIncrement ?? 0.2,
+ failureDecrement: settings?.routeWeightFailureDecrement ?? 0.2,
+ maxWeight: settings?.maxRouteWeight ?? 5.0,
);
+
+ // Flood path attribution: when a flood delivery succeeds, credit the
+ // contact's current device path so the route the ACK traveled back
+ // through gets a weight boost in the path history.
+ if (selection.useFlood && success) {
+ final contact = _contacts.cast().firstWhere(
+ (c) => c?.publicKeyHex == contactPubKeyHex,
+ orElse: () => null,
+ );
+ if (contact != null &&
+ contact.pathLength >= 0 &&
+ contact.path.isNotEmpty) {
+ _pathHistoryService!.recordFloodPathAttribution(
+ contactPubKeyHex: contactPubKeyHex,
+ pathBytes: contact.path,
+ hopCount: contact.pathLength,
+ tripTimeMs: tripTimeMs,
+ successIncrement: settings?.routeWeightSuccessIncrement ?? 0.2,
+ maxWeight: settings?.maxRouteWeight ?? 5.0,
+ );
+ }
+
+ // Request a fresh contact from the device so the next flood
+ // attribution uses the most up-to-date path.
+ if (contact != null) {
+ unawaited(getContactByKey(contact.publicKey));
+ }
+ }
}
- Contact _applyAutoSelection(Contact contact, PathSelection? selection) {
- if (selection == null || selection.useFlood || selection.pathBytes.isEmpty) {
- return contact;
+ PathSelection? _selectAutoPathForAttempt(
+ String contactPubKeyHex, {
+ required int attemptIndex,
+ required int maxRetries,
+ List recentSelections = const [],
+ }) {
+ final hasKnownPaths =
+ _pathHistoryService?.getRecentPaths(contactPubKeyHex).isNotEmpty ??
+ false;
+ if (!hasKnownPaths) {
+ return null;
}
- return Contact(
- publicKey: contact.publicKey,
- name: contact.name,
- type: contact.type,
- pathLength: selection.hopCount >= 0 ? selection.hopCount : contact.pathLength,
- path: Uint8List.fromList(selection.pathBytes),
- latitude: contact.latitude,
- longitude: contact.longitude,
- lastSeen: contact.lastSeen,
- lastMessageAt: contact.lastMessageAt,
+ final selection = _pathHistoryService?.selectPathForAttempt(
+ contactPubKeyHex,
+ attemptIndex: attemptIndex,
+ maxRetries: maxRetries,
+ recentSelections: recentSelections,
);
+ if (selection != null) {
+ _pathHistoryService?.recordPathAttempt(contactPubKeyHex, selection);
+ }
+ return selection;
}
- Future startScan({Duration timeout = const Duration(seconds: 10)}) async {
+ Future startScan({
+ Duration timeout = const Duration(seconds: 10),
+ }) async {
if (_state == MeshCoreConnectionState.scanning) return;
_scanResults.clear();
+ _linuxSystemScanResults.clear();
_setState(MeshCoreConnectionState.scanning);
- _scanSubscription = FlutterBluePlus.scanResults.listen((results) {
- _scanResults.clear();
- for (var result in results) {
- if (result.device.platformName.startsWith("MeshCore-") ||
- result.advertisementData.advName.startsWith("MeshCore-")) {
- _scanResults.add(result);
- }
+ // Ensure any previous scan is fully stopped. Guard with isScanningNow to
+ // avoid triggering stale native callbacks when no scan is active.
+ if (FlutterBluePlus.isScanningNow) {
+ try {
+ await FlutterBluePlus.stopScan();
+ } catch (e) {
+ _appDebugLogService?.warn(
+ 'stopScan error in startScan (ignored): $e',
+ tag: 'BLE Scan',
+ );
}
+ }
+ await _scanSubscription?.cancel();
+
+ // On iOS/macOS, wait for Bluetooth to be powered on before scanning
+ if (defaultTargetPlatform == TargetPlatform.iOS ||
+ defaultTargetPlatform == TargetPlatform.macOS) {
+ // Wait for adapter state to be powered on
+ final adapterState = await FlutterBluePlus.adapterState.first;
+ if (adapterState != BluetoothAdapterState.on) {
+ // Wait for the adapter to turn on, with timeout
+ await FlutterBluePlus.adapterState
+ .firstWhere((state) => state == BluetoothAdapterState.on)
+ .timeout(
+ const Duration(seconds: 5),
+ onTimeout: () {
+ _setState(MeshCoreConnectionState.disconnected);
+ throw Exception('Bluetooth adapter not available');
+ },
+ );
+ }
+
+ // Add a small delay to allow BLE stack to fully initialize
+ await Future.delayed(const Duration(milliseconds: 300));
+ }
+
+ if (PlatformInfo.isLinux) {
+ await _loadLinuxSystemDevicesForScan();
+ }
+
+ _scanSubscription = FlutterBluePlus.scanResults.listen((results) {
+ _scanResults
+ ..clear()
+ ..addAll(results);
+ _mergeLinuxSystemScanResults();
notifyListeners();
});
- await FlutterBluePlus.startScan(
- timeout: timeout,
- androidScanMode: AndroidScanMode.lowLatency,
- );
+ try {
+ await FlutterBluePlus.startScan(
+ withKeywords: MeshCoreUuids.deviceNamePrefixes,
+ webOptionalServices: [Guid(MeshCoreUuids.service)],
+ timeout: timeout,
+ androidScanMode: AndroidScanMode.lowLatency,
+ );
+ } catch (error) {
+ _appDebugLogService?.warn('Scan/picker failure: $error', tag: 'BLE Scan');
+ _setState(MeshCoreConnectionState.disconnected);
+ rethrow;
+ }
await Future.delayed(timeout);
await stopScan();
}
+ Future _loadLinuxSystemDevicesForScan() async {
+ try {
+ final systemDevices = await FlutterBluePlus.systemDevices([
+ Guid(MeshCoreUuids.service),
+ ]);
+ _linuxSystemScanResults
+ ..clear()
+ ..addAll(
+ systemDevices
+ .where(
+ (device) => MeshCoreUuids.deviceNamePrefixes.any(
+ device.platformName.startsWith,
+ ),
+ )
+ .map(
+ (device) => ScanResult(
+ device: device,
+ advertisementData: AdvertisementData(
+ advName: device.platformName,
+ txPowerLevel: null,
+ appearance: null,
+ connectable: true,
+ manufacturerData: const >{},
+ serviceData: const >{},
+ serviceUuids: [Guid(MeshCoreUuids.service)],
+ ),
+ rssi: 0,
+ timeStamp: DateTime.now(),
+ ),
+ ),
+ );
+ _mergeLinuxSystemScanResults();
+ notifyListeners();
+ } catch (error) {
+ _appDebugLogService?.warn(
+ 'Failed loading Linux paired/system BLE devices: $error',
+ tag: 'BLE Scan',
+ );
+ }
+ }
+
+ void _mergeLinuxSystemScanResults() {
+ if (!PlatformInfo.isLinux || _linuxSystemScanResults.isEmpty) {
+ return;
+ }
+ final existingIds = _scanResults
+ .map((result) => result.device.remoteId.str)
+ .toSet();
+ for (final result in _linuxSystemScanResults) {
+ if (existingIds.contains(result.device.remoteId.str)) {
+ continue;
+ }
+ _scanResults.add(result);
+ }
+ }
+
Future stopScan() async {
- await FlutterBluePlus.stopScan();
+ // Only call FlutterBluePlus.stopScan() when a scan is actually running.
+ // Calling it when idle triggers a native BLE completion callback even
+ // though no scan was started. After a hot restart Dart has already freed
+ // those callback handles, so the callback crashes with
+ // "Callback invoked after it has been deleted".
+ if (FlutterBluePlus.isScanningNow) {
+ try {
+ await FlutterBluePlus.stopScan();
+ } catch (e) {
+ _appDebugLogService?.warn(
+ 'stopScan error (ignored): $e',
+ tag: 'BLE Scan',
+ );
+ }
+ }
await _scanSubscription?.cancel();
_scanSubscription = null;
@@ -627,12 +1333,252 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
- Future connect(BluetoothDevice device, {String? displayName}) async {
+ Future> listUsbPorts() => _usbManager.listPorts();
+
+ void setUsbRequestPortLabel(String label) {
+ _usbManager.setRequestPortLabel(label);
+ }
+
+ void setUsbFallbackDeviceName(String label) {
+ _usbManager.setFallbackDeviceName(label);
+ }
+
+ Future connectUsb({
+ required String portName,
+ int baudRate = 115200,
+ }) async {
+ if (_state == MeshCoreConnectionState.connecting ||
+ _state == MeshCoreConnectionState.connected) {
+ _appDebugLogService?.warn(
+ 'connectUsb ignored: already $_state',
+ tag: 'USB',
+ );
+ return;
+ }
+
+ _appDebugLogService?.info(
+ 'connectUsb: port=$portName baud=$baudRate',
+ tag: 'USB',
+ );
+
+ await stopScan();
+ _cancelReconnectTimer();
+ _manualDisconnect = false;
+ _resetConnectionHandshakeState();
+ _activeTransport = MeshCoreTransportType.usb;
+ _setState(MeshCoreConnectionState.connecting);
+
+ try {
+ await _usbFrameSubscription?.cancel();
+ _usbFrameSubscription = null;
+ _appDebugLogService?.info('connectUsb: opening serial port…', tag: 'USB');
+ await _usbManager.connect(portName: portName, baudRate: baudRate);
+ _appDebugLogService?.info(
+ 'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
+ tag: 'USB',
+ );
+ notifyListeners();
+ if (PlatformInfo.isWeb) {
+ await stopScan();
+ }
+ await Future.delayed(const Duration(milliseconds: 200));
+ _usbFrameSubscription = _usbManager.frameStream.listen(
+ _handleFrame,
+ onError: (error, stackTrace) {
+ _appDebugLogService?.error('USB transport error: $error', tag: 'USB');
+ unawaited(disconnect(manual: false));
+ },
+ onDone: () {
+ _appDebugLogService?.warn('USB frame stream ended', tag: 'USB');
+ unawaited(disconnect(manual: false));
+ },
+ );
+
+ _setState(MeshCoreConnectionState.connected);
+ _pendingInitialChannelSync = true;
+ _appDebugLogService?.info(
+ 'connectUsb: requesting device info…',
+ tag: 'USB',
+ );
+ await _requestDeviceInfo();
+ _startBatteryPolling();
+ if (_radioStatsPollRefCount > 0) _startRadioStatsPolling();
+ var gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ if (!gotSelfInfo) {
+ _appDebugLogService?.warn(
+ 'connectUsb: SELF_INFO timeout, retrying…',
+ tag: 'USB',
+ );
+ await refreshDeviceInfo();
+ gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ }
+ if (!gotSelfInfo) {
+ throw StateError('Timed out waiting for SELF_INFO during connect');
+ }
+
+ _appDebugLogService?.info('connectUsb: syncing time…', tag: 'USB');
+ await syncTime();
+ _appDebugLogService?.info('connectUsb: complete', tag: 'USB');
+ } catch (error) {
+ _appDebugLogService?.error('USB connection error: $error', tag: 'USB');
+ await disconnect(manual: false);
+ rethrow;
+ }
+ }
+
+ Future connectTcp({required String host, required int port}) async {
+ if (_state == MeshCoreConnectionState.connecting ||
+ _state == MeshCoreConnectionState.connected) {
+ _appDebugLogService?.warn(
+ 'connectTcp ignored: already $_state',
+ tag: 'TCP',
+ );
+ return;
+ }
+
+ _appDebugLogService?.info('connectTcp: endpoint=$host:$port', tag: 'TCP');
+
+ await stopScan();
+ _cancelReconnectTimer();
+ _manualDisconnect = false;
+ _resetConnectionHandshakeState();
+ _activeTransport = MeshCoreTransportType.tcp;
+ _setState(MeshCoreConnectionState.connecting);
+
+ try {
+ Future handleTcpConnectAbort({required String message}) async {
+ _appDebugLogService?.warn(message, tag: 'TCP');
+ final shouldResetState = shouldResetStateAfterTcpConnectAbort(
+ state: _state,
+ activeTransport: _activeTransport,
+ );
+ if (shouldResetState) {
+ await disconnect(manual: false);
+ return;
+ }
+ if (_tcpConnector.isConnected) {
+ await _tcpConnector.disconnect();
+ }
+ }
+
+ await _tcpConnector.cancelFrameSubscription();
+ await _tcpConnector.connect(host: host, port: port);
+ final isTcpConnectCancelled =
+ _activeTransport != MeshCoreTransportType.tcp ||
+ _state != MeshCoreConnectionState.connecting ||
+ !_tcpConnector.isConnected;
+ if (isTcpConnectCancelled) {
+ await handleTcpConnectAbort(
+ message:
+ 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
+ );
+ return;
+ }
+ notifyListeners();
+
+ await Future.delayed(const Duration(milliseconds: 200));
+ final isTcpConnectCancelledAfterDelay =
+ _activeTransport != MeshCoreTransportType.tcp ||
+ _state != MeshCoreConnectionState.connecting ||
+ !_tcpConnector.isConnected;
+ if (isTcpConnectCancelledAfterDelay) {
+ await handleTcpConnectAbort(
+ message:
+ 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
+ );
+ return;
+ }
+ _tcpConnector.listenFrames(
+ onFrame: _handleFrame,
+ onError: (error, stackTrace) {
+ _appDebugLogService?.error('TCP transport error: $error', tag: 'TCP');
+ unawaited(disconnect(manual: false));
+ },
+ onDone: () {
+ _appDebugLogService?.warn('TCP frame stream ended', tag: 'TCP');
+ unawaited(disconnect(manual: false));
+ },
+ );
+
+ _setState(MeshCoreConnectionState.connected);
+ _pendingInitialChannelSync = true;
+ await _requestDeviceInfo();
+ _startBatteryPolling();
+ if (_radioStatsPollRefCount > 0) _startRadioStatsPolling();
+
+ var gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ if (!gotSelfInfo) {
+ await refreshDeviceInfo();
+ gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ }
+ if (!gotSelfInfo) {
+ throw StateError('Timed out waiting for SELF_INFO during TCP connect');
+ }
+
+ await syncTime();
+ } catch (error) {
+ _appDebugLogService?.error('TCP connection error: $error', tag: 'TCP');
+ final tcpConnectCancelledBeforeHandshake =
+ shouldIgnoreLateTcpConnectError(
+ manualDisconnect: _manualDisconnect,
+ state: _state,
+ activeTransport: _activeTransport,
+ tcpManagerConnected: _tcpConnector.isConnected,
+ );
+ if (tcpConnectCancelledBeforeHandshake) {
+ _appDebugLogService?.info(
+ 'Ignoring late TCP connect error after cancellation/switch: state=$_state transport=$_activeTransport',
+ tag: 'TCP',
+ );
+ return;
+ }
+ await disconnect(manual: false);
+ rethrow;
+ }
+ }
+
+ @visibleForTesting
+ static bool shouldIgnoreLateTcpConnectError({
+ required bool manualDisconnect,
+ required MeshCoreConnectionState state,
+ required MeshCoreTransportType activeTransport,
+ required bool tcpManagerConnected,
+ }) {
+ return manualDisconnect &&
+ (state == MeshCoreConnectionState.disconnected ||
+ state == MeshCoreConnectionState.disconnecting) &&
+ (activeTransport != MeshCoreTransportType.tcp || !tcpManagerConnected);
+ }
+
+ @visibleForTesting
+ static bool shouldResetStateAfterTcpConnectAbort({
+ required MeshCoreConnectionState state,
+ required MeshCoreTransportType activeTransport,
+ }) {
+ return state == MeshCoreConnectionState.connecting &&
+ activeTransport == MeshCoreTransportType.tcp;
+ }
+
+ Future connect(
+ BluetoothDevice device, {
+ String? displayName,
+ Future Function()? linuxPairingPinProvider,
+ }) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
return;
}
+ _activeTransport = MeshCoreTransportType.bluetooth;
+
await stopScan();
_setState(MeshCoreConnectionState.connecting);
_device = device;
@@ -647,31 +1593,215 @@ class MeshCoreConnector extends ChangeNotifier {
_lastDeviceDisplayName = _deviceDisplayName;
_manualDisconnect = false;
_cancelReconnectTimer();
+ _bleInitialSyncStarted = false;
+ if (PlatformInfo.isWeb) {
+ _resetConnectionHandshakeState();
+ }
unawaited(_backgroundService?.start());
notifyListeners();
try {
+ final connectLabel = _deviceDisplayName ?? _deviceId;
+ _appDebugLogService?.info(
+ 'Starting connect to $connectLabel',
+ tag: 'BLE Connect',
+ );
+ await _connectionSubscription?.cancel();
+ _connectionSubscription = null;
+ await _notifySubscription?.cancel();
+ _notifySubscription = null;
_connectionSubscription = device.connectionState.listen((state) {
- if (state == BluetoothConnectionState.disconnected) {
+ if (state == BluetoothConnectionState.disconnected && isConnected) {
_handleDisconnection();
}
});
- await device.connect(
- timeout: const Duration(seconds: 15),
- mtu: null,
- license: License.free,
- );
-
- // Request larger MTU for sending larger frames
- try {
- final mtu = await device.requestMtu(185);
- debugPrint('MTU set to: $mtu');
- } catch (e) {
- debugPrint('MTU request failed: $e, using default');
+ if (PlatformInfo.isLinux) {
+ final remoteId = device.remoteId.str;
+ _appDebugLogService?.info(
+ 'Linux pre-connect BlueZ disconnect for $remoteId',
+ tag: 'BLE Connect',
+ );
+ await _linuxBlePairingService.disconnectDevice(
+ remoteId,
+ onLog: (message) {
+ _appDebugLogService?.info(message, tag: 'BLE Pair');
+ },
+ );
}
- List services = await device.discoverServices();
+ final connectTimeout = PlatformInfo.isLinux
+ ? const Duration(seconds: 6)
+ : const Duration(seconds: 15);
+ _appDebugLogService?.info(
+ 'device.connect timeout set to ${connectTimeout.inSeconds}s',
+ tag: 'BLE Connect',
+ );
+ if (PlatformInfo.isLinux) {
+ Future attemptConnect() {
+ return device
+ .connect(
+ timeout: connectTimeout,
+ mtu: null,
+ license: License.free,
+ )
+ .timeout(
+ connectTimeout + const Duration(seconds: 2),
+ onTimeout: () {
+ throw TimeoutException(
+ 'Linux connect hard-timeout after ${connectTimeout.inSeconds + 2}s',
+ );
+ },
+ );
+ }
+
+ try {
+ await attemptConnect();
+ } catch (error) {
+ _appDebugLogService?.error(
+ 'device.connect() failure: $error',
+ tag: 'BLE Connect',
+ );
+ final remoteId = device.remoteId.str;
+ _appDebugLogService?.warn(
+ 'Linux immediate retry: forcing BlueZ disconnect before second connect attempt',
+ tag: 'BLE Connect',
+ );
+ await _linuxBlePairingService.disconnectDevice(
+ remoteId,
+ onLog: (message) {
+ _appDebugLogService?.info(message, tag: 'BLE Pair');
+ },
+ );
+ await Future.delayed(const Duration(milliseconds: 700));
+ try {
+ await attemptConnect();
+ _appDebugLogService?.info(
+ 'Linux immediate retry connect succeeded',
+ tag: 'BLE Connect',
+ );
+ } catch (retryError, retryStackTrace) {
+ Object finalConnectError = retryError;
+ StackTrace finalConnectStackTrace = retryStackTrace;
+ final retryErrorText = retryError.toString().toLowerCase();
+ final isAbortByLocal = retryErrorText.contains(
+ 'le-connection-abort-by-local',
+ );
+ var recoveredOnThirdAttempt = false;
+ if (isAbortByLocal) {
+ _appDebugLogService?.warn(
+ 'Linux immediate retry aborted by local stack; waiting and retrying once more',
+ tag: 'BLE Connect',
+ );
+ await Future.delayed(const Duration(milliseconds: 1200));
+ try {
+ await attemptConnect();
+ _appDebugLogService?.info(
+ 'Linux third-attempt connect succeeded after local abort',
+ tag: 'BLE Connect',
+ );
+ recoveredOnThirdAttempt = true;
+ } catch (thirdError, thirdStackTrace) {
+ finalConnectError = thirdError;
+ finalConnectStackTrace = thirdStackTrace;
+ _appDebugLogService?.error(
+ 'device.connect() third-attempt failure: $thirdError',
+ tag: 'BLE Connect',
+ );
+ }
+ }
+ if (!recoveredOnThirdAttempt) {
+ final recoveredByPairing = await _recoverLinuxConnectFailure(
+ device,
+ attemptConnect: attemptConnect,
+ onRequestPin: linuxPairingPinProvider,
+ );
+ if (recoveredByPairing) {
+ _appDebugLogService?.info(
+ 'Linux connect succeeded after pairing/trust recovery',
+ tag: 'BLE Connect',
+ );
+ } else {
+ _appDebugLogService?.error(
+ 'device.connect() retry failure: $finalConnectError',
+ tag: 'BLE Connect',
+ );
+ Error.throwWithStackTrace(
+ _wrapLinuxConnectStageError(finalConnectError),
+ finalConnectStackTrace,
+ );
+ }
+ }
+ }
+ }
+ } else {
+ try {
+ await device.connect(
+ timeout: connectTimeout,
+ mtu: null,
+ license: License.free,
+ );
+ } catch (error) {
+ _appDebugLogService?.error(
+ 'device.connect() failure: $error',
+ tag: 'BLE Connect',
+ );
+ rethrow;
+ }
+ }
+
+ if (PlatformInfo.isLinux) {
+ await _ensureLinuxBleBond(
+ device,
+ onRequestPin: linuxPairingPinProvider,
+ );
+ }
+
+ // Request larger MTU only where the platform path supports it.
+ if (!PlatformInfo.isWeb && !PlatformInfo.isLinux) {
+ try {
+ final mtu = await device.requestMtu(185);
+ _appDebugLogService?.info('MTU set to: $mtu', tag: 'BLE Connect');
+ } catch (e) {
+ _appDebugLogService?.warn(
+ 'MTU request failed: $e, using default',
+ tag: 'BLE Connect',
+ );
+ }
+ } else if (PlatformInfo.isLinux) {
+ _appDebugLogService?.info(
+ 'Skipping MTU request on Linux; flutter_blue_plus only supports requestMtu on Android',
+ tag: 'BLE Connect',
+ );
+ }
+
+ late final List services;
+ try {
+ services = await device.discoverServices();
+ } catch (error) {
+ _appDebugLogService?.error(
+ 'service discovery failure: $error',
+ tag: 'BLE Connect',
+ );
+ if (PlatformInfo.isWeb &&
+ error.toString().contains('GATT Server is disconnected')) {
+ // Chrome Web Bluetooth intermittently disconnects between connect()
+ // and service discovery; retry once to recover that transient state.
+ _appDebugLogService?.warn(
+ 'retrying service discovery after transient web disconnect',
+ tag: 'BLE Connect',
+ );
+ await Future.delayed(const Duration(milliseconds: 300));
+ await device.connect(
+ timeout: const Duration(seconds: 15),
+ mtu: null,
+ license: License.free,
+ );
+ services = await device.discoverServices();
+ } else {
+ rethrow;
+ }
+ }
BluetoothService? uartService;
for (var service in services) {
@@ -698,45 +1828,280 @@ class MeshCoreConnector extends ChangeNotifier {
throw Exception("MeshCore characteristics not found");
}
- // Retry setNotifyValue with increasing delays
- bool notifySet = false;
- for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
- try {
- if (attempt > 0) {
- await Future.delayed(Duration(milliseconds: 500 * attempt));
+ if (PlatformInfo.isWeb) {
+ _appDebugLogService?.info(
+ 'Starting setNotifyValue(true)',
+ tag: 'BLE Connect',
+ );
+ _appDebugLogService?.info(
+ 'Web: Calling setNotifyValue(true) without awaiting',
+ tag: 'BLE Connect',
+ );
+ unawaited(() async {
+ try {
+ await _txCharacteristic!.setNotifyValue(true);
+ } catch (error) {
+ _appDebugLogService?.warn(
+ 'notify failure (web, ignored): $error',
+ tag: 'BLE Connect',
+ );
+ _appDebugLogService?.warn(
+ 'Web setNotifyValue error (ignoring): $error',
+ tag: 'BLE Connect',
+ );
+ }
+ }());
+ _appDebugLogService?.info(
+ 'setNotifyValue(true) configuration completed',
+ tag: 'BLE Connect',
+ );
+ } else {
+ bool notifySet = false;
+ for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
+ try {
+ if (attempt > 0) {
+ await Future.delayed(Duration(milliseconds: 500 * attempt));
+ }
+ await _txCharacteristic!.setNotifyValue(true);
+ notifySet = true;
+ } catch (e) {
+ _appDebugLogService?.warn('notify failure: $e', tag: 'BLE Connect');
+ _appDebugLogService?.warn(
+ 'setNotifyValue attempt ${attempt + 1}/3 failed: $e',
+ tag: 'BLE Connect',
+ );
+ if (attempt == 2) rethrow;
}
- await _txCharacteristic!.setNotifyValue(true);
- notifySet = true;
- } catch (e) {
- debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
- if (attempt == 2) rethrow;
}
}
- _notifySubscription = _txCharacteristic!.onValueReceived.listen(_handleFrame);
+ _notifySubscription = _txCharacteristic!.onValueReceived.listen(
+ _handleFrame,
+ );
_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),
- );
- if (!gotSelfInfo) {
- await refreshDeviceInfo();
- await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ if (_shouldGateInitialChannelSync) {
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = true;
}
-
- // Keep device clock aligned on every connection.
- await syncTime();
+ await _startBleInitialSync();
} catch (e) {
- debugPrint("Connection error: $e");
- await disconnect(manual: false);
+ _appDebugLogService?.error('Connection error: $e', tag: 'BLE Connect');
+ final errorText = e.toString();
+ final lowerErrorText = errorText.toLowerCase();
+ final isLinuxPairingFailure =
+ PlatformInfo.isLinux && isLinuxBlePairingFailureText(errorText);
+ final isLikelyPairingTimeout = isLikelyLinuxBlePairingTimeoutText(
+ errorText,
+ );
+ final isConnectFailure = isLinuxBleConnectFailureText(errorText);
+ final isConnectTimeoutFailure =
+ isConnectFailure && lowerErrorText.contains('timed out');
+ final isLinuxConnectFailure = PlatformInfo.isLinux && isConnectFailure;
+ // Linux pairing failures should not enter auto-reconnect loops; user
+ // needs to retry manually so they can re-enter PIN / resolve pairing.
+ if (isLinuxPairingFailure) {
+ _appDebugLogService?.warn(
+ isLikelyPairingTimeout
+ ? 'Linux pairing timed out: stopping reconnect until user retries manually'
+ : 'Linux pairing failure: stopping reconnect until user retries manually',
+ tag: 'BLE Connect',
+ );
+ await disconnect(manual: true);
+ } else if (isLinuxConnectFailure) {
+ _appDebugLogService?.warn(
+ isConnectTimeoutFailure
+ ? 'Linux connect timeout: issuing BlueZ disconnect before reconnect'
+ : 'Linux connect failure: issuing BlueZ disconnect before reconnect',
+ tag: 'BLE Connect',
+ );
+ final remoteId = _device?.remoteId.str;
+ if (remoteId != null) {
+ await _linuxBlePairingService.disconnectDevice(
+ remoteId,
+ onLog: (message) {
+ _appDebugLogService?.info(message, tag: 'BLE Pair');
+ },
+ );
+ }
+ await disconnect(manual: false, skipBleDeviceDisconnect: true);
+ } else {
+ await disconnect(manual: false);
+ }
rethrow;
}
}
+ Future _recoverLinuxConnectFailure(
+ BluetoothDevice device, {
+ required Future Function() attemptConnect,
+ Future Function()? onRequestPin,
+ }) async {
+ if (!PlatformInfo.isLinux ||
+ !await _linuxBlePairingService.isBluetoothctlAvailable()) {
+ return false;
+ }
+ final remoteId = device.remoteId.str;
+ final pluginBondState = await _getLinuxPluginBondState(device);
+ final trustedByBluez = await _linuxBlePairingService.isPairedAndTrusted(
+ remoteId,
+ );
+ final needsBondRecovery =
+ (pluginBondState != null &&
+ pluginBondState != BmBondStateEnum.bonded) ||
+ !trustedByBluez;
+ if (!needsBondRecovery) {
+ return false;
+ }
+ _appDebugLogService?.warn(
+ pluginBondState == BmBondStateEnum.bonded
+ ? 'Linux connect failed with an untrusted bond; attempting trust/pair recovery'
+ : 'Linux connect failed before bond completed; attempting pairing fallback',
+ tag: 'BLE Connect',
+ );
+ await _ensureLinuxBleBond(device, onRequestPin: onRequestPin);
+ _appDebugLogService?.info(
+ 'Resetting BlueZ connection after Linux pairing/trust recovery',
+ tag: 'BLE Connect',
+ );
+ await _linuxBlePairingService.disconnectDevice(
+ remoteId,
+ onLog: (message) {
+ _appDebugLogService?.info(message, tag: 'BLE Pair');
+ },
+ );
+ await Future.delayed(const Duration(milliseconds: 700));
+ try {
+ await attemptConnect();
+ } catch (error, stackTrace) {
+ Error.throwWithStackTrace(_wrapLinuxConnectStageError(error), stackTrace);
+ }
+ return true;
+ }
+
+ Object _wrapLinuxConnectStageError(Object error) {
+ final errorText = error.toString();
+ if (errorText.toLowerCase().contains(linuxConnectStageFailureMarker)) {
+ return error;
+ }
+ return StateError('Linux connect stage failure: $error');
+ }
+
+ Future _getLinuxPluginBondState(
+ BluetoothDevice device,
+ ) async {
+ try {
+ final response = await FlutterBluePlusPlatform.instance.getBondState(
+ BmBondStateRequest(remoteId: device.remoteId),
+ );
+ return response.bondState;
+ } catch (error) {
+ _appDebugLogService?.warn(
+ 'Linux getBondState unavailable for ${device.remoteId.str}: $error',
+ tag: 'BLE Connect',
+ );
+ return null;
+ }
+ }
+
+ Future _ensureLinuxBleBond(
+ BluetoothDevice device, {
+ Future Function()? onRequestPin,
+ }) async {
+ final remoteId = device.remoteId.str;
+ final bluetoothctlAvailable = await _linuxBlePairingService
+ .isBluetoothctlAvailable();
+ final beforeBondState = await _getLinuxPluginBondState(device);
+ if (!bluetoothctlAvailable) {
+ if (beforeBondState == BmBondStateEnum.bonded) {
+ _appDebugLogService?.warn(
+ 'bluetoothctl unavailable; continuing with plugin bonded state',
+ tag: 'BLE Connect',
+ );
+ } else if (beforeBondState == null) {
+ _appDebugLogService?.warn(
+ 'bluetoothctl unavailable and plugin bond state is unknown; skipping Linux pairing fallback',
+ tag: 'BLE Connect',
+ );
+ } else {
+ _appDebugLogService?.warn(
+ 'bluetoothctl unavailable and device is not bonded; skipping Linux pairing fallback',
+ tag: 'BLE Connect',
+ );
+ }
+ return;
+ }
+
+ final trustedByBluez = await _linuxBlePairingService.isPairedAndTrusted(
+ remoteId,
+ );
+ if (trustedByBluez) {
+ _appDebugLogService?.info(
+ 'Linux BLE device already paired/trusted, skipping pairing flow',
+ tag: 'BLE Connect',
+ );
+ return;
+ }
+
+ if (beforeBondState == BmBondStateEnum.bonded && !trustedByBluez) {
+ _appDebugLogService?.warn(
+ 'Linux BLE device is bonded but not trusted in BlueZ; repairing trust',
+ tag: 'BLE Connect',
+ );
+ final trustRepaired = await _linuxBlePairingService.trustDevice(
+ remoteId,
+ onLog: (message) {
+ _appDebugLogService?.info(message, tag: 'BLE Pair');
+ },
+ );
+ if (trustRepaired) {
+ _appDebugLogService?.info(
+ 'Linux BLE trust repair succeeded without re-pairing',
+ tag: 'BLE Connect',
+ );
+ return;
+ }
+ _appDebugLogService?.warn(
+ 'Linux BLE trust repair did not stick; retrying pairing flow',
+ tag: 'BLE Connect',
+ );
+ }
+
+ _appDebugLogService?.info(
+ beforeBondState == BmBondStateEnum.bonded
+ ? 'Linux BLE device still untrusted after repair; requesting pair'
+ : beforeBondState == null
+ ? 'Linux BLE device bond state unknown; requesting pair'
+ : 'Linux BLE device not bonded, requesting pair',
+ tag: 'BLE Connect',
+ );
+ final paired = await _linuxBlePairingService.pairAndTrust(
+ remoteId: remoteId,
+ onLog: (message) {
+ _appDebugLogService?.info(message, tag: 'BLE Pair');
+ },
+ onRequestPin: onRequestPin,
+ );
+ if (!paired) {
+ throw StateError('Linux pairing fallback failed');
+ }
+
+ final afterBondState = await _getLinuxPluginBondState(device);
+ if (afterBondState != null && afterBondState != BmBondStateEnum.bonded) {
+ throw StateError('Linux BLE pairing did not complete');
+ } else if (afterBondState == null) {
+ _appDebugLogService?.warn(
+ 'Linux plugin bond state unavailable after pairing; relying on BlueZ trust verification',
+ tag: 'BLE Connect',
+ );
+ }
+ final trustedAfter = await _linuxBlePairingService.isPairedAndTrusted(
+ remoteId,
+ );
+ if (!trustedAfter) {
+ throw StateError('Linux BLE trust repair did not complete');
+ }
+ }
+
Future _waitForSelfInfo({required Duration timeout}) async {
if (_selfPublicKey != null) return true;
if (!isConnected) return false;
@@ -768,8 +2133,57 @@ class MeshCoreConnector extends ChangeNotifier {
return result;
}
+ Future _startBleInitialSync() async {
+ if (_bleInitialSyncStarted ||
+ !isConnected ||
+ _activeTransport != MeshCoreTransportType.bluetooth) {
+ return;
+ }
+ _bleInitialSyncStarted = true;
+
+ await _requestDeviceInfo();
+ _startBatteryPolling();
+ if (_radioStatsPollRefCount > 0) _startRadioStatsPolling();
+
+ final gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ if (!gotSelfInfo) {
+ await refreshDeviceInfo();
+ await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ }
+
+ await syncTime();
+ unawaited(getChannels());
+ }
+
+ void _resetConnectionHandshakeState() {
+ _selfPublicKey = null;
+ _selfName = null;
+ _selfLatitude = null;
+ _selfLongitude = null;
+ _awaitingSelfInfo = false;
+ _webInitialHandshakeRequestSent = false;
+ _selfInfoRetryTimer?.cancel();
+ _selfInfoRetryTimer = null;
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
+ _bleInitialSyncStarted = false;
+ _pendingDeferredChannelSyncAfterContacts = false;
+ _pathHashByteWidth = 1;
+ }
+
bool get _shouldAutoReconnect =>
- !_manualDisconnect && _lastDeviceId != null;
+ !_manualDisconnect &&
+ _lastDeviceId != null &&
+ _activeTransport == MeshCoreTransportType.bluetooth;
+
+ bool get _shouldGateInitialChannelSync =>
+ _activeTransport == MeshCoreTransportType.usb ||
+ _activeTransport == MeshCoreTransportType.tcp ||
+ (_activeTransport == MeshCoreTransportType.bluetooth &&
+ PlatformInfo.isWeb);
void _cancelReconnectTimer() {
_reconnectTimer?.cancel();
@@ -796,7 +2210,8 @@ class MeshCoreConnector extends ChangeNotifier {
return;
}
- final device = _lastDevice ??
+ final device =
+ _lastDevice ??
(_lastDeviceId == null
? null
: BluetoothDevice.fromId(_lastDeviceId!));
@@ -810,8 +2225,22 @@ class MeshCoreConnector extends ChangeNotifier {
});
}
- Future disconnect({bool manual = true}) async {
+ Future disconnect({
+ bool manual = true,
+ bool skipBleDeviceDisconnect = false,
+ }) async {
if (_state == MeshCoreConnectionState.disconnecting) return;
+ final transportAtDisconnect = _activeTransport;
+ final transportLabel = switch (transportAtDisconnect) {
+ MeshCoreTransportType.bluetooth => 'BLE',
+ MeshCoreTransportType.usb => 'USB',
+ MeshCoreTransportType.tcp => 'TCP',
+ };
+
+ _appDebugLogService?.info(
+ 'Starting disconnect transport=$transportLabel manual=$manual',
+ tag: 'Connection',
+ );
if (manual) {
_manualDisconnect = true;
@@ -821,9 +2250,13 @@ class MeshCoreConnector extends ChangeNotifier {
_manualDisconnect = false;
}
_setState(MeshCoreConnectionState.disconnecting);
+ _stopBatteryPolling();
+ _stopRadioStatsPolling();
- // Disable wake lock when disconnecting
- await WakelockPlus.disable();
+ await _usbFrameSubscription?.cancel();
+ _usbFrameSubscription = null;
+ await _usbManager.disconnect();
+ await _tcpConnector.disconnect();
await _notifySubscription?.cancel();
_notifySubscription = null;
@@ -838,12 +2271,20 @@ class MeshCoreConnector extends ChangeNotifier {
_channelSyncTimeout?.cancel();
_channelSyncTimeout = null;
_channelSyncRetries = 0;
+ await _translationService?.releaseModel();
- try {
- // Skip queued BLE operations so disconnect doesn't get stuck behind them.
- await _device?.disconnect(queue: false);
- } catch (e) {
- debugPrint("Disconnect error: $e");
+ if (!skipBleDeviceDisconnect) {
+ try {
+ // Skip queued BLE operations so disconnect doesn't get stuck behind them.
+ await _device?.disconnect(queue: false);
+ } catch (e) {
+ _appDebugLogService?.warn('Disconnect error: $e', tag: 'BLE Connect');
+ }
+ } else {
+ _appDebugLogService?.info(
+ 'Skipping plugin BLE disconnect and continuing cleanup',
+ tag: 'BLE Connect',
+ );
}
_device = null;
@@ -852,15 +2293,23 @@ class MeshCoreConnector extends ChangeNotifier {
_deviceDisplayName = null;
_deviceId = null;
_contacts.clear();
+ _discoveredContacts.clear();
_conversations.clear();
_loadedConversationKeys.clear();
_selfPublicKey = null;
_selfName = null;
_selfLatitude = null;
_selfLongitude = null;
+ _clientRepeat = null;
+ _rememberedNonRepeatRadioState = null;
+ _firmwareVerCode = null;
_batteryMillivolts = null;
+ _repeaterBatterySnapshots.clear();
_batteryRequested = false;
_awaitingSelfInfo = false;
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
@@ -869,31 +2318,61 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingQueueSync = false;
_isSyncingChannels = false;
_channelSyncInFlight = false;
+ _hasLoadedChannels = false;
+ _pendingChannelSentQueue.clear();
+ _pendingGenericAckQueue.clear();
+ _reactionSendQueueSequence = 0;
+
+ _activeTransport = MeshCoreTransportType.bluetooth;
_setState(MeshCoreConnectionState.disconnected);
- if (!manual) {
+ _appDebugLogService?.info(
+ 'Disconnect complete transport=$transportLabel manual=$manual',
+ tag: 'Connection',
+ );
+ if (!manual && transportAtDisconnect == MeshCoreTransportType.bluetooth) {
_scheduleReconnect();
}
}
- Future sendFrame(Uint8List data) async {
- if (!isConnected || _rxCharacteristic == null) {
+ Future sendFrame(
+ Uint8List data, {
+ String? channelSendQueueId,
+ bool expectsGenericAck = false,
+ }) async {
+ if (!isConnected) {
throw Exception("Not connected to a MeshCore device");
}
-
_bleDebugLogService?.logFrame(data, outgoing: true);
- // Prefer write without response when supported; fall back to write with response.
- final properties = _rxCharacteristic!.properties;
- final canWriteWithoutResponse = properties.writeWithoutResponse;
- final canWriteWithResponse = properties.write;
- if (!canWriteWithoutResponse && !canWriteWithResponse) {
- throw Exception("MeshCore RX characteristic does not support write");
+ if (_activeTransport == MeshCoreTransportType.usb) {
+ await _usbManager.write(data);
+ // Brief pause so the device firmware can process each frame before the
+ // next arrives. Without this, rapid-fire frames over USB can cause the
+ // device to miss responses (especially on reconnect).
+ await Future.delayed(const Duration(milliseconds: 10));
+ } else if (_activeTransport == MeshCoreTransportType.tcp) {
+ await _tcpConnector.write(data);
+ } else {
+ if (_rxCharacteristic == null) {
+ throw Exception("MeshCore RX characteristic not available");
+ }
+ // Prefer write without response when supported; fall back to write with response.
+ final properties = _rxCharacteristic!.properties;
+ final canWriteWithoutResponse = properties.writeWithoutResponse;
+ final canWriteWithResponse = properties.write;
+ if (!canWriteWithoutResponse && !canWriteWithResponse) {
+ throw Exception("MeshCore RX characteristic does not support write");
+ }
+ await _rxCharacteristic!.write(
+ data.toList(),
+ withoutResponse: canWriteWithoutResponse,
+ );
}
-
- await _rxCharacteristic!.write(
- data.toList(),
- withoutResponse: canWriteWithoutResponse,
+ _trackPendingGenericAck(
+ data,
+ channelSendQueueId: channelSendQueueId,
+ expectsGenericAck: expectsGenericAck,
);
}
@@ -904,40 +2383,167 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildGetBattAndStorageFrame());
}
+ void _startBatteryPolling() {
+ _batteryPollTimer?.cancel();
+ _batteryPollTimer = Timer.periodic(_batteryPollInterval, (timer) {
+ if (!isConnected) {
+ timer.cancel();
+ return;
+ }
+ unawaited(requestBatteryStatus(force: true));
+ });
+ }
+
+ void _stopBatteryPolling() {
+ _batteryPollTimer?.cancel();
+ _batteryPollTimer = null;
+ }
+
+ void setPollingInterval(int i) {
+ _pollingInterval = i.clamp(1, 60);
+ if (isConnected) {
+ _startRadioStatsPolling();
+ }
+ }
+
+ void _startRadioStatsPolling() {
+ _radioStatsPollTimer?.cancel();
+ _radioStatsPollTimer = Timer.periodic(Duration(seconds: _pollingInterval), (
+ _,
+ ) {
+ if (!isConnected) {
+ _stopRadioStatsPolling();
+ return;
+ }
+ unawaited(requestRadioStats());
+ });
+ }
+
+ void _stopRadioStatsPolling() {
+ _radioStatsPollTimer?.cancel();
+ _radioStatsPollTimer = null;
+ }
+
+ void acquireRadioStatsPolling() {
+ _radioStatsPollRefCount++;
+ if (_radioStatsPollRefCount == 1 && isConnected) {
+ _startRadioStatsPolling();
+ }
+ }
+
+ void releaseRadioStatsPolling() {
+ _radioStatsPollRefCount = (_radioStatsPollRefCount - 1).clamp(0, 999);
+ if (_radioStatsPollRefCount == 0) {
+ _stopRadioStatsPolling();
+ }
+ }
+
+ Future requestRadioStats() async {
+ if (!isConnected) return;
+ if (!supportsCompanionRadioStats) return;
+ try {
+ await sendFrame(buildGetStatsFrame(statsTypeRadio));
+ } catch (_) {}
+ }
+
+ Future setPathHashMode(int mode) async {
+ if (!isConnected) return;
+ await sendFrame(buildSetPathHashModeFrame(mode.clamp(0, 2)));
+ }
+
Future refreshDeviceInfo() async {
if (!isConnected) return;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _webInitialHandshakeRequestSent &&
+ _selfPublicKey == null) {
+ return;
+ }
_awaitingSelfInfo = true;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _selfPublicKey == null) {
+ _webInitialHandshakeRequestSent = true;
+ }
await sendFrame(buildDeviceQueryFrame());
await sendFrame(buildAppStartFrame());
await requestBatteryStatus(force: true);
- await sendFrame(buildGetRadioSettingsFrame());
+ await sendFrame(buildGetCustomVarsFrame());
+ await sendFrame(buildGetAutoAddFlagsFrame());
+
_scheduleSelfInfoRetry();
}
Future _requestDeviceInfo() async {
+ if (!isConnected || _awaitingSelfInfo) return;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _webInitialHandshakeRequestSent &&
+ _selfPublicKey == null) {
+ return;
+ }
_awaitingSelfInfo = true;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _selfPublicKey == null) {
+ _webInitialHandshakeRequestSent = true;
+ }
await sendFrame(buildDeviceQueryFrame());
await sendFrame(buildAppStartFrame());
+ await sendFrame(buildGetCustomVarsFrame());
await requestBatteryStatus();
-
+ await sendFrame(buildGetAutoAddFlagsFrame());
_scheduleSelfInfoRetry();
}
void _scheduleSelfInfoRetry() {
_selfInfoRetryTimer?.cancel();
- _selfInfoRetryTimer = Timer.periodic(
- const Duration(milliseconds: 3500),
- (timer) {
- if (!isConnected) {
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth) {
+ var attempts = 0;
+ const maxAttempts = 3;
+ _selfInfoRetryTimer = Timer.periodic(const Duration(seconds: 10), (
+ timer,
+ ) {
+ if (!isConnected || !_awaitingSelfInfo) {
timer.cancel();
return;
}
- if (!_awaitingSelfInfo) {
- timer.cancel();
+ if (_isLoadingContacts || _isSyncingChannels || _channelSyncInFlight) {
return;
}
+ attempts += 1;
unawaited(sendFrame(buildAppStartFrame()));
- },
+ if (attempts >= maxAttempts) {
+ timer.cancel();
+ }
+ });
+ return;
+ }
+ _selfInfoRetryTimer = Timer.periodic(const Duration(milliseconds: 3500), (
+ timer,
+ ) {
+ if (!isConnected) {
+ timer.cancel();
+ return;
+ }
+ if (!_awaitingSelfInfo) {
+ timer.cancel();
+ return;
+ }
+ unawaited(sendFrame(buildAppStartFrame()));
+ });
+ }
+
+ Contact getFromDiscovered(Contact contact) {
+ final tmp = _discoveredContacts.firstWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ orElse: () => contact,
+ );
+ return contact.copyWith(
+ rawPacket: tmp.rawPacket,
+ latitude: tmp.latitude,
+ longitude: tmp.longitude,
);
}
@@ -959,10 +2565,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
Future refreshContactsSinceLastmod() async {
- await getContacts(
- since: _latestContactLastmod(),
- preserveExisting: true,
- );
+ await getContacts(since: _latestContactLastmod(), preserveExisting: true);
}
Future getContactByKey(Uint8List pubKey) async {
@@ -972,56 +2575,64 @@ class MeshCoreConnector extends ChangeNotifier {
Future sendMessage(
Contact contact,
- String text,
- ) async {
+ String text, {
+ String? originalText,
+ String? translatedLanguageCode,
+ String? translationModelId,
+ }) async {
if (!isConnected || text.isEmpty) return;
- // Handle auto-rotation if enabled
- PathSelection? autoSelection;
- if (_appSettingsService?.settings.autoRouteRotationEnabled == true) {
- autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex);
- if (autoSelection != null) {
- _pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection);
- if (!autoSelection.useFlood && autoSelection.pathBytes.isNotEmpty) {
- await setContactPath(
- contact,
- Uint8List.fromList(autoSelection.pathBytes),
- autoSelection.pathBytes.length,
- );
- }
+ // Check if this is a reaction - apply locally with pending status and route through retry service
+ final reactionInfo = ReactionHelper.parseReaction(text);
+ if (reactionInfo != null) {
+ _conversations.putIfAbsent(contact.publicKeyHex, () => []);
+ final messages = _conversations[contact.publicKeyHex]!;
+
+ // Apply reaction locally with pending status
+ _processOutgoingContactReaction(messages, reactionInfo, contact);
+ _setReactionStatus(
+ contact.publicKeyHex,
+ reactionInfo,
+ MessageStatus.pending,
+ );
+ _messageStore.saveMessages(contact.publicKeyHex, messages);
+ notifyListeners();
+
+ // Route through retry service (same as normal messages)
+ // Don't use auto-rotation for reactions — just send directly
+ if (_retryService != null) {
+ _retryService!.sendMessageWithRetry(contact: contact, text: text);
+ } else {
+ final outboundText = prepareContactOutboundText(contact, text);
+ await sendFrame(buildSendTextMsgFrame(contact.publicKey, outboundText));
}
+ return;
}
if (_retryService != null) {
- final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
- final pathLength = _resolveOutgoingPathLength(contact, autoSelection);
- final selectedContact = _applyAutoSelection(contact, autoSelection);
await _retryService!.sendMessageWithRetry(
- contact: selectedContact,
+ contact: contact,
text: text,
- pathSelection: autoSelection,
- pathBytes: pathBytes,
- pathLength: pathLength,
+ originalText: originalText,
+ translatedLanguageCode: translatedLanguageCode,
+ translationModelId: translationModelId,
);
} else {
// Fallback to old behavior if retry service not initialized
- final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
- final pathLength = _resolveOutgoingPathLength(contact, autoSelection);
+ final resolved = resolvePathSelection(contact);
final message = Message.outgoing(
contact.publicKey,
text,
- pathLength: pathLength,
- pathBytes: pathBytes,
+ pathLength: resolved.useFlood ? -1 : resolved.hopCount,
+ pathBytes: Uint8List.fromList(resolved.pathBytes),
+ originalText: originalText,
+ translatedLanguageCode: translatedLanguageCode,
+ translationModelId: translationModelId,
);
_addMessage(contact.publicKeyHex, message);
notifyListeners();
final outboundText = prepareContactOutboundText(contact, text);
- await sendFrame(
- buildSendTextMsgFrame(
- contact.publicKey,
- outboundText,
- ),
- );
+ await sendFrame(buildSendTextMsgFrame(contact.publicKey, outboundText));
}
}
@@ -1030,15 +2641,132 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List customPath,
int pathLen,
) async {
- if (!isConnected) return;
+ // Serialize path operations to prevent interleaved async calls from
+ // leaving in-memory state inconsistent with the device.
+ final prev = _pathOpLock;
+ final completer = Completer();
+ _pathOpLock = completer.future;
+ await prev;
+ try {
+ if (!isConnected) return;
- await sendFrame(buildUpdateContactPathFrame(
- contact.publicKey,
- customPath,
- pathLen,
- type: contact.type,
- name: contact.name,
- ));
+ await sendFrame(
+ buildUpdateContactPathFrame(
+ contact.publicKey,
+ customPath,
+ pathLen,
+ type: contact.type,
+ flags: contact.flags,
+ name: contact.name,
+ ),
+ );
+ // USB writes return instantly (no BLE flow control), so give the firmware
+ // time to persist the path change before subsequent commands.
+ if (_activeTransport == MeshCoreTransportType.usb) {
+ await Future.delayed(const Duration(milliseconds: 100));
+ }
+ final idx = _contacts.indexWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
+ if (idx != -1) {
+ _contacts[idx] = _contacts[idx].copyWith(
+ pathLength: customPath.length,
+ path: customPath,
+ );
+ notifyListeners();
+ }
+ } finally {
+ completer.complete();
+ }
+ }
+
+ Future setContactFlags(
+ Contact contact, {
+ bool? isFavorite,
+ bool? teleBase,
+ bool? teleLoc,
+ bool? teleEnv,
+ }) async {
+ if (!isConnected) return;
+ final latestContact =
+ await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact;
+ int updatedFlags = isFavorite != null
+ ? (isFavorite
+ ? (latestContact.flags | contactFlagFavorite)
+ : (latestContact.flags & ~contactFlagFavorite))
+ : latestContact.flags;
+ updatedFlags = teleBase != null
+ ? (teleBase
+ ? (updatedFlags | contactFlagTeleBase)
+ : (updatedFlags & ~contactFlagTeleBase))
+ : updatedFlags;
+ updatedFlags = teleLoc != null
+ ? (teleLoc
+ ? (updatedFlags | contactFlagTeleLoc)
+ : (updatedFlags & ~contactFlagTeleLoc))
+ : updatedFlags;
+ updatedFlags = teleEnv != null
+ ? (teleEnv
+ ? (updatedFlags | contactFlagTeleEnv)
+ : (updatedFlags & ~contactFlagTeleEnv))
+ : updatedFlags;
+
+ await sendFrame(
+ buildUpdateContactPathFrame(
+ latestContact.publicKey,
+ latestContact.path,
+ latestContact.pathLength,
+ type: latestContact.type,
+ flags: updatedFlags,
+ name: latestContact.name,
+ ),
+ );
+
+ final index = _contacts.indexWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
+ if (index >= 0) {
+ _contacts[index] = _contacts[index].copyWith(
+ type: latestContact.type,
+ name: latestContact.name,
+ pathLength: latestContact.pathLength,
+ path: latestContact.path,
+ flags: updatedFlags,
+ );
+ notifyListeners();
+ unawaited(_persistContacts());
+ }
+ }
+
+ Future _fetchContactSnapshotFromDevice(
+ Uint8List pubKey, {
+ Duration timeout = const Duration(seconds: 3),
+ }) async {
+ if (!isConnected) return null;
+ final expectedKeyHex = pubKeyToHex(pubKey);
+ final completer = Completer();
+
+ void finish(Contact? result) {
+ if (!completer.isCompleted) {
+ completer.complete(result);
+ }
+ }
+
+ final subscription = receivedFrames.listen((frame) {
+ if (frame.isEmpty || frame[0] != respCodeContact) return;
+ final parsed = Contact.fromFrame(frame);
+ if (parsed == null || parsed.publicKeyHex != expectedKeyHex) return;
+ finish(parsed);
+ });
+
+ final timer = Timer(timeout, () => finish(null));
+ try {
+ await getContactByKey(pubKey);
+ return await completer.future;
+ } finally {
+ timer.cancel();
+ await subscription.cancel();
+ }
}
/// Set path override for a contact (persists across contact refreshes)
@@ -1048,16 +2776,27 @@ class MeshCoreConnector extends ChangeNotifier {
int? pathLen,
Uint8List? pathBytes,
}) async {
- appLogger.info('setPathOverride called for ${contact.name}: pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}', tag: 'Connector');
+ appLogger.info(
+ 'setPathOverride called for ${contact.name}: pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}',
+ tag: 'Connector',
+ );
// Find contact in list
- final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
+ final index = _contacts.indexWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
if (index == -1) {
- appLogger.warn('setPathOverride: Contact not found in list: ${contact.name}', tag: 'Connector');
+ appLogger.warn(
+ 'setPathOverride: Contact not found in list: ${contact.name}',
+ tag: 'Connector',
+ );
return;
}
- appLogger.info('Found contact at index $index. Current override: ${_contacts[index].pathOverride}', tag: 'Connector');
+ appLogger.info(
+ 'Found contact at index $index. Current override: ${_contacts[index].pathOverride}',
+ tag: 'Connector',
+ );
// Update contact with new path override
_contacts[index] = _contacts[index].copyWith(
@@ -1066,12 +2805,18 @@ class MeshCoreConnector extends ChangeNotifier {
clearPathOverride: pathLen == null, // Clear if pathLen is null
);
- appLogger.info('Updated contact. New override: ${_contacts[index].pathOverride}, bytesLen: ${_contacts[index].pathOverrideBytes?.length}', tag: 'Connector');
+ appLogger.info(
+ 'Updated contact. New override: ${_contacts[index].pathOverride}, bytesLen: ${_contacts[index].pathOverrideBytes?.length}',
+ tag: 'Connector',
+ );
// Save to storage
await _contactStore.saveContacts(_contacts);
appLogger.info('Saved contacts to storage', tag: 'Connector');
+ // Update any in-flight retries so they use the new path override
+ _retryService?.updatePendingContact(_contacts[index]);
+
// If setting a specific path (not flood, not auto), also sync with device
if (pathLen != null && pathLen >= 0 && pathBytes != null) {
appLogger.info('Sending path to device...', tag: 'Connector');
@@ -1079,7 +2824,9 @@ class MeshCoreConnector extends ChangeNotifier {
appLogger.info('Path sent to device', tag: 'Connector');
}
- debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}');
+ debugPrint(
+ 'Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}',
+ );
notifyListeners();
}
@@ -1088,27 +2835,27 @@ class MeshCoreConnector extends ChangeNotifier {
final autoRotationEnabled =
_appSettingsService?.settings.autoRouteRotationEnabled == true;
if (autoRotationEnabled && contact.pathOverride == null) {
- autoSelection = _pathHistoryService?.getNextAutoPathSelection(
+ final maxRetries = _appSettingsService?.settings.maxMessageRetries ?? 5;
+ autoSelection = _selectAutoPathForAttempt(
contact.publicKeyHex,
+ attemptIndex: 0,
+ maxRetries: maxRetries,
);
- if (autoSelection != null) {
- _pathHistoryService?.recordPathAttempt(
- contact.publicKeyHex,
- autoSelection,
- );
- }
}
- final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
- final pathLength = _resolveOutgoingPathLength(contact, autoSelection) ?? -1;
+ final resolved = resolvePathSelection(contact, selection: autoSelection);
- if (pathLength < 0) {
+ if (resolved.useFlood) {
await clearContactPath(contact);
} else {
- await setContactPath(contact, pathBytes, pathLength);
+ await setContactPath(
+ contact,
+ Uint8List.fromList(resolved.pathBytes),
+ resolved.hopCount,
+ );
}
- return _selectionFromPath(pathLength, pathBytes);
+ return resolved;
}
void trackRepeaterAck({
@@ -1128,7 +2875,7 @@ class MeshCoreConnector extends ChangeNotifier {
outboundText,
selfKey,
);
- final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
+ final ackHashHex = ackHashToHex(ackHash);
final messageBytes = utf8.encode(outboundText).length;
_pendingRepeaterAcks[ackHashHex]?.timeout?.cancel();
_pendingRepeaterAcks[ackHashHex] = _RepeaterAckContext(
@@ -1186,7 +2933,13 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
- Future sendChannelMessage(Channel channel, String text) async{
+ Future sendChannelMessage(
+ Channel channel,
+ String text, {
+ String? originalText,
+ String? translatedLanguageCode,
+ String? translationModelId,
+ }) async {
if (!isConnected || text.isEmpty) return;
// Check if this is a reaction - if so, process it immediately instead of adding as a message
@@ -1194,10 +2947,12 @@ class MeshCoreConnector extends ChangeNotifier {
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;
+ final reactionIdentifier =
+ '${reactionInfo.targetHash}_${reactionInfo.emoji}';
- if (reactionIdentifier != null && _processedChannelReactions[channel.index]!.contains(reactionIdentifier)) {
+ if (_processedChannelReactions[channel.index]!.contains(
+ reactionIdentifier,
+ )) {
// Already processed, don't process again
return;
}
@@ -1211,63 +2966,165 @@ class MeshCoreConnector extends ChangeNotifier {
await _channelMessageStore.saveChannelMessages(channel.index, messages);
// Mark this reaction as processed
- if (reactionIdentifier != null) {
- _processedChannelReactions[channel.index]!.add(reactionIdentifier);
- }
+ _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));
+ final reactionQueueId = _nextReactionSendQueueId();
+ _pendingChannelSentQueue.add(reactionQueueId);
+ await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
+ await sendFrame(
+ buildSendChannelTextMsgFrame(channel.index, text),
+ channelSendQueueId: reactionQueueId,
+ expectsGenericAck: true,
+ );
return;
}
- final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index);
+ final message = ChannelMessage.outgoing(
+ text,
+ _selfName ?? 'Me',
+ channel.index,
+ originalText: originalText,
+ translatedLanguageCode: translatedLanguageCode,
+ translationModelId: translationModelId,
+ );
_addChannelMessage(channel.index, message);
+ _pendingChannelSentQueue.add(message.messageId);
notifyListeners();
final trimmed = text.trim();
- final isStructuredPayload = trimmed.startsWith('g:') || trimmed.startsWith('m:');
- final outboundText = (isChannelSmazEnabled(channel.index) && !isStructuredPayload)
+ final isStructuredPayload =
+ trimmed.startsWith('g:') || trimmed.startsWith('m:');
+ final outboundText =
+ (isChannelSmazEnabled(channel.index) && !isStructuredPayload)
? Smaz.encodeIfSmaller(text)
: text;
- await sendFrame(buildSendChannelTextMsgFrame(channel.index, outboundText));
+ await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
+ await sendFrame(
+ buildSendChannelTextMsgFrame(channel.index, outboundText),
+ channelSendQueueId: message.messageId,
+ expectsGenericAck: true,
+ );
}
Future removeContact(Contact contact) async {
if (!isConnected) return;
+ _handleDiscovery(
+ contact,
+ contact.rawPacket ?? Uint8List(0),
+ noNotify: true,
+ );
+
await sendFrame(buildRemoveContactFrame(contact.publicKey));
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
_knownContactKeys.remove(contact.publicKeyHex);
unawaited(_persistContacts());
_conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex);
- _contactLastReadMs.remove(contact.publicKeyHex);
- _unreadStore.saveContactLastRead(
- Map.from(_contactLastReadMs),
+ _contactUnreadCount.remove(contact.publicKeyHex);
+ _unreadStore.saveContactUnreadCount(
+ Map.from(_contactUnreadCount),
);
_messageStore.clearMessages(contact.publicKeyHex);
notifyListeners();
}
- Future clearContactPath(Contact contact) async {
+ Future updateKnownDiscovered() async {
+ if (!isConnected) return;
+ for (int i = 0; i < _discoveredContacts.length; i++) {
+ _discoveredContacts[i] = _discoveredContacts[i].copyWith(
+ isActive: _knownContactKeys.contains(
+ _discoveredContacts[i].publicKeyHex,
+ ),
+ );
+ }
+ unawaited(_persistDiscoveredContacts());
+ notifyListeners();
+ }
+
+ Future removeDiscoveredContact(Contact contact) async {
+ if (!isConnected) return;
+ _discoveredContacts.removeWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
+ unawaited(_persistDiscoveredContacts());
+ notifyListeners();
+ }
+
+ Future importDiscoveredContact(Contact contact) async {
if (!isConnected) return;
- await sendFrame(buildResetPathFrame(contact.publicKey));
- final existingIndex =
- _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
- if (existingIndex >= 0) {
- final existing = _contacts[existingIndex];
- // Use copyWith to preserve pathOverride and pathOverrideBytes
- _contacts[existingIndex] = existing.copyWith(
- pathLength: -1,
- path: Uint8List(0),
- );
- notifyListeners();
- unawaited(_persistContacts());
+ await sendFrame(
+ buildUpdateContactPathFrame(
+ contact.publicKey,
+ contact.path,
+ contact.pathLength,
+ type: contact.type,
+ flags: contact.flags,
+ name: contact.name,
+ lat: contact.latitude,
+ lon: contact.longitude,
+ lastModified: contact.lastSeen,
+ ),
+ );
+
+ // Update the discovered contact to mark it as active (imported)
+ final discoveredIndex = _discoveredContacts.indexWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
+ if (discoveredIndex >= 0) {
+ _discoveredContacts[discoveredIndex] =
+ _discoveredContacts[discoveredIndex].copyWith(isActive: true);
+ }
+
+ _handleContactAdvert(
+ Contact(
+ publicKey: contact.publicKey,
+ name: contact.name,
+ type: contact.type,
+ pathLength: contact.pathLength,
+ path: contact.path,
+ latitude: contact.latitude,
+ longitude: contact.longitude,
+ lastSeen: DateTime.now(),
+ flags: contact.flags,
+ ),
+ );
+ notifyListeners();
+ }
+
+ Future clearContactPath(Contact contact) async {
+ // Serialize path operations to prevent interleaved async calls.
+ final prev = _pathOpLock;
+ final completer = Completer();
+ _pathOpLock = completer.future;
+ await prev;
+ try {
+ if (!isConnected) return;
+
+ await sendFrame(buildResetPathFrame(contact.publicKey));
+ if (_activeTransport == MeshCoreTransportType.usb) {
+ await Future.delayed(const Duration(milliseconds: 100));
+ }
+ final existingIndex = _contacts.indexWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
+ if (existingIndex >= 0) {
+ final existing = _contacts[existingIndex];
+ // Preserve pathOverride and pathOverrideBytes — only reset device path
+ _contacts[existingIndex] = existing.copyWith(
+ pathLength: -1,
+ path: Uint8List(0),
+ );
+ notifyListeners();
+ unawaited(_persistContacts());
+ }
+ } finally {
+ completer.complete();
}
- // The device will send updated contact info with path_len = -1
}
void updateContactInMemory(
@@ -1275,8 +3132,9 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List? pathBytes,
int? pathLength,
}) {
- final existingIndex =
- _contacts.indexWhere((c) => c.publicKeyHex == publicKeyHex);
+ final existingIndex = _contacts.indexWhere(
+ (c) => c.publicKeyHex == publicKeyHex,
+ );
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
_contacts[existingIndex] = existing.copyWith(
@@ -1324,7 +3182,9 @@ class MeshCoreConnector extends ChangeNotifier {
_handleQueueSyncTimeout();
});
- debugPrint('[QueueSync] Requesting next message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)');
+ debugPrint(
+ '[QueueSync] Requesting next message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)',
+ );
try {
await sendFrame(buildSyncNextMessageFrame());
@@ -1338,7 +3198,9 @@ class MeshCoreConnector extends ChangeNotifier {
}
void _handleQueueSyncTimeout() {
- debugPrint('[QueueSync] Timeout waiting for message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)');
+ debugPrint(
+ '[QueueSync] Timeout waiting for message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)',
+ );
if (_queueSyncRetries < _maxQueueSyncRetries) {
// Retry
@@ -1369,11 +3231,19 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildSetAdvertNameFrame(name));
}
- Future setNodeLocation({required double lat, required double lon}) async {
+ Future setNodeLocation({
+ required double lat,
+ required double lon,
+ }) async {
if (!isConnected) return;
await sendFrame(buildSetAdvertLatLonFrame(lat, lon));
}
+ Future setCustomVar(String value) async {
+ if (!isConnected) return;
+ await sendFrame(buildSetCustomVarFrame(value));
+ }
+
Future sendSelfAdvert({bool flood = true}) async {
if (!isConnected) return;
await sendFrame(buildSendSelfAdvertFrame(flood: flood));
@@ -1388,13 +3258,46 @@ class MeshCoreConnector extends ChangeNotifier {
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
}
- Future getChannels({int? maxChannels}) async {
+ Future setTelemetryModeBase(
+ int base,
+ int location,
+ int env,
+ int advert,
+ int multiAcks,
+ ) async {
+ if (!isConnected) return;
+ _telemetryModeBase = base.clamp(teleModeDeny, teleModeAllowAll).toInt();
+ _telemetryModeLoc = location.clamp(teleModeDeny, teleModeAllowAll).toInt();
+ _telemetryModeEnv = env.clamp(teleModeDeny, teleModeAllowAll).toInt();
+ _advertLocPolicy = advert.clamp(0, 1).toInt();
+ _multiAcks = multiAcks.clamp(0, 2).toInt();
+ await sendFrame(
+ buildSetOtherParamsFrame(
+ (_telemetryModeEnv << 4) |
+ (_telemetryModeLoc << 2) |
+ _telemetryModeBase,
+ _advertLocPolicy,
+ _multiAcks,
+ ),
+ );
+ notifyListeners();
+ }
+
+ Future getChannels({int? maxChannels, bool force = false}) async {
if (!isConnected) return;
if (_isSyncingChannels) {
debugPrint('[ChannelSync] Already syncing channels, ignoring request');
return;
}
+ // Skip fetching if already loaded and not forced
+ if (_hasLoadedChannels && !force) {
+ debugPrint(
+ '[ChannelSync] Channels already loaded, skipping fetch (use force=true to reload)',
+ );
+ return;
+ }
+
_isLoadingChannels = true;
_isSyncingChannels = true;
_previousChannelsCache = List.from(_channels);
@@ -1404,7 +3307,9 @@ class MeshCoreConnector extends ChangeNotifier {
_channelSyncRetries = 0;
notifyListeners();
- debugPrint('[ChannelSync] Starting sync for $_totalChannelsToRequest channels');
+ debugPrint(
+ '[ChannelSync] Starting sync for $_totalChannelsToRequest channels',
+ );
// Start sequential sync
await _requestNextChannel();
@@ -1436,7 +3341,9 @@ class MeshCoreConnector extends ChangeNotifier {
() => _handleChannelSyncTimeout(channelIndex),
);
- debugPrint('[ChannelSync] Requesting channel $channelIndex/$_totalChannelsToRequest (retry: $_channelSyncRetries/$_maxChannelSyncRetries)');
+ debugPrint(
+ '[ChannelSync] Requesting channel $channelIndex/$_totalChannelsToRequest (retry: $_channelSyncRetries/$_maxChannelSyncRetries)',
+ );
try {
await sendFrame(buildGetChannelFrame(channelIndex));
@@ -1448,7 +3355,9 @@ class MeshCoreConnector extends ChangeNotifier {
}
void _handleChannelSyncTimeout(int channelIndex) {
- debugPrint('[ChannelSync] Timeout waiting for channel $channelIndex (retry: $_channelSyncRetries/$_maxChannelSyncRetries)');
+ debugPrint(
+ '[ChannelSync] Timeout waiting for channel $channelIndex (retry: $_channelSyncRetries/$_maxChannelSyncRetries)',
+ );
if (_channelSyncRetries < _maxChannelSyncRetries) {
// Retry the same channel
@@ -1457,16 +3366,20 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_requestNextChannel());
} else {
// Max retries reached for this channel, restore from cache and move to next
- debugPrint('[ChannelSync] Max retries reached for channel $channelIndex, attempting cache restore');
+ debugPrint(
+ '[ChannelSync] Max retries reached for channel $channelIndex, attempting cache restore',
+ );
// Try to restore this channel from cache
try {
final cachedChannel = _previousChannelsCache.firstWhere(
- (c) => c.index == channelIndex
+ (c) => c.index == channelIndex,
);
if (!cachedChannel.isEmpty) {
_channels.add(cachedChannel);
- debugPrint('[ChannelSync] Restored channel $channelIndex (${cachedChannel.name}) from cache');
+ debugPrint(
+ '[ChannelSync] Restored channel $channelIndex (${cachedChannel.name}) from cache',
+ );
}
} catch (e) {
// No cached channel found, that's okay
@@ -1483,10 +3396,16 @@ class MeshCoreConnector extends ChangeNotifier {
void _completeChannelSync() {
_channelSyncTimeout?.cancel();
- debugPrint('[ChannelSync] Sync complete: received ${_channels.length}/$_totalChannelsToRequest channels');
+ debugPrint(
+ '[ChannelSync] Sync complete: received ${_channels.length}/$_totalChannelsToRequest channels',
+ );
_cleanupChannelSync(completed: true);
+ // Cache channels for offline use
+ _cachedChannels = List.from(_channels);
+ unawaited(_channelStore.saveChannels(_channels));
+
// Apply ordering and notify UI
_applyChannelOrder();
notifyListeners();
@@ -1502,8 +3421,17 @@ class MeshCoreConnector extends ChangeNotifier {
_totalChannelsToRequest = 0;
if (completed) {
+ _hasLoadedChannels = true;
_previousChannelsCache.clear();
}
+
+ // Fallback: if contact sync was deferred waiting for channel 0 but
+ // channel sync finished without triggering it, start contacts now.
+ if (_pendingInitialContactsSync && isConnected) {
+ _pendingInitialContactsSync = false;
+ unawaited(getContacts());
+ }
+
// Keep cache on failure/disconnection for future attempts
}
@@ -1512,7 +3440,7 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildSetChannelFrame(index, name, psk));
// Refresh channels after setting
- await getChannels();
+ await getChannels(force: true);
}
Future deleteChannel(int index) async {
@@ -1520,25 +3448,29 @@ class MeshCoreConnector extends ChangeNotifier {
// Delete by setting empty name and zero PSK
await sendFrame(buildSetChannelFrame(index, '', Uint8List(16)));
- _channelLastReadMs.remove(index);
- _unreadStore.saveChannelLastRead(
- Map.from(_channelLastReadMs),
- );
+ // Clear stored messages for this channel
+ await _channelMessageStore.clearChannelMessages(index);
+ // Clear in-memory messages for this channel
+ _channelMessages.remove(index);
// Refresh channels after deleting
- await getChannels();
+ await getChannels(force: true);
}
void _handleFrame(List data) {
if (data.isEmpty) return;
+ _lastRxTime = DateTime.now();
final frame = Uint8List.fromList(data);
_receivedFramesController.add(frame);
_bleDebugLogService?.logFrame(frame, outgoing: false);
final code = frame[0];
- debugPrint('RX frame: code=$code len=${frame.length}');
+ // debugPrint('RX frame: code=$code len=${frame.length}');
switch (code) {
+ case respCodeOk:
+ _handleOk();
+ break;
case respCodeDeviceInfo:
_handleDeviceInfo(frame);
break;
@@ -1554,6 +3486,14 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true;
notifyListeners();
break;
+ case pushCodeAdvert:
+ // Known contact was seen again - just a pub key, no action needed
+ break;
+ case pushCodeNewAdvert:
+ debugPrint('Got New CONTACT');
+ // It's the same format as respCodeContact, so we can reuse the handler
+ _handleContact(frame, isContact: false);
+ break;
case respCodeContact:
debugPrint('Got CONTACT');
_handleContact(frame);
@@ -1562,13 +3502,28 @@ class MeshCoreConnector extends ChangeNotifier {
debugPrint('Got END_OF_CONTACTS');
_isLoadingContacts = false;
_preserveContactsOnRefresh = false;
+ unawaited(updateKnownDiscovered());
notifyListeners();
unawaited(_persistContacts());
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _isSyncingChannels &&
+ !_channelSyncInFlight) {
+ unawaited(_requestNextChannel());
+ }
if (!_didInitialQueueSync || _pendingQueueSync) {
_didInitialQueueSync = true;
_pendingQueueSync = false;
unawaited(syncQueuedMessages(force: true));
}
+ if (_pendingDeferredChannelSyncAfterContacts &&
+ (_activeTransport == MeshCoreTransportType.bluetooth ||
+ _activeTransport == MeshCoreTransportType.usb ||
+ _activeTransport == MeshCoreTransportType.tcp)) {
+ _pendingDeferredChannelSyncAfterContacts = false;
+ _pendingInitialChannelSync = false;
+ unawaited(getChannels());
+ }
break;
case respCodeContactMsgRecv:
case respCodeContactMsgRecvV3:
@@ -1598,22 +3553,54 @@ class MeshCoreConnector extends ChangeNotifier {
case pushCodeStatusResponse:
break;
case pushCodeLogRxData:
+ _lastRadioRxTime = DateTime.now();
+ _handleRxData(frame);
_handleLogRxData(frame);
break;
case respCodeChannelInfo:
_handleChannelInfo(frame);
break;
- case respCodeRadioSettings:
- _handleRadioSettings(frame);
+ case respCodeAutoAddConfig:
+ _handleAutoAddConfig(frame);
+ _checkManualAddContacts();
break;
case respCodeBattAndStorage:
_handleBatteryAndStorage(frame);
break;
+ case respCodeStats:
+ _handleStatsFrame(frame);
+ break;
+ case respCodeCustomVars:
+ _handleCustomVars(frame);
+ break;
+ // RESP_CODE_ERR is a defined firmware response (code 1), not an unknown frame.
+ case respCodeErr:
+ _handleErrorFrame(frame);
+ break;
default:
debugPrint('Unknown frame code: $code');
}
}
+ void _handleErrorFrame(Uint8List frame) {
+ final errCode = frame.length > 1 ? frame[1] : -1;
+ _appDebugLogService?.warn(
+ 'Firmware responded with error code: $errCode',
+ tag: 'Protocol',
+ );
+
+ if (_pendingGenericAckQueue.isEmpty) {
+ return;
+ }
+
+ final failedAck = _pendingGenericAckQueue.removeAt(0);
+ if (failedAck.commandCode != cmdSendChannelTxtMsg ||
+ failedAck.channelSendQueueId == null) {
+ return;
+ }
+ _pendingChannelSentQueue.remove(failedAck.channelSendQueueId);
+ }
+
void _handlePathUpdated(Uint8List frame) {
// Frame format: [0]=code, [1-32]=pub_key
if (frame.length >= 33 && _pathHistoryService != null) {
@@ -1651,42 +3638,122 @@ class MeshCoreConnector extends ChangeNotifier {
// [56] = sf
// [57] = cr
// [58+] = node_name
- if (frame.length < 4 + pubKeySize) return;
+ final wasAwaitingSelfInfo = _awaitingSelfInfo;
+ final reader = BufferReader(frame);
+ try {
+ reader.skipBytes(2);
+ _currentTxPower = reader.readInt8();
+ _maxTxPower = reader.readInt8();
+ _selfPublicKey = reader.readBytes(pubKeySize);
+ _selfLatitude = reader.readInt32LE() / 1000000.0;
+ _selfLongitude = reader.readInt32LE() / 1000000.0;
+ _multiAcks = reader.readByte();
+ _advertLocPolicy = reader.readByte();
+ final telemetryFlag = reader.readByte();
+ _telemetryModeBase = telemetryFlag & 0x03;
+ _telemetryModeEnv = telemetryFlag >> 2 & 0x03;
+ _telemetryModeLoc = telemetryFlag >> 4 & 0x03;
- _currentTxPower = frame[2];
- _maxTxPower = frame[3];
- _selfPublicKey = Uint8List.fromList(frame.sublist(4, 4 + pubKeySize));
- _selfLatitude = readInt32LE(frame, 36) / 1000000.0;
- _selfLongitude = readInt32LE(frame, 40) / 1000000.0;
+ _manualAddContacts = reader.readByte() & 0x01 == 0x00;
- // Radio settings (if frame is long enough)
- if (frame.length >= 58) {
- _currentFreqHz = readUint32LE(frame, 48);
- _currentBwHz = readUint32LE(frame, 52);
- _currentSf = frame[56];
- _currentCr = frame[57];
+ _currentFreqHz = reader.readUInt32LE();
+ _currentBwHz = reader.readUInt32LE();
+ _currentSf = reader.readByte();
+ _currentCr = reader.readByte();
+
+ _selfName = reader.readCString();
+ } catch (e) {
+ _appDebugLogService?.error(
+ 'Error parsing SELF_INFO frame: $e',
+ tag: 'Connector',
+ );
+ }
+ final selfName = _selfName?.trim();
+ if (_activeTransport == MeshCoreTransportType.usb &&
+ selfName != null &&
+ selfName.isNotEmpty) {
+ _usbManager.updateConnectedLabel(selfName);
}
- // Node name starts at offset 58 if frame is long enough
- if (frame.length > 58) {
- _selfName = readCString(frame, 58, frame.length - 58);
- }
+ //set all the stores' public key so they can load the correct data
+ _channelMessageStore.setPublicKeyHex = selfPublicKeyHex;
+ _messageStore.setPublicKeyHex = selfPublicKeyHex;
+ _channelOrderStore.setPublicKeyHex = selfPublicKeyHex;
+ _channelSettingsStore.setPublicKeyHex = selfPublicKeyHex;
+ _contactSettingsStore.setPublicKeyHex = selfPublicKeyHex;
+ _contactStore.setPublicKeyHex = selfPublicKeyHex;
+ _channelStore.setPublicKeyHex = selfPublicKeyHex;
+ _unreadStore.setPublicKeyHex = selfPublicKeyHex;
+
+ // Now that we have self info, we can load all the persisted data for this node
+ _loadChannelOrder();
+ loadContactCache();
+ loadChannelSettings();
+ loadCachedChannels();
+
+ // Load persisted channel messages
+ loadAllChannelMessages();
+ loadUnreadState();
+ _loadDiscoveredContactCache();
+
_awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null;
notifyListeners();
- // Auto-fetch contacts after getting self info
- getContacts();
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ !wasAwaitingSelfInfo) {
+ return;
+ }
+
+ // Auto-fetch contacts after getting self info. On web BLE, defer this
+ // until after channel 0 so startup writes stay serialized.
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth) {
+ _pendingInitialContactsSync = true;
+ } else if (_activeTransport == MeshCoreTransportType.usb ||
+ _activeTransport == MeshCoreTransportType.tcp) {
+ _pendingDeferredChannelSyncAfterContacts = true;
+ getContacts();
+ } else {
+ getContacts();
+ }
+ if (_shouldGateInitialChannelSync &&
+ _activeTransport != MeshCoreTransportType.usb &&
+ _activeTransport != MeshCoreTransportType.tcp) {
+ _maybeStartInitialChannelSync();
+ }
}
void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return;
+ if (_shouldGateInitialChannelSync) {
+ _hasReceivedDeviceInfo = true;
+ }
+ _firmwareVerCode = frame[1];
+
+ // Parse client_repeat from firmware v9+ (byte 80)
+ if (frame.length >= 81) {
+ _clientRepeat = frame[80] != 0;
+ }
+ // Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop
+ if (frame.length >= 82) {
+ final mode = (frame[81] & 0xFF).clamp(0, 2);
+ _pathHashByteWidth = mode + 1;
+ } else {
+ _pathHashByteWidth = 1;
+ }
+
// Firmware reports MAX_CONTACTS / 2 for v3+ device info.
final reportedContacts = frame[2];
final reportedChannels = frame[3];
- final nextMaxContacts = reportedContacts > 0 ? reportedContacts * 2 : _maxContacts;
- final nextMaxChannels = reportedChannels > 0 ? reportedChannels : _maxChannels;
+ final nextMaxContacts = reportedContacts > 0
+ ? reportedContacts * 2
+ : _maxContacts;
+ final nextMaxChannels = reportedChannels > 0
+ ? reportedChannels
+ : _maxChannels;
final previousMaxChannels = _maxChannels;
if (nextMaxContacts != _maxContacts || nextMaxChannels != _maxChannels) {
_maxContacts = nextMaxContacts;
@@ -1694,12 +3761,29 @@ class MeshCoreConnector extends ChangeNotifier {
if (nextMaxChannels > previousMaxChannels) {
unawaited(loadChannelSettings(maxChannels: nextMaxChannels));
unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels));
- if (isConnected) {
+ if (isConnected &&
+ _selfPublicKey != null &&
+ (!_shouldGateInitialChannelSync || !_pendingInitialChannelSync)) {
unawaited(getChannels(maxChannels: nextMaxChannels));
}
}
- notifyListeners();
}
+ notifyListeners();
+ if (_shouldGateInitialChannelSync) {
+ _maybeStartInitialChannelSync();
+ }
+ }
+
+ void _maybeStartInitialChannelSync() {
+ if (!_pendingInitialChannelSync || !isConnected) {
+ return;
+ }
+ if (_selfPublicKey == null || !_hasReceivedDeviceInfo) {
+ return;
+ }
+
+ _pendingInitialChannelSync = false;
+ unawaited(getChannels(maxChannels: _maxChannels));
}
void _handleNoMoreMessages() {
@@ -1719,21 +3803,17 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_requestNextQueuedMessage());
}
- void _handleRadioSettings(Uint8List frame) {
- // Frame format from C++:
- // [0] = RESP_CODE_RADIO_SETTINGS
- // [1-4] = freq (uint32 LE, in Hz)
- // [5-8] = bw (uint32 LE, in Hz)
- // [9] = sf
- // [10] = cr
- if (frame.length >= 11) {
- _currentFreqHz = readUint32LE(frame, 1);
- _currentBwHz = readUint32LE(frame, 5);
- _currentSf = frame[9];
- _currentCr = frame[10];
- debugPrint('Radio settings: freq=$_currentFreqHz bw=$_currentBwHz sf=$_currentSf cr=$_currentCr');
- notifyListeners();
+ void _handleStatsFrame(Uint8List frame) {
+ final stats = CompanionRadioStats.tryParse(frame);
+ if (stats == null) return;
+ final total = stats.txAirSecs + stats.rxAirSecs;
+ if (total > _prevTotalAirSecs) {
+ (_airtimeBumpStopwatch ??= Stopwatch()).reset();
+ _airtimeBumpStopwatch!.start();
}
+ _prevTotalAirSecs = total;
+ _latestRadioStats = stats;
+ radioStatsNotifier.value = stats;
}
void _handleBatteryAndStorage(Uint8List frame) {
@@ -1742,51 +3822,140 @@ class MeshCoreConnector extends ChangeNotifier {
// [1-2] = battery_mv (uint16 LE)
// [3-6] = storage_used_kb (uint32 LE)
// [7-10] = storage_total_kb (uint32 LE)
- if (frame.length >= 3) {
- _batteryMillivolts = readUint16LE(frame, 1);
+ try {
+ final reader = BufferReader(frame);
+ reader.skipBytes(1);
+ _batteryMillivolts = reader.readUInt16LE();
+ _storageUsedKb = reader.readUInt32LE();
+ _storageTotalKb = reader.readUInt32LE();
+ final volts = (_batteryMillivolts! / 1000.0).toStringAsFixed(2);
+ _appDebugLogService?.info(
+ 'Pulled battery: $volts V ($_batteryMillivolts mV)',
+ tag: 'Battery',
+ );
notifyListeners();
+ } catch (e) {
+ _appDebugLogService?.error(
+ 'Error parsing battery and storage frame: $e',
+ tag: 'Connector',
+ );
}
}
- /// Calculate timeout for a message based on radio settings and path length
- /// Returns timeout in milliseconds, considering number of hops
- int calculateTimeout({required int pathLength, int messageBytes = 100}) {
- // If we have radio settings, use them for accurate calculation
+ void _checkManualAddContacts() async {
+ // If manual add contacts is enabled, set auto add config and other params.
+ // and disable it after
+ if (_manualAddContacts) {
+ await sendFrame(
+ buildSetAutoAddConfigFrame(
+ autoAddChat: true,
+ autoAddRepeater: true,
+ autoAddRoomServer: true,
+ autoAddSensor: true,
+ overwriteOldest: _overwriteOldest,
+ ),
+ );
+ await sendFrame(
+ buildSetOtherParamsFrame(
+ (_telemetryModeEnv << 4) |
+ (_telemetryModeLoc << 2) |
+ (_telemetryModeBase),
+ _advertLocPolicy,
+ _multiAcks,
+ ),
+ );
+ _manualAddContacts = false;
+ }
+ }
+
+ /// Estimate single-packet airtime in ms from radio settings, or a fallback.
+ int _estimateAirtimeMs(int messageBytes) {
if (_currentFreqHz != null &&
_currentBwHz != null &&
_currentSf != null &&
_currentCr != null) {
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
- return calculateMessageTimeout(
- freqHz: _currentFreqHz!,
- bwHz: _currentBwHz!,
- sf: _currentSf!,
- cr: cr,
- pathLength: pathLength,
- messageBytes: messageBytes,
+ return calculateLoRaAirtime(
+ payloadBytes: messageBytes,
+ spreadingFactor: _currentSf!,
+ bandwidthHz: _currentBwHz!,
+ codingRate: cr,
+ lowDataRateOptimize: _currentSf! >= 11,
);
}
+ return 50; // fallback: ~SF7/BW125 for 100 bytes
+ }
- // Fallback: Conservative estimates based on typical settings
- // Assume SF7, BW125, which gives ~50ms airtime for 100 bytes
- const estimatedAirtime = 50;
-
+ /// Physics-based worst-case timeout (ceiling).
+ int _physicsMaxTimeout(int pathLength, int airtime) {
if (pathLength < 0) {
- // Flood mode: Base delay + 16× airtime
- return 500 + (16 * estimatedAirtime);
+ // Match firmware: SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * airtime)
+ return 500 + (16 * airtime);
} else {
- // Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1))
- return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
+ return 500 + ((airtime * 6 + 250) * (pathLength + 1));
}
}
- void _handleContact(Uint8List frame) {
- final contact = Contact.fromFrame(frame);
- if (contact != null) {
+ int _physicsMinTimeout(int pathLength, int airtime) {
+ if (pathLength < 0) {
+ // Same as max for flood — firmware uses a single formula
+ return 500 + (16 * airtime);
+ } else {
+ return airtime * (pathLength + 1);
+ }
+ }
+
+ /// Calculate timeout for a message based on radio settings and path length.
+ /// Returns timeout in milliseconds, considering number of hops.
+ int calculateTimeout({
+ required int pathLength,
+ int messageBytes = 100,
+ String? contactKey,
+ }) {
+ final airtime = _estimateAirtimeMs(messageBytes);
+ final physicsMin = _physicsMinTimeout(pathLength, airtime);
+ final physicsMax = _physicsMaxTimeout(pathLength, airtime);
+
+ // Try ML-based prediction
+ final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
+ final mlTimeout = _timeoutPredictionService?.predictTimeout(
+ contactKey: contactKey,
+ pathLength: pathLength,
+ messageBytes: messageBytes,
+ secondsSinceLastRx: secSinceRx,
+ );
+ if (mlTimeout != null) {
+ if (pathLength < 0) {
+ // Flood: trust ML, only enforce firmware formula as floor
+ if (mlTimeout < physicsMin) {
+ return physicsMin;
+ }
+ }
+ return mlTimeout.clamp(physicsMin, physicsMax);
+ }
+
+ // No ML data — use firmware formula
+ return physicsMax;
+ }
+
+ void _handleContact(Uint8List frame, {bool isContact = true}) {
+ final contactTmp = Contact.fromFrame(frame);
+ if (contactTmp != null) {
+ if (listEquals(contactTmp.publicKey, _selfPublicKey)) {
+ appLogger.info(
+ 'Ignoring contact with self public key: ${contactTmp.name}',
+ tag: 'Connector',
+ );
+ removeContact(contactTmp);
+ return;
+ }
+ final contact = getFromDiscovered(contactTmp);
+ _handleDiscovery(contact, frame, noNotify: true, addActive: true);
+
if (contact.type == advTypeRepeater) {
- _contactLastReadMs.remove(contact.publicKeyHex);
- _unreadStore.saveContactLastRead(
- Map.from(_contactLastReadMs),
+ _contactUnreadCount.remove(contact.publicKeyHex);
+ _unreadStore.saveContactUnreadCount(
+ Map.from(_contactUnreadCount),
);
}
// Check if this is a new contact
@@ -1797,23 +3966,48 @@ class MeshCoreConnector extends ChangeNotifier {
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
- final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt)
+ final mergedLastMessageAt =
+ existing.lastMessageAt.isAfter(contact.lastMessageAt)
? existing.lastMessageAt
: contact.lastMessageAt;
- appLogger.info('Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}', tag: 'Connector');
+ appLogger.info(
+ 'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}',
+ tag: 'Connector',
+ );
- // CRITICAL: Preserve user's path override when contact is refreshed from device
+ // Preserve user-selected path settings and previously known GPS when
+ // refreshed frames omit coordinates (lat/lon encoded as 0,0).
_contacts[existingIndex] = contact.copyWith(
lastMessageAt: mergedLastMessageAt,
pathOverride: existing.pathOverride, // Preserve user's path choice
pathOverrideBytes: existing.pathOverrideBytes,
+ latitude: contact.latitude ?? existing.latitude,
+ longitude: contact.longitude ?? existing.longitude,
);
- appLogger.info('After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', tag: 'Connector');
+ appLogger.info(
+ 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
+ tag: 'Connector',
+ );
} else {
- _contacts.add(contact);
- appLogger.info('Added new contact ${contact.name}: pathLen=${contact.pathLength}', tag: 'Connector');
+ if ((_autoAddUsers && contact.type == advTypeChat) ||
+ (_autoAddRepeaters && contact.type == advTypeRepeater) ||
+ (_autoAddRoomServers && contact.type == advTypeRoom) ||
+ (_autoAddSensors && contact.type == advTypeSensor) ||
+ isContact) {
+ _contacts.add(contact);
+ appLogger.info(
+ 'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
+ tag: 'Connector',
+ );
+ } else {
+ appLogger.info(
+ "Discovered contact ${contact.name} (type ${contact.typeLabel}) not added due to auto-add settings",
+ tag: 'Connector',
+ );
+ return;
+ }
}
_knownContactKeys.add(contact.publicKeyHex);
_loadMessagesForContact(contact.publicKeyHex);
@@ -1843,10 +4037,88 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
+ void _handleContactAdvert(Contact contact) {
+ if (listEquals(contact.publicKey, _selfPublicKey)) {
+ return;
+ }
+
+ if (contact.type == advTypeRepeater) {
+ _contactUnreadCount.remove(contact.publicKeyHex);
+ _unreadStore.saveContactUnreadCount(
+ Map.from(_contactUnreadCount),
+ );
+ }
+ // Check if this is a new contact
+ final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex);
+ final existingIndex = _contacts.indexWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
+
+ if (existingIndex >= 0) {
+ final existing = _contacts[existingIndex];
+ final mergedLastMessageAt =
+ existing.lastMessageAt.isAfter(contact.lastMessageAt)
+ ? existing.lastMessageAt
+ : contact.lastMessageAt;
+
+ appLogger.info(
+ 'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}',
+ tag: 'Connector',
+ );
+
+ // 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,
+ );
+
+ appLogger.info(
+ 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
+ tag: 'Connector',
+ );
+ } else {
+ _contacts.add(contact);
+ appLogger.info(
+ 'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
+ tag: 'Connector',
+ );
+ }
+ _knownContactKeys.add(contact.publicKeyHex);
+ _loadMessagesForContact(contact.publicKeyHex);
+
+ // Add path to history if we have a valid path
+ if (_pathHistoryService != null && contact.pathLength >= 0) {
+ _pathHistoryService!.handlePathUpdated(contact);
+ }
+
+ notifyListeners();
+
+ // Show notification for new contact (advertisement)
+ if (isNewContact && _appSettingsService != null) {
+ final settings = _appSettingsService!.settings;
+ if (settings.notificationsEnabled && settings.notifyOnNewAdvert) {
+ _notificationService.showAdvertNotification(
+ contactName: contact.name,
+ contactType: contact.typeLabel,
+ contactId: contact.publicKeyHex,
+ );
+ }
+ }
+
+ if (!_isLoadingContacts) {
+ unawaited(_persistContacts());
+ }
+ }
+
Future _persistContacts() async {
await _contactStore.saveContacts(_contacts);
}
+ Future _persistDiscoveredContacts() async {
+ await _discoveryContactStore.saveContacts(_discoveredContacts);
+ }
+
int _latestContactLastmod() {
if (_contacts.isEmpty) return 0;
var latest = 0;
@@ -1926,9 +4198,10 @@ class MeshCoreConnector extends ChangeNotifier {
}
bool _pathMatchesContact(Uint8List pathBytes, Uint8List publicKey) {
- if (pathBytes.isEmpty || publicKey.length < pathHashSize) return false;
- for (int i = 0; i + pathHashSize <= pathBytes.length; i += pathHashSize) {
- final prefix = pathBytes.sublist(i, i + pathHashSize);
+ final w = _pathHashByteWidth;
+ if (pathBytes.isEmpty || publicKey.length < w) return false;
+ for (int i = 0; i + w <= pathBytes.length; i += w) {
+ final prefix = pathBytes.sublist(i, i + w);
if (_matchesPrefix(publicKey, prefix)) {
return true;
}
@@ -1945,9 +4218,13 @@ class MeshCoreConnector extends ChangeNotifier {
if (message == null && !_isLoadingContacts) {
final senderPrefix = _extractSenderPrefix(frame);
if (senderPrefix != null) {
- final hasContact = _contacts.any((c) => _matchesPrefix(c.publicKey, senderPrefix));
+ final hasContact = _contacts.any(
+ (c) => _matchesPrefix(c.publicKey, senderPrefix),
+ );
if (!hasContact) {
- debugPrint('Received message from unknown contact, refreshing contacts...');
+ debugPrint(
+ 'Received message from unknown contact, refreshing contacts...',
+ );
await refreshContactsSinceLastmod();
// Retry parsing after refresh
message = _parseContactMessage(frame);
@@ -1959,6 +4236,18 @@ class MeshCoreConnector extends ChangeNotifier {
}
if (message != null) {
+ if (!message.isOutgoing) {
+ _lastContactMsgRxTime = DateTime.now();
+ }
+ // Ignore messages from self (device hearing its own broadcast)
+ // BUT allow repeated messages (pathLength indicates it went through repeater)
+ if (_selfPublicKey != null &&
+ message.senderKeyHex == pubKeyToHex(_selfPublicKey!) &&
+ (message.pathLength == null || message.pathLength == 0)) {
+ debugPrint('Ignoring direct message from self');
+ return;
+ }
+
final contact = _contacts.cast().firstWhere(
(c) => c?.publicKeyHex == message!.senderKeyHex,
orElse: () => null,
@@ -1988,20 +4277,37 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
_addMessage(message.senderKeyHex, message);
- _maybeMarkActiveContactRead(message);
+ if (!message.isOutgoing) {
+ unawaited(
+ _translateIncomingContactMessage(message.senderKeyHex, message),
+ );
+ }
+ _maybeIncrementContactUnread(message);
notifyListeners();
// Show notification for new incoming message
- if (!message.isOutgoing && !message.isCli && _appSettingsService != null) {
+ if (!message.isOutgoing &&
+ !message.isCli &&
+ _appSettingsService != null) {
final settings = _appSettingsService!.settings;
if (settings.notificationsEnabled && settings.notifyOnNewMessage) {
- // Find the contact name
- _notificationService.showMessageNotification(
- contactName: contact?.name ?? 'Unknown',
- message: message.text,
- contactId: message.senderKeyHex,
- badgeCount: getTotalUnreadCount(),
- );
+ if (contact?.type == advTypeChat) {
+ _notificationService.showMessageNotification(
+ contactName: contact?.name ?? 'Unknown',
+ message: message.text,
+ contactId: message.senderKeyHex,
+ badgeCount: getTotalUnreadCount(),
+ );
+ } else if (contact?.type == advTypeRoom) {
+ _notificationService.showMessageNotification(
+ contactName: contact?.name ?? 'Unknown Room',
+ message: message.text.length > 4
+ ? message.text.substring(4)
+ : message.text,
+ contactId: message.senderKeyHex,
+ badgeCount: getTotalUnreadCount(),
+ );
+ }
}
}
_handleQueuedMessageReceived();
@@ -2011,61 +4317,93 @@ class MeshCoreConnector extends ChangeNotifier {
}
Message? _parseContactMessage(Uint8List frame) {
- if (frame.isEmpty) return null;
- final code = frame[0];
- if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
+ if (frame.isEmpty) {
+ appLogger.warn('Received empty frame, ignoring');
return null;
}
+ final reader = BufferReader(frame);
- // Companion radio layout:
- // [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
- final prefixOffset = code == respCodeContactMsgRecvV3 ? 4 : 1;
- const prefixLen = 6;
- final pathLenOffset = prefixOffset + prefixLen;
- final txtTypeOffset = pathLenOffset + 1;
- final timestampOffset = txtTypeOffset + 1;
- final baseTextOffset = timestampOffset + 4;
+ try {
+ final code = reader.readByte();
+ if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
+ appLogger.warn(
+ 'Unexpected message code: $code, expected contact message receive codes',
+ );
+ return null;
+ }
- if (frame.length <= baseTextOffset) return null;
+ // Companion radio layout:
+ // [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
+ // double snr = 0;
+ if (code == respCodeContactMsgRecvV3) {
+ // Older firmware layout with SNR as a signed byte after the code
+ // snr = reader.readInt8().toDouble() * 4; // SNR in dB, scaled by 4
+ reader.skipBytes(1); // Skip SNR byte
+ reader.skipBytes(2); // Skip reserved bytes
+ }
- final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
- final flags = frame[txtTypeOffset];
- final shiftedType = flags >> 2;
- final rawType = flags;
- final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
- final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
- if (!isPlain && !isCli) {
+ final senderPrefix = reader.readBytes(6);
+ final pathLength = reader.readByte();
+ final txtType = reader.readByte();
+ final timestampRaw = reader.readUInt32LE();
+ final timestamp = DateTime.fromMillisecondsSinceEpoch(
+ timestampRaw * 1000,
+ );
+
+ if (txtType == 2) {
+ reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants
+ }
+
+ final msgText = reader.readCString();
+
+ final flags = txtType;
+ final shiftedType = flags >> 2;
+ final rawType = flags;
+ final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
+ final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
+ if (!isPlain && !isCli) {
+ appLogger.warn(
+ 'Unknown message type received: txtType=$txtType, shifted=$shiftedType, raw=$rawType',
+ );
+ return null;
+ }
+
+ if (msgText.isEmpty) {
+ appLogger.warn('Received message with empty text, ignoring');
+ return null;
+ }
+ final decodedText = isCli
+ ? msgText
+ : (Smaz.tryDecodePrefixed(msgText) ?? msgText);
+
+ final contact = _contacts.cast().firstWhere(
+ (c) => c != null && _matchesPrefix(c.publicKey, senderPrefix),
+ orElse: () => null,
+ );
+ if (contact == null) {
+ appLogger.warn(
+ 'Received message from unknown contact with prefix: ${senderPrefix.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join('')}',
+ );
+ return null;
+ }
+
+ return Message(
+ senderKey: contact.publicKey,
+ text: decodedText,
+ timestamp: timestamp,
+ isOutgoing: false,
+ isCli: isCli,
+ status: MessageStatus.delivered,
+ pathLength: pathLength == 0xFF ? 0 : pathLength,
+ pathBytes: Uint8List(0),
+ fourByteRoomContactKey: msgText.length >= 4
+ ? Uint8List.fromList(msgText.substring(0, 4).codeUnits)
+ : null,
+ );
+ } catch (e) {
+ appLogger.warn('Error parsing contact direct message: $e');
return null;
}
-
- // Try base text offset; if empty and there is room for the optional 4-byte extra
- // (used by signed/plain variants), try again skipping those bytes.
- var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset);
- if (text.isEmpty && frame.length > baseTextOffset + 4) {
- text = readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4));
- }
- if (text.isEmpty) return null;
- final decodedText = isCli ? text : (Smaz.tryDecodePrefixed(text) ?? text);
-
- final timestampRaw = readUint32LE(frame, timestampOffset);
- final pathLenByte = frame[pathLenOffset];
-
- final contact = _contacts.cast().firstWhere(
- (c) => c != null && _matchesPrefix(c.publicKey, senderPrefix),
- orElse: () => null,
- );
- if (contact == null) return null;
-
- return Message(
- senderKey: contact.publicKey,
- text: decodedText,
- timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
- isOutgoing: false,
- isCli: isCli,
- status: MessageStatus.delivered,
- pathLength: pathLenByte == 0xFF ? 0 : pathLenByte,
- pathBytes: Uint8List(0),
- );
}
bool _matchesPrefix(Uint8List fullKey, Uint8List prefix) {
@@ -2105,17 +4443,15 @@ class MeshCoreConnector extends ChangeNotifier {
String prepareContactOutboundText(Contact contact, String text) {
final trimmed = text.trim();
final isStructuredPayload =
- trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|');
+ trimmed.startsWith('g:') ||
+ trimmed.startsWith('m:') ||
+ trimmed.startsWith('V1|');
if (!isStructuredPayload && isContactSmazEnabled(contact.publicKeyHex)) {
return Smaz.encodeIfSmaller(text);
}
return text;
}
-
-
-
-
String _channelDisplayName(int channelIndex) {
for (final channel in _channels) {
if (channel.index != channelIndex) continue;
@@ -2138,8 +4474,11 @@ class MeshCoreConnector extends ChangeNotifier {
}
final label = channelName ?? _channelDisplayName(channelIndex);
+ if (_appSettingsService!.isChannelMuted(label)) return;
+
_notificationService.showChannelMessageNotification(
channelName: label,
+ senderName: message.senderName,
message: message.text,
channelIndex: channelIndex,
badgeCount: getTotalUnreadCount(),
@@ -2147,18 +4486,30 @@ class MeshCoreConnector extends ChangeNotifier {
}
void _handleIncomingChannelMessage(Uint8List frame) {
- final message = ChannelMessage.fromFrame(frame);
- if (message != null && message.channelIndex != null) {
- if (_shouldDropSelfChannelMessage(message.senderName, message.pathBytes)) {
+ final parsed = ChannelMessage.fromFrame(frame);
+ if (parsed != null && parsed.channelIndex != null) {
+ if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) {
return;
}
+ _lastChannelMsgRxTime = DateTime.now();
+ final contentHash = _computeContentHash(
+ parsed.channelIndex!,
+ parsed.timestamp.millisecondsSinceEpoch ~/ 1000,
+ '${parsed.senderName}: ${parsed.text}',
+ );
+ final message = parsed.copyWith(packetHash: contentHash);
_updateContactLastMessageAtByName(
message.senderName,
message.timestamp,
pathBytes: message.pathBytes,
);
final isNew = _addChannelMessage(message.channelIndex!, message);
- _maybeMarkActiveChannelRead(message);
+ if (isNew && !message.isOutgoing) {
+ unawaited(
+ _translateIncomingChannelMessage(message.channelIndex!, message),
+ );
+ }
+ _maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners();
if (isNew) {
_maybeNotifyChannelMessage(message);
@@ -2171,61 +4522,90 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleLogRxData(Uint8List frame) {
if (frame.length < 4) return;
- final raw = Uint8List.fromList(frame.sublist(3));
- final packet = _parseRawPacket(raw);
- if (packet == null || packet.payloadType != _payloadTypeGroupText) return;
+ try {
+ final reader = BufferReader(frame);
+ reader.skipBytes(3); // Skip header
- final payload = packet.payload;
- if (payload.length <= _cipherMacSize) return;
- final channelHash = payload[0];
- final encrypted = Uint8List.fromList(payload.sublist(1));
+ final raw = reader.readRemainingBytes();
+ final packet = _parseRawPacket(raw);
+ if (packet == null || packet.payloadType != _payloadTypeGroupText) return;
- for (final channel in _channels) {
- if (channel.isEmpty) continue;
- final hash = _computeChannelHash(channel.psk);
- if (hash != channelHash) continue;
+ final payload = BufferReader(packet.payload);
+ final channelHash = payload.readByte();
+ final encrypted = Uint8List.fromList(payload.readRemainingBytes());
- final decrypted = _decryptPayload(channel.psk, encrypted);
- if (decrypted == null || decrypted.length < 6) return;
+ // Use cached channels as fallback if live channels not yet loaded
+ final channelsToSearch = _channels.isNotEmpty
+ ? _channels
+ : _cachedChannels;
+ for (final channel in channelsToSearch) {
+ if (channel.isEmpty) continue;
+ final hash = _computeChannelHash(channel.psk);
+ if (hash != channelHash) continue;
+ try {
+ final decryptedBytes = _decryptPayload(channel.psk, encrypted);
+ if (decryptedBytes == null || decryptedBytes.length < 6) return;
+ final decrypted = BufferReader(decryptedBytes);
- final txtType = decrypted[4];
- if ((txtType >> 2) != 0) {
- return;
+ final timestampRaw = decrypted.readUInt32LE();
+ final txtType = decrypted.readByte();
+ if ((txtType >> 2) != 0) {
+ return;
+ }
+
+ final text = decrypted.readCString();
+ final parsed = _splitSenderText(text);
+ final decodedText =
+ Smaz.tryDecodePrefixed(parsed.text) ?? parsed.text;
+ if (_shouldDropSelfChannelMessage(
+ parsed.senderName,
+ packet.pathBytes,
+ )) {
+ return;
+ }
+
+ final pktHash = _computePacketHash(
+ packet.payloadType,
+ packet.payload,
+ );
+
+ final message = ChannelMessage(
+ senderKey: null,
+ senderName: parsed.senderName,
+ text: decodedText,
+ timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
+ isOutgoing: false,
+ status: ChannelMessageStatus.sent,
+ pathLength: packet.isFlood ? packet.hopCount : 0,
+ pathBytes: packet.pathBytes,
+ channelIndex: channel.index,
+ packetHash: pktHash,
+ );
+
+ _updateContactLastMessageAtByName(
+ parsed.senderName,
+ message.timestamp,
+ pathBytes: message.pathBytes,
+ );
+ final isNew = _addChannelMessage(channel.index, message);
+ if (isNew && !message.isOutgoing) {
+ unawaited(_translateIncomingChannelMessage(channel.index, message));
+ }
+ _maybeIncrementChannelUnread(message, isNew: isNew);
+ notifyListeners();
+ if (isNew) {
+ final label = channel.name.isEmpty
+ ? 'Channel ${channel.index}'
+ : channel.name;
+ _maybeNotifyChannelMessage(message, channelName: label);
+ }
+ return;
+ } catch (e) {
+ appLogger.warn('Decryption failed for channel ${channel.index}: $e');
+ }
}
-
- final timestampRaw = readUint32LE(decrypted, 0);
- final text = readCString(decrypted, 5, decrypted.length - 5);
- final parsed = _splitSenderText(text);
- final decodedText = Smaz.tryDecodePrefixed(parsed.text) ?? parsed.text;
- if (_shouldDropSelfChannelMessage(parsed.senderName, packet.pathBytes)) {
- return;
- }
-
- final message = ChannelMessage(
- senderKey: null,
- senderName: parsed.senderName,
- text: decodedText,
- timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
- isOutgoing: false,
- status: ChannelMessageStatus.sent,
- pathLength: packet.isFlood ? packet.pathBytes.length : 0,
- pathBytes: packet.pathBytes,
- channelIndex: channel.index,
- );
-
- _updateContactLastMessageAtByName(
- parsed.senderName,
- message.timestamp,
- pathBytes: message.pathBytes,
- );
- final isNew = _addChannelMessage(channel.index, message);
- _maybeMarkActiveChannelRead(message);
- notifyListeners();
- if (isNew) {
- final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name;
- _maybeNotifyChannelMessage(message, channelName: label);
- }
- return;
+ } catch (e) {
+ appLogger.warn('Error handling log RX data frame: $e');
}
}
@@ -2236,14 +4616,15 @@ class MeshCoreConnector extends ChangeNotifier {
// [2-5] = expected_ack_hash (uint32)
// [6-9] = estimated_timeout_ms (uint32)
- if (frame.length >= 10) {
- final isFlood = frame[1] != 0;
- final ackHash = Uint8List.fromList(frame.sublist(2, 6));
- final timeoutMs = readUint32LE(frame, 6);
+ try {
+ final reader = BufferReader(frame);
+ reader.skipBytes(2); //Skip code and is_flood
+ final ackHash = reader.readUInt32LE();
+ final timeoutMs = reader.readUInt32LE();
// Check if this is a CLI command ACK - if so, ignore it
if (_lastSentWasCliCommand) {
- final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
+ final ackHashHex = ackHashToHex(ackHash);
debugPrint('Ignoring CLI command ACK (sent): $ackHashHex');
_lastSentWasCliCommand = false;
return;
@@ -2253,14 +4634,22 @@ class MeshCoreConnector extends ChangeNotifier {
return;
}
- if (_retryService != null) {
- _retryService!.updateMessageFromSent(ackHash, timeoutMs);
+ final retryService = _retryService;
+ if (retryService != null &&
+ retryService.updateMessageFromSent(ackHash, timeoutMs)) {
+ return;
}
- } else {
+
+ if (_markNextPendingChannelMessageSent()) {
+ return;
+ }
+ } catch (e) {
+ appLogger.warn('Error handling message sent frame: $e');
// Fallback to old behavior
for (var messages in _conversations.values) {
for (int i = messages.length - 1; i >= 0; i--) {
- if (messages[i].isOutgoing && messages[i].status == MessageStatus.pending) {
+ if (messages[i].isOutgoing &&
+ messages[i].status == MessageStatus.pending) {
messages[i] = messages[i].copyWith(status: MessageStatus.sent);
notifyListeners();
return;
@@ -2270,15 +4659,75 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
+ bool _markNextPendingChannelMessageSent() {
+ while (_pendingChannelSentQueue.isNotEmpty) {
+ final queuedMessageId = _pendingChannelSentQueue.removeAt(0);
+ if (_isReactionSendQueueId(queuedMessageId)) {
+ return true;
+ }
+ if (_markPendingChannelMessageSentById(queuedMessageId)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ bool _markPendingChannelMessageSentById(String messageId) {
+ for (final entry in _channelMessages.entries) {
+ final channelMessages = entry.value;
+ for (int i = channelMessages.length - 1; i >= 0; i--) {
+ final message = channelMessages[i];
+ if (message.messageId != messageId) {
+ continue;
+ }
+ if (!message.isOutgoing ||
+ message.status != ChannelMessageStatus.pending) {
+ return false;
+ }
+ channelMessages[i] = message.copyWith(
+ status: ChannelMessageStatus.sent,
+ );
+ _pendingChannelSentQueue.remove(messageId);
+ unawaited(
+ _channelMessageStore.saveChannelMessages(entry.key, channelMessages),
+ );
+ notifyListeners();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ void _handleOk() {
+ if (_pendingGenericAckQueue.isEmpty) {
+ return;
+ }
+
+ final pendingAck = _pendingGenericAckQueue.removeAt(0);
+ if (pendingAck.commandCode != cmdSendChannelTxtMsg ||
+ pendingAck.channelSendQueueId == null) {
+ return;
+ }
+
+ final queueId = pendingAck.channelSendQueueId!;
+ _pendingChannelSentQueue.remove(queueId);
+ if (_isReactionSendQueueId(queueId)) {
+ return;
+ }
+ _markPendingChannelMessageSentById(queueId);
+ }
+
void _handleSendConfirmed(Uint8List frame) {
// Frame format from C++:
// [0] = PUSH_CODE_SEND_CONFIRMED
// [1-4] = ack_hash (uint32)
// [5-8] = trip_time_ms (uint32)
- if (frame.length >= 9) {
- final ackHash = Uint8List.fromList(frame.sublist(1, 5));
- final tripTimeMs = readUint32LE(frame, 5);
+ try {
+ final reader = BufferReader(frame);
+ reader.skipBytes(1); // Skip code
+ final ackHash = reader.readUInt32LE();
+ final tripTimeMs = reader.readUInt32LE();
// CLI command ACKs are already filtered in _handleMessageSent, so this should only see real messages
@@ -2290,11 +4739,13 @@ class MeshCoreConnector extends ChangeNotifier {
if (_retryService != null) {
_retryService!.handleAckReceived(ackHash, tripTimeMs);
}
- } else {
+ } catch (e) {
+ appLogger.warn('Error handling send confirmed frame: $e');
// Fallback to old behavior
for (var messages in _conversations.values) {
for (int i = messages.length - 1; i >= 0; i--) {
- if (messages[i].isOutgoing && messages[i].status == MessageStatus.sent) {
+ if (messages[i].isOutgoing &&
+ messages[i].status == MessageStatus.sent) {
messages[i] = messages[i].copyWith(status: MessageStatus.delivered);
notifyListeners();
return;
@@ -2304,8 +4755,8 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
- bool _handleRepeaterCommandSent(Uint8List ackHash, int timeoutMs) {
- final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
+ bool _handleRepeaterCommandSent(int ackHash, int timeoutMs) {
+ final ackHashHex = ackHashToHex(ackHash);
final entry = _pendingRepeaterAcks[ackHashHex];
if (entry == null) return false;
@@ -2323,8 +4774,8 @@ class MeshCoreConnector extends ChangeNotifier {
return true;
}
- bool _handleRepeaterCommandAck(Uint8List ackHash, int tripTimeMs) {
- final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
+ bool _handleRepeaterCommandAck(int ackHash, int tripTimeMs) {
+ final ackHashHex = ackHashToHex(ackHash);
final entry = _pendingRepeaterAcks.remove(ackHashHex);
if (entry == null) return false;
entry.timeout?.cancel();
@@ -2336,7 +4787,18 @@ class MeshCoreConnector extends ChangeNotifier {
final channel = Channel.fromFrame(frame);
if (channel == null) return;
- debugPrint('[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}');
+ debugPrint(
+ '[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}',
+ );
+
+ // Preserve unread count from cached channel
+ final cachedChannel = _cachedChannels.cast().firstWhere(
+ (c) => c?.index == channel.index,
+ orElse: () => null,
+ );
+ if (cachedChannel != null) {
+ channel.unreadCount = cachedChannel.unreadCount;
+ }
// If we're syncing and this is the channel we're waiting for
if (_isSyncingChannels && _channelSyncInFlight) {
@@ -2353,14 +4815,25 @@ class MeshCoreConnector extends ChangeNotifier {
// Move to next channel
_nextChannelIndexToRequest++;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ channel.index == 0 &&
+ _pendingInitialContactsSync) {
+ _pendingInitialContactsSync = false;
+ unawaited(getContacts());
+ return;
+ }
unawaited(_requestNextChannel());
return;
} else {
// Received a channel but not the one we're waiting for
// This can happen if device sends unsolicited updates
- debugPrint('[ChannelSync] Received unexpected channel ${channel.index}, expected $_nextChannelIndexToRequest');
+ debugPrint(
+ '[ChannelSync] Received unexpected channel ${channel.index}, expected $_nextChannelIndexToRequest',
+ );
// Add it anyway but don't advance sync
- if (!channel.isEmpty && !_channels.any((c) => c.index == channel.index)) {
+ if (!channel.isEmpty &&
+ !_channels.any((c) => c.index == channel.index)) {
_channels.add(channel);
}
return;
@@ -2370,8 +4843,12 @@ class MeshCoreConnector extends ChangeNotifier {
// Not syncing, or received unsolicited update - handle normally
if (!channel.isEmpty) {
// Update or add channel
- final existingIndex = _channels.indexWhere((c) => c.index == channel.index);
+ final existingIndex = _channels.indexWhere(
+ (c) => c.index == channel.index,
+ );
if (existingIndex >= 0) {
+ // Preserve unread count from existing channel
+ channel.unreadCount = _channels[existingIndex].unreadCount;
_channels[existingIndex] = channel;
} else {
_channels.add(channel);
@@ -2422,63 +4899,98 @@ class MeshCoreConnector extends ChangeNotifier {
return contact.type != advTypeRepeater;
}
- int _calculateReadTimestampMs(Iterable? timestamps) {
- var latestMs = 0;
- if (timestamps != null) {
- for (final timestamp in timestamps) {
- final ms = timestamp.millisecondsSinceEpoch;
- if (ms > latestMs) {
- latestMs = ms;
- }
- }
+ Channel? _findChannelByIndex(int index) {
+ return _channels.cast().firstWhere(
+ (c) => c?.index == index,
+ orElse: () => null,
+ ) ??
+ _cachedChannels.cast().firstWhere(
+ (c) => c?.index == index,
+ orElse: () => null,
+ );
+ }
+
+ void _maybeIncrementChannelUnread(
+ ChannelMessage message, {
+ required bool isNew,
+ }) {
+ if (!isNew || message.isOutgoing) {
+ _appDebugLogService?.info(
+ 'Skip unread increment: isNew=$isNew, isOutgoing=${message.isOutgoing}',
+ tag: 'Unread',
+ );
+ return;
}
- return latestMs;
- }
-
- void _setContactLastReadMs(String contactKeyHex, int timestampMs, {bool notify = true}) {
- if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return;
- final existing = _contactLastReadMs[contactKeyHex] ?? 0;
- if (timestampMs <= existing) return;
- _contactLastReadMs[contactKeyHex] = timestampMs;
- _unreadStore.saveContactLastRead(
- Map.from(_contactLastReadMs),
- );
- if (notify) {
- notifyListeners();
- }
- }
-
- void _setChannelLastReadMs(int channelIndex, int timestampMs, {bool notify = true}) {
- final existing = _channelLastReadMs[channelIndex] ?? 0;
- if (timestampMs <= existing) return;
- _channelLastReadMs[channelIndex] = timestampMs;
- _unreadStore.saveChannelLastRead(
- Map.from(_channelLastReadMs),
- );
- if (notify) {
- notifyListeners();
- }
- }
-
- void _maybeMarkActiveContactRead(Message message) {
- if (message.isOutgoing || message.isCli) return;
- if (_activeContactKey != message.senderKeyHex) return;
- if (!_shouldTrackUnreadForContactKey(message.senderKeyHex)) return;
- _setContactLastReadMs(
- message.senderKeyHex,
- message.timestamp.millisecondsSinceEpoch,
- notify: false,
- );
- }
-
- void _maybeMarkActiveChannelRead(ChannelMessage message) {
- if (message.isOutgoing) return;
final channelIndex = message.channelIndex;
- if (channelIndex == null || _activeChannelIndex != channelIndex) return;
- _setChannelLastReadMs(
- channelIndex,
- message.timestamp.millisecondsSinceEpoch,
- notify: false,
+ if (channelIndex == null) {
+ _appDebugLogService?.info(
+ 'Skip unread increment: channelIndex is null',
+ tag: 'Unread',
+ );
+ return;
+ }
+ // Don't increment if user is viewing this channel
+ if (_activeChannelIndex == channelIndex) {
+ _appDebugLogService?.info(
+ 'Skip unread increment: channel $channelIndex is active',
+ tag: 'Unread',
+ );
+ return;
+ }
+
+ final channel = _findChannelByIndex(channelIndex);
+ if (channel != null) {
+ channel.unreadCount++;
+ _appDebugLogService?.info(
+ 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}',
+ tag: 'Unread',
+ );
+ unawaited(
+ _channelStore.saveChannels(
+ _channels.isNotEmpty ? _channels : _cachedChannels,
+ ),
+ );
+ } else {
+ _appDebugLogService?.info(
+ 'Channel $channelIndex not found in _channels (${_channels.length}) or _cachedChannels (${_cachedChannels.length})',
+ tag: 'Unread',
+ );
+ }
+ }
+
+ void _maybeIncrementContactUnread(Message message) {
+ if (message.isOutgoing || message.isCli) {
+ _appDebugLogService?.info(
+ 'Skip contact unread increment: isOutgoing=${message.isOutgoing}, isCli=${message.isCli}',
+ tag: 'Unread',
+ );
+ return;
+ }
+ final contactKey = message.senderKeyHex;
+ if (!_shouldTrackUnreadForContactKey(contactKey)) {
+ _appDebugLogService?.info(
+ 'Skip contact unread increment: should not track for $contactKey',
+ tag: 'Unread',
+ );
+ return;
+ }
+ // Don't increment if user is viewing this contact
+ if (_activeContactKey == contactKey) {
+ _appDebugLogService?.info(
+ 'Skip contact unread increment: contact $contactKey is active',
+ tag: 'Unread',
+ );
+ return;
+ }
+
+ final currentCount = _contactUnreadCount[contactKey] ?? 0;
+ _contactUnreadCount[contactKey] = currentCount + 1;
+ _appDebugLogService?.info(
+ 'Contact $contactKey unread count incremented to ${currentCount + 1}',
+ tag: 'Unread',
+ );
+ _unreadStore.saveContactUnreadCount(
+ Map.from(_contactUnreadCount),
);
}
@@ -2489,23 +5001,22 @@ 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 using lightweight key
+ // Check if we've already processed this exact reaction
_processedContactReactions.putIfAbsent(pubKeyHex, () => {});
- final reactionKey = reactionInfo.reactionKey;
- final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
+ final reactionIdentifier =
+ '${reactionInfo.targetHash}_${reactionInfo.emoji}';
- final isDuplicate = reactionIdentifier != null &&
- _processedContactReactions[pubKeyHex]!.contains(reactionIdentifier);
+ final isDuplicate = _processedContactReactions[pubKeyHex]!.contains(
+ reactionIdentifier,
+ );
if (!isDuplicate) {
// New reaction - process it
- _processContactReaction(messages, reactionInfo);
+ _processContactReaction(messages, reactionInfo, pubKeyHex);
_messageStore.saveMessages(pubKeyHex, messages);
// Mark as processed
- if (reactionIdentifier != null) {
- _processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
- }
+ _processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
notifyListeners();
}
@@ -2517,46 +5028,133 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
}
- void _processContactReaction(List messages, ReactionInfo reactionInfo) {
- // Find target message by messageId
- for (int i = 0; i < messages.length; i++) {
- if (messages[i].messageId == reactionInfo.targetMessageId) {
- final currentReactions = Map.from(messages[i].reactions);
- currentReactions[reactionInfo.emoji] =
- (currentReactions[reactionInfo.emoji] ?? 0) + 1;
+ void _processContactReaction(
+ List messages,
+ ReactionInfo reactionInfo,
+ String contactPubKeyHex,
+ ) {
+ final contact = _contacts.cast().firstWhere(
+ (c) => c?.publicKeyHex == contactPubKeyHex,
+ orElse: () => null,
+ );
+ final isRoomServer = contact?.type == advTypeRoom;
- messages[i] = messages[i].copyWith(reactions: currentReactions);
+ ReactionHelper.applyReaction(
+ messages: messages,
+ reactionInfo: reactionInfo,
+ // Incoming reactions in 1:1: match against outgoing messages only
+ shouldSkip: (msg) => isRoomServer != true && !msg.isOutgoing,
+ getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000,
+ getSenderName: (msg) =>
+ _resolveContactSenderName(msg, contact, isRoomServer == true),
+ getMessageText: (msg) => msg.text,
+ getReactions: (msg) => msg.reactions,
+ updateMessage: (i, reactions) {
+ messages[i] = messages[i].copyWith(reactions: reactions);
+ },
+ );
+ }
+
+ void _processOutgoingContactReaction(
+ List messages,
+ ReactionInfo reactionInfo,
+ Contact contact,
+ ) {
+ final isRoomServer = contact.type == advTypeRoom;
+
+ ReactionHelper.applyReaction(
+ messages: messages,
+ reactionInfo: reactionInfo,
+ // Outgoing reactions in 1:1: match against incoming messages
+ shouldSkip: (msg) => !isRoomServer && msg.isOutgoing,
+ getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000,
+ getSenderName: (msg) =>
+ _resolveContactSenderName(msg, contact, isRoomServer),
+ getMessageText: (msg) => msg.text,
+ getReactions: (msg) => msg.reactions,
+ updateMessage: (i, reactions) {
+ messages[i] = messages[i].copyWith(reactions: reactions);
+ },
+ );
+ }
+
+ void _setReactionStatus(
+ String pubKeyHex,
+ ReactionInfo reactionInfo,
+ MessageStatus status,
+ ) {
+ final messages = _conversations[pubKeyHex];
+ if (messages == null) return;
+ final contact = _contacts.cast().firstWhere(
+ (c) => c?.publicKeyHex == pubKeyHex,
+ orElse: () => null,
+ );
+ final isRoomServer = contact?.type == advTypeRoom;
+ for (int i = messages.length - 1; i >= 0; i--) {
+ final msg = messages[i];
+ final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000;
+ final msgHash = ReactionHelper.computeReactionHash(
+ timestampSecs,
+ _resolveContactSenderName(msg, contact, isRoomServer == true),
+ msg.text,
+ );
+ if (msgHash == reactionInfo.targetHash) {
+ final statuses = Map.from(msg.reactionStatuses);
+ statuses[reactionInfo.emoji] = status;
+ messages[i] = msg.copyWith(reactionStatuses: statuses);
break;
}
}
}
- _RawPacket? _parseRawPacket(Uint8List raw) {
- if (raw.length < 3) return null;
- var index = 0;
- final header = raw[index++];
- final routeType = header & _phRouteMask;
- final hasTransport = routeType == _routeTransportFlood || routeType == _routeTransportDirect;
- if (hasTransport) {
- if (raw.length < index + 4) return null;
- index += 4;
+ String? _resolveContactSenderName(
+ Message msg,
+ Contact? contact,
+ bool isRoomServer,
+ ) {
+ if (!isRoomServer) return null;
+ if (!msg.isOutgoing) {
+ final senderContact = _contacts.cast().firstWhere(
+ (c) =>
+ c != null &&
+ _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
+ orElse: () => null,
+ );
+ return senderContact?.name;
}
- if (raw.length <= index) return null;
- final pathLen = raw[index++];
- if (raw.length < index + pathLen) return null;
- final pathBytes = Uint8List.fromList(raw.sublist(index, index + pathLen));
- index += pathLen;
- if (raw.length <= index) return null;
- final payload = Uint8List.fromList(raw.sublist(index));
+ return selfName;
+ }
- return _RawPacket(
- header: header,
- routeType: routeType,
- payloadType: (header >> _phTypeShift) & _phTypeMask,
- payloadVer: (header >> _phVerShift) & _phVerMask,
- pathBytes: pathBytes,
- payload: payload,
- );
+ _RawPacket? _parseRawPacket(Uint8List raw) {
+ try {
+ final reader = BufferReader(raw);
+ final header = reader.readByte();
+ final routeType = header & _phRouteMask;
+ final hasTransport =
+ routeType == _routeTransportFlood ||
+ routeType == _routeTransportDirect;
+ if (hasTransport) {
+ // Skip reserved bytes in transport header made up of two u16 fields
+ reader.skipBytes(4);
+ }
+ final pathLenRaw = reader.readByte();
+ final pathByteLen = _decodePathByteLen(pathLenRaw);
+ final pathBytes = reader.readBytes(pathByteLen);
+ final payload = reader.readBytes(reader.remaining);
+
+ return _RawPacket(
+ header: header,
+ routeType: routeType,
+ payloadType: (header >> _phTypeShift) & _phTypeMask,
+ payloadVer: (header >> _phVerShift) & _phVerMask,
+ pathLenRaw: pathLenRaw,
+ pathBytes: pathBytes,
+ payload: payload,
+ );
+ } catch (e) {
+ appLogger.warn('Error parsing raw packet: $e');
+ return null;
+ }
}
int _computeChannelHash(Uint8List psk) {
@@ -2564,6 +5162,37 @@ class MeshCoreConnector extends ChangeNotifier {
return digest[0];
}
+ /// Firmware-compatible packet hash: SHA256(payloadType + payload) -> first 8 bytes as hex.
+ String _computePacketHash(int payloadType, Uint8List payload) {
+ final input = Uint8List(1 + payload.length);
+ input[0] = payloadType;
+ input.setRange(1, input.length, payload);
+ final digest = crypto.sha256.convert(input).bytes;
+ return digest
+ .sublist(0, 8)
+ .map((b) => b.toRadixString(16).padLeft(2, '0'))
+ .join();
+ }
+
+ /// Content-based dedup hash for sync queue messages (no raw payload available).
+ /// Prefixed with 'c:' to avoid collisions with packet hashes.
+ String _computeContentHash(
+ int channelIdx,
+ int timestampSecs,
+ String fullText,
+ ) {
+ final textBytes = utf8.encode(fullText);
+ final input = Uint8List(5 + textBytes.length);
+ input[0] = channelIdx;
+ input[1] = timestampSecs & 0xFF;
+ input[2] = (timestampSecs >> 8) & 0xFF;
+ input[3] = (timestampSecs >> 16) & 0xFF;
+ input[4] = (timestampSecs >> 24) & 0xFF;
+ input.setRange(5, 5 + textBytes.length, textBytes);
+ final digest = crypto.sha256.convert(input).bytes;
+ return 'c:${digest.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
+ }
+
Uint8List? _decryptPayload(Uint8List psk, Uint8List encrypted) {
if (encrypted.length <= _cipherMacSize) return null;
final mac = encrypted.sublist(0, _cipherMacSize);
@@ -2583,7 +5212,7 @@ class MeshCoreConnector extends ChangeNotifier {
final keyLen = psk.length < 16 ? psk.length : 16;
key16.setRange(0, keyLen, psk);
- final cipher = ECBBlockCipher(AESFastEngine());
+ final cipher = ECBBlockCipher(AESEngine());
cipher.init(false, KeyParameter(key16));
final out = Uint8List(cipherText.length);
for (var i = 0; i < cipherText.length; i += 16) {
@@ -2599,7 +5228,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
return _ParsedText(senderName: 'Unknown', text: text);
}
- final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
+ final offset =
+ (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
return _ParsedText(
@@ -2610,66 +5240,6 @@ class MeshCoreConnector extends ChangeNotifier {
return _ParsedText(senderName: 'Unknown', text: text);
}
- Uint8List _resolveOutgoingPathBytes(
- Contact contact,
- 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 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,
- PathSelection? selection,
- ) {
- // 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;
- }
-
- PathSelection _selectionFromPath(int pathLength, Uint8List pathBytes) {
- if (pathLength < 0) {
- return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
- }
- return PathSelection(
- pathBytes: pathBytes,
- hopCount: pathLength,
- useFlood: false,
- );
- }
-
bool _addChannelMessage(int channelIndex, ChannelMessage message) {
_channelMessages.putIfAbsent(channelIndex, () => []);
final messages = _channelMessages[channelIndex]!;
@@ -2677,13 +5247,14 @@ 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 using lightweight key
+ // Check if we've already processed this exact reaction
_processedChannelReactions.putIfAbsent(channelIndex, () => {});
- final reactionKey = reactionInfo.reactionKey;
- final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
+ final reactionIdentifier =
+ '${reactionInfo.targetHash}_${reactionInfo.emoji}';
- final isDuplicate = reactionIdentifier != null &&
- _processedChannelReactions[channelIndex]!.contains(reactionIdentifier);
+ final isDuplicate = _processedChannelReactions[channelIndex]!.contains(
+ reactionIdentifier,
+ );
if (!isDuplicate) {
// New reaction - process it
@@ -2692,9 +5263,7 @@ class MeshCoreConnector extends ChangeNotifier {
_channelMessageStore.saveChannelMessages(channelIndex, messages);
// Mark as processed
- if (reactionIdentifier != null) {
- _processedChannelReactions[channelIndex]!.add(reactionIdentifier);
- }
+ _processedChannelReactions[channelIndex]!.add(reactionIdentifier);
}
return false; // Don't add reaction as a visible message
}
@@ -2705,7 +5274,10 @@ class MeshCoreConnector extends ChangeNotifier {
if (replyInfo != null) {
// Find original message by sender name (most recent match)
- final originalMessage = _findMessageBySender(messages, replyInfo.mentionedNode);
+ final originalMessage = _findMessageBySender(
+ messages,
+ replyInfo.mentionedNode,
+ );
if (originalMessage != null) {
// Create new message with reply metadata
@@ -2713,6 +5285,11 @@ class MeshCoreConnector extends ChangeNotifier {
senderKey: message.senderKey,
senderName: message.senderName,
text: replyInfo.actualMessage,
+ originalText: message.originalText,
+ translatedText: message.translatedText,
+ translatedLanguageCode: message.translatedLanguageCode,
+ translationStatus: message.translationStatus,
+ translationModelId: message.translationModelId,
timestamp: message.timestamp,
isOutgoing: message.isOutgoing,
status: message.status,
@@ -2735,37 +5312,50 @@ class MeshCoreConnector extends ChangeNotifier {
if (existingIndex >= 0) {
isNew = false;
final existing = messages[existingIndex];
- final mergedPathBytes = _selectPreferredPathBytes(existing.pathBytes, processedMessage.pathBytes);
- final mergedPathVariants = _mergePathVariants(existing.pathVariants, processedMessage.pathVariants);
+ final mergedPathBytes = _selectPreferredPathBytes(
+ existing.pathBytes,
+ processedMessage.pathBytes,
+ );
+ final mergedPathVariants = _mergePathVariants(
+ existing.pathVariants,
+ processedMessage.pathVariants,
+ );
final mergedPathLength = _mergePathLength(
existing.pathLength,
processedMessage.pathLength,
mergedPathBytes.length,
);
final newRepeatCount = existing.repeatCount + 1;
+ final promotedFromPending =
+ newRepeatCount == 1 &&
+ existing.status == ChannelMessageStatus.pending;
messages[existingIndex] = existing.copyWith(
repeatCount: newRepeatCount,
pathLength: mergedPathLength,
pathBytes: mergedPathBytes,
pathVariants: mergedPathVariants,
+ packetHash: existing.packetHash ?? processedMessage.packetHash,
// Mark as sent when first repeat is heard
- status: newRepeatCount == 1 && existing.status == ChannelMessageStatus.pending
+ status: promotedFromPending
? ChannelMessageStatus.sent
: existing.status,
);
+ if (promotedFromPending) {
+ _pendingChannelSentQueue.remove(existing.messageId);
+ }
} else {
messages.add(processedMessage);
}
// Save to persistent storage
- _channelMessageStore.saveChannelMessages(
- channelIndex,
- messages,
- );
+ _channelMessageStore.saveChannelMessages(channelIndex, messages);
return isNew;
}
- ChannelMessage? _findMessageBySender(List messages, String mentionedNode) {
+ ChannelMessage? _findMessageBySender(
+ List messages,
+ String mentionedNode,
+ ) {
// Search backwards for most recent message from this sender
for (int i = messages.length - 1; i >= 0; i--) {
if (messages[i].senderName == mentionedNode && !messages[i].isOutgoing) {
@@ -2775,25 +5365,42 @@ class MeshCoreConnector extends ChangeNotifier {
return null;
}
- void _processReaction(List messages, ReactionInfo reactionInfo) {
- // Find target message by messageId
- for (int i = 0; i < messages.length; i++) {
- if (messages[i].messageId == reactionInfo.targetMessageId) {
- final currentReactions = Map.from(messages[i].reactions);
- currentReactions[reactionInfo.emoji] =
- (currentReactions[reactionInfo.emoji] ?? 0) + 1;
-
- messages[i] = messages[i].copyWith(reactions: currentReactions);
+ void _processReaction(
+ List messages,
+ ReactionInfo reactionInfo,
+ ) {
+ ReactionHelper.applyReaction(
+ messages: messages,
+ reactionInfo: reactionInfo,
+ shouldSkip: (_) => false,
+ getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000,
+ getSenderName: (msg) => msg.senderName,
+ getMessageText: (msg) => msg.text,
+ getReactions: (msg) => msg.reactions,
+ updateMessage: (i, reactions) {
+ messages[i] = messages[i].copyWith(reactions: reactions);
notifyListeners();
- break;
- }
- }
+ },
+ );
}
- int _findChannelRepeatIndex(List messages, ChannelMessage incoming) {
+ int _findChannelRepeatIndex(
+ List messages,
+ ChannelMessage incoming,
+ ) {
+ // First pass: match by packet hash (exact dedup)
+ final incomingHash = incoming.packetHash;
+ if (incomingHash != null) {
+ for (int i = messages.length - 1; i >= 0; i--) {
+ final existingHash = messages[i].packetHash;
+ if (existingHash != null && existingHash == incomingHash) {
+ return i;
+ }
+ }
+ }
+ // Second pass: heuristic fallback (outgoing echo, old messages without hash)
for (int i = messages.length - 1; i >= 0; i--) {
- final existing = messages[i];
- if (_isChannelRepeat(existing, incoming)) {
+ if (_isChannelRepeat(messages[i], incoming)) {
return i;
}
}
@@ -2803,10 +5410,11 @@ class MeshCoreConnector extends ChangeNotifier {
bool _isChannelRepeat(ChannelMessage existing, ChannelMessage incoming) {
if (existing.text != incoming.text) return false;
- final diffMs = (existing.timestamp.millisecondsSinceEpoch -
- incoming.timestamp.millisecondsSinceEpoch)
- .abs();
- if (diffMs > 5000) return false;
+ final diffMs =
+ (existing.timestamp.millisecondsSinceEpoch -
+ incoming.timestamp.millisecondsSinceEpoch)
+ .abs();
+ if (diffMs > 30000) return false;
if (existing.senderName == incoming.senderName) return true;
@@ -2821,28 +5429,19 @@ class MeshCoreConnector extends ChangeNotifier {
}
bool _shouldDropSelfChannelMessage(String senderName, Uint8List pathBytes) {
- final selfKey = _selfPublicKey;
- if (selfKey == null) return false;
- if (pathBytes.length < pathHashSize) return false;
final trimmed = senderName.trim();
if (trimmed.isEmpty) return false;
+
final selfName = _selfName?.trim();
if (selfName == null || selfName.isEmpty) return false;
+
+ // If sender name doesn't match, keep the message
if (trimmed != selfName) return false;
- final prefix = selfKey.sublist(0, pathHashSize);
- for (int i = 0; i + pathHashSize <= pathBytes.length; i += pathHashSize) {
- var match = true;
- for (int j = 0; j < pathHashSize; j++) {
- if (pathBytes[i + j] != prefix[j]) {
- match = false;
- break;
- }
- }
- if (match) {
- return true;
- }
- }
- return false;
+
+ // Name matches - this is from self
+ // Drop only if pathBytes is empty (direct broadcast)
+ // Keep if pathBytes has data (repeated through another node)
+ return pathBytes.isEmpty;
}
Uint8List _selectPreferredPathBytes(Uint8List existing, Uint8List incoming) {
@@ -2896,8 +5495,13 @@ class MeshCoreConnector extends ChangeNotifier {
}
void _handleDisconnection() {
- // Disable wake lock when connection is lost
- WakelockPlus.disable();
+ _stopBatteryPolling();
+ _stopRadioStatsPolling();
+ _latestRadioStats = null;
+ radioStatsNotifier.value = null;
+ _prevTotalAirSecs = 0;
+ _airtimeBumpStopwatch?.stop();
+ _airtimeBumpStopwatch = null;
for (final entry in _pendingRepeaterAcks.values) {
entry.timeout?.cancel();
@@ -2914,17 +5518,78 @@ class MeshCoreConnector extends ChangeNotifier {
_txCharacteristic = null;
// Preserve deviceId and displayName for UI display during reconnection
// They're only cleared on manual disconnect via disconnect() method
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
_queuedMessageSyncInFlight = false;
_isSyncingChannels = false;
_channelSyncInFlight = false;
+ _pendingChannelSentQueue.clear();
+ _pendingGenericAckQueue.clear();
+ _reactionSendQueueSequence = 0;
_setState(MeshCoreConnectionState.disconnected);
_scheduleReconnect();
}
+ void _trackPendingGenericAck(
+ Uint8List data, {
+ String? channelSendQueueId,
+ required bool expectsGenericAck,
+ }) {
+ if (!expectsGenericAck || data.isEmpty) return;
+ _pendingGenericAckQueue.add(
+ _PendingCommandAck(
+ commandCode: data[0],
+ channelSendQueueId: channelSendQueueId,
+ ),
+ );
+ }
+
+ String _nextReactionSendQueueId() {
+ _reactionSendQueueSequence++;
+ return '$_reactionSendQueuePrefix$_reactionSendQueueSequence';
+ }
+
+ bool _isReactionSendQueueId(String queueId) {
+ return queueId.startsWith(_reactionSendQueuePrefix);
+ }
+
+ Map _parseKeyValueString(String input) {
+ final result = {};
+
+ // Split on commas first – empty entries are ignored.
+ for (final pair in input.split(',')) {
+ final trimmedPair = pair.trim();
+ if (trimmedPair.isEmpty) continue;
+
+ // Each pair must contain exactly one ':'.
+ final separatorIndex = trimmedPair.indexOf(':');
+ if (separatorIndex == -1) continue; // malformed, skip
+
+ final key = trimmedPair.substring(0, separatorIndex).trim();
+ final value = trimmedPair.substring(separatorIndex + 1).trim();
+
+ if (key.isNotEmpty) {
+ result[key] = value;
+ }
+ }
+
+ return result;
+ }
+
+ void _handleCustomVars(Uint8List frame) {
+ final buf = BufferReader(frame.sublist(1));
+ try {
+ _currentCustomVars = _parseKeyValueString(buf.readCString());
+ } catch (e) {
+ appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector');
+ }
+ }
+
void _setState(MeshCoreConnectionState newState) {
if (_state != newState) {
_state = newState;
@@ -2932,19 +5597,465 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
+ void markNotifyDirty() {
+ if (_notifyListenersDirty && _notifyListenersTimer != null) {
+ return;
+ }
+
+ _notifyListenersDirty = true;
+ _notifyListenersTimer ??= Timer(
+ _notifyListenersDebounce,
+ _flushBatchedNotify,
+ );
+ }
+
+ void _flushBatchedNotify() {
+ _notifyListenersTimer = null;
+ if (!_notifyListenersDirty) {
+ return;
+ }
+
+ _notifyListenersDirty = false;
+ super.notifyListeners();
+
+ if (_notifyListenersDirty && _notifyListenersTimer == null) {
+ _notifyListenersTimer = Timer(
+ _notifyListenersDebounce,
+ _flushBatchedNotify,
+ );
+ }
+ }
+
+ @override
+ void notifyListeners() {
+ markNotifyDirty();
+ }
+
@override
void dispose() {
_scanSubscription?.cancel();
_connectionSubscription?.cancel();
+ _usbFrameSubscription?.cancel();
_notifySubscription?.cancel();
+ _notifyListenersTimer?.cancel();
_reconnectTimer?.cancel();
+ _batteryPollTimer?.cancel();
+ _radioStatsPollTimer?.cancel();
+ radioStatsNotifier.dispose();
_receivedFramesController.close();
+ _usbManager.dispose();
+ _tcpConnector.dispose();
// Flush pending unread writes before disposal
_unreadStore.flush();
super.dispose();
}
+
+ void _handleRxData(Uint8List frame) {
+ final packet = BufferReader(frame);
+ try {
+ packet.skipBytes(1); // Skip frame type byte
+ final snr = packet.readInt8() / 4.0;
+ packet.skipBytes(1); // Skip RSSI byte
+ //final rssi = packet.readByte();
+ final header = packet.readByte();
+ final routeType = header & 0x03;
+ final payloadType = (header >> 2) & 0x0F;
+ if (routeType == _routeTransportFlood ||
+ routeType == _routeTransportDirect) {
+ packet.skipBytes(4); // Skip transport-specific bytes
+ }
+ //final payloadVer = (header >> 6) & 0x03;
+ final pathLenRaw = packet.readByte();
+ final pathByteLen = _decodePathByteLen(pathLenRaw);
+ final pathBytes = packet.readBytes(pathByteLen);
+ final payload = packet.readBytes(packet.remaining);
+
+ final rawPacket = frame.sublist(3);
+ switch (payloadType) {
+ case payloadTypeADVERT:
+ _handlePayloadAdvertReceived(
+ rawPacket,
+ payload,
+ pathBytes,
+ routeType,
+ snr,
+ );
+ break;
+ default:
+ }
+ } catch (e) {
+ appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
+ return;
+ }
+ }
+
+ void importContact(Uint8List frame) {
+ final packet = BufferReader(frame);
+ int payloadType = 0;
+ Uint8List pathBytes = Uint8List(0);
+ try {
+ packet.skipBytes(1); // Skip frame type byte
+ packet.skipBytes(1); // Skip SNR byte
+ packet.skipBytes(1); // Skip RSSI byte
+ final header = packet.readByte();
+ final routeType = header & 0x03;
+ payloadType = (header >> 2) & 0x0F;
+ if (routeType == _routeTransportFlood ||
+ routeType == _routeTransportDirect) {
+ packet.skipBytes(4); // Skip transport-specific bytes
+ }
+ //final payloadVer = (header >> 6) & 0x03;
+ final pathLenRaw = packet.readByte();
+ final pathByteLen = _decodePathByteLen(pathLenRaw);
+ pathBytes = packet.readBytes(pathByteLen);
+ } catch (e) {
+ appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
+ return;
+ }
+ double? latitude;
+ double? longitude;
+ String name = '';
+ Uint8List publicKey = Uint8List(0);
+ int type = 0;
+ int timestamp = 0;
+ bool hasLocation = false;
+ bool hasName = false;
+ if (payloadType != payloadTypeADVERT) {
+ appLogger.warn('Unexpected payload type: $payloadType', tag: 'Connector');
+ return;
+ }
+ try {
+ publicKey = packet.readBytes(32);
+ timestamp = packet.readInt32LE();
+ //TODO add signature verification
+ packet.skipBytes(64); // Skip signature for now
+ final flags = packet.readByte();
+ type = flags & 0x0F;
+ hasLocation = (flags & 0x10) != 0;
+ // For future use:
+ //final hasFeature1 = (flags & 0x20) != 0;
+ //final hasFeature2 = (flags & 0x40) != 0;
+ hasName = (flags & 0x80) != 0;
+ if (hasLocation && packet.remaining >= 8) {
+ latitude = packet.readInt32LE() / 1e6;
+ longitude = packet.readInt32LE() / 1e6;
+ }
+ if (hasName && packet.remaining > 0) {
+ name = packet.readCString();
+ }
+ } catch (e) {
+ appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
+ return;
+ }
+
+ importDiscoveredContact(
+ Contact(
+ rawPacket: frame,
+ publicKey: publicKey,
+ name: name,
+ type: type,
+ pathLength: pathBytes.isEmpty ? -1 : pathBytes.length,
+ path: Uint8List.fromList(
+ pathBytes.reversed.toList(),
+ ), // Store path in reverse for easier use in outgoing messages
+ latitude: latitude,
+ longitude: longitude,
+ lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
+ ),
+ );
+ }
+
+ bool hasValidLocation(double? latitude, double? longitude) {
+ const double epsilon = 1e-6;
+ final lat = latitude ?? 0.0;
+ final lon = longitude ?? 0.0;
+ return (lat.abs() > epsilon || lon.abs() > epsilon) &&
+ lat >= -90.0 &&
+ lat <= 90.0 &&
+ lon >= -180.0 &&
+ lon <= 180.0;
+ }
+
+ void _handlePayloadAdvertReceived(
+ Uint8List rawPacket,
+ Uint8List payload,
+ Uint8List path,
+ int routeType,
+ double snr,
+ ) {
+ final advert = BufferReader(payload);
+ double? latitude;
+ double? longitude;
+ String name = '';
+ String contactKeyHex = '';
+ Uint8List publicKey = Uint8List(0);
+ int type = 0;
+ int timestamp = 0;
+ bool hasLocation = false;
+ bool hasName = false;
+ try {
+ publicKey = advert.readBytes(32);
+ contactKeyHex = publicKey
+ .map((b) => b.toRadixString(16).padLeft(2, '0'))
+ .join();
+
+ timestamp = advert.readInt32LE();
+ //TODO add signature verification
+ advert.skipBytes(64); // Skip signature for now
+ final flags = advert.readByte();
+ type = flags & 0x0F;
+ hasLocation = (flags & 0x10) != 0;
+ // For future use:
+ //final hasFeature1 = (flags & 0x20) != 0;
+ //final hasFeature2 = (flags & 0x40) != 0;
+ hasName = (flags & 0x80) != 0;
+ if (hasLocation && advert.remaining >= 8) {
+ latitude = advert.readInt32LE() / 1e6;
+ longitude = advert.readInt32LE() / 1e6;
+ }
+ // Validate location values if present
+ hasLocation = hasValidLocation(latitude, longitude);
+
+ if (hasName && advert.remaining > 0) {
+ name = advert.readCString();
+ }
+ } catch (e) {
+ appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
+ return;
+ }
+
+ //We ignore our own adverts
+ if (listEquals(publicKey, _selfPublicKey)) {
+ return;
+ }
+
+ // Check if this is a new contact
+ final isNewContact = !_knownContactKeys.contains(contactKeyHex);
+
+ if (isNewContact) {
+ final newContact = Contact(
+ rawPacket: rawPacket,
+ publicKey: publicKey,
+ name: name,
+ type: type,
+ pathLength: path.length,
+ path: Uint8List.fromList(
+ path.reversed.toList(),
+ ), // Store path in reverse for easier use in outgoing messages
+ latitude: latitude,
+ longitude: longitude,
+ lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
+ );
+ if ((_autoAddUsers && type == advTypeChat) ||
+ (_autoAddRepeaters && type == advTypeRepeater) ||
+ (_autoAddRoomServers && type == advTypeRoom) ||
+ (_autoAddSensors && type == advTypeSensor)) {
+ _handleContactAdvert(newContact);
+ _handleDiscovery(
+ newContact,
+ rawPacket,
+ noNotify: true,
+ addActive: true,
+ );
+ } else {
+ _handleDiscovery(newContact, rawPacket);
+ }
+ _updateDirectRepeater(newContact, snr, path);
+ return;
+ }
+
+ final existingIndex = _contacts.indexWhere(
+ (c) => c.publicKeyHex == contactKeyHex,
+ );
+
+ if (existingIndex >= 0) {
+ final existing = _contacts[existingIndex];
+ final mergedLastMessageAt = existing.lastMessageAt.isAfter(DateTime.now())
+ ? DateTime.now()
+ : existing.lastMessageAt;
+
+ appLogger.info(
+ 'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}',
+ tag: 'Connector',
+ );
+
+ // CRITICAL: Preserve user's path override when contact is refreshed from device
+ _contacts[existingIndex] = existing.copyWith(
+ latitude: hasLocation ? latitude : existing.latitude,
+ longitude: hasLocation ? longitude : existing.longitude,
+ name: hasName ? name : existing.name,
+ path: Uint8List.fromList(path.reversed.toList()),
+ pathLength: path.length,
+ lastMessageAt: mergedLastMessageAt,
+ lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
+ pathOverride: existing.pathOverride, // Preserve user's path choice
+ pathOverrideBytes: existing.pathOverrideBytes,
+ );
+
+ // Add path to history if we have a valid path
+ if (_pathHistoryService != null &&
+ _contacts[existingIndex].pathLength >= 0) {
+ _pathHistoryService!.handlePathUpdated(_contacts[existingIndex]);
+ }
+
+ _updateDirectRepeater(_contacts[existingIndex], snr, path);
+
+ appLogger.info(
+ 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
+ tag: 'Connector',
+ );
+ }
+ }
+
+ void _updateDirectRepeater(Contact contact, double snr, Uint8List path) {
+ final pubkeyFirstByte = path.isNotEmpty
+ ? path.last
+ : contact.publicKey.first;
+
+ _directRepeaters.removeWhere((r) => r.isStale());
+
+ //We can use adverts from chat and sensor nodes, but only if the advert has a path to get the last hop.
+ if ((contact.type == advTypeChat || contact.type == advTypeSensor) &&
+ path.isEmpty) {
+ notifyListeners();
+ return;
+ }
+
+ final isTracked = _directRepeaters.where(
+ (r) => r.pubkeyFirstByte == pubkeyFirstByte,
+ );
+
+ final sortedRepeaters = List.from(_directRepeaters)
+ ..sort((a, b) => b.snr.compareTo(a.snr));
+ final weakestRepeater = sortedRepeaters.isNotEmpty
+ ? sortedRepeaters.last
+ : null;
+
+ if (_directRepeaters.length >= 5 &&
+ weakestRepeater != null &&
+ isTracked.isEmpty) {
+ _directRepeaters.remove(weakestRepeater);
+ }
+
+ if (isTracked.isNotEmpty) {
+ final repeater = isTracked.first;
+ repeater.update(snr);
+ } else if (_directRepeaters.length < 5) {
+ _directRepeaters.add(
+ DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr),
+ );
+ }
+ notifyListeners();
+ }
+
+ void _handleAutoAddConfig(Uint8List frame) {
+ final reader = BufferReader(frame);
+ try {
+ reader.skipBytes(1); // Skip the response code byte
+ final flags = reader.readByte();
+ _autoAddUsers = (flags & autoAddChatFlag) != 0;
+ _autoAddRepeaters = (flags & autoAddRepeaterFlag) != 0;
+ _autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0;
+ _autoAddSensors = (flags & autoAddSensorFlag) != 0;
+ _overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0;
+ } catch (e) {
+ appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
+ }
+ }
+
+ void _handleDiscovery(
+ Contact contact,
+ Uint8List rawPacket, {
+ bool noNotify = false,
+ bool addActive = false,
+ }) {
+ appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector');
+
+ final existingIndex = _discoveredContacts.indexWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
+
+ // Update existing contact
+ if (existingIndex >= 0) {
+ _discoveredContacts[existingIndex] = _discoveredContacts[existingIndex]
+ .copyWith(
+ rawPacket: rawPacket,
+ name: contact.name,
+ type: contact.type,
+ pathLength: contact.pathLength,
+ path: contact.path,
+ latitude: contact.latitude,
+ longitude: contact.longitude,
+ lastSeen: contact.lastSeen,
+ flags: 0,
+ isActive: addActive,
+ );
+ notifyListeners();
+ unawaited(_persistDiscoveredContacts());
+ return;
+ }
+
+ final disContact = Contact(
+ rawPacket: rawPacket,
+ publicKey: contact.publicKey,
+ name: contact.name,
+ type: contact.type,
+ pathLength: contact.pathLength,
+ path: contact.path,
+ latitude: contact.latitude,
+ longitude: contact.longitude,
+ lastSeen: contact.lastSeen,
+ lastMessageAt: contact.lastMessageAt,
+ isActive: addActive,
+ flags: 0,
+ );
+ _discoveredContacts.add(disContact);
+
+ unawaited(_persistDiscoveredContacts());
+
+ // Show notification for new contact (advertisement)
+ if (_appSettingsService != null && !noNotify) {
+ final settings = _appSettingsService!.settings;
+ if (settings.notificationsEnabled && settings.notifyOnNewAdvert) {
+ _notificationService.showAdvertNotification(
+ contactName: contact.name,
+ contactType: contact.typeLabel,
+ contactId: contact.publicKeyHex,
+ );
+ }
+ }
+ }
+
+ void removeAllDiscoveredContacts() {
+ _discoveredContacts.clear();
+ unawaited(_persistDiscoveredContacts());
+ notifyListeners();
+ }
+
+ void clearMessagesForContact(Contact contact) {
+ final contactKeyHex = contact.publicKeyHex;
+ final messages = _conversations[contactKeyHex];
+ if (messages == null) return;
+ messages.clear();
+ unawaited(_messageStore.saveMessages(contactKeyHex, messages));
+ markContactRead(contactKeyHex);
+ notifyListeners();
+ }
+
+ void clearMessagesForChannel(int channelIndex) {
+ final messages = _channelMessages[channelIndex];
+ if (messages == null) return;
+ messages.clear();
+ unawaited(_channelMessageStore.saveChannelMessages(channelIndex, messages));
+ markChannelRead(channelIndex);
+ notifyListeners();
+ }
+
+ void deleteAllPaths() {
+ _pathHistoryService?.clearAllHistories();
+ }
}
const int _phRouteMask = 0x03;
@@ -2955,17 +6066,25 @@ const int _phVerMask = 0x03;
const int _routeTransportFlood = 0x00;
const int _routeFlood = 0x01;
-const int _routeDirect = 0x02;
const int _routeTransportDirect = 0x03;
const int _payloadTypeGroupText = 0x05;
const int _cipherMacSize = 2;
+/// Decodes the firmware's encoded path_len byte into actual byte length.
+/// Bits 0-5: hash count (0-63), Bits 6-7: hash size code (0=1byte, 1=2bytes, 2=3bytes).
+int _decodePathByteLen(int pathLenRaw) {
+ final hashCount = pathLenRaw & 63;
+ final hashSize = ((pathLenRaw >> 6) & 0x03) + 1;
+ return hashCount * hashSize;
+}
+
class _RawPacket {
final int header;
final int routeType;
final int payloadType;
final int payloadVer;
+ final int pathLenRaw;
final Uint8List pathBytes;
final Uint8List payload;
@@ -2974,21 +6093,22 @@ class _RawPacket {
required this.routeType,
required this.payloadType,
required this.payloadVer,
+ required this.pathLenRaw,
required this.pathBytes,
required this.payload,
});
- bool get isFlood => routeType == _routeFlood || routeType == _routeTransportFlood;
+ bool get isFlood =>
+ routeType == _routeFlood || routeType == _routeTransportFlood;
+
+ int get hopCount => pathLenRaw & 63;
}
class _ParsedText {
final String senderName;
final String text;
- _ParsedText({
- required this.senderName,
- required this.text,
- });
+ _ParsedText({required this.senderName, required this.text});
}
class _RepeaterAckContext {
@@ -3005,3 +6125,10 @@ class _RepeaterAckContext {
required this.messageBytes,
});
}
+
+class _PendingCommandAck {
+ final int commandCode;
+ final String? channelSendQueueId;
+
+ _PendingCommandAck({required this.commandCode, this.channelSendQueueId});
+}
diff --git a/lib/connector/meshcore_connector_tcp.dart b/lib/connector/meshcore_connector_tcp.dart
new file mode 100644
index 0000000..7c93d9f
--- /dev/null
+++ b/lib/connector/meshcore_connector_tcp.dart
@@ -0,0 +1,70 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+import '../services/app_debug_log_service.dart';
+import '../services/tcp_transport_service.dart';
+
+/// Manages TCP transport for MeshCore devices.
+///
+/// Owns the [TcpTransportService] and TCP-specific connection state.
+/// The main [MeshCoreConnector] delegates all TCP operations here.
+class MeshCoreTcpConnector {
+ final TcpTransportService _service = TcpTransportService();
+ AppDebugLogService? _debugLog;
+ StreamSubscription? _frameSubscription;
+
+ // --- Getters ---
+ String? get activeEndpoint => _service.activeEndpoint;
+ bool get isConnected => _service.isConnected;
+
+ // --- Configuration ---
+ void setDebugLogService(AppDebugLogService? service) {
+ _debugLog = service;
+ _service.setDebugLogService(service);
+ }
+
+ // --- Connection lifecycle ---
+ Future connect({required String host, required int port}) async {
+ _debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP');
+ await _frameSubscription?.cancel();
+ _frameSubscription = null;
+ await _service.connect(host: host, port: port);
+ _debugLog?.info(
+ 'TcpConnector.connect done, endpoint=${_service.activeEndpoint}',
+ tag: 'TCP',
+ );
+ }
+
+ StreamSubscription listenFrames({
+ required void Function(Uint8List) onFrame,
+ required void Function(Object, StackTrace?) onError,
+ required void Function() onDone,
+ }) {
+ _frameSubscription = _service.frameStream.listen(
+ onFrame,
+ onError: onError,
+ onDone: onDone,
+ );
+ return _frameSubscription!;
+ }
+
+ Future cancelFrameSubscription() async {
+ await _frameSubscription?.cancel();
+ _frameSubscription = null;
+ }
+
+ Future disconnect() async {
+ if (!_service.isConnected && _frameSubscription == null) return;
+ _debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
+ await _frameSubscription?.cancel();
+ _frameSubscription = null;
+ await _service.disconnect();
+ }
+
+ Future write(Uint8List data) => _service.write(data);
+
+ void dispose() {
+ _frameSubscription?.cancel();
+ _service.dispose();
+ }
+}
diff --git a/lib/connector/meshcore_connector_usb.dart b/lib/connector/meshcore_connector_usb.dart
new file mode 100644
index 0000000..56718bc
--- /dev/null
+++ b/lib/connector/meshcore_connector_usb.dart
@@ -0,0 +1,78 @@
+import 'dart:typed_data';
+
+import '../services/app_debug_log_service.dart';
+import '../services/usb_serial_service.dart';
+
+/// Manages USB serial transport for MeshCore devices.
+///
+/// Owns the [UsbSerialService] and USB-specific connection state.
+/// The main [MeshCoreConnector] delegates all USB operations here.
+class MeshCoreUsbManager {
+ MeshCoreUsbManager();
+
+ final UsbSerialService _service = UsbSerialService();
+ AppDebugLogService? _debugLog;
+ String? _activePortKey;
+ String? _activePortLabel;
+
+ // --- Getters ---
+ String? get activePortKey => _activePortKey;
+ String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
+ bool get isConnected => _service.isConnected;
+ Stream get frameStream => _service.frameStream;
+
+ // --- Configuration ---
+ Future> listPorts() => _service.listPorts();
+
+ void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
+
+ void setFallbackDeviceName(String label) =>
+ _service.setFallbackDeviceName(label);
+
+ void setDebugLogService(AppDebugLogService? service) {
+ _debugLog = service;
+ _service.setDebugLogService(service);
+ }
+
+ // --- Connection lifecycle ---
+ Future connect({
+ required String portName,
+ int baudRate = 115200,
+ }) async {
+ _debugLog?.info(
+ 'UsbManager.connect: portName=$portName baud=$baudRate',
+ tag: 'USB',
+ );
+ await _service.connect(portName: portName, baudRate: baudRate);
+ _activePortKey = _service.activePortKey ?? portName;
+ _activePortLabel = _service.activePortDisplayLabel ?? portName;
+ _debugLog?.info(
+ 'UsbManager.connect: done, key=$_activePortKey label=$_activePortLabel',
+ tag: 'USB',
+ );
+ }
+
+ Future disconnect() async {
+ if (!_service.isConnected && _activePortKey == null) {
+ return;
+ }
+ _debugLog?.info('UsbManager.disconnect', tag: 'USB');
+ await _service.disconnect();
+ _activePortKey = null;
+ _activePortLabel = null;
+ }
+
+ Future write(Uint8List data) => _service.write(data);
+
+ Future writeRaw(Uint8List data) => _service.writeRaw(data);
+
+ // --- Label management ---
+ void updateConnectedLabel(String selfName) {
+ _service.updateConnectedLabel(selfName);
+ _activePortLabel = _service.activePortDisplayLabel ?? _activePortLabel;
+ }
+
+ void dispose() {
+ _service.dispose();
+ }
+}
diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart
index ac4b6de..396d78b 100644
--- a/lib/connector/meshcore_protocol.dart
+++ b/lib/connector/meshcore_protocol.dart
@@ -1,6 +1,178 @@
import 'dart:convert';
import 'dart:typed_data';
+import 'package:flutter/widgets.dart';
+
+// Buffer Reader - sequential binary data reader with pointer tracking
+class BufferReader {
+ int _pointer = 0;
+ int _lastPointer = 0;
+ final Uint8List _buffer;
+
+ BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
+
+ int get remaining => _buffer.length - _pointer;
+
+ int readByte() => readBytes(1)[0];
+
+ Uint8List readBytes(int count) {
+ _lastPointer = _pointer;
+ if (_pointer + count > _buffer.length) {
+ throw RangeError(
+ 'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
+ );
+ }
+ final data = _buffer.sublist(_pointer, _pointer + count);
+ _pointer += count;
+ return data;
+ }
+
+ void skipBytes(int count) {
+ _lastPointer = _pointer;
+ if (_pointer + count > _buffer.length) {
+ throw RangeError(
+ 'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
+ );
+ }
+ _pointer += count;
+ }
+
+ Uint8List readRemainingBytes() => readBytes(remaining);
+
+ String readCStringGreedy(int maxLength) {
+ _lastPointer = _pointer;
+ final value = [];
+ final bytes = readBytes(maxLength);
+ for (final byte in bytes) {
+ if (byte == 0) break;
+ value.add(byte);
+ }
+ try {
+ return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
+ } catch (e) {
+ return String.fromCharCodes(value); // Latin-1 fallback
+ }
+ }
+
+ String readCString({int maxLength = -1}) {
+ final backupPointer = _pointer;
+ final value = [];
+ int counter = 0;
+ final maxLen = maxLength >= 0 ? maxLength : remaining;
+ while (counter < maxLen) {
+ final byte = readByte();
+ if (byte == 0) break;
+ value.add(byte);
+ counter++;
+ }
+ _lastPointer = backupPointer;
+ try {
+ return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
+ } catch (e) {
+ return String.fromCharCodes(value); // Latin-1 fallback
+ }
+ }
+
+ int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
+ int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
+ int readUInt16LE() =>
+ readBytes(2).buffer.asByteData().getUint16(0, Endian.little);
+ int readUInt16BE() =>
+ readBytes(2).buffer.asByteData().getUint16(0, Endian.big);
+ int readUInt32LE() =>
+ readBytes(4).buffer.asByteData().getUint32(0, Endian.little);
+ int readUInt32BE() =>
+ readBytes(4).buffer.asByteData().getUint32(0, Endian.big);
+ int readInt16LE() =>
+ readBytes(2).buffer.asByteData().getInt16(0, Endian.little);
+ int readInt16BE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.big);
+ int readInt32LE() =>
+ readBytes(4).buffer.asByteData().getInt32(0, Endian.little);
+
+ int readInt24BE() {
+ var value = (readByte() << 16) | (readByte() << 8) | readByte();
+ if ((value & 0x800000) != 0) value -= 0x1000000;
+ return value;
+ }
+
+ void resetPointer() => _pointer = 0;
+ void rewind() => _pointer = _lastPointer;
+}
+
+// Buffer Writer - accumulating binary data builder
+class BufferWriter {
+ final BytesBuilder _builder = BytesBuilder();
+
+ Uint8List toBytes() => _builder.toBytes();
+
+ void writeByte(int byte) => _builder.addByte(byte);
+ void writeBytes(Uint8List bytes) => _builder.add(bytes);
+
+ void writeUInt16LE(int num) {
+ final bytes = Uint8List(2)
+ ..buffer.asByteData().setUint16(0, num, Endian.little);
+ writeBytes(bytes);
+ }
+
+ void writeUInt32LE(int num) {
+ final bytes = Uint8List(4)
+ ..buffer.asByteData().setUint32(0, num, Endian.little);
+ writeBytes(bytes);
+ }
+
+ void writeInt32LE(int num) {
+ final bytes = Uint8List(4)
+ ..buffer.asByteData().setInt32(0, num, Endian.little);
+ writeBytes(bytes);
+ }
+
+ void writeString(String string) =>
+ writeBytes(Uint8List.fromList(utf8.encode(string)));
+
+ void writeCString(String string, int maxLength) {
+ final bytes = Uint8List(maxLength);
+ final encoded = utf8.encode(string);
+ for (var i = 0; i < maxLength - 1 && i < encoded.length; i++) {
+ bytes[i] = encoded[i];
+ }
+ writeBytes(bytes);
+ }
+
+ void writeHex(String hex) {
+ writeBytes(hex2Uint8List(hex));
+ }
+
+ void writeBytesPadded(Uint8List bytes, int totalLength) {
+ // Path data (64 bytes, zero-padded)
+ final bytesPadded = Uint8List(totalLength);
+ final len = bytes.length < totalLength ? bytes.length : totalLength;
+ if (bytes.isNotEmpty && len > 0) {
+ final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
+ for (int i = 0; i < copyLen; i++) {
+ bytesPadded[i] = bytes[i];
+ }
+ }
+ writeBytes(bytesPadded);
+ }
+}
+
+Uint8List hex2Uint8List(String hex) {
+ // Validate hex string length is even and not empty
+ if (hex.isEmpty || hex.length % 2 != 0) {
+ throw FormatException('Invalid hex string length: ${hex.length}');
+ }
+ List result = [];
+ for (int i = 0; i < hex.length ~/ 2; i++) {
+ final hexByte = hex.substring(i * 2, i * 2 + 2);
+ final byte = int.tryParse(hexByte, radix: 16);
+ if (byte == null) {
+ throw FormatException('Invalid hex characters at position $i: $hexByte');
+ }
+ result.add(byte);
+ }
+ return Uint8List.fromList(result);
+}
+
// Command codes (to device)
const int cmdAppStart = 1;
const int cmdSendTxtMsg = 2;
@@ -28,18 +200,29 @@ const int cmdSendStatusReq = 27;
const int cmdGetContactByKey = 30;
const int cmdGetChannel = 31;
const int cmdSetChannel = 32;
-const int cmdGetRadioSettings = 57;
+const int cmdSendTracePath = 36;
+const int cmdSetOtherParams = 38;
+const int cmdSendTelemetryReq = 39;
+const int cmdGetCustomVar = 40;
+const int cmdSetCustomVar = 41;
+const int cmdSendBinaryReq = 50;
+const int cmdGetStats = 56;
+const int cmdSendAnonReq = 57;
+const int cmdSetAutoAddConfig = 58;
+const int cmdGetAutoAddConfig = 59;
+const int cmdSetPathHashMode = 61;
// Text message types
const int txtTypePlain = 0;
const int txtTypeCliData = 1;
+const int txtTypeSigned = 2;
// Repeater request types (for server requests)
const int reqTypeGetStatus = 0x01;
const int reqTypeKeepAlive = 0x02;
const int reqTypeGetTelemetry = 0x03;
const int reqTypeGetAccessList = 0x05;
-const int reqTypeGetNeighbours = 0x06;
+const int reqTypeGetNeighbors = 0x06;
// Repeater response codes
const int respServerLoginOk = 0;
@@ -56,12 +239,19 @@ const int respCodeContactMsgRecv = 7;
const int respCodeChannelMsgRecv = 8;
const int respCodeCurrTime = 9;
const int respCodeNoMoreMessages = 10;
+const int respCodeExportContact = 11;
const int respCodeBattAndStorage = 12;
const int respCodeDeviceInfo = 13;
const int respCodeContactMsgRecvV3 = 16;
const int respCodeChannelMsgRecvV3 = 17;
const int respCodeChannelInfo = 18;
-const int respCodeRadioSettings = 25;
+const int respCodeCustomVars = 21;
+const int respCodeAutoAddConfig = 25;
+const int respCodeStats = 24;
+
+const int statsTypeCore = 0;
+const int statsTypeRadio = 1;
+const int statsTypePackets = 2;
// Push codes (async from device)
const int pushCodeAdvert = 0x80;
@@ -72,7 +262,10 @@ const int pushCodeLoginSuccess = 0x85;
const int pushCodeLoginFail = 0x86;
const int pushCodeStatusResponse = 0x87;
const int pushCodeLogRxData = 0x88;
+const int pushCodeTraceData = 0x89;
const int pushCodeNewAdvert = 0x8A;
+const int pushCodeTelemetryResponse = 0x8B;
+const int pushCodeBinaryResponse = 0x8C;
// Contact/advertisement types
const int advTypeChat = 1;
@@ -80,8 +273,49 @@ const int advTypeRepeater = 2;
const int advTypeRoom = 3;
const int advTypeSensor = 4;
+const int teleModeDeny = 0;
+const int teleModeAllowFlags = 1; // use contact.flags
+const int teleModeAllowAll = 2;
+
+// Payload Types
+const int payloadTypeREQ =
+ 0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
+const int payloadTypeRESPONSE =
+ 0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
+const int payloadTypeTXTMSG =
+ 0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)
+const int payloadTypeACK = 0x03; // a simple ack
+const int payloadTypeADVERT = 0x04; // a node advertising its Identity
+const int payloadTypeGRPTXT =
+ 0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg")
+const int payloadTypeGRPDATA =
+ 0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob)
+const int payloadTypeANONREQ =
+ 0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
+const int payloadTypePATH =
+ 0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
+const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop
+const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets
+const int payloadTypeCONTROL = 0x0B; // a control/discovery packet
+//...
+const int payloadTypeRawCustom =
+ 0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc
+
+//auto-add flags
+const int autoAddOverwriteOldestFlag =
+ 1 << 0; // 0x01 - overwrite oldest non-favourite when full
+const int autoAddChatFlag =
+ 1 << 1; // 0x02 - auto-add Chat (Companion) (ADV_TYPE_CHAT)
+const int autoAddRepeaterFlag =
+ 1 << 2; // 0x04 - auto-add Repeater (ADV_TYPE_REPEATER)
+const int autoAddRoomServerFlag =
+ 1 << 3; // 0x08 - auto-add Room Server (ADV_TYPE_ROOM)
+const int autoAddSensorFlag =
+ 1 << 4; // 0x10 - auto-add Sensor (ADV_TYPE_SENSOR)
+
// Sizes
const int pubKeySize = 32;
+const int signatureSize = 64;
const int maxPathSize = 64;
const int pathHashSize = 1;
const int maxNameSize = 32;
@@ -89,8 +323,10 @@ const int maxFrameSize = 172;
const int appProtocolVersion = 3;
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
const int maxTextPayloadBytes = 160;
-const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1;
-const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1;
+const int _sendTextMsgOverheadBytes =
+ 1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
+const int _sendChannelTextMsgOverheadBytes =
+ 1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
int maxContactMessageBytes() {
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
@@ -121,13 +357,17 @@ int _minPositive(int a, int b) {
const int contactPubKeyOffset = 1;
const int contactTypeOffset = 33;
const int contactFlagsOffset = 34;
+const int contactFlagFavorite = 0x01;
+const int contactFlagTeleBase = 0x02; // 'base' permission includes battery
+const int contactFlagTeleLoc = 0x04;
+const int contactFlagTeleEnv = 0x08; //access environment sensors
const int contactPathLenOffset = 35;
const int contactPathOffset = 36;
const int contactNameOffset = 100;
const int contactTimestampOffset = 132;
const int contactLatOffset = 136;
const int contactLonOffset = 140;
-const int contactLastmodOffset = 144;
+const int contactLastModOffset = 144;
const int contactFrameSize = 148;
// Message frame offsets
@@ -139,48 +379,44 @@ const int msgTextOffset = 38;
class ParsedContactText {
final Uint8List senderPrefix;
final String text;
-
- const ParsedContactText({
- required this.senderPrefix,
- required this.text,
- });
+ const ParsedContactText({required this.senderPrefix, required this.text});
}
ParsedContactText? parseContactMessageText(Uint8List frame) {
if (frame.isEmpty) return null;
- final code = frame[0];
- if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
+
+ final message = BufferReader(frame);
+ try {
+ final code = message.readByte();
+ if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
+ return null;
+ }
+
+ // Companion radio layout:
+ // [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
+ if (code == respCodeContactMsgRecvV3) {
+ // Skip SNR and reserved bytes in v3 layout
+ message.skipBytes(3);
+ }
+ final senderPrefix = message.readBytes(6); // public key
+ message.skipBytes(1); // path length
+ final textType = message.readByte();
+ message.skipBytes(4); // timestamp (4 bytes)
+
+ final shiftedType = textType >> 2;
+ final isSigned = shiftedType == txtTypeSigned || textType == txtTypeSigned;
+ if (isSigned) {
+ // Signed messages have a 4-byte signature after the timestamp, before the text
+ message.skipBytes(4);
+ }
+ final text = message.readCString();
+ if (text.isEmpty) return null;
+
+ return ParsedContactText(senderPrefix: senderPrefix, text: text);
+ } catch (e) {
+ debugPrint('Error parsing contact message text: $e');
return null;
}
-
- // Companion radio layout:
- // [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
- final isV3 = code == respCodeContactMsgRecvV3;
- final prefixOffset = isV3 ? 4 : 1;
- const prefixLen = 6;
- final txtTypeOffset = prefixOffset + prefixLen + 1;
- final timestampOffset = txtTypeOffset + 1;
- final baseTextOffset = timestampOffset + 4;
- if (frame.length <= baseTextOffset) return null;
-
- final flags = frame[txtTypeOffset];
- final shiftedType = flags >> 2;
- final rawType = flags;
- final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
- final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
- if (!isPlain && !isCli) {
- return null;
- }
-
- var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset).trim();
- if (text.isEmpty && frame.length > baseTextOffset + 4) {
- text =
- readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim();
- }
- if (text.isEmpty) return null;
-
- final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
- return ParsedContactText(senderPrefix: senderPrefix, text: text);
}
// Helper to read uint32 little-endian
@@ -203,31 +439,9 @@ int readInt32LE(Uint8List data, int offset) {
return val;
}
-// Helper to write uint32 little-endian
-void writeUint32LE(Uint8List data, int offset, int value) {
- data[offset] = value & 0xFF;
- data[offset + 1] = (value >> 8) & 0xFF;
- data[offset + 2] = (value >> 16) & 0xFF;
- data[offset + 3] = (value >> 24) & 0xFF;
-}
-
-// Helper to write int32 little-endian
-void writeInt32LE(Uint8List data, int offset, int value) {
- writeUint32LE(data, offset, value & 0xFFFFFFFF);
-}
-
-// Helper to read null-terminated UTF-8 string
-String readCString(Uint8List data, int offset, int maxLen) {
- int end = offset;
- while (end < offset + maxLen && end < data.length && data[end] != 0) {
- end++;
- }
- try {
- return utf8.decode(data.sublist(offset, end), allowMalformed: true);
- } catch (e) {
- // Fallback to Latin-1 if UTF-8 decoding fails
- return String.fromCharCodes(data.sublist(offset, end));
- }
+// Helper to convert uint32 to hex string
+String ackHashToHex(int ackHash) {
+ return ackHash.toRadixString(16).padLeft(8, '0');
}
// Helper to convert public key to hex string
@@ -246,34 +460,32 @@ Uint8List hexToPubKey(String hex) {
// Build CMD_GET_CONTACTS frame
Uint8List buildGetContactsFrame({int? since}) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdGetContacts);
if (since != null) {
- final frame = Uint8List(5);
- frame[0] = cmdGetContacts;
- writeUint32LE(frame, 1, since);
- return frame;
+ writer.writeUInt32LE(since);
}
- return Uint8List.fromList([cmdGetContacts]);
+ return writer.toBytes();
}
// Build CMD_SEND_LOGIN frame
// Format: [cmd][pub_key x32][password...]\0
Uint8List buildSendLoginFrame(Uint8List recipientPubKey, String password) {
- final passwordBytes = utf8.encode(password);
- final frame = Uint8List(1 + pubKeySize + passwordBytes.length + 1);
- frame[0] = cmdSendLogin;
- frame.setRange(1, 1 + pubKeySize, recipientPubKey);
- frame.setRange(1 + pubKeySize, 1 + pubKeySize + passwordBytes.length, passwordBytes);
- frame[frame.length - 1] = 0;
- return frame;
+ final writer = BufferWriter();
+ writer.writeByte(cmdSendLogin);
+ writer.writeBytes(recipientPubKey);
+ writer.writeString(password);
+ writer.writeByte(0);
+ return writer.toBytes();
}
// Build CMD_SEND_STATUS_REQ frame
// Format: [cmd][pub_key x32]
Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) {
- final frame = Uint8List(1 + pubKeySize);
- frame[0] = cmdSendStatusReq;
- frame.setRange(1, 1 + pubKeySize, recipientPubKey);
- return frame;
+ final writer = BufferWriter();
+ writer.writeByte(cmdSendStatusReq);
+ writer.writeBytes(recipientPubKey);
+ return writer.toBytes();
}
// Build CMD_SEND_TXT_MSG frame (companion_radio format)
@@ -284,48 +496,39 @@ Uint8List buildSendTextMsgFrame(
int attempt = 0,
int? timestampSeconds,
}) {
- final textBytes = utf8.encode(text);
- final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
- const prefixSize = 6;
- final safeAttempt = attempt.clamp(0, 3);
- final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
- int offset = 0;
-
- frame[offset++] = cmdSendTxtMsg;
- frame[offset++] = txtTypePlain;
- frame[offset++] = safeAttempt;
- writeUint32LE(frame, offset, timestamp);
- offset += 4;
-
- frame.setRange(offset, offset + prefixSize, recipientPubKey.sublist(0, prefixSize));
- offset += prefixSize;
-
- frame.setRange(offset, offset + textBytes.length, textBytes);
- frame[frame.length - 1] = 0; // null terminator
- return frame;
+ final timestamp =
+ timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
+ final writer = BufferWriter();
+ writer.writeByte(cmdSendTxtMsg);
+ writer.writeByte(txtTypePlain);
+ writer.writeByte(attempt.clamp(0, 255));
+ writer.writeUInt32LE(timestamp);
+ writer.writeBytes(recipientPubKey.sublist(0, 6));
+ writer.writeString(text);
+ writer.writeByte(0);
+ return writer.toBytes();
}
// Build CMD_SEND_CHANNEL_TXT_MSG frame
// Format: [cmd][txt_type][channel_idx][timestamp x4][text...]
Uint8List buildSendChannelTextMsgFrame(int channelIndex, String text) {
- final textBytes = utf8.encode(text);
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
- final frame = Uint8List(1 + 1 + 1 + 4 + textBytes.length + 1);
- frame[0] = cmdSendChannelTxtMsg;
- frame[1] = 0; // TXT_TYPE_PLAIN
- frame[2] = channelIndex;
- writeUint32LE(frame, 3, timestamp);
- frame.setRange(7, 7 + textBytes.length, textBytes);
- frame[frame.length - 1] = 0; // null terminator
- return frame;
+ final writer = BufferWriter();
+ writer.writeByte(cmdSendChannelTxtMsg);
+ writer.writeByte(txtTypePlain);
+ writer.writeByte(channelIndex);
+ writer.writeUInt32LE(timestamp);
+ writer.writeString(text);
+ writer.writeByte(0);
+ return writer.toBytes();
}
// Build CMD_REMOVE_CONTACT frame
Uint8List buildRemoveContactFrame(Uint8List pubKey) {
- final frame = Uint8List(1 + pubKeySize);
- frame[0] = cmdRemoveContact;
- frame.setRange(1, 1 + pubKeySize, pubKey);
- return frame;
+ final writer = BufferWriter();
+ writer.writeByte(cmdRemoveContact);
+ writer.writeBytes(pubKey);
+ return writer.toBytes();
}
// Build CMD_APP_START frame
@@ -334,14 +537,13 @@ Uint8List buildAppStartFrame({
String appName = 'MeshCoreOpen',
int appVersion = 1,
}) {
- final nameBytes = utf8.encode(appName);
- final frame = Uint8List(8 + nameBytes.length + 1);
- frame[0] = cmdAppStart;
- frame[1] = appVersion;
- // bytes 2-7 are reserved (zeros)
- frame.setRange(8, 8 + nameBytes.length, nameBytes);
- frame[frame.length - 1] = 0; // null terminator
- return frame;
+ final writer = BufferWriter();
+ writer.writeByte(cmdAppStart);
+ writer.writeByte(appVersion);
+ writer.writeBytes(Uint8List(6)); // reserved bytes
+ writer.writeString(appName);
+ writer.writeByte(0);
+ return writer.toBytes();
}
// Build CMD_DEVICE_QUERY frame
@@ -359,12 +561,23 @@ Uint8List buildGetBattAndStorageFrame() {
return Uint8List.fromList([cmdGetBattAndStorage]);
}
+/// Companion radio stats: [56][statsType] where statsType is statsTypeCore/Radio/Packets.
+Uint8List buildGetStatsFrame(int statsType) {
+ return Uint8List.fromList([cmdGetStats, statsType & 0xFF]);
+}
+
+/// Path hash width on air: [61][0][mode], mode 0..2 → (mode+1) bytes per hop hash.
+Uint8List buildSetPathHashModeFrame(int mode) {
+ final m = mode.clamp(0, 2);
+ return Uint8List.fromList([cmdSetPathHashMode, 0, m]);
+}
+
// Build CMD_SET_DEVICE_TIME frame
Uint8List buildSetDeviceTimeFrame(int timestamp) {
- final frame = Uint8List(5);
- frame[0] = cmdSetDeviceTime;
- writeUint32LE(frame, 1, timestamp);
- return frame;
+ final writer = BufferWriter();
+ writer.writeByte(cmdSetDeviceTime);
+ writer.writeUInt32LE(timestamp);
+ return writer.toBytes();
}
// Build CMD_SEND_SELF_ADVERT frame
@@ -377,21 +590,31 @@ Uint8List buildSendSelfAdvertFrame({bool flood = false}) {
// Format: [cmd][name...]
Uint8List buildSetAdvertNameFrame(String name) {
final nameBytes = utf8.encode(name);
- final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1;
- final frame = Uint8List(1 + nameLen);
- frame[0] = cmdSetAdvertName;
- frame.setRange(1, 1 + nameLen, nameBytes.sublist(0, nameLen));
- return frame;
+ final nameLen = nameBytes.length < maxNameSize
+ ? nameBytes.length
+ : maxNameSize - 1;
+ final writer = BufferWriter();
+ writer.writeByte(cmdSetAdvertName);
+ writer.writeBytes(Uint8List.fromList(nameBytes.sublist(0, nameLen)));
+ return writer.toBytes();
}
// Build CMD_SET_ADVERT_LATLON frame
// Format: [cmd][lat x4][lon x4]
Uint8List buildSetAdvertLatLonFrame(double lat, double lon) {
- final frame = Uint8List(9);
- frame[0] = cmdSetAdvertLatLon;
- writeInt32LE(frame, 1, (lat * 1000000).round());
- writeInt32LE(frame, 5, (lon * 1000000).round());
- return frame;
+ final writer = BufferWriter();
+ writer.writeByte(cmdSetAdvertLatLon);
+ writer.writeInt32LE((lat * 1000000).round());
+ writer.writeInt32LE((lon * 1000000).round());
+ return writer.toBytes();
+}
+
+Uint8List buildSetCustomVarFrame(String value) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdSetCustomVar);
+ writer.writeString(value);
+ writer.writeByte(0);
+ return writer.toBytes();
}
// Build CMD_REBOOT frame
@@ -413,37 +636,44 @@ Uint8List buildGetChannelFrame(int channelIndex) {
// Build CMD_SET_CHANNEL frame
// Format: [cmd][idx][name x32][psk x16]
Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
- final frame = Uint8List(2 + 32 + 16);
- frame[0] = cmdSetChannel;
- frame[1] = channelIndex;
- // Write name (max 32 bytes UTF-8, null-padded)
- final nameBytes = utf8.encode(name);
- final nameLen = nameBytes.length < 32 ? nameBytes.length : 31; // Reserve 1 byte for null
- for (int i = 0; i < nameLen; i++) {
- frame[2 + i] = nameBytes[i];
- }
- // frame[2 + nameLen] is already 0 (null terminator)
- // Write PSK (16 bytes)
+ final writer = BufferWriter();
+ writer.writeByte(cmdSetChannel);
+ writer.writeByte(channelIndex);
+ writer.writeCString(name, 32);
+ // Write PSK (16 bytes, zero-padded)
+ final pskPadded = Uint8List(16);
for (int i = 0; i < 16 && i < psk.length; i++) {
- frame[34 + i] = psk[i];
+ pskPadded[i] = psk[i];
}
- return frame;
+ writer.writeBytes(pskPadded);
+ return writer.toBytes();
}
// Build CMD_SET_RADIO_PARAMS frame
-// Format: [cmd][freq x4][bw x4][sf][cr]
+// Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9)
+// [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+)
// freq: frequency in Hz (300000-2500000)
// bw: bandwidth in Hz (7000-500000)
// sf: spreading factor (5-12)
// cr: coding rate (5-8)
-Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) {
- final frame = Uint8List(11);
- frame[0] = cmdSetRadioParams;
- writeUint32LE(frame, 1, freqHz);
- writeUint32LE(frame, 5, bwHz);
- frame[9] = sf;
- frame[10] = cr;
- return frame;
+// clientRepeat: enable off-grid packet repeat (firmware v9+, omit for older)
+Uint8List buildSetRadioParamsFrame(
+ int freqHz,
+ int bwHz,
+ int sf,
+ int cr, {
+ bool? clientRepeat,
+}) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdSetRadioParams);
+ writer.writeUInt32LE(freqHz);
+ writer.writeUInt32LE(bwHz);
+ writer.writeByte(sf);
+ writer.writeByte(cr);
+ if (clientRepeat != null) {
+ writer.writeByte(clientRepeat ? 1 : 0);
+ }
+ return writer.toBytes();
}
// Build CMD_SET_RADIO_TX_POWER frame
@@ -455,71 +685,81 @@ Uint8List buildSetRadioTxPowerFrame(int powerDbm) {
// Build CMD_RESET_PATH frame
// Format: [cmd][pub_key x32]
Uint8List buildResetPathFrame(Uint8List pubKey) {
- final frame = Uint8List(1 + pubKeySize);
- frame[0] = cmdResetPath;
- frame.setRange(1, 1 + pubKeySize, pubKey);
- return frame;
+ final writer = BufferWriter();
+ writer.writeByte(cmdResetPath);
+ writer.writeBytes(pubKey);
+ return writer.toBytes();
}
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
-// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
+// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
Uint8List buildUpdateContactPathFrame(
Uint8List pubKey,
- Uint8List customPath,
+ Uint8List path,
int pathLen, {
int type = 1, // ADV_TYPE_CHAT
int flags = 0,
String name = '',
+ double? lat,
+ double? lon,
+ DateTime? lastModified,
}) {
- // Frame size: 1 + 32 + 1 + 1 + 1 + 64 + 32 + 4 = 136 bytes minimum
- final frame = Uint8List(1 + pubKeySize + 1 + 1 + 1 + maxPathSize + maxNameSize + 4);
- int offset = 0;
+ final writer = BufferWriter();
+ writer.writeByte(cmdAddUpdateContact);
+ writer.writeBytes(pubKey);
+ writer.writeByte(type);
+ writer.writeByte(flags);
+ writer.writeByte(pathLen);
- frame[offset++] = cmdAddUpdateContact;
-
- // Public key (32 bytes)
- frame.setRange(offset, offset + pubKeySize, pubKey);
- offset += pubKeySize;
-
- // Type and flags
- frame[offset++] = type;
- frame[offset++] = flags;
-
- // Path length and path data
- frame[offset++] = pathLen;
- if (customPath.isNotEmpty && pathLen > 0) {
- final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize;
- frame.setRange(offset, offset + copyLen, customPath.sublist(0, copyLen));
- }
- offset += maxPathSize;
+ writer.writeBytesPadded(path, maxPathSize);
// Name (32 bytes, null-padded)
- if (name.isNotEmpty) {
- final nameBytes = utf8.encode(name);
- final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1;
- frame.setRange(offset, offset + nameLen, nameBytes.sublist(0, nameLen));
- }
- offset += maxNameSize;
+ writer.writeCString(name, maxNameSize);
- // Timestamp (current time)
+ // Timestamp
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
- writeUint32LE(frame, offset, timestamp);
+ writer.writeUInt32LE(timestamp);
- return frame;
+ if ((lat == null || lon == null) && lastModified != null) {
+ // If lat/lon not provided, write zeros
+ writer.writeInt32LE(0);
+ writer.writeInt32LE(0);
+ } else {
+ // Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
+ // Latitude
+ final latitude = lat ?? 0.0;
+ writer.writeInt32LE((latitude * 1e6).round());
+
+ // Longitude
+ final longitude = lon ?? 0.0;
+ writer.writeInt32LE((longitude * 1e6).round());
+ }
+
+ if (lastModified != null) {
+ // Last modified
+ final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
+ writer.writeUInt32LE(lastModifiedTimestamp);
+ }
+
+ return writer.toBytes();
}
// 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;
+ final writer = BufferWriter();
+ writer.writeByte(cmdGetContactByKey);
+ writer.writeBytes(pubKey);
+ return writer.toBytes();
}
-// Build CMD_GET_RADIO_SETTINGS frame
-Uint8List buildGetRadioSettingsFrame() {
- return Uint8List.fromList([cmdGetRadioSettings]);
+//Build CMD_GET_CUSTOM_VARS frame
+Uint8List buildGetCustomVarsFrame() {
+ return Uint8List.fromList([cmdGetCustomVar]);
+}
+
+Uint8List buildGetAutoAddFlagsFrame() {
+ return Uint8List.fromList([cmdGetAutoAddConfig]);
}
// Calculate LoRa airtime for a packet
@@ -545,9 +785,11 @@ int calculateLoRaAirtime({
final crc = 1; // CRC enabled
final de = lowDataRateOptimize ? 1 : 0;
- final numerator = 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
+ final numerator =
+ 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
final denominator = 4 * (spreadingFactor - 2 * de);
- var payloadSymbols = 8 + ((numerator / denominator).ceil()) * (codingRate + 4);
+ var payloadSymbols =
+ 8 + ((numerator / denominator).ceil()) * (codingRate + 4);
if (payloadSymbols < 0) {
payloadSymbols = 8;
@@ -594,23 +836,122 @@ Uint8List buildSendCliCommandFrame(
int attempt = 0,
int? timestampSeconds,
}) {
- final textBytes = utf8.encode(command);
- final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
- const prefixSize = 6;
- final safeAttempt = attempt.clamp(0, 3);
- final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
- int offset = 0;
-
- frame[offset++] = cmdSendTxtMsg;
- frame[offset++] = txtTypeCliData;
- frame[offset++] = safeAttempt;
- writeUint32LE(frame, offset, timestamp);
- offset += 4;
-
- frame.setRange(offset, offset + prefixSize, repeaterPubKey.sublist(0, prefixSize));
- offset += prefixSize;
-
- frame.setRange(offset, offset + textBytes.length, textBytes);
- frame[frame.length - 1] = 0; // null terminator
- return frame;
+ final timestamp =
+ timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
+ final writer = BufferWriter();
+ writer.writeByte(cmdSendTxtMsg);
+ writer.writeByte(txtTypeCliData);
+ writer.writeByte(attempt.clamp(0, 255));
+ writer.writeUInt32LE(timestamp);
+ writer.writeBytes(repeaterPubKey.sublist(0, 6));
+ writer.writeString(command);
+ writer.writeByte(0);
+ return writer.toBytes();
+}
+
+// Build a telemetry request frame
+// Format: [cmd][pub_key x32][payload]
+Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdSendBinaryReq);
+ writer.writeBytes(repeaterPubKey);
+ if (payload != null && payload.isNotEmpty) {
+ writer.writeBytes(payload);
+ }
+ return writer.toBytes();
+}
+
+//Build a trace request frame
+//[cmd][tag x4][auth x4][flag][payload]
+Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdSendTracePath);
+ writer.writeUInt32LE(tag);
+ writer.writeUInt32LE(auth);
+ writer.writeByte(flag);
+ if (payload != null && payload.isNotEmpty) {
+ writer.writeBytes(payload);
+ }
+ return writer.toBytes();
+}
+
+// Build a export contact frame
+// [cmd][pub_key x32 / if empty exports your contact info]
+Uint8List buildExportContactFrame(Uint8List pubKey) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdExportContact);
+ writer.writeBytes(pubKey);
+ return writer.toBytes();
+}
+
+// Build a import contact frame
+// [cmd][contact_frame x98+]
+Uint8List buildImportContactFrame(Uint8List contactFrame) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdImportContact);
+ writer.writeBytes(contactFrame);
+ return writer.toBytes();
+}
+
+// Build a export contact frame
+// [cmd][pub_key x32]
+Uint8List buildZeroHopContact(Uint8List pubKey) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdShareContact);
+ writer.writeBytes(pubKey);
+ return writer.toBytes();
+}
+
+// Build CMD_SET_OTHER_PARAMS frame
+// Format: [cmd][allowTelemetryFlags][advertLocationPolicy][multiAcks]
+Uint8List buildSetOtherParamsFrame(
+ int allowTelemetryFlags,
+ int advertLocationPolicy,
+ int multiAcks,
+) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdSetOtherParams);
+ //Going forward the app will just set Auto Add Contacts to disabled, and use the filter flags
+ //Allow Auto Add Contacts use inverted logic (0x01 = disabled, 0x00 = enabled).
+ writer.writeByte(0x01);
+ writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags
+ writer.writeByte(advertLocationPolicy); // Advertisement Location Policy
+ writer.writeByte(multiAcks); // Multi Acknowledgements
+ return writer.toBytes();
+}
+
+// Build CMD_SET_AUTO_ADD_CONFIG frame
+// Format: [cmd][flags]
+Uint8List buildSetAutoAddConfigFrame({
+ required bool autoAddChat,
+ required bool autoAddRepeater,
+ required bool autoAddRoomServer,
+ required bool autoAddSensor,
+ required bool overwriteOldest,
+}) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdSetAutoAddConfig);
+ int flags = 0;
+ if (autoAddChat) flags |= autoAddChatFlag;
+ if (autoAddRepeater) flags |= autoAddRepeaterFlag;
+ if (autoAddRoomServer) flags |= autoAddRoomServerFlag;
+ if (autoAddSensor) flags |= autoAddSensorFlag;
+ if (overwriteOldest) flags |= autoAddOverwriteOldestFlag;
+ writer.writeByte(flags);
+ return writer.toBytes();
+}
+
+//Build CMD_SEND_TELEMETRY_REQ
+// Format: [cmd][reserved x3][pub_key? x32]
+Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdSendTelemetryReq);
+
+ if (pubKey != null && pubKey.length == pubKeySize) {
+ writer.writeBytes(Uint8List(3)); // reserved bytes
+ writer.writeBytes(pubKey);
+ } else {
+ writer.writeBytes(Uint8List(4)); // reserved bytes
+ }
+ return writer.toBytes();
}
diff --git a/lib/connector/meshcore_uuids.dart b/lib/connector/meshcore_uuids.dart
new file mode 100644
index 0000000..ae6697b
--- /dev/null
+++ b/lib/connector/meshcore_uuids.dart
@@ -0,0 +1,15 @@
+class MeshCoreUuids {
+ static const String service = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
+ static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
+ static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
+
+ static const List deviceNamePrefixes = [
+ "MeshCore-",
+ "Whisper-",
+ "WisCore-",
+ "Seeed",
+ "Lilygo",
+ "HT-",
+ "LowMesh_MC_",
+ ];
+}
diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart
new file mode 100644
index 0000000..07909e6
--- /dev/null
+++ b/lib/helpers/cayenne_lpp.dart
@@ -0,0 +1,277 @@
+import 'dart:typed_data';
+import 'package:meshcore_open/utils/app_logger.dart';
+
+import '../connector/meshcore_protocol.dart';
+
+class CayenneLpp {
+ static const int lppDigitalInput = 0; // 1 byte
+ static const int lppDigitalOutput = 1; // 1 byte
+ static const int lppAnalogInput = 2; // 2 bytes, 0.01 signed
+ static const int lppAnalogOutput = 3; // 2 bytes, 0.01 signed
+ static const int lppGenericSensor = 100; // 4 bytes, unsigned
+ static const int lppLuminosity = 101; // 2 bytes, 1 lux unsigned
+ static const int lppPresence = 102; // 1 byte, bool
+ static const int lppTemperature = 103; // 2 bytes, 0.1°C signed
+ static const int lppRelativeHumidity = 104; // 1 byte, 0.5% unsigned
+ static const int lppAccelerometer = 113; // 2 bytes per axis, 0.001G
+ static const int lppBarometricPressure = 115; // 2 bytes 0.1hPa unsigned
+ static const int lppVoltage = 116; // 2 bytes 0.01V unsigned
+ static const int lppCurrent = 117; // 2 bytes 0.001A unsigned
+ static const int lppFrequency = 118; // 4 bytes 1Hz unsigned
+ static const int lppPercentage = 120; // 1 byte 1-100% unsigned
+ static const int lppAltitude = 121; // 2 byte 1m signed
+ static const int lppConcentration = 125; // 2 bytes, 1 ppm unsigned
+ static const int lppPower = 128; // 2 byte, 1W, unsigned
+ static const int lppDistance = 130; // 4 byte, 0.001m, unsigned
+ static const int lppEnergy = 131; // 4 byte, 0.001kWh, unsigned
+ static const int lppDirection = 132; // 2 bytes, 1deg, unsigned
+ static const int lppUnixTime = 133; // 4 bytes, unsigned
+ static const int lppGyrometer = 134; // 2 bytes per axis, 0.01 °/s
+ static const int lppColour = 135; // 1 byte per RGB Color
+ static const int lppGps =
+ 136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter
+ static const int lppSwitch = 142; // 1 byte, 0/1
+ static const int lppPolyline =
+ 240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas
+
+ final BufferWriter _writer = BufferWriter();
+
+ Uint8List toBytes() {
+ return _writer.toBytes();
+ }
+
+ void addDigitalInput(int channel, int value) {
+ _writer.writeByte(channel);
+ _writer.writeByte(lppDigitalInput);
+ _writer.writeByte(value);
+ }
+
+ void addTemperature(int channel, double value) {
+ _writer.writeByte(channel);
+ _writer.writeByte(lppTemperature);
+ final val = (value * 10).toInt();
+ _writer.writeBytes(_int16ToBE(val));
+ }
+
+ void addVoltage(int channel, double value) {
+ _writer.writeByte(channel);
+ _writer.writeByte(lppVoltage);
+ final val = (value * 100).toInt();
+ _writer.writeBytes(_int16ToBE(val));
+ }
+
+ void addGps(int channel, double lat, double lon, double alt) {
+ _writer.writeByte(channel);
+ _writer.writeByte(lppGps);
+ _writer.writeBytes(_int24ToBE((lat * 10000).toInt()));
+ _writer.writeBytes(_int24ToBE((lon * 10000).toInt()));
+ _writer.writeBytes(_int24ToBE((alt * 100).toInt()));
+ }
+
+ Uint8List _int16ToBE(int value) {
+ final bytes = Uint8List(2);
+ final data = ByteData.view(bytes.buffer);
+ data.setInt16(0, value, Endian.big);
+ return bytes;
+ }
+
+ Uint8List _int24ToBE(int value) {
+ final bytes = Uint8List(3);
+ bytes[0] = (value >> 16) & 0xFF;
+ bytes[1] = (value >> 8) & 0xFF;
+ bytes[2] = value & 0xFF;
+ return bytes;
+ }
+
+ static List> parse(Uint8List bytes) {
+ final buffer = BufferReader(bytes);
+ final telemetry = >[];
+ try {
+ while (buffer.remaining >= 2) {
+ final channel = buffer.readUInt8();
+ final type = buffer.readUInt8();
+
+ if (channel == 0 && type == 0) {
+ break;
+ }
+
+ switch (type) {
+ case lppGenericSensor:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readUInt32BE(),
+ });
+ break;
+ case lppLuminosity:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readUInt16BE(),
+ });
+ break;
+ case lppPresence:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readUInt8(),
+ });
+ break;
+ case lppTemperature:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readInt16BE() / 10,
+ });
+ break;
+ case lppRelativeHumidity:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readUInt8() / 2,
+ });
+ break;
+ case lppBarometricPressure:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readUInt16BE() / 10,
+ });
+ break;
+ case lppVoltage:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readInt16BE() / 100,
+ });
+ break;
+ case lppCurrent:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readInt16BE() / 1000,
+ });
+ break;
+ case lppPercentage:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readUInt8(),
+ });
+ break;
+ case lppConcentration:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readUInt16BE(),
+ });
+ break;
+ case lppPower:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': buffer.readUInt16BE(),
+ });
+ break;
+ case lppGps:
+ telemetry.add({
+ 'channel': channel,
+ 'type': type,
+ 'value': {
+ 'latitude': buffer.readInt24BE() / 10000,
+ 'longitude': buffer.readInt24BE() / 10000,
+ 'altitude': buffer.readInt24BE() / 100,
+ },
+ });
+ break;
+ default:
+ return telemetry;
+ }
+ }
+ return telemetry;
+ } catch (e) {
+ // Handle parsing errors, possibly due to malformed data
+ appLogger.error('Error parsing Cayenne LPP data: $e');
+ // Return any telemetry parsed so far to preserve partial data
+ return telemetry;
+ }
+ }
+
+ static List> parseByChannel(Uint8List bytes) {
+ final buffer = BufferReader(bytes);
+ final Map> channels = {};
+ try {
+ while (buffer.remaining >= 2) {
+ final channel = buffer.readUInt8();
+ final type = buffer.readUInt8();
+
+ // Optional: stop on padding (00 00)
+ if (channel == 0 && type == 0) {
+ break;
+ }
+
+ final channelData = channels.putIfAbsent(
+ channel,
+ () => {'channel': channel, 'values': {}},
+ );
+
+ switch (type) {
+ case lppGenericSensor:
+ channelData['values']['generic'] = buffer.readUInt32BE();
+ break;
+ case lppLuminosity:
+ channelData['values']['luminosity'] = buffer.readUInt16BE();
+ break;
+ case lppPresence:
+ channelData['values']['presence'] = buffer.readUInt8() != 0;
+ break;
+ case lppTemperature:
+ channelData['values']['temperature'] = buffer.readInt16BE() / 10.0;
+ break;
+ case lppRelativeHumidity:
+ channelData['values']['humidity'] = buffer.readUInt8() / 2.0;
+ break;
+ case lppBarometricPressure:
+ channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0;
+ break;
+ case lppVoltage:
+ channelData['values']['voltage'] = buffer.readInt16BE() / 100.0;
+ break;
+ case lppCurrent:
+ channelData['values']['current'] = buffer.readInt16BE() / 1000.0;
+ break;
+ case lppPercentage:
+ channelData['values']['percentage'] = buffer.readUInt8();
+ break;
+ case lppConcentration:
+ channelData['values']['concentration'] = buffer.readUInt16BE();
+ break;
+ case lppPower:
+ channelData['values']['power'] = buffer.readUInt16BE();
+ break;
+ case lppGps:
+ channelData['values']['gps'] = {
+ 'latitude': buffer.readInt24BE() / 10000.0,
+ 'longitude': buffer.readInt24BE() / 10000.0,
+ 'altitude': buffer.readInt24BE() / 100.0,
+ };
+ break;
+ // Add more types as needed...
+ default:
+ //Stopped parsing to avoid misalignment
+ return channels.values.toList();
+ }
+ }
+
+ final List> channelsOut = channels.values.toList();
+ channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
+ return channelsOut;
+ } catch (e) {
+ // Handle parsing errors, possibly due to malformed data
+ appLogger.error('Error parsing Cayenne LPP data: $e');
+ return <
+ Map
+ >[]; // Return an empty list on error to avoid crashing the app
+ }
+ }
+}
diff --git a/lib/helpers/chat_scroll_controller.dart b/lib/helpers/chat_scroll_controller.dart
new file mode 100644
index 0000000..d2c73fb
--- /dev/null
+++ b/lib/helpers/chat_scroll_controller.dart
@@ -0,0 +1,68 @@
+import 'package:flutter/material.dart';
+
+class ChatScrollController extends ScrollController {
+ final ValueNotifier showJumpToBottom = ValueNotifier(false);
+ VoidCallback? onScrollNearTop;
+
+ static const _bottomThreshold = 100.0;
+ static const _topThreshold = 50.0;
+
+ ChatScrollController() {
+ addListener(_handleScroll);
+ }
+
+ void _handleScroll() {
+ if (!hasClients) return;
+ final pos = position;
+
+ // With reverse: true, position 0 is bottom, maxScrollExtent is top
+ // Show jump button when scrolled away from bottom (position > threshold)
+ final isAtBottom = pos.pixels <= _bottomThreshold;
+ if (showJumpToBottom.value == isAtBottom) {
+ showJumpToBottom.value = !isAtBottom;
+ }
+
+ // Pagination trigger when scrolled near top (maxScrollExtent)
+ if (pos.pixels >= pos.maxScrollExtent - _topThreshold) {
+ onScrollNearTop?.call();
+ }
+ }
+
+ void jumpToBottom() {
+ if (hasClients && position.maxScrollExtent > 0) {
+ animateTo(
+ 0, // With reverse: true, position 0 is bottom
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeOut,
+ );
+ }
+ }
+
+ void handleKeyboardOpen() {
+ // Simple: just scroll to bottom when keyboard opens
+ if (hasClients) {
+ animateTo(
+ 0, // With reverse: true, position 0 is bottom
+ duration: const Duration(milliseconds: 200),
+ curve: Curves.easeOut,
+ );
+ }
+ }
+
+ void scrollToBottomIfAtBottom() {
+ // Only scroll if jump button is NOT showing (i.e., already at bottom)
+ if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
+ animateTo(
+ 0, // With reverse: true, position 0 is bottom
+ duration: const Duration(milliseconds: 200),
+ curve: Curves.easeOut,
+ );
+ }
+ }
+
+ @override
+ void dispose() {
+ showJumpToBottom.dispose();
+ super.dispose();
+ }
+}
diff --git a/lib/helpers/gif_helper.dart b/lib/helpers/gif_helper.dart
new file mode 100644
index 0000000..5b68e90
--- /dev/null
+++ b/lib/helpers/gif_helper.dart
@@ -0,0 +1,38 @@
+class GifHelper {
+ /// Parse a known GIF format, which can be any of:
+ /// g:GIFID
+ /// https://media.giphy.com/media/GIFID/giphy.gif
+ /// https://giphy.com/gifs/Optional-title-with-dashes-GIFID
+ ///
+ /// GIFID is a Giphy GIF ID. The https:// is optional (and
+ /// can also be http://). The giphy.com/gifs form can also
+ /// include a trailing slash.
+ ///
+ /// Returns null if text is not a valid GIF format
+ static String? parseGif(String text) {
+ final trimmed = text.trim();
+ final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
+ if (match != null) {
+ return match.group(1);
+ }
+ final directUrlMatch = RegExp(
+ r'^(?:https?:\/\/)?media\.giphy\.com\/media\/([A-Za-z0-9_-]+)\/giphy\.gif$',
+ ).firstMatch(trimmed);
+ if (directUrlMatch != null) {
+ return directUrlMatch.group(1);
+ }
+ // Giphy understands page URLs with just the ID, or any string and a
+ // dash before the ID, and redirects to a page with a dash-separated
+ // title, a dash, and the ID. IDs in this form *probably* can't
+ // contain dashes.
+ final pageMatch = RegExp(
+ r'^(?:https?:\/\/)?giphy\.com\/gifs\/(?:[^/?]*-)?([A-Za-z0-9_]+)\/?$',
+ ).firstMatch(trimmed);
+ return pageMatch?.group(1);
+ }
+
+ /// Encode a GIF in a format that parseGif() can parse.
+ static String encodeGif(String gifId) {
+ return 'g:$gifId';
+ }
+}
diff --git a/lib/helpers/link_handler.dart b/lib/helpers/link_handler.dart
new file mode 100644
index 0000000..c2eae29
--- /dev/null
+++ b/lib/helpers/link_handler.dart
@@ -0,0 +1,114 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_linkify/flutter_linkify.dart';
+import 'package:url_launcher/url_launcher.dart';
+import '../l10n/l10n.dart';
+import '../utils/platform_info.dart';
+import '../helpers/snack_bar_builder.dart';
+
+class LinkHandler {
+ static TextStyle defaultLinkStyle(BuildContext context, TextStyle base) {
+ final brightness = Theme.of(context).brightness;
+ final orange = brightness == Brightness.dark
+ ? const Color(0xFFFFB74D)
+ : const Color(0xFFE65100);
+ return base.copyWith(color: orange, decoration: TextDecoration.underline);
+ }
+
+ /// Returns a [SelectableLinkify] on desktop or a [Linkify] on mobile.
+ static Widget buildLinkifyText({
+ required BuildContext context,
+ required String text,
+ required TextStyle style,
+ TextStyle? linkStyle,
+ }) {
+ final effectiveLinkStyle = linkStyle ?? defaultLinkStyle(context, style);
+ const options = LinkifyOptions(humanize: false, defaultToHttps: false);
+ const linkifiers = [UrlLinkifier(), EmailLinkifier()];
+ void onOpen(LinkableElement link) => handleLinkTap(context, link.url);
+
+ if (PlatformInfo.isDesktop) {
+ return SelectableLinkify(
+ text: text,
+ style: style,
+ linkStyle: effectiveLinkStyle,
+ options: options,
+ linkifiers: linkifiers,
+ onOpen: onOpen,
+ );
+ }
+ return Linkify(
+ text: text,
+ style: style,
+ linkStyle: effectiveLinkStyle,
+ options: options,
+ linkifiers: linkifiers,
+ onOpen: onOpen,
+ );
+ }
+
+ static Future handleLinkTap(BuildContext context, String url) async {
+ // Show confirmation dialog
+ final shouldOpen = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: Text(context.l10n.chat_openLink),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ context.l10n.chat_openLinkConfirmation,
+ style: const TextStyle(fontSize: 14),
+ ),
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: SelectableText(
+ url,
+ style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: Text(context.l10n.common_cancel),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: Text(context.l10n.chat_open),
+ ),
+ ],
+ ),
+ );
+
+ if (shouldOpen != true) return;
+
+ // Launch URL
+ try {
+ final uri = Uri.parse(url);
+ if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
+ if (context.mounted) {
+ showDismissibleSnackBar(
+ context,
+ content: Text(context.l10n.chat_couldNotOpenLink(url)),
+ backgroundColor: Colors.red,
+ );
+ }
+ }
+ } catch (e) {
+ if (context.mounted) {
+ showDismissibleSnackBar(
+ context,
+ content: Text(context.l10n.chat_invalidLink),
+ backgroundColor: Colors.red,
+ );
+ }
+ }
+ }
+}
diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart
new file mode 100644
index 0000000..fe51d63
--- /dev/null
+++ b/lib/helpers/path_helper.dart
@@ -0,0 +1,31 @@
+import '../models/contact.dart';
+import '../connector/meshcore_protocol.dart';
+
+class PathHelper {
+ static String formatPathHex(List