mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Compare commits
No commits in common. "main" and "Alpha2" have entirely different histories.
260 changed files with 7111 additions and 151169 deletions
78
.github/workflows/build.yml
vendored
78
.github/workflows/build.yml
vendored
|
|
@ -1,78 +0,0 @@
|
|||
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
|
||||
38
.github/workflows/deploy.yml
vendored
38
.github/workflows/deploy.yml
vendored
|
|
@ -1,38 +0,0 @@
|
|||
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
|
||||
31
.github/workflows/flutter_dart.yml
vendored
31
.github/workflows/flutter_dart.yml
vendored
|
|
@ -1,31 +0,0 @@
|
|||
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
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -30,12 +30,8 @@ migrate_working_dir/
|
|||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
pubspec.lock
|
||||
/build/
|
||||
/coverage/
|
||||
# fvm project files
|
||||
.fvm/
|
||||
.fvmrc
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
|
@ -61,7 +57,6 @@ secrets.dart
|
|||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
macos/Flutter/GeneratedPluginRegistrant.swift
|
||||
|
||||
# iOS
|
||||
**/ios/Pods/
|
||||
|
|
@ -70,13 +65,11 @@ macos/Flutter/GeneratedPluginRegistrant.swift
|
|||
**/ios/Flutter/Flutter.podspec
|
||||
|
||||
# Android
|
||||
.gradle/
|
||||
**/android/.gradle/
|
||||
**/android/captures/
|
||||
**/android/local.properties
|
||||
**/android/.externalNativeBuild/
|
||||
*.jks
|
||||
key.properties
|
||||
keystore.properties
|
||||
|
||||
# Generated files
|
||||
|
|
@ -87,6 +80,3 @@ keystore.properties
|
|||
# IDE
|
||||
.vscode/launch.json
|
||||
.vscode/settings.json
|
||||
|
||||
# Cloudflare Wrangler
|
||||
.wrangler
|
||||
|
|
|
|||
0
.gitmodules
vendored
0
.gitmodules
vendored
|
|
@ -1 +0,0 @@
|
|||
4.0.0
|
||||
|
|
@ -1 +0,0 @@
|
|||
6.2.4
|
||||
|
|
@ -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 names matching known prefixes and filters by `platformName`/`advertisementData.advName`.
|
||||
- Discovery: scans for device name prefix `MeshCore-` 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.
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ lib/
|
|||
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
|
||||
|
||||
### Device Discovery
|
||||
- Scans for devices with known name prefixes
|
||||
- Scans for devices with name prefix `MeshCore-`
|
||||
- Filters by `platformName` or `advertisementData.advName`
|
||||
|
||||
### Connection States
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
# 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.
|
||||
63
README.md
63
README.md
|
|
@ -6,10 +6,6 @@ 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.
|
||||
|
||||
<a href="http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/zjs81/meshcore-open">
|
||||
<img src="assets/badges/badge_obtainium.png" height="80" align="center" alt="Get it on Obtainium"/>
|
||||
</a>
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
|
|
@ -25,7 +21,6 @@ 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
|
||||
|
|
@ -34,7 +29,6 @@ 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
|
||||
|
|
@ -42,7 +36,6 @@ 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
|
||||
|
|
@ -50,14 +43,12 @@ 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, USB, TCP Connection**: Scan and connect to MeshCore devices via Bluetooth, USB or TCP
|
||||
- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth
|
||||
- **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
|
||||
|
|
@ -66,7 +57,6 @@ 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
|
||||
|
|
@ -74,20 +64,11 @@ 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
|
||||
|
||||
| Feature | Android (API 21+) | iOS (12+) | Linux | Windows | macOS | Web |
|
||||
|--------------------|:-----------------:|:---------:|:-----:|:-------:|:-----:|:---------------------------------:|
|
||||
| BLE companion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| USB companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ❌<br>(requires websocket bridge) |
|
||||
| Core Functionality | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mesh Network | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Map & Location | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Device Management | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Repeater Hub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
- ✅ **Android**: Full support (API 21+)
|
||||
- ✅ **iOS**: Full support (iOS 12+)
|
||||
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
|
||||
|
||||
### Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| flutter_blue_plus | Bluetooth Low Energy communication |
|
||||
|
|
@ -103,7 +84,6 @@ 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
|
||||
|
|
@ -111,20 +91,17 @@ 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
|
||||
```
|
||||
|
|
@ -132,13 +109,11 @@ 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
|
||||
```
|
||||
|
|
@ -150,8 +125,7 @@ lib/
|
|||
├── main.dart # App entry point
|
||||
├── connector/
|
||||
│ ├── meshcore_connector.dart # BLE communication & state management
|
||||
│ ├── meshcore_protocol.dart # Protocol definitions & frame parsing
|
||||
│ └── meshcore_uuids.dart # Device names and IDs (add prefixes here!)
|
||||
│ └── meshcore_protocol.dart # Protocol definitions & frame parsing
|
||||
├── screens/
|
||||
│ ├── scanner_screen.dart # Device scanning (home screen)
|
||||
│ ├── contacts_screen.dart # Contact list
|
||||
|
|
@ -178,39 +152,25 @@ 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 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`.
|
||||
|
||||
Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-`
|
||||
|
||||
### 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
|
||||
|
|
@ -222,24 +182,22 @@ 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>
|
||||
https://github.com/zjs81/meshcore-open/issues
|
||||
|
||||
## Donate
|
||||
|
||||
|
|
@ -247,11 +205,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,244 +0,0 @@
|
|||
# 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)
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
|
|
@ -7,25 +5,19 @@ 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 = "29.0.14206865"
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
|
|
@ -48,25 +40,11 @@ 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 {
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,5 +61,5 @@ flutter {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<!-- Camera permission for QR code scanning -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
|
||||
|
||||
<application
|
||||
android:label="meshcore_open"
|
||||
|
|
@ -68,14 +64,5 @@
|
|||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- URL launcher intents for opening links -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="http"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,5 @@
|
|||
package com.meshcore.meshcore_open
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
class MainActivity : FlutterActivity()
|
||||
|
|
|
|||
|
|
@ -1,582 +0,0 @@
|
|||
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<String> {
|
||||
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<String>("portName")
|
||||
val baudRate = call.argument<Int>("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<ByteArray>("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
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M268-240 42-466l57-56 170 170 56 56-57 56Zm226 0L268-466l56-57 170 170 368-368 56 57-424 424Zm0-226-57-56 198-198 57 56-198 198Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 253 B |
Binary file not shown.
|
Before Width: | Height: | Size: 579 KiB |
|
|
@ -21,12 +21,7 @@ The MeshCore BLE protocol implements a binary frame-based communication system u
|
|||
|
||||
### Connection Flow
|
||||
|
||||
1. **Scan** for devices with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`):
|
||||
- `MeshCore-`
|
||||
- `Whisper-`
|
||||
- `WisCore-`
|
||||
- `HT-`
|
||||
- `LowMesh_MC_`
|
||||
1. **Scan** for devices with name prefix `MeshCore-`
|
||||
2. **Connect** with 15-second timeout
|
||||
3. **Request MTU** of 185 bytes (falls back to default if unsupported)
|
||||
4. **Discover services** and locate NUS characteristics
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
# 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.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 104 KiB |
|
|
@ -1,30 +0,0 @@
|
|||
# MeshCore Open - Feature Documentation
|
||||
|
||||
MeshCore Open is an open-source Flutter client for MeshCore LoRa mesh networking devices. This documentation covers every user-facing feature, how to access it, and what it does.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Scanner & Connection](scanner-and-connection.md) - BLE scanning, USB serial, and TCP connection
|
||||
2. [Navigation](navigation.md) - App flow, device screen, and quick-switch navigation
|
||||
3. [Contacts](contacts.md) - Contact management, groups, discovery, and sharing
|
||||
4. [Chat & Messaging](chat-and-messaging.md) - Direct messages, message status, reactions, and retries
|
||||
5. [Channels](channels.md) - Broadcast channels, communities, and channel chat
|
||||
6. [Map & Location](map-and-location.md) - Node map, path tracing, line-of-sight, and offline caching
|
||||
7. [Settings](settings.md) - Device settings, app settings, radio configuration, and exports
|
||||
8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences
|
||||
9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors
|
||||
10. [Additional Features](additional-features.md) - GIF picker, localization, debug logs, SMAZ compression, and more
|
||||
11. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture
|
||||
|
||||
## App Overview
|
||||
|
||||
MeshCore Open connects to MeshCore LoRa mesh radios over BLE, USB, or TCP. Once connected, users can:
|
||||
|
||||
- **Chat** with other mesh nodes via encrypted direct messages
|
||||
- **Broadcast** on shared channels (public, hashtag, private, or community-scoped)
|
||||
- **View nodes on a map** with GPS locations, predicted positions, and path traces
|
||||
- **Manage repeaters** with CLI access, telemetry, neighbor info, and settings
|
||||
- **Share contacts** via `meshcore://` URIs and QR codes
|
||||
- **Configure radio settings** including frequency, power, bandwidth, and spreading factor
|
||||
- **Cache offline maps** for use without internet connectivity
|
||||
- **Analyze line-of-sight** between nodes with terrain elevation profiles
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
# Additional Features
|
||||
|
||||
## GIF Picker (Giphy Integration)
|
||||
|
||||
### How to Access
|
||||
In any chat screen (direct or channel), tap the GIF button in the message input bar.
|
||||
|
||||
### What the User Sees
|
||||
A bottom sheet with a search field and a grid of GIF thumbnails.
|
||||
|
||||
### Key Interactions
|
||||
- On open, loads trending GIFs (G-rated, 25 results)
|
||||
- Type to search and press the keyboard submit button (search triggers on submit, not on each keystroke). Clearing the search field reloads trending GIFs
|
||||
- On network/API errors, a "Retry" button is shown in-place
|
||||
- Tap a GIF to select it — the chat input shows an inline preview with an X button to dismiss
|
||||
- Send the message to transmit the GIF reference (`g:<giphy-id>`)
|
||||
- Recipients see the GIF rendered inline via Giphy CDN
|
||||
- "Powered by Giphy" attribution is always shown at the bottom of the picker
|
||||
- The bottom sheet occupies 70% of screen height
|
||||
|
||||
---
|
||||
|
||||
## Localization / Multi-Language Support
|
||||
|
||||
### How to Access
|
||||
App Settings → Appearance → Language
|
||||
|
||||
### Supported Languages (15)
|
||||
English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian
|
||||
|
||||
### How It Works
|
||||
- All UI strings go through Flutter's ARB localization system
|
||||
- Language can follow the system locale or be explicitly overridden
|
||||
- Changes take effect immediately
|
||||
|
||||
---
|
||||
|
||||
## Discovered Contacts Screen
|
||||
|
||||
### How to Access
|
||||
From Contacts screen → overflow menu → "Discovered Contacts"
|
||||
|
||||
### What the User Sees
|
||||
A list of nodes heard passively over the air but not yet added as contacts. Each shows:
|
||||
- Color-coded avatar (by type)
|
||||
- Name
|
||||
- Short public key
|
||||
- Last-seen time
|
||||
|
||||
### Key Interactions
|
||||
- Search bar with debounced filtering
|
||||
- Sort by last seen or name; filter by type
|
||||
- **Tap**: Import the contact (adds to your contact list)
|
||||
- **Long-press**: Add Contact, Copy `meshcore://` URI to clipboard, or Delete
|
||||
- Overflow menu → "Delete All" (with confirmation)
|
||||
- Already-known contacts and your own node are filtered out
|
||||
|
||||
---
|
||||
|
||||
## SMAZ Compression
|
||||
|
||||
### What It Is
|
||||
An optional per-contact and per-channel text compression feature using the SMAZ algorithm (optimized for short English text).
|
||||
|
||||
### How to Enable
|
||||
- **Per contact**: Chat screen → info button → toggle "SMAZ compression"
|
||||
- **Per channel**: Long-press channel → Edit → toggle "SMAZ compression"
|
||||
|
||||
### How It Works
|
||||
- When enabled, compression is applied using a "compress only if smaller" strategy — the message is only transmitted compressed if the encoded result is actually shorter than the original. Otherwise, the original text is sent uncompressed
|
||||
- Compressed messages are transmitted with a `s:` prefix followed by base64-encoded data
|
||||
- Recipients using MeshCore Open will decompress automatically. **Recipients using other software** that is not SMAZ-aware will see garbled `s:...` text
|
||||
- The codec operates on ASCII. Non-ASCII / non-English text generally does not benefit from compression and may even expand. Best suited for short English messages
|
||||
- Disabled by default
|
||||
|
||||
---
|
||||
|
||||
## Community QR Scanner
|
||||
|
||||
### How to Access
|
||||
From Channels screen → "+" FAB → "Scan Community QR"
|
||||
|
||||
### What the User Sees
|
||||
A live QR scanner view with instruction text overlay.
|
||||
|
||||
### Key Interactions
|
||||
- Scan a community QR code shared by another member
|
||||
- On valid scan: confirmation dialog showing community name and ID
|
||||
- Option to "Add public channel to device" on join
|
||||
- If already a member: shows an "Already a member" dialog
|
||||
- Invalid QR: shows an orange error snackbar
|
||||
|
||||
---
|
||||
|
||||
## Channel Message Path Viewing
|
||||
|
||||
### How to Access
|
||||
In a channel chat, tap a message bubble (mobile) or use the "Path" action (desktop).
|
||||
|
||||
### What the User Sees
|
||||
- Summary card: sender, time, repeat count, path type, observed hops
|
||||
- "Other Observed Paths" section (if multiple paths detected)
|
||||
- "Repeater Hops" section listing each hop with hex prefix, resolved name, and GPS coordinates
|
||||
|
||||
### Actions
|
||||
- **Radar icon**: Opens path trace map for live trace
|
||||
- **Map icon**: Opens a map with hop markers and polyline
|
||||
- **Path dropdown**: Switch between observed path variants (if multiple)
|
||||
|
||||
---
|
||||
|
||||
## Debug Logging
|
||||
|
||||
### BLE Debug Log
|
||||
**Access**: Settings → BLE Debug Log
|
||||
|
||||
Two views:
|
||||
- **Frames**: Each BLE frame with direction, description, hex preview, timestamp. Long-press to copy hex.
|
||||
- **Raw Log RX**: Decoded LoRa packets with route type, payload type, path bytes, and summary.
|
||||
|
||||
### App Debug Log
|
||||
**Access**: Settings → App Debug Log (must be enabled first in App Settings → Debug)
|
||||
|
||||
Structured log entries with level (Info/Warning/Error), tag, message, and timestamp.
|
||||
|
||||
Both logs support copy-all and clear operations.
|
||||
|
||||
---
|
||||
|
||||
## Chrome Required Screen
|
||||
|
||||
### When It Appears
|
||||
Automatically shown on web platforms when a non-Chromium browser is detected.
|
||||
|
||||
### What the User Sees
|
||||
A full-screen informational page explaining that Web Bluetooth requires a Chromium-based browser. No interactive elements — purely informational.
|
||||
|
||||
---
|
||||
|
||||
## Path History Service
|
||||
|
||||
### What It Does (Background Service)
|
||||
Maintains an in-memory LRU cache of up to 50 contacts, each with up to 100 route history entries, tracking:
|
||||
- Hop count and trip time
|
||||
- Success/failure counts and route weights
|
||||
- Flood vs. direct discovery
|
||||
|
||||
### Path Scoring
|
||||
Paths are scored using a weighted formula: reliability (45%), route weight (20%), latency (25%), and freshness (10%). These weights are internal and not user-configurable. Paths whose weight drops to zero or below are automatically deleted. Flood deliveries that receive an ACK give a weight boost (+0.5) to the specific return path.
|
||||
|
||||
Used internally for:
|
||||
- **Auto route rotation**: Cycles through known paths using configurable weights on retries, with a diversity window to avoid re-using recently tried paths
|
||||
- **Path selection**: Picks the best-scored path for each retry attempt
|
||||
- **Flood statistics**: Tracks flood vs. direct discovery ratios
|
||||
|
||||
---
|
||||
|
||||
## Message Retry Service
|
||||
|
||||
### What It Does (Background Service)
|
||||
Handles reliable delivery of outgoing direct messages:
|
||||
1. Assigns a UUID and sends immediately. Only one message per contact can be in-flight at a time (avoids overflowing the firmware's 8-entry ACK table); subsequent messages are queued
|
||||
2. Listens for ACK frames matched via SHA-256 hash of `[timestamp][attempt][text][sender_pubkey]`
|
||||
3. On timeout, retries with exponential backoff: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s...)
|
||||
4. Each retry may use a different path (via path history diversity window)
|
||||
5. After max retries: marks failed but keeps a **30-second grace window** during which a late ACK can still resolve the message to "delivered". Optionally clears the contact's path
|
||||
6. Reports RTT and path data for quality learning
|
||||
7. Maintains an ACK hash history (last 50 entries) to handle duplicate ACKs
|
||||
|
||||
### Configurable Settings (App Settings → Messaging)
|
||||
- Max retries (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)
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
# BLE Protocol & Data Layer
|
||||
|
||||
This is a technical reference for the communication protocol and data architecture.
|
||||
|
||||
## Transport Layer
|
||||
|
||||
The app supports three transports, all sharing the same command/response protocol:
|
||||
|
||||
| Transport | Method | Implementation |
|
||||
|---|---|---|
|
||||
| Bluetooth LE | Nordic UART Service (NUS) GATT | `flutter_blue_plus` |
|
||||
| USB Serial | Packet-framed serial | `MeshCoreUsbManager` |
|
||||
| TCP | Packet-framed socket | `MeshCoreTcpConnector` |
|
||||
|
||||
### BLE (Nordic UART Service)
|
||||
|
||||
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- **RX Characteristic** (write to device): `6e400002-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- **TX Characteristic** (notify from device): `6e400003-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
|
||||
Raw `Uint8List` payloads are written directly to the RX characteristic. Writes use "write without response" if supported, falling back to "write with response".
|
||||
|
||||
### USB and TCP Framing
|
||||
|
||||
Both use a lightweight packet framing codec:
|
||||
|
||||
```
|
||||
TX (host → device): [0x3C][len_lo][len_hi][payload...]
|
||||
RX (device → host): [0x3E][len_lo][len_hi][payload...]
|
||||
```
|
||||
|
||||
- Frame start: `0x3C` (`<`) for outgoing, `0x3E` (`>`) for incoming
|
||||
- Length: 2-byte little-endian, payload only
|
||||
- Max payload: 172 bytes
|
||||
- TCP: `tcpNoDelay: true` (Nagle disabled), writes serialized to prevent interleaving
|
||||
- USB: 10ms post-write delay between frames
|
||||
|
||||
## Connection State Machine
|
||||
|
||||
```
|
||||
enum MeshCoreConnectionState {
|
||||
disconnected,
|
||||
scanning,
|
||||
connecting,
|
||||
connected,
|
||||
disconnecting,
|
||||
}
|
||||
```
|
||||
|
||||
## BLE Connection Lifecycle
|
||||
|
||||
1. **Scan** with 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<pubKey10>` | Per device identity |
|
||||
| Messages | `messages_<pubKey10><contactKey>` | Per device + contact |
|
||||
| Channel Messages | `channel_messages_<pubKey10><index>` | Per device + channel |
|
||||
| Channels | `channels<pubKey10>` | Per device identity |
|
||||
| Channel Order | `channel_order_<pubKey10>` | Per device identity |
|
||||
| Contact Groups | `contact_groups<pubKey10>` | Per device identity |
|
||||
| Communities | `communities_v1<pubKey10>` | Per device identity |
|
||||
| Unread Counts | `contact_unread_count<pubKey10>` | Per device identity |
|
||||
| Discovered Contacts | `discovered_contacts` | Global |
|
||||
| App Settings | `app_settings` | Global |
|
||||
| Path History | `path_history_<contactKey>` | Per contact |
|
||||
|
||||
## Auto-Add Configuration Bitmask
|
||||
|
||||
Used by `CMD_SET_AUTO_ADD_CONFIG` (58) and `RESP_CODE_AUTO_ADD_CONFIG` (25):
|
||||
|
||||
| Bit | Flag | Description |
|
||||
|-----|------|-------------|
|
||||
| 0 | 0x01 | Overwrite oldest contact when list is full |
|
||||
| 1 | 0x02 | Auto-add chat users |
|
||||
| 2 | 0x04 | Auto-add repeaters |
|
||||
| 3 | 0x08 | Auto-add room servers |
|
||||
| 4 | 0x10 | Auto-add sensors |
|
||||
|
||||
## Radio Packet Payload Types
|
||||
|
||||
Seen inside `PUSH_CODE_LOG_RX_DATA` raw packets:
|
||||
|
||||
| Code | Type |
|
||||
|------|------|
|
||||
| 0x00 | REQ (request) |
|
||||
| 0x01 | RESPONSE |
|
||||
| 0x02 | TXTMSG (text message) |
|
||||
| 0x03 | ACK |
|
||||
| 0x04 | ADVERT |
|
||||
| 0x05 | GRPTXT (group/channel text) |
|
||||
| 0x06 | GRPDATA (group data) |
|
||||
| 0x07 | ANONREQ (anonymous request) |
|
||||
| 0x08 | PATH |
|
||||
| 0x09 | TRACE |
|
||||
| 0x0A | MULTIPART |
|
||||
| 0x0B | CONTROL |
|
||||
| 0x0F | RAW_CUSTOM |
|
||||
|
||||
## State Management
|
||||
|
||||
Uses Flutter `Provider` with `ChangeNotifier`. The central state holder is `MeshCoreConnector`, which owns all in-memory collections and fires debounced (50ms) `notifyListeners()` to update the UI. In-memory conversations are windowed to 200 messages per contact; older messages remain on disk and are loaded on demand.
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. Raw frames arrive over BLE/USB/TCP
|
||||
2. First byte is parsed as response/push code
|
||||
3. Appropriate model factory (`fromFrame()`) parses the data
|
||||
4. In-memory collections are updated
|
||||
5. Storage stores are persisted (async)
|
||||
6. `notifyListeners()` triggers UI rebuilds
|
||||
7. Screens read current state via getters
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
# Channels
|
||||
|
||||
## Overview
|
||||
|
||||
Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh.
|
||||
|
||||
Up to 8 channels (indices 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": "<base64url-secret>"}
|
||||
```
|
||||
|
||||
### 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 |
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
# Chat & Messaging
|
||||
|
||||
## Overview
|
||||
|
||||
The app supports two chat modes:
|
||||
- **Direct messages**: Encrypted point-to-point messages to individual contacts
|
||||
- **Channel messages**: Broadcast messages to shared channels (see [Channels](channels.md))
|
||||
|
||||
This page covers direct messaging. For channel chat, see the Channels documentation.
|
||||
|
||||
## How to Access
|
||||
|
||||
From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
|
||||
|
||||
## Chat Screen Layout
|
||||
|
||||
### App Bar
|
||||
|
||||
- **Title**: Contact name
|
||||
- **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details.
|
||||
- **Action buttons**:
|
||||
- **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing
|
||||
- **Path management** (timeline icon): View recent paths with hop count, round-trip time, age, and success count. Paths are color-coded by direct repeater (green/yellow/red/blue for ranked repeaters, grey for unknown). Tap a path to activate it (the device verifies and confirms via snackbar), long-press to view full path details, set custom paths, or force flood mode. A warning banner appears when history reaches 100 entries.
|
||||
- **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle
|
||||
|
||||
### Message List
|
||||
|
||||
- Scrollable list with newest messages at the bottom
|
||||
- **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background
|
||||
- **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name)
|
||||
- Bubble width capped at 65% of screen width
|
||||
- Hyperlinks rendered as tappable green underlined text
|
||||
- **Pinch-to-zoom**: Two-finger zoom (0.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:<giphy-id>` | Inline GIF image from Giphy CDN |
|
||||
| Location pin | `m:<lat>,<lon>\|<label>\|...` | Location icon + label; tap to open map |
|
||||
| Reaction | `r:<hash>:<emoji-index>` | Applied to target message as emoji pill |
|
||||
|
||||
## Message Status
|
||||
|
||||
Outgoing messages display a status indicator:
|
||||
|
||||
| Status | Icon | Meaning |
|
||||
|---|---|---|
|
||||
| Pending | Grey double-check | Queued, waiting for device to transmit (visually identical to Sent) |
|
||||
| Sent | Grey double-check | Device confirmed transmission (visually identical to Pending) |
|
||||
| Delivered | Green double-check | Remote node acknowledged receipt |
|
||||
| Failed | Red X | All retries exhausted |
|
||||
|
||||
### Message Tracing Mode
|
||||
|
||||
When enabled in App Settings, additional metadata appears inside each bubble:
|
||||
- Timestamp (HH:MM)
|
||||
- Retry count (e.g., "Retry 2 of 4")
|
||||
- Status icon
|
||||
- Round-trip time in seconds (if delivered)
|
||||
|
||||
## Message Length Limits
|
||||
|
||||
- **Direct messages**: 156 bytes (UTF-8) — enforced in real-time by the input formatter
|
||||
- **Channel messages**: 160 minus sender name length minus 2 bytes for the `"<name>: "` prefix
|
||||
- Over-length paste shows a snackbar error
|
||||
|
||||
## Send Queue
|
||||
|
||||
Only one message per contact can be in-flight at a time (to avoid overflowing the firmware's 8-entry ACK table). If you send multiple messages rapidly, they are queued and sent sequentially — each waits for the previous one to be delivered, fail, or exhaust retries before transmitting.
|
||||
|
||||
## Retry Mechanism
|
||||
|
||||
When a direct message is sent:
|
||||
|
||||
1. The app computes an expected ACK hash: `SHA256([timestamp][attempt][text][selfPubKey])[0:4]` — matching the firmware's hash calculation. If SMAZ compression is enabled, the compressed text (not the original) is hashed
|
||||
2. On device acknowledgment (`RESP_CODE_SENT`), the message transitions to "sent" and a timeout timer starts
|
||||
3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise `3000 + 3000 × path_length` milliseconds (15000ms for flood)
|
||||
4. On timeout, the message is retried with **exponential backoff**: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s, 16s...)
|
||||
5. **Max retries**: Configurable (default 5, range 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 |
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
# Contacts
|
||||
|
||||
## Overview
|
||||
|
||||
The Contacts screen is the primary hub for managing mesh nodes your radio has a relationship with. A "contact" is any node whose cryptographic advertisement has been received — it can be a chat user, repeater, room server, or sensor.
|
||||
|
||||
## How to Access
|
||||
|
||||
- Automatically shown after connecting to a device
|
||||
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens
|
||||
- Back navigation from Chat or Settings screens
|
||||
|
||||
## Contact Types
|
||||
|
||||
| Type | Avatar Color | Icon | Description |
|
||||
|---|---|---|---|
|
||||
| Chat | Blue | Chat bubble | Another user's mesh radio |
|
||||
| Repeater | Orange | Cell tower | A mesh repeater/relay node |
|
||||
| Room | Purple | Group | A room server for group chat |
|
||||
| Sensor | Green | Sensors | A sensor device |
|
||||
|
||||
## Contact List
|
||||
|
||||
Each contact is displayed as a list tile showing:
|
||||
|
||||
- **Avatar**: Color-coded circle with type icon (or first emoji of the contact's name if it starts with one)
|
||||
- **Name**: Contact name (single line)
|
||||
- **Path label**: "Direct", "N hops", or "Flood" (with forced variants if a path override is active)
|
||||
- **Public key**: Shortened hex format `<XXXXXXXX...XXXXXXXX>`
|
||||
- **Unread badge**: Red pill with count (if unread messages exist)
|
||||
- **Last seen**: Relative timestamp ("Now", "5 mins ago", "2 hours ago", "3 days ago"). For chat contacts, this shows whichever is more recent: the last advertisement time or the last message time
|
||||
- **Favorite star**: Amber star icon if favorited
|
||||
- **Location pin**: Grey pin icon if the contact has GPS coordinates
|
||||
|
||||
Pull-to-refresh re-fetches the full contact list from the device.
|
||||
|
||||
## Search and Filter
|
||||
|
||||
A toolbar at the top provides:
|
||||
|
||||
**Search**: Matches contact name (case-insensitive) or public key hex prefix. Debounced at 300ms.
|
||||
|
||||
**Sort options**:
|
||||
- Latest Messages (by most recent message)
|
||||
- Heard Recently (by last seen / last message)
|
||||
- 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://<hex>` URI to clipboard |
|
||||
| Share Contact Zero-Hop | All types | Broadcasts the contact's advertisement one hop |
|
||||
| Delete Contact | All types | Confirmation dialog → removes from device and clears messages |
|
||||
|
||||
## App Bar Menus
|
||||
|
||||
The Contacts screen has **two separate popup menus** in the app bar:
|
||||
|
||||
**Antenna icon menu** (contact sharing):
|
||||
- Zero-Hop Advert — broadcasts your advertisement to immediately adjacent nodes
|
||||
- Flood Advert — broadcasts across the full mesh network
|
||||
- Copy Advert to Clipboard — copies your `meshcore://<hex>` URI for sharing externally
|
||||
- Add Contact from Clipboard — reads a `meshcore://<hex>` URI from clipboard and imports it
|
||||
|
||||
**Three-dot overflow menu**:
|
||||
- Disconnect — disconnects from the device
|
||||
- Discovered Contacts — opens the DiscoveryScreen
|
||||
- Settings — opens the Settings screen
|
||||
|
||||
## Adding Contacts
|
||||
|
||||
### Automatic (Passive)
|
||||
When the radio hears an advertisement, the contact appears automatically if auto-add is enabled for that type (configurable in Settings → Contact Settings).
|
||||
|
||||
### Import from Clipboard
|
||||
Antenna menu → "Add Contact from Clipboard". Reads a `meshcore://<hex>` URI from clipboard and imports it to the device.
|
||||
|
||||
### Import from Discovered Contacts
|
||||
Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Add, Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms, Favorites), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts.
|
||||
|
||||
## Contact Sharing Format
|
||||
|
||||
Contacts are shared using the `meshcore://` URI scheme:
|
||||
```
|
||||
meshcore://<hex-encoded-advertisement-packet>
|
||||
```
|
||||
This contains the node's public key and metadata. Paste it into another MeshCore app to import.
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
# Map & Location
|
||||
|
||||
## Overview
|
||||
|
||||
The Map feature is a full-featured node-location visualization and radio-planning tool built on OpenStreetMap tiles. It is one of the three primary views accessible from the QuickSwitchBar.
|
||||
|
||||
## How to Access
|
||||
|
||||
- **QuickSwitchBar tab 2** (rightmost) from Contacts or Channels
|
||||
- **Deep-link from a chat message**: Tapping a shared location pin in a chat opens the map centered on that pin
|
||||
- **Settings → Offline Map Cache**: Opens the tile cache management screen
|
||||
|
||||
## What the Map Displays
|
||||
|
||||
### Self Location (Teal Circle)
|
||||
Your own node's position, obtained from the device firmware. Displayed as a teal `person_pin_circle` icon. Only appears if the device has GPS data or a manually-set location.
|
||||
|
||||
### Contact / Node Markers (Color-Coded)
|
||||
All contacts with known GPS coordinates are plotted:
|
||||
|
||||
| Type | Color | Icon |
|
||||
|---|---|---|
|
||||
| Chat user | Blue | Person |
|
||||
| Repeater | Green | Router |
|
||||
| Room | Purple | Meeting room |
|
||||
| Sensor | Orange | Sensors |
|
||||
|
||||
Node name labels appear automatically at zoom level 12 and above.
|
||||
|
||||
### Shared Map Pins (Flag Icons)
|
||||
Location pins shared in chat messages are displayed as flags:
|
||||
- **Blue flag**: From a direct message
|
||||
- **Purple flag**: From a private channel
|
||||
- **Orange flag**: From a public channel
|
||||
|
||||
Tap a pin to see its info. Options to "Hide" (session only) or "Remove" (persistent).
|
||||
|
||||
### Predicted / Guessed Locations (Semi-Transparent)
|
||||
|
||||
Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as semi-transparent markers with a `not_listed_location` icon, visually distinct from confirmed-location markers.
|
||||
|
||||
#### Why guessed locations exist
|
||||
|
||||
In a mesh network, every message hops through one or more repeaters on its way to the destination. Each repeater in the path is identified by the first byte of its public key. If any of those repeaters have a known GPS location (because they advertise it), then a contact that routes through those repeaters must be somewhere within radio range of them. By combining the positions of multiple repeaters a contact is known to use, the app can triangulate a rough area where the contact is likely located.
|
||||
|
||||
#### How the algorithm works
|
||||
|
||||
1. **Build a repeater index**: The app collects all known contacts of type Repeater that have a valid GPS position and indexes them by the first byte of their public key.
|
||||
|
||||
2. **Collect anchor points**: For each contact that lacks GPS, the app looks at the **last-hop byte** of the contact's current path and also searches the `PathHistoryService` for recent paths. Each last-hop byte that matches a located repeater becomes an "anchor point" — a GPS coordinate the contact is likely near.
|
||||
|
||||
3. **Resolve ambiguity**: If multiple repeaters share the same first public-key byte (a hash collision), that byte is discarded as ambiguous. Only unambiguous one-to-one matches are kept.
|
||||
|
||||
4. **Filter geometric inconsistencies**: Two anchor points separated by more than `2 × maxRangeKm` (the estimated LoRa radio range, computed from the current frequency, bandwidth, spreading factor, and TX power using a free-space path loss model) cannot both be in range of the same node. Outlier anchors are removed to keep only a geometrically consistent set.
|
||||
|
||||
5. **Compute the estimated position**:
|
||||
- **Single anchor**: The contact is placed on a small circle (330m radius) around the repeater. The angle on the circle is deterministic — derived from an FNV-1a hash of the contact's public key — so the same contact always appears at the same offset, preventing markers from stacking on top of each other.
|
||||
- **Two or more anchors**: The position is the average (centroid) of all anchor coordinates, with a smaller offset radius (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:<lat>,<lon>|<label>|poi`
|
||||
- **Set as my location**: Updates your device's advertised location
|
||||
|
||||
### Filter Dialog (FAB)
|
||||
Toggle visibility of: chat nodes, repeaters, other nodes, guessed locations, discovery contacts.
|
||||
Additional filters:
|
||||
- **Key prefix filter**: Show only contacts whose public key starts with a given prefix
|
||||
- **Last-seen time slider**: From 1 hour to "all time"
|
||||
|
||||
### Legend Card (Top-Right)
|
||||
Shows node count and pin count. Tappable to expand a legend of all marker types.
|
||||
|
||||
---
|
||||
|
||||
## Path Trace Map
|
||||
|
||||
### How to Access
|
||||
- From the main map's radar icon
|
||||
- From a contact's long-press menu → "Path Trace / Ping"
|
||||
- From a message's path view → radar icon
|
||||
|
||||
### What the User Sees
|
||||
A map with a polyline showing the route from your node through repeater hops to the target:
|
||||
- **Green circles**: Hops with known GPS coordinates
|
||||
- **Orange circles** (`~HH`): Inferred positions (no GPS but deducible from contacts)
|
||||
- **Red endpoint**: Target contact with known GPS
|
||||
- **Purple semi-transparent endpoint**: Target with guessed position
|
||||
|
||||
A legend card at the bottom lists each hop pair with SNR quality icons and total path distance.
|
||||
|
||||
### How It Works
|
||||
Sends a trace request frame over the mesh. The repeater network traces the path hop-by-hop and returns per-hop SNR data. For hops without GPS, positions are inferred by averaging GPS coordinates of contacts sharing that last-hop byte.
|
||||
|
||||
---
|
||||
|
||||
## Line-of-Sight (LOS) Analysis
|
||||
|
||||
### How to Access
|
||||
From the main map, tap the terrain/antenna icon.
|
||||
|
||||
### What the User Sees
|
||||
A full-screen map with a collapsible control panel containing:
|
||||
- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow)
|
||||
- **Status**: Clear (green) or blocked (red) with distance and minimum clearance
|
||||
- **Options panel**: Node toggles, endpoint dropdowns, antenna height sliders (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.
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
# Navigation
|
||||
|
||||
## App Flow
|
||||
|
||||
The app follows this general flow:
|
||||
|
||||
```
|
||||
Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Contacts Screen
|
||||
```
|
||||
|
||||
After connecting, the three main screens (Contacts, Channels, Map) are accessible via a persistent bottom navigation bar called the **QuickSwitchBar**.
|
||||
|
||||
## Quick Switch Bar
|
||||
|
||||
The QuickSwitchBar is a Material 3 `NavigationBar` with a frosted-glass visual treatment (blur backdrop, transparent theme, rounded corners). It appears at the bottom of all three main screens.
|
||||
|
||||
| Index | Icon | Label | Screen |
|
||||
|---|---|---|---|
|
||||
| 0 | People | Contacts | ContactsScreen |
|
||||
| 1 | Tag | Channels | ChannelsScreen |
|
||||
| 2 | Map | Map | MapScreen |
|
||||
|
||||
Tapping a tab replaces the current screen with a subtle fade + slight horizontal nudge transition (220ms forward, 200ms reverse). The back button is suppressed on all three main screens — navigation between them is flat, not stacked. All icons use outline variants (`people_outline`, `tag`, `map_outlined`) following Material 3 conventions.
|
||||
|
||||
## Device Screen
|
||||
|
||||
The Device Screen is a transitional hub that shows after connection. In practice, the app navigates directly to Contacts after connecting, but the Device Screen is reachable via the QuickSwitchBar.
|
||||
|
||||
### What the User Sees
|
||||
|
||||
**App Bar**:
|
||||
- Left: Battery indicator chip (tappable — toggles between percentage and voltage display). Icon changes based on level: `battery_unknown` when data unavailable, `battery_alert` (orange) at 15% or below, `battery_full` otherwise
|
||||
- Left-aligned title (`centerTitle: false`): Two-line layout — small grey "MeshCore" label above the device name in bold
|
||||
- Right: Disconnect button (`bluetooth_disabled` crossed-out icon) and Settings button (tune icon)
|
||||
|
||||
**Body**:
|
||||
- **Connection Card**: Device avatar, device name, device ID, "Connected" chip, and battery chip
|
||||
- **Quick Switch** section: The QuickSwitchBar widget for navigating to Contacts/Channels/Map
|
||||
|
||||
### Disconnection
|
||||
|
||||
- The disconnect button shows a confirmation dialog before disconnecting
|
||||
- If the device disconnects unexpectedly, the app automatically navigates back to the Scanner screen (fires after the current frame completes via a post-frame callback)
|
||||
- This auto-navigation behavior (`DisconnectNavigationMixin`) is shared across all main screens
|
||||
|
||||
## Theme and Locale
|
||||
|
||||
- **Theme mode** is user-configurable in App Settings (System / Light / Dark) — not locked to system
|
||||
- **Language** can be overridden to one of 15 supported languages, or follow the system locale
|
||||
- On web, if a non-Chromium browser is detected, the app shows a `ChromeRequiredScreen` instead of the Scanner (Web Bluetooth requires Chromium)
|
||||
|
||||
## Full Navigation Graph
|
||||
|
||||
```
|
||||
ScannerScreen (root, always on stack)
|
||||
├─ [BLE connect] → push → ContactsScreen
|
||||
├─ [TCP FAB] → push → TcpScreen
|
||||
│ └─ [TCP connected] → pushReplacement → ContactsScreen
|
||||
└─ [USB FAB] → push → UsbScreen
|
||||
└─ [USB connected] → pushReplacement → ContactsScreen
|
||||
|
||||
ContactsScreen (selected=0)
|
||||
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
|
||||
├─ [quick-switch 2] → pushReplacement → MapScreen
|
||||
├─ [tap contact] → push → ChatScreen
|
||||
├─ [overflow > Settings] → push → SettingsScreen
|
||||
└─ [overflow > Discovered] → push → DiscoveryScreen
|
||||
|
||||
ChannelsScreen (selected=1)
|
||||
├─ [quick-switch 0] → pushReplacement → ContactsScreen
|
||||
├─ [quick-switch 2] → pushReplacement → MapScreen
|
||||
├─ [tap channel] → push → ChannelChatScreen
|
||||
└─ [overflow > Settings] → push → SettingsScreen
|
||||
|
||||
MapScreen (selected=2)
|
||||
├─ [quick-switch 0] → pushReplacement → ContactsScreen
|
||||
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
|
||||
├─ [radar button] → push → PathTraceMapScreen
|
||||
├─ [terrain button] → push → LineOfSightMapScreen
|
||||
└─ [long-press] → share marker / set location
|
||||
|
||||
Settings (push from any main screen)
|
||||
└─ [App Settings] → push → AppSettingsScreen
|
||||
└─ [Offline Map Cache] → push → MapCacheScreen
|
||||
```
|
||||
|
||||
Any disconnection from any screen triggers `popUntil(route.isFirst)`, returning to the Scanner.
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
# Notifications
|
||||
|
||||
## Overview
|
||||
|
||||
MeshCore Open provides both **system notifications** (push-style OS alerts) and **in-app unread badges** to inform users of new activity.
|
||||
|
||||
## Notification Types
|
||||
|
||||
### 1. Direct Message Notifications
|
||||
- **Triggered when**: A new incoming message arrives from a Chat or Room contact
|
||||
- **Title**: Contact's name
|
||||
- **Body**: Message text (reactions show "Reacted [emoji]", GIFs show "Sent a GIF")
|
||||
- **Priority**: High
|
||||
- **Android channel**: `messages`
|
||||
|
||||
### 2. Channel Message Notifications
|
||||
- **Triggered when**: A new message arrives on a non-muted channel
|
||||
- **Title**: Channel name (or "Channel N" if unnamed)
|
||||
- **Body**: `"<senderName>: <message text>"`
|
||||
- **Priority**: High
|
||||
- **Android channel**: `channel_messages`
|
||||
|
||||
### 3. Advertisement Notifications
|
||||
- **Triggered when**: A new node is discovered on the mesh for the first time
|
||||
- **Title**: "New [type] discovered" (e.g., "New chat node discovered")
|
||||
- **Body**: Contact's name
|
||||
- **Priority**: Default
|
||||
- **Android channel**: `adverts`
|
||||
|
||||
### 4. Background Service Notification (Android Only)
|
||||
- A persistent low-priority notification: "MeshCore running — Keeping BLE connected"
|
||||
- Required by Android for foreground services to keep BLE alive in the background
|
||||
- Tap to re-launch the app
|
||||
- **Does not auto-start on reboot** — the user must re-open the app manually after a phone restart
|
||||
|
||||
### Notification Tap Behavior
|
||||
|
||||
Tapping a notification currently re-launches the app at the root route. It does **not** navigate directly to the relevant chat or channel.
|
||||
|
||||
## In-App Unread Badges
|
||||
|
||||
Red numeric badges appear throughout the UI:
|
||||
- **Contacts list**: Each contact row shows a red pill badge (e.g., "3") for unread messages
|
||||
- **Channels list**: Each channel row shows an unread badge
|
||||
- **Chat screen subtitle**: Shows unread count inline
|
||||
- Badges cap at "99+" for display
|
||||
|
||||
### How Unread Counts Work
|
||||
|
||||
- Stored per contact (by public key) and per channel, **scoped to the connected device's identity** (first 10 hex characters of its public key). Switching between different radios gives each its own independent unread state
|
||||
- **Suppressed when viewing**: Opening a chat resets the count to 0 and cancels the OS notification
|
||||
- **Ignored for**: Outgoing messages, CLI messages, and repeater contacts
|
||||
- Debounced writes (500ms) to avoid excessive storage I/O during message bursts
|
||||
|
||||
## Notification Settings
|
||||
|
||||
Access via **App Settings → Notifications**:
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---|---|---|
|
||||
| Enable Notifications | On | Master toggle; requests OS permission when turned on |
|
||||
| Message Notifications | On | DM alerts (greyed out if master is off) |
|
||||
| Channel Message Notifications | On | Channel alerts (greyed out if master is off) |
|
||||
| Advertisement Notifications | On | New node alerts (greyed out if master is off) |
|
||||
|
||||
### Per-Channel Muting
|
||||
|
||||
Long-press a channel in the channels list → "Mute channel" / "Unmute channel". Muted channels do not generate OS notifications.
|
||||
|
||||
There is no per-contact muting.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The notification system prevents notification storms:
|
||||
- **Minimum interval**: 3 seconds between individual notifications
|
||||
- **Batch window**: If multiple notifications arrive within 5 seconds, they are combined into a single summary notification on a fourth Android channel (`batch_summary`): "MeshCore Activity — 2 messages, 1 channel message, 3 new nodes". Note: batch summaries are Android-only; on Apple platforms individual notifications are shown
|
||||
|
||||
## Notification Clearing
|
||||
|
||||
- **Opening a contact chat**: Cancels the OS notification and resets unread count
|
||||
- **Opening a channel**: Cancels the channel notification and resets unread count
|
||||
- **Opening Contacts screen**: Cancels all advertisement notifications
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Message Notifs | Badge | Background Service |
|
||||
|---|---|---|---|
|
||||
| Android | Yes | Via notification number | Yes (foreground service) |
|
||||
| iOS | Yes | Yes (app badge) | No |
|
||||
| macOS | Yes | Yes | No |
|
||||
| Windows | Yes | No | No |
|
||||
| Linux | Yes (if D-Bus available) | No | No |
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
# Repeater Management
|
||||
|
||||
## Overview
|
||||
|
||||
Repeater Management provides tools for administering MeshCore repeater and room server nodes. It includes device status monitoring, CLI access, telemetry reading, neighbor discovery, and remote configuration.
|
||||
|
||||
## How to Access
|
||||
|
||||
From the Contacts screen:
|
||||
1. Long-press a **Repeater** or **Room** contact
|
||||
2. Select "Manage Repeater" or "Room Management"
|
||||
3. Enter the admin password in the login dialog
|
||||
4. Navigate to the Repeater Hub Screen
|
||||
|
||||
### Login Dialog
|
||||
|
||||
- Password field with show/hide toggle
|
||||
- "Save password" checkbox (persists for future logins). If a saved password exists, it is pre-filled and the checkbox is pre-checked, making login one-tap
|
||||
- Routing mode selector and "Manage Paths" link are available directly in the dialog (configure routing before login)
|
||||
- Auto-retries up to 5 times on timeout, showing progress ("Attempt 2 of 5"). A wrong password stops immediately after the first attempt — only timeouts trigger retries
|
||||
- After 5 failed attempts, further login attempts are blocked
|
||||
|
||||
---
|
||||
|
||||
## Repeater Hub Screen
|
||||
|
||||
The central management screen showing:
|
||||
|
||||
- **Header card**: Repeater name, short public key, path label, GPS coordinates (if known)
|
||||
- **Battery chemistry selector**: NMC / LiFePO4 / LiPo (saved per repeater)
|
||||
- **Management tool cards** (full-width cards with chevron arrows, not a grid). Title dynamically shows "Repeater Management" or "Room Management" based on contact type:
|
||||
|
||||
| Card | Destination |
|
||||
|---|---|
|
||||
| Status | Repeater Status Screen |
|
||||
| Telemetry | Telemetry Screen |
|
||||
| CLI | Repeater CLI Screen |
|
||||
| Neighbors | Neighbors Screen |
|
||||
| Settings | Repeater Settings Screen |
|
||||
|
||||
---
|
||||
|
||||
## Repeater Status
|
||||
|
||||
### What the User Sees
|
||||
|
||||
Three information cards:
|
||||
|
||||
**System Information**:
|
||||
- Battery percentage
|
||||
- Uptime
|
||||
- Queue length
|
||||
- Error flags
|
||||
- Clock at login time
|
||||
|
||||
**Radio Statistics**:
|
||||
- Last RSSI and SNR
|
||||
- Noise floor
|
||||
- TX and RX airtime
|
||||
|
||||
**Packet Statistics**:
|
||||
- Packets sent, received, and duplicates
|
||||
- Broken down by flood vs. direct
|
||||
|
||||
### Key Interactions
|
||||
- Auto-queries the repeater on open; shows a loading spinner until data arrives
|
||||
- On timeout: red snackbar error. On success: data appears with a green snackbar confirmation
|
||||
- Pull-to-refresh or refresh button to re-query
|
||||
- Routing mode popup and path management dialog in app bar (these controls appear on **all** management sub-screens, not just Status)
|
||||
|
||||
---
|
||||
|
||||
## Repeater CLI
|
||||
|
||||
A terminal-style interface for sending commands directly to the repeater.
|
||||
|
||||
### What the User Sees
|
||||
|
||||
- **Quick-command bar** (horizontal scroll): Shortcut buttons for common commands (get name, get radio, get tx, neighbors, ver, advert, clock)
|
||||
- **Command history list**: Sent commands in primary color, responses in secondary color
|
||||
- **Input bar**: Up/down history arrows, monospace text field with `> ` prefix, send button
|
||||
|
||||
### Key Interactions
|
||||
|
||||
- Type a command and press send (or Enter on desktop)
|
||||
- Up/down arrows navigate through command history
|
||||
- Quick-command buttons populate and send common commands
|
||||
- Bug report icon: Shows raw frame debug info for the next typed command (shows error snackbar if input field is empty)
|
||||
- Help icon: Opens a scrollable reference of all known CLI commands. Tapping any command populates the input field immediately
|
||||
- Clear icon: Wipes the command/response history
|
||||
- Failed/timed-out commands are automatically retried once
|
||||
|
||||
### Available CLI Commands
|
||||
|
||||
**General**: `advert`, `reboot`, `clock`, `password`, `ver`, `clear stats`
|
||||
|
||||
**Settings**: `set name`, `set af`, `set tx`, `set repeat`, `set allow.read.only`, `set flood.max`, `set int.thresh`, `set agc.reset.interval`, `set multi.acks`, `set advert.interval`, `set flood.advert.interval`, `set guest.password`, `set lat`, `set lon`, `set radio`, `set rxdelay`, `set txdelay`, `set direct.txdelay`, `set bridge.*`, `set adc.multiplier`, `tempradio`, `setperm`
|
||||
|
||||
**Bridge**: `get bridge.type`
|
||||
|
||||
**Logging**: `log start`, `log stop`, `log erase`
|
||||
|
||||
**Neighbors**: `neighbors`, `neighbor.remove`
|
||||
|
||||
**Region Management**: `region`, `region load/get/put/remove/allowf/denyf/home/save`
|
||||
|
||||
**GPS**: `gps`, `gps on/off/sync/setloc/advert`
|
||||
|
||||
---
|
||||
|
||||
## Telemetry
|
||||
|
||||
### What the User Sees
|
||||
|
||||
A list of Cayenne LPP sensor channel cards:
|
||||
|
||||
- **Channel 1** (special): Battery voltage (shown as percentage or raw mV) and MCU temperature
|
||||
- **Other channels**: Raw sensor values with appropriate labels
|
||||
|
||||
Shows "No data" until a response arrives from the repeater.
|
||||
|
||||
### Key Interactions
|
||||
- Auto-queries on open
|
||||
- Pull-to-refresh
|
||||
- Temperature respects metric/imperial setting
|
||||
- Battery readings are stored for the repeater's battery snapshot
|
||||
|
||||
---
|
||||
|
||||
## Neighbors
|
||||
|
||||
### What the User Sees
|
||||
|
||||
A card titled "Repeater's Neighbors - N" listing each neighbor as:
|
||||
- Repeater name (or hex key prefix if unknown)
|
||||
- Time since last heard
|
||||
- SNR quality icon with color coding and label
|
||||
|
||||
### Key Interactions
|
||||
- Auto-queries up to 15 neighbors on open
|
||||
- Matches public key prefixes against known contacts to show names
|
||||
- Pull-to-refresh
|
||||
|
||||
---
|
||||
|
||||
## Repeater Settings
|
||||
|
||||
### What the User Sees
|
||||
|
||||
Five configuration cards:
|
||||
|
||||
**1. Basic Settings**
|
||||
- Name field
|
||||
- Admin password field
|
||||
- Guest password field
|
||||
|
||||
**2. Radio Settings**
|
||||
- Frequency (MHz)
|
||||
- TX Power (dBm)
|
||||
- Bandwidth dropdown (kHz)
|
||||
- Spreading Factor (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
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
# Scanner & Connection
|
||||
|
||||
## BLE Scanner (Home Screen)
|
||||
|
||||
The BLE Scanner is the app's home screen, displayed immediately on launch.
|
||||
|
||||
### How to Access
|
||||
|
||||
- Opens automatically when the app starts
|
||||
- Returns here when disconnecting from any device
|
||||
- Accessible by navigating back from a connected session
|
||||
|
||||
### What the User Sees
|
||||
|
||||
**App Bar**: Centered title "Scanner".
|
||||
|
||||
**Bluetooth-Off Warning Banner** (conditional): Appears when the Bluetooth adapter is off, showing a `bluetooth_disabled` icon, a warning message, and on Android, an "Enable Bluetooth" button.
|
||||
|
||||
**Status Bar**: A full-width colored strip reflecting the current connection state:
|
||||
|
||||
| State | Text | Color |
|
||||
|---|---|---|
|
||||
| Disconnected | "Not connected" | Grey |
|
||||
| Scanning | "Scanning..." | Blue |
|
||||
| Connecting | "Connecting..." | Orange |
|
||||
| Connected | "Connected to \<device name\>" | Green |
|
||||
| Disconnecting | "Disconnecting..." | Orange |
|
||||
|
||||
**Device List**: When no devices are found, shows a large Bluetooth icon with a prompt. The prompt text is dynamic: "Searching for devices..." while actively scanning, or "Tap Scan to search" when idle. When devices are found, shows a scrollable list of `DeviceTile` widgets.
|
||||
|
||||
**Bottom FAB Row**: Up to three floating action buttons:
|
||||
- **USB** button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
|
||||
- **TCP/IP** button - Opens TCP connection screen (all non-web platforms)
|
||||
- **BLE Scan** button - Toggles BLE scanning on/off; shows a spinner when scanning. **Disabled** (greyed out, not tappable) when Bluetooth is off
|
||||
|
||||
### Device Tile
|
||||
|
||||
Each discovered device is displayed as a list tile showing:
|
||||
- **Signal strength icon** (color-coded by RSSI):
|
||||
- Green: >= -60 dBm (excellent)
|
||||
- Light green: -60 to -70 dBm (good)
|
||||
- Amber: -70 to -80 dBm (fair)
|
||||
- Orange: -80 to -90 dBm (weak)
|
||||
- Red: < -90 dBm (poor)
|
||||
- **RSSI value** in dBm (e.g., "-72 dBm")
|
||||
- **Device name** (falls back to "Unknown Device")
|
||||
- **Device ID** (BLE MAC address on Android; a system-assigned UUID on iOS/macOS)
|
||||
- **Connect button** (the entire tile row is also tappable — both trigger connection)
|
||||
|
||||
Note: The weak (-80 to -90 dBm) and poor (< -90 dBm) tiers share the same icon shape and are only differentiated by color (orange vs. red).
|
||||
|
||||
### How Scanning Works
|
||||
|
||||
- Filters for devices with names starting with `MeshCore-` or `Whisper-`
|
||||
- Uses low-latency scan mode on Android
|
||||
- Scans for 10 seconds then auto-stops
|
||||
- On iOS/macOS, waits for BLE adapter initialization before starting
|
||||
- If Bluetooth is turned off during a scan, scanning stops immediately
|
||||
|
||||
### Connecting to a Device
|
||||
|
||||
Tap a device tile or its Connect button:
|
||||
1. The connector stops scanning and transitions to "connecting"
|
||||
2. Connects to the device with a 15-second timeout
|
||||
3. Requests MTU 185 bytes for optimal throughput
|
||||
4. Discovers BLE services and locates the Nordic UART Service
|
||||
5. Subscribes to TX notifications for receiving data
|
||||
6. On success, automatically navigates to the Contacts screen
|
||||
7. On failure, shows a red error snackbar
|
||||
|
||||
---
|
||||
|
||||
## USB Connection
|
||||
|
||||
### How to Access
|
||||
|
||||
From the Scanner screen, tap the **USB** FAB button.
|
||||
|
||||
### What the User Sees
|
||||
|
||||
- A colored status bar at the top (same color scheme as BLE scanner)
|
||||
- A list of detected USB serial ports, each showing:
|
||||
- Friendly display name
|
||||
- Raw port name (subtitle, only shown when it differs from the display name)
|
||||
- "Connect" button
|
||||
- FABs at the bottom to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
|
||||
|
||||
### Key Interactions
|
||||
|
||||
- On desktop (Windows, Linux, macOS): ports are polled every 2 seconds for hot-plug detection (polling pauses while connecting/connected)
|
||||
- On mobile: tap the "Scan" FAB to manually refresh
|
||||
- Tap a port or its Connect button to connect
|
||||
- On successful connection, navigates to Contacts screen
|
||||
- On connection failure, the port list automatically refreshes
|
||||
- Platform-specific error messages for common USB failures (permission denied, device missing, device detached, device busy, driver missing, port invalid, timeout, and more)
|
||||
|
||||
---
|
||||
|
||||
## TCP Connection
|
||||
|
||||
### How to Access
|
||||
|
||||
From the Scanner screen, tap the **TCP/IP** FAB button.
|
||||
|
||||
### What the User Sees
|
||||
|
||||
- A colored status bar at the top
|
||||
- **Host address** text field
|
||||
- **Port number** text field
|
||||
- **Connect** button
|
||||
- FABs at the bottom to switch to USB or BLE
|
||||
|
||||
### Key Interactions
|
||||
|
||||
- Last-used host and port are pre-populated from saved settings
|
||||
- Tap Connect to validate inputs and connect
|
||||
- Host must not be empty
|
||||
- Port must be a number between 1 and 65535
|
||||
- Validation errors are shown as red snackbars
|
||||
- The Connect button shows a spinner and "Connecting..." label while in progress
|
||||
- The status bar shows the specific host:port being connected to (e.g., "Connecting to 192.168.1.1:5000")
|
||||
- On success, navigates to Contacts screen and saves the host/port to settings
|
||||
- On connection, the status bar shows the active TCP endpoint (e.g., "Connected to 192.168.1.1:5000")
|
||||
- Error messages for timeout, unsupported platform, and connection failures
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
# Settings
|
||||
|
||||
## How to Access
|
||||
|
||||
- From the Device Screen: tap the tune/sliders icon in the app bar
|
||||
- From Contacts or Channels: overflow menu (three-dot) → Settings
|
||||
|
||||
Settings are only accessible while a device is connected.
|
||||
|
||||
## Settings Screen Layout
|
||||
|
||||
The settings screen is a scrollable list of cards:
|
||||
|
||||
1. [Device Info](#device-info)
|
||||
2. [App Settings](#app-settings) (link to sub-screen)
|
||||
3. [Node Settings](#node-settings)
|
||||
4. [Actions](#actions)
|
||||
5. [Debug](#debug)
|
||||
6. [Export](#export)
|
||||
7. [About](#about)
|
||||
|
||||
---
|
||||
|
||||
## Device Info
|
||||
|
||||
A collapsible card showing read-only device information. **Collapsed by default** — tap the header to expand with an animated chevron indicator:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| Name | Connected device's display name |
|
||||
| ID | Device identifier |
|
||||
| Status | Connected / Disconnected |
|
||||
| Battery | Percentage or voltage (tap to toggle) |
|
||||
| Node Name | The node's mesh identity name |
|
||||
| Public Key | First 16 hex characters + "..." |
|
||||
| Contacts Count | Number of known contacts |
|
||||
| Channel Count | Number of configured channels |
|
||||
|
||||
Battery shows an alert icon and orange text when at 15% or below. The toggle only works when millivolt data is available from the firmware.
|
||||
|
||||
---
|
||||
|
||||
## App Settings
|
||||
|
||||
A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
|
||||
|
||||
### Appearance
|
||||
- **Theme**: System / Light / Dark
|
||||
- **Language**: System default or one of 15 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian)
|
||||
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
|
||||
|
||||
### Notifications
|
||||
- **Master enable/disable**: Requests OS permission when enabling
|
||||
- **Message notifications**: New direct message alerts
|
||||
- **Channel message notifications**: New channel message alerts
|
||||
- **Advertisement notifications**: New node discovery alerts
|
||||
|
||||
### Messaging
|
||||
- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
|
||||
- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
|
||||
- Max Route Weight (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.
|
||||
61
flake.lock
generated
61
flake.lock
generated
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
86
flake.nix
86
flake.nix
|
|
@ -1,86 +0,0 @@
|
|||
{
|
||||
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."
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -20,5 +20,7 @@
|
|||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
platform :ios, '16.4'
|
||||
platform :ios, '12.0'
|
||||
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
|
|
@ -32,8 +32,5 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
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 */
|
||||
|
|
@ -43,13 +42,9 @@
|
|||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
|
|
@ -67,7 +62,6 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -100,8 +94,6 @@
|
|||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
DEE6F094D3B70E76087722E1 /* Pods */,
|
||||
DAE613E34DF694C2E33B64C7 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
|
@ -129,25 +121,6 @@
|
|||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DAE613E34DF694C2E33B64C7 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4268181FCF3E12817B700E9C /* libPods-Runner.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -172,15 +145,12 @@
|
|||
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 = (
|
||||
);
|
||||
|
|
@ -283,61 +253,6 @@
|
|||
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 */
|
||||
|
|
@ -431,7 +346,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
|
@ -453,7 +368,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -469,7 +384,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
|
@ -486,7 +401,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
|
|
@ -501,7 +416,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
|
|
@ -557,7 +472,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
|
|
@ -608,7 +523,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
|
@ -632,7 +547,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
|
@ -654,7 +569,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
|
|||
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
|
|
@ -4,7 +4,4 @@
|
|||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,12 @@ import Flutter
|
|||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
|
|
@ -24,46 +22,8 @@
|
|||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app uses the camera to scan QR codes for joining communities.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
|
@ -81,5 +41,17 @@
|
|||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
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
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,70 +0,0 @@
|
|||
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<Uint8List>? _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<void> 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<Uint8List> 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<void> cancelFrameSubscription() async {
|
||||
await _frameSubscription?.cancel();
|
||||
_frameSubscription = null;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
if (!_service.isConnected && _frameSubscription == null) return;
|
||||
_debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
|
||||
await _frameSubscription?.cancel();
|
||||
_frameSubscription = null;
|
||||
await _service.disconnect();
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) => _service.write(data);
|
||||
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_service.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
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<Uint8List> get frameStream => _service.frameStream;
|
||||
|
||||
// --- Configuration ---
|
||||
Future<List<String>> 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<void> 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<void> disconnect() async {
|
||||
if (!_service.isConnected && _activePortKey == null) {
|
||||
return;
|
||||
}
|
||||
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
|
||||
await _service.disconnect();
|
||||
_activePortKey = null;
|
||||
_activePortLabel = null;
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) => _service.write(data);
|
||||
|
||||
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
|
||||
|
||||
// --- Label management ---
|
||||
void updateConnectedLabel(String selfName) {
|
||||
_service.updateConnectedLabel(selfName);
|
||||
_activePortLabel = _service.activePortDisplayLabel ?? _activePortLabel;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_service.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +1,6 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||
class BufferReader {
|
||||
int _pointer = 0;
|
||||
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 = <int>[];
|
||||
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>[];
|
||||
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<int> 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;
|
||||
|
|
@ -200,29 +28,18 @@ const int cmdSendStatusReq = 27;
|
|||
const int cmdGetContactByKey = 30;
|
||||
const int cmdGetChannel = 31;
|
||||
const int cmdSetChannel = 32;
|
||||
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;
|
||||
const int cmdGetRadioSettings = 57;
|
||||
|
||||
// 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 reqTypeGetNeighbors = 0x06;
|
||||
const int reqTypeGetNeighbours = 0x06;
|
||||
|
||||
// Repeater response codes
|
||||
const int respServerLoginOk = 0;
|
||||
|
|
@ -239,19 +56,12 @@ 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 respCodeCustomVars = 21;
|
||||
const int respCodeAutoAddConfig = 25;
|
||||
const int respCodeStats = 24;
|
||||
|
||||
const int statsTypeCore = 0;
|
||||
const int statsTypeRadio = 1;
|
||||
const int statsTypePackets = 2;
|
||||
const int respCodeRadioSettings = 25;
|
||||
|
||||
// Push codes (async from device)
|
||||
const int pushCodeAdvert = 0x80;
|
||||
|
|
@ -262,10 +72,7 @@ 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;
|
||||
|
|
@ -273,49 +80,8 @@ 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;
|
||||
|
|
@ -323,10 +89,8 @@ 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 + 2; // +2 safety margin
|
||||
const int _sendChannelTextMsgOverheadBytes =
|
||||
1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
|
||||
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1;
|
||||
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1;
|
||||
|
||||
int maxContactMessageBytes() {
|
||||
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
|
||||
|
|
@ -357,17 +121,13 @@ 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
|
||||
|
|
@ -379,44 +139,48 @@ 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 message = BufferReader(frame);
|
||||
try {
|
||||
final code = message.readByte();
|
||||
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Companion radio layout:
|
||||
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
|
||||
if (code == respCodeContactMsgRecvV3) {
|
||||
// Skip SNR and reserved bytes in v3 layout
|
||||
message.skipBytes(3);
|
||||
}
|
||||
final senderPrefix = message.readBytes(6); // public key
|
||||
message.skipBytes(1); // path length
|
||||
final textType = message.readByte();
|
||||
message.skipBytes(4); // timestamp (4 bytes)
|
||||
|
||||
final shiftedType = textType >> 2;
|
||||
final isSigned = shiftedType == txtTypeSigned || textType == txtTypeSigned;
|
||||
if (isSigned) {
|
||||
// Signed messages have a 4-byte signature after the timestamp, before the text
|
||||
message.skipBytes(4);
|
||||
}
|
||||
final text = message.readCString();
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
return ParsedContactText(senderPrefix: senderPrefix, text: text);
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing contact message text: $e');
|
||||
final code = frame[0];
|
||||
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Companion radio layout:
|
||||
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
|
||||
final isV3 = code == respCodeContactMsgRecvV3;
|
||||
final prefixOffset = isV3 ? 4 : 1;
|
||||
const prefixLen = 6;
|
||||
final txtTypeOffset = prefixOffset + prefixLen + 1;
|
||||
final timestampOffset = txtTypeOffset + 1;
|
||||
final baseTextOffset = timestampOffset + 4;
|
||||
if (frame.length <= baseTextOffset) return null;
|
||||
|
||||
final flags = frame[txtTypeOffset];
|
||||
final shiftedType = flags >> 2;
|
||||
final rawType = flags;
|
||||
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
|
||||
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
|
||||
if (!isPlain && !isCli) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset).trim();
|
||||
if (text.isEmpty && frame.length > baseTextOffset + 4) {
|
||||
text =
|
||||
readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim();
|
||||
}
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
|
||||
return ParsedContactText(senderPrefix: senderPrefix, text: text);
|
||||
}
|
||||
|
||||
// Helper to read uint32 little-endian
|
||||
|
|
@ -439,9 +203,31 @@ int readInt32LE(Uint8List data, int offset) {
|
|||
return val;
|
||||
}
|
||||
|
||||
// Helper to convert uint32 to hex string
|
||||
String ackHashToHex(int ackHash) {
|
||||
return ackHash.toRadixString(16).padLeft(8, '0');
|
||||
// Helper to 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 public key to hex string
|
||||
|
|
@ -460,32 +246,34 @@ Uint8List hexToPubKey(String hex) {
|
|||
|
||||
// Build CMD_GET_CONTACTS frame
|
||||
Uint8List buildGetContactsFrame({int? since}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdGetContacts);
|
||||
if (since != null) {
|
||||
writer.writeUInt32LE(since);
|
||||
final frame = Uint8List(5);
|
||||
frame[0] = cmdGetContacts;
|
||||
writeUint32LE(frame, 1, since);
|
||||
return frame;
|
||||
}
|
||||
return writer.toBytes();
|
||||
return Uint8List.fromList([cmdGetContacts]);
|
||||
}
|
||||
|
||||
// Build CMD_SEND_LOGIN frame
|
||||
// Format: [cmd][pub_key x32][password...]\0
|
||||
Uint8List buildSendLoginFrame(Uint8List recipientPubKey, String password) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendLogin);
|
||||
writer.writeBytes(recipientPubKey);
|
||||
writer.writeString(password);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
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;
|
||||
}
|
||||
|
||||
// Build CMD_SEND_STATUS_REQ frame
|
||||
// Format: [cmd][pub_key x32]
|
||||
Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendStatusReq);
|
||||
writer.writeBytes(recipientPubKey);
|
||||
return writer.toBytes();
|
||||
final frame = Uint8List(1 + pubKeySize);
|
||||
frame[0] = cmdSendStatusReq;
|
||||
frame.setRange(1, 1 + pubKeySize, recipientPubKey);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Build CMD_SEND_TXT_MSG frame (companion_radio format)
|
||||
|
|
@ -496,39 +284,48 @@ Uint8List buildSendTextMsgFrame(
|
|||
int attempt = 0,
|
||||
int? timestampSeconds,
|
||||
}) {
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 writer = BufferWriter();
|
||||
writer.writeByte(cmdSendChannelTxtMsg);
|
||||
writer.writeByte(txtTypePlain);
|
||||
writer.writeByte(channelIndex);
|
||||
writer.writeUInt32LE(timestamp);
|
||||
writer.writeString(text);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
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;
|
||||
}
|
||||
|
||||
// Build CMD_REMOVE_CONTACT frame
|
||||
Uint8List buildRemoveContactFrame(Uint8List pubKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdRemoveContact);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
final frame = Uint8List(1 + pubKeySize);
|
||||
frame[0] = cmdRemoveContact;
|
||||
frame.setRange(1, 1 + pubKeySize, pubKey);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Build CMD_APP_START frame
|
||||
|
|
@ -537,13 +334,14 @@ Uint8List buildAppStartFrame({
|
|||
String appName = 'MeshCoreOpen',
|
||||
int appVersion = 1,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdAppStart);
|
||||
writer.writeByte(appVersion);
|
||||
writer.writeBytes(Uint8List(6)); // reserved bytes
|
||||
writer.writeString(appName);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
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;
|
||||
}
|
||||
|
||||
// Build CMD_DEVICE_QUERY frame
|
||||
|
|
@ -561,23 +359,12 @@ 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 writer = BufferWriter();
|
||||
writer.writeByte(cmdSetDeviceTime);
|
||||
writer.writeUInt32LE(timestamp);
|
||||
return writer.toBytes();
|
||||
final frame = Uint8List(5);
|
||||
frame[0] = cmdSetDeviceTime;
|
||||
writeUint32LE(frame, 1, timestamp);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Build CMD_SEND_SELF_ADVERT frame
|
||||
|
|
@ -590,31 +377,21 @@ 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 writer = BufferWriter();
|
||||
writer.writeByte(cmdSetAdvertName);
|
||||
writer.writeBytes(Uint8List.fromList(nameBytes.sublist(0, nameLen)));
|
||||
return writer.toBytes();
|
||||
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;
|
||||
}
|
||||
|
||||
// Build CMD_SET_ADVERT_LATLON frame
|
||||
// Format: [cmd][lat x4][lon x4]
|
||||
Uint8List buildSetAdvertLatLonFrame(double lat, double lon) {
|
||||
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();
|
||||
final frame = Uint8List(9);
|
||||
frame[0] = cmdSetAdvertLatLon;
|
||||
writeInt32LE(frame, 1, (lat * 1000000).round());
|
||||
writeInt32LE(frame, 5, (lon * 1000000).round());
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Build CMD_REBOOT frame
|
||||
|
|
@ -636,44 +413,37 @@ 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 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++) {
|
||||
pskPadded[i] = psk[i];
|
||||
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];
|
||||
}
|
||||
writer.writeBytes(pskPadded);
|
||||
return writer.toBytes();
|
||||
// frame[2 + nameLen] is already 0 (null terminator)
|
||||
// Write PSK (16 bytes)
|
||||
for (int i = 0; i < 16 && i < psk.length; i++) {
|
||||
frame[34 + i] = psk[i];
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Build CMD_SET_RADIO_PARAMS frame
|
||||
// Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9)
|
||||
// [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+)
|
||||
// Format: [cmd][freq x4][bw x4][sf][cr]
|
||||
// freq: frequency in Hz (300000-2500000)
|
||||
// bw: bandwidth in Hz (7000-500000)
|
||||
// sf: spreading factor (5-12)
|
||||
// cr: coding rate (5-8)
|
||||
// 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();
|
||||
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;
|
||||
}
|
||||
|
||||
// Build CMD_SET_RADIO_TX_POWER frame
|
||||
|
|
@ -685,81 +455,71 @@ Uint8List buildSetRadioTxPowerFrame(int powerDbm) {
|
|||
// Build CMD_RESET_PATH frame
|
||||
// Format: [cmd][pub_key x32]
|
||||
Uint8List buildResetPathFrame(Uint8List pubKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdResetPath);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
final frame = Uint8List(1 + pubKeySize);
|
||||
frame[0] = cmdResetPath;
|
||||
frame.setRange(1, 1 + pubKeySize, pubKey);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
|
||||
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
|
||||
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
|
||||
Uint8List buildUpdateContactPathFrame(
|
||||
Uint8List pubKey,
|
||||
Uint8List path,
|
||||
Uint8List customPath,
|
||||
int pathLen, {
|
||||
int type = 1, // ADV_TYPE_CHAT
|
||||
int flags = 0,
|
||||
String name = '',
|
||||
double? lat,
|
||||
double? lon,
|
||||
DateTime? lastModified,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdAddUpdateContact);
|
||||
writer.writeBytes(pubKey);
|
||||
writer.writeByte(type);
|
||||
writer.writeByte(flags);
|
||||
writer.writeByte(pathLen);
|
||||
// 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;
|
||||
|
||||
writer.writeBytesPadded(path, maxPathSize);
|
||||
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;
|
||||
|
||||
// Name (32 bytes, null-padded)
|
||||
writer.writeCString(name, maxNameSize);
|
||||
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;
|
||||
|
||||
// Timestamp
|
||||
// Timestamp (current time)
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
writer.writeUInt32LE(timestamp);
|
||||
writeUint32LE(frame, offset, timestamp);
|
||||
|
||||
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();
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Build CMD_GET_CONTACT_BY_KEY frame
|
||||
// Format: [cmd][pub_key x32]
|
||||
Uint8List buildGetContactByKeyFrame(Uint8List pubKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdGetContactByKey);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
final frame = Uint8List(1 + pubKeySize);
|
||||
frame[0] = cmdGetContactByKey;
|
||||
frame.setRange(1, 1 + pubKeySize, pubKey);
|
||||
return frame;
|
||||
}
|
||||
|
||||
//Build CMD_GET_CUSTOM_VARS frame
|
||||
Uint8List buildGetCustomVarsFrame() {
|
||||
return Uint8List.fromList([cmdGetCustomVar]);
|
||||
}
|
||||
|
||||
Uint8List buildGetAutoAddFlagsFrame() {
|
||||
return Uint8List.fromList([cmdGetAutoAddConfig]);
|
||||
// Build CMD_GET_RADIO_SETTINGS frame
|
||||
Uint8List buildGetRadioSettingsFrame() {
|
||||
return Uint8List.fromList([cmdGetRadioSettings]);
|
||||
}
|
||||
|
||||
// Calculate LoRa airtime for a packet
|
||||
|
|
@ -785,11 +545,9 @@ 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;
|
||||
|
|
@ -836,122 +594,23 @@ Uint8List buildSendCliCommandFrame(
|
|||
int attempt = 0,
|
||||
int? timestampSeconds,
|
||||
}) {
|
||||
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();
|
||||
}
|
||||
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;
|
||||
|
||||
// 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();
|
||||
}
|
||||
frame[offset++] = cmdSendTxtMsg;
|
||||
frame[offset++] = txtTypeCliData;
|
||||
frame[offset++] = safeAttempt;
|
||||
writeUint32LE(frame, offset, timestamp);
|
||||
offset += 4;
|
||||
|
||||
//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();
|
||||
}
|
||||
frame.setRange(offset, offset + prefixSize, repeaterPubKey.sublist(0, prefixSize));
|
||||
offset += prefixSize;
|
||||
|
||||
// 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();
|
||||
frame.setRange(offset, offset + textBytes.length, textBytes);
|
||||
frame[frame.length - 1] = 0; // null terminator
|
||||
return frame;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
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<String> deviceNamePrefixes = [
|
||||
"MeshCore-",
|
||||
"Whisper-",
|
||||
"WisCore-",
|
||||
"Seeed",
|
||||
"Lilygo",
|
||||
"HT-",
|
||||
"LowMesh_MC_",
|
||||
];
|
||||
}
|
||||
|
|
@ -1,277 +0,0 @@
|
|||
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<Map<String, dynamic>> parse(Uint8List bytes) {
|
||||
final buffer = BufferReader(bytes);
|
||||
final telemetry = <Map<String, dynamic>>[];
|
||||
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<Map<String, dynamic>> parseByChannel(Uint8List bytes) {
|
||||
final buffer = BufferReader(bytes);
|
||||
final Map<int, Map<String, dynamic>> 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': <String, dynamic>{}},
|
||||
);
|
||||
|
||||
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<Map<String, dynamic>> 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<String, dynamic>
|
||||
>[]; // Return an empty list on error to avoid crashing the app
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatScrollController extends ScrollController {
|
||||
final ValueNotifier<bool> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
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<void> handleLinkTap(BuildContext context, String url) async {
|
||||
// Show confirmation dialog
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import '../models/contact.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class PathHelper {
|
||||
static String formatPathHex(List<int> pathBytes) {
|
||||
return pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
static String resolvePathNames(
|
||||
List<int> pathBytes,
|
||||
List<Contact> allContacts,
|
||||
) {
|
||||
return pathBytes
|
||||
.map((b) {
|
||||
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
final matches = allContacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.publicKey.first == b &&
|
||||
(c.type == advTypeRepeater || c.type == advTypeRoom),
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return hex;
|
||||
if (matches.length == 1) return matches.first.name;
|
||||
return matches.map((c) => c.name).join(' | ');
|
||||
})
|
||||
.join(' \u2192 ');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +1,53 @@
|
|||
import '../widgets/emoji_picker.dart';
|
||||
|
||||
class ReactionInfo {
|
||||
final String targetHash;
|
||||
final String targetMessageId;
|
||||
final String emoji;
|
||||
final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix
|
||||
|
||||
ReactionInfo({required this.targetHash, required this.emoji});
|
||||
ReactionInfo({
|
||||
required this.targetMessageId,
|
||||
required this.emoji,
|
||||
this.reactionKey,
|
||||
});
|
||||
}
|
||||
|
||||
class ReactionHelper {
|
||||
/// Apply a reaction to a list of messages by matching the reaction hash.
|
||||
///
|
||||
/// [messages] - the message list to search
|
||||
/// [reactionInfo] - the parsed reaction
|
||||
/// [getTimestampSecs] - extract timestamp seconds from a message
|
||||
/// [getSenderName] - extract sender name for hash (null for 1:1 implicit)
|
||||
/// [getMessageText] - extract message text
|
||||
/// [getReactions] - extract current reactions map
|
||||
/// [shouldSkip] - filter function to skip messages (e.g., skip outgoing for incoming reactions)
|
||||
/// [updateMessage] - callback to update the message at index with new reactions
|
||||
///
|
||||
/// Returns whether a match was found.
|
||||
static bool applyReaction<T>({
|
||||
required List<T> messages,
|
||||
required ReactionInfo reactionInfo,
|
||||
required int Function(T) getTimestampSecs,
|
||||
required String? Function(T) getSenderName,
|
||||
required String Function(T) getMessageText,
|
||||
required Map<String, int> Function(T) getReactions,
|
||||
required bool Function(T) shouldSkip,
|
||||
required void Function(int index, Map<String, int> newReactions)
|
||||
updateMessage,
|
||||
}) {
|
||||
final targetHash = reactionInfo.targetHash;
|
||||
for (int i = messages.length - 1; i >= 0; i--) {
|
||||
final msg = messages[i];
|
||||
if (shouldSkip(msg)) continue;
|
||||
|
||||
final msgHash = computeReactionHash(
|
||||
getTimestampSecs(msg),
|
||||
getSenderName(msg),
|
||||
getMessageText(msg),
|
||||
);
|
||||
if (msgHash == targetHash) {
|
||||
final currentReactions = Map<String, int>.from(getReactions(msg));
|
||||
currentReactions[reactionInfo.emoji] =
|
||||
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
|
||||
updateMessage(i, currentReactions);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static List<String>? _cachedEmojis;
|
||||
|
||||
/// Combined list of all reaction emojis in fixed order.
|
||||
/// Order must stay stable for index compatibility.
|
||||
static List<String> get reactionEmojis {
|
||||
return _cachedEmojis ??= [
|
||||
...EmojiPicker.quickEmojis,
|
||||
...EmojiPicker.smileys,
|
||||
...EmojiPicker.gestures,
|
||||
...EmojiPicker.hearts,
|
||||
...EmojiPicker.objects,
|
||||
];
|
||||
}
|
||||
|
||||
/// Convert emoji to 2-char hex index. Returns null if emoji not in list.
|
||||
static String? emojiToIndex(String emoji) {
|
||||
final idx = reactionEmojis.indexOf(emoji);
|
||||
if (idx < 0) return null;
|
||||
return idx.toRadixString(16).padLeft(2, '0');
|
||||
}
|
||||
|
||||
/// Convert 2-char hex index to emoji. Returns null if invalid index.
|
||||
static String? indexToEmoji(String hexIndex) {
|
||||
final idx = int.tryParse(hexIndex, radix: 16);
|
||||
if (idx == null || idx < 0 || idx >= reactionEmojis.length) return null;
|
||||
return reactionEmojis[idx];
|
||||
}
|
||||
|
||||
/// Compute a 4-char hex hash for a message reaction.
|
||||
/// Hash input: timestampSeconds + [senderName] + first 5 chars of text
|
||||
/// For 1:1 chats, senderName can be null (sender is implicit).
|
||||
static String computeReactionHash(
|
||||
int timestampSeconds,
|
||||
String? senderName,
|
||||
String text,
|
||||
) {
|
||||
final first5 = text.length >= 5 ? text.substring(0, 5) : text;
|
||||
final input = senderName != null
|
||||
? '$timestampSeconds$senderName$first5'
|
||||
: '$timestampSeconds$first5';
|
||||
// Use hashCode and take lower 16 bits, format as 4 hex chars
|
||||
final hash = input.hashCode & 0xFFFF;
|
||||
return hash.toRadixString(16).padLeft(4, '0');
|
||||
}
|
||||
|
||||
/// Parse reaction format: r:HASH:INDEX (where INDEX is 2-char hex emoji index)
|
||||
/// Returns null if text is not a valid reaction format
|
||||
/// Parse reaction format: r:[messageId]:[emoji]
|
||||
/// Supports both old format (full messageId) and new format (timestamp_senderPrefix)
|
||||
static ReactionInfo? parseReaction(String text) {
|
||||
final regex = RegExp(r'^r:([0-9a-f]{4}):([0-9a-f]{2})$');
|
||||
final regex = RegExp(r'^r:([^:]+):(.+)$');
|
||||
final match = regex.firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final emoji = indexToEmoji(match.group(2)!);
|
||||
if (emoji == null) return null;
|
||||
final targetId = match.group(1)!;
|
||||
final emoji = match.group(2)!;
|
||||
|
||||
return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
|
||||
// Extract reaction key for deduplication
|
||||
// If targetId is in new format (timestamp_senderPrefix), use it directly
|
||||
// Otherwise, extract timestamp from old format (timestamp_nameHash_textHash)
|
||||
String? reactionKey;
|
||||
if (targetId.contains('_')) {
|
||||
final parts = targetId.split('_');
|
||||
if (parts.length >= 2) {
|
||||
// New format: timestamp_senderPrefix, or old format with at least timestamp
|
||||
reactionKey = '${parts[0]}_${parts[1]}';
|
||||
}
|
||||
}
|
||||
|
||||
return ReactionInfo(
|
||||
targetMessageId: targetId,
|
||||
emoji: emoji,
|
||||
reactionKey: reactionKey,
|
||||
);
|
||||
}
|
||||
|
||||
/// Encode a reaction message that parseReaction() can parse.
|
||||
static String encodeReaction(String hash, String emojiIndex) {
|
||||
return 'r:$hash:$emojiIndex';
|
||||
/// Generate a lightweight reaction key for a message
|
||||
/// Format: r:[timestamp]_[senderPrefix]:[emoji]
|
||||
static String buildReactionText(String timestamp, String senderPrefix, String emoji) {
|
||||
return 'r:${timestamp}_$senderPrefix:$emoji';
|
||||
}
|
||||
|
||||
/// Extract sender prefix from public key hex (first 8 chars)
|
||||
static String getSenderPrefix(String senderKeyHex) {
|
||||
return senderKeyHex.substring(0, 8);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,9 +262,8 @@ class Smaz {
|
|||
".com",
|
||||
];
|
||||
|
||||
static final List<Uint8List> _rcbBytes = _rcb
|
||||
.map((s) => Uint8List.fromList(ascii.encode(s)))
|
||||
.toList(growable: false);
|
||||
static final List<Uint8List> _rcbBytes =
|
||||
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false);
|
||||
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
|
||||
return entry.length > maxLen ? entry.length : maxLen;
|
||||
});
|
||||
|
|
@ -359,32 +358,24 @@ class Smaz {
|
|||
final code = input[index];
|
||||
if (code == _verbatimSingle) {
|
||||
if (index + 1 >= input.length) {
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim byte.',
|
||||
);
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim byte.');
|
||||
}
|
||||
out.addByte(input[index + 1]);
|
||||
index += 2;
|
||||
} else if (code == _verbatimRun) {
|
||||
if (index + 1 >= input.length) {
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim length.',
|
||||
);
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim length.');
|
||||
}
|
||||
final len = input[index + 1] + 1;
|
||||
final end = index + 2 + len;
|
||||
if (end > input.length) {
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim run.',
|
||||
);
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim run.');
|
||||
}
|
||||
out.add(input.sublist(index + 2, end));
|
||||
index = end;
|
||||
} else {
|
||||
if (code >= _rcbBytes.length) {
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: code out of range.',
|
||||
);
|
||||
throw const FormatException('Invalid SMAZ stream: code out of range.');
|
||||
}
|
||||
out.add(_rcbBytes[code]);
|
||||
index += 1;
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
// showDismissibleSnackBar shows a [SnackBar] with tap to dismiss
|
||||
// all other properties are default and optional
|
||||
void showDismissibleSnackBar(
|
||||
BuildContext context, {
|
||||
Key? key,
|
||||
required Widget content,
|
||||
Color? backgroundColor,
|
||||
double? elevation,
|
||||
EdgeInsetsGeometry? margin,
|
||||
EdgeInsetsGeometry? padding,
|
||||
double? width,
|
||||
ShapeBorder? shape,
|
||||
HitTestBehavior? hitTestBehavior,
|
||||
SnackBarBehavior? behavior,
|
||||
SnackBarAction? action,
|
||||
double? actionOverflowThreshold,
|
||||
bool? showCloseIcon,
|
||||
Color? closeIconColor,
|
||||
Duration? duration,
|
||||
bool? persist,
|
||||
Animation<double>? animation,
|
||||
void Function()? onVisible,
|
||||
DismissDirection? dismissDirection,
|
||||
Clip? clipBehavior,
|
||||
}) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
key: key,
|
||||
content: GestureDetector(
|
||||
onTap: () => messenger.hideCurrentSnackBar(),
|
||||
child: content,
|
||||
),
|
||||
backgroundColor: backgroundColor,
|
||||
elevation: elevation,
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
width: width,
|
||||
shape: shape,
|
||||
hitTestBehavior: hitTestBehavior,
|
||||
behavior: behavior,
|
||||
action: action,
|
||||
actionOverflowThreshold: actionOverflowThreshold,
|
||||
showCloseIcon: showCloseIcon,
|
||||
closeIconColor: closeIconColor,
|
||||
duration: duration ?? const Duration(seconds: 4),
|
||||
persist: persist,
|
||||
animation: animation,
|
||||
onVisible: onVisible,
|
||||
dismissDirection: dismissDirection ?? DismissDirection.down,
|
||||
clipBehavior: clipBehavior ?? Clip.hardEdge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -8,10 +8,7 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
|
|||
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
if (maxBytes <= 0) return oldValue;
|
||||
final bytes = utf8.encode(newValue.text);
|
||||
if (bytes.length <= maxBytes) return newValue;
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
|
||||
class LosIcon extends StatelessWidget {
|
||||
final double size;
|
||||
final Color? color;
|
||||
|
||||
const LosIcon({super.key, this.size = 24, this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final iconTheme = IconTheme.of(context);
|
||||
final iconColor =
|
||||
color ??
|
||||
iconTheme.color ??
|
||||
theme.iconTheme.color ??
|
||||
theme.colorScheme.onSurface;
|
||||
|
||||
return Icon(Symbols.elevation, size: size, color: iconColor);
|
||||
}
|
||||
}
|
||||
2077
lib/l10n/app_bg.arb
2077
lib/l10n/app_bg.arb
File diff suppressed because it is too large
Load diff
2105
lib/l10n/app_de.arb
2105
lib/l10n/app_de.arb
File diff suppressed because it is too large
Load diff
2125
lib/l10n/app_en.arb
2125
lib/l10n/app_en.arb
File diff suppressed because it is too large
Load diff
2105
lib/l10n/app_es.arb
2105
lib/l10n/app_es.arb
File diff suppressed because it is too large
Load diff
2077
lib/l10n/app_fr.arb
2077
lib/l10n/app_fr.arb
File diff suppressed because it is too large
Load diff
2115
lib/l10n/app_hu.arb
2115
lib/l10n/app_hu.arb
File diff suppressed because it is too large
Load diff
2077
lib/l10n/app_it.arb
2077
lib/l10n/app_it.arb
File diff suppressed because it is too large
Load diff
2115
lib/l10n/app_ja.arb
2115
lib/l10n/app_ja.arb
File diff suppressed because it is too large
Load diff
2115
lib/l10n/app_ko.arb
2115
lib/l10n/app_ko.arb
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
2077
lib/l10n/app_nl.arb
2077
lib/l10n/app_nl.arb
File diff suppressed because it is too large
Load diff
2115
lib/l10n/app_pl.arb
2115
lib/l10n/app_pl.arb
File diff suppressed because it is too large
Load diff
2077
lib/l10n/app_pt.arb
2077
lib/l10n/app_pt.arb
File diff suppressed because it is too large
Load diff
1317
lib/l10n/app_ru.arb
1317
lib/l10n/app_ru.arb
File diff suppressed because it is too large
Load diff
2077
lib/l10n/app_sk.arb
2077
lib/l10n/app_sk.arb
File diff suppressed because it is too large
Load diff
2077
lib/l10n/app_sl.arb
2077
lib/l10n/app_sl.arb
File diff suppressed because it is too large
Load diff
2077
lib/l10n/app_sv.arb
2077
lib/l10n/app_sv.arb
File diff suppressed because it is too large
Load diff
2077
lib/l10n/app_uk.arb
2077
lib/l10n/app_uk.arb
File diff suppressed because it is too large
Load diff
2082
lib/l10n/app_zh.arb
2082
lib/l10n/app_zh.arb
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +0,0 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'app_localizations.dart';
|
||||
|
||||
extension LocalizationExtension on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue