diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8826c55..05c82de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,8 @@ name: Build on: push: + branches: + - main pull_request: jobs: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..c3926c9 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,38 @@ +name: Deploy to Cloudflare Workers + +on: + push: + tags: + - '*' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + # Match local development version which provides Dart 3.11.0 + flutter-version: '3.41.2' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Get dependencies + run: flutter pub get + + - name: Build Web + run: bun run build + + - name: Deploy to Cloudflare + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: deploy diff --git a/.github/workflows/flutter_analyze.yml b/.github/workflows/flutter_dart.yml similarity index 61% rename from .github/workflows/flutter_analyze.yml rename to .github/workflows/flutter_dart.yml index af4a3b7..117eb4f 100644 --- a/.github/workflows/flutter_analyze.yml +++ b/.github/workflows/flutter_dart.yml @@ -1,8 +1,10 @@ -name: Flutter Analyze +name: Flutter and Dart on: pull_request: push: + branches: + - main jobs: analyze: @@ -19,5 +21,11 @@ jobs: - name: Install dependencies run: flutter pub get - - name: Analyze + - name: Analyze code run: flutter analyze --fatal-infos --fatal-warnings + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Run tests + run: flutter test -r github diff --git a/.gitignore b/.gitignore index b918113..88295e7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,12 @@ migrate_working_dir/ .flutter-plugins-dependencies .pub-cache/ .pub/ +pubspec.lock /build/ /coverage/ +# fvm project files +.fvm/ +.fvmrc # Symbolication related app.*.symbols @@ -57,6 +61,7 @@ secrets.dart .DS_Store .AppleDouble .LSOverride +macos/Flutter/GeneratedPluginRegistrant.swift # iOS **/ios/Pods/ @@ -65,6 +70,7 @@ secrets.dart **/ios/Flutter/Flutter.podspec # Android +.gradle/ **/android/.gradle/ **/android/captures/ **/android/local.properties @@ -81,3 +87,6 @@ keystore.properties # IDE .vscode/launch.json .vscode/settings.json + +# Cloudflare Wrangler +.wrangler diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e69de29 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..fcdb2e1 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.0 diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..31b44b0 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.2.4 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index bac981d..273bb96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ ## BLE Frames & Protocol Notes - Nordic UART Service (NUS) UUIDs: Service `6e400001-b5a3-f393-e0a9-e50e24dcca9e`, RX `6e400002-b5a3-f393-e0a9-e50e24dcca9e`, TX `6e400003-b5a3-f393-e0a9-e50e24dcca9e`. -- Discovery: scans for device name prefix `MeshCore-` and filters by `platformName`/`advertisementData.advName`. +- Discovery: scans for device names matching known prefixes and filters by `platformName`/`advertisementData.advName`. - Frames are capped at `maxFrameSize = 172` bytes; byte 0 is the command/response/push code. I/O is `MeshCoreConnector.sendFrame` and `MeshCoreConnector.receivedFrames`. - Command codes (to device): `cmdAppStart`=1, `cmdSendTxtMsg`=2, `cmdSendChannelTxtMsg`=3, `cmdGetContacts`=4, `cmdGetDeviceTime`=5, `cmdSetDeviceTime`=6, `cmdSendSelfAdvert`=7, `cmdSetAdvertName`=8, `cmdAddUpdateContact`=9, `cmdSyncNextMessage`=10, `cmdSetRadioParams`=11, `cmdSetRadioTxPower`=12, `cmdResetPath`=13, `cmdSetAdvertLatLon`=14, `cmdRemoveContact`=15, `cmdShareContact`=16, `cmdExportContact`=17, `cmdImportContact`=18, `cmdReboot`=19, `cmdSendLogin`=26, `cmdGetChannel`=31, `cmdSetChannel`=32, `cmdGetRadioSettings`=57. - Response codes (from device): `respCodeOk`=0, `respCodeErr`=1, `respCodeContactsStart`=2, `respCodeContact`=3, `respCodeEndOfContacts`=4, `respCodeSelfInfo`=5, `respCodeSent`=6, `respCodeContactMsgRecv`=7, `respCodeChannelMsgRecv`=8, `respCodeCurrTime`=9, `respCodeNoMoreMessages`=10, `respCodeContactMsgRecvV3`=16, `respCodeChannelMsgRecvV3`=17, `respCodeChannelInfo`=18, `respCodeRadioSettings`=25. diff --git a/CLAUDE.md b/CLAUDE.md index 08ef342..55af890 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ lib/ - **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device) ### Device Discovery -- Scans for devices with name prefix `MeshCore-` +- Scans for devices with known name prefixes - Filters by `platformName` or `advertisementData.advName` ### Connection States diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ac727ba --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# How to contribute to Meshcore Open + +Before submitting any pull requests (PR), please review the following information. + +Unsolicited PRs without previous discussion or open issues may be +rejected. As may changes that are too broad (i.e. 100 files changed) or that +cover too many separate changes. If the changes are clearly AI generated they +may also be rejected. [See more](#ai-use) + +## First Step Checklist + +### **Did you find a bug?** + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/zjs81/meshcore-open/issues). + +* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/zjs81/meshcore-open/issues/new). +Be sure to include a **title and clear description**, as much relevant +information as possible, and a **code sample** or an **executable test case** +demonstrating the expected behavior that is not occurring. You can also include +screenshots or video. + +* DO NOT start work and submit a PR at this time, please discuss the issue and +your implementation plan first. + +### **Did you fix whitespace, format code, or make a purely cosmetic patch?** + +Changes that are cosmetic in nature and do not add anything substantial to the +stability, functionality, or testability of the application will generally not +be accepted. + +### **Do you intend to add a new feature or change an existing one?** + +* Suggest your change in a new issue as a feature request. + +* DO NOT start work and submit a PR at this time, please discuss the change and +your implementation plan first. + +* After it is generally decided that the feature or change fits the goals of the +project you can start work or open a PR if you have already started. + +## Submitting your patch + +* All changes should be based on the `dev` branch. When creating your PR please +be sure to change the target to merge into dev, and when starting work on a new +branch be sure to start on latest `dev`. + +* Ensure the PR description clearly describes the problem and solution. Include +the relevant issue number if applicable. + +* The PR should contain **one commit** only, the commit message should have a +clear title followed by a new line and then brief description if needed. PR with +multiple commits will be squashed into one before merging if required. See +[Git Mastery](https://git-mastery.org/lessons/commitMessage/) for more +information on good commit messages. + +* **Before committing changes** on your branch, be sure to run both +`dart format .` and `flutter analyze`. The continuous development checks will +fail if issues here are not addressed before hand. + +## AI-use + +Everyone loves some help, AI agents are a tool in many of our belts. The project +is not anti-AI. + +There are some limits to acceptable use however. Generally: + +* All code generated by AI should be thoroughly reviewed by the contributor. +* The changes should be tightly controlled to not change anything out of scope +for the patch, bug fix, etc. +* The contributor should have a good understanding of what the code does and how +the application works in order to effectively be able to manage the agent. diff --git a/README.md b/README.md index 2acb390..ac188f6 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices. MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities. + + Get it on Obtainium + + ## Screenshots @@ -21,6 +25,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh ## Features ### Core Functionality + - **Direct Messaging**: Private encrypted conversations with individual contacts - **Public Channels**: Broadcast messages to channel subscribers on the mesh network - **Contact Management**: Organize contacts, track last seen times, and manage conversation history @@ -29,6 +34,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh - **Message Replies**: Thread conversations with inline reply functionality ### Mesh Network + - **Path Visualization**: View routing paths and signal quality for each contact - **Route Management**: Manual path overriding and automatic route rotation - **Signal Metrics**: Real-time SNR (Signal-to-Noise Ratio) tracking @@ -36,6 +42,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh - **Repeater Support**: Connect to and manage repeater nodes for extended range ### Map & Location + - **Live Map View**: Real-time visualization of mesh network nodes on an interactive map - **Node Filtering**: Filter by node type (chat, repeater, sensor) and time range - **Location Sharing**: Share GPS coordinates and custom markers with contacts @@ -43,12 +50,14 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh - **MGRS Coordinates**: Support for Military Grid Reference System coordinate format ### Device Management -- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth + +- **BLE, USB, TCP Connection**: Scan and connect to MeshCore devices via Bluetooth, USB or TCP - **Device Settings**: Configure radio parameters, power settings, and network options - **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves - **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon) ### Repeater Hub + - **CLI Access**: Full command-line interface to repeater nodes - **Settings Management**: Configure repeater behavior, power limits, and network settings - **Statistics Dashboard**: View repeater traffic, connected clients, and system health @@ -57,6 +66,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh ## Technical Details ### Architecture + - **Framework**: Flutter 3.38.5 / Dart 3.10.4 - **State Management**: Provider pattern with ChangeNotifier - **BLE Protocol**: Nordic UART Service (NUS) over Bluetooth Low Energy @@ -64,11 +74,20 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh - **Encryption**: End-to-end encryption for private messages using the MeshCore protocol ### Platform Support -- ✅ **Android**: Full support (API 21+) -- ✅ **iOS**: Full support (iOS 12+) -- 🚧 **Desktop**: Limited support (macOS/Linux/Windows) + +| Feature | Android (API 21+) | iOS (12+) | Linux | Windows | macOS | Web | +|--------------------|:-----------------:|:---------:|:-----:|:-------:|:-----:|:---------------------------------:| +| BLE companion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| USB companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ | +| TCP companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ❌
(requires websocket bridge) | +| Core Functionality | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Mesh Network | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Map & Location | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Device Management | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Repeater Hub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ### Dependencies + | Package | Purpose | |---------|---------| | flutter_blue_plus | Bluetooth Low Energy communication | @@ -84,6 +103,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh ## Getting Started ### Prerequisites + - Flutter SDK 3.38.5 or later - Android Studio / Xcode (for mobile development) - A MeshCore-compatible LoRa device @@ -91,17 +111,20 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh ### Installation 1. **Clone the repository** + ```bash git clone https://github.com/zjs81/meshcore-open.git cd meshcore-open ``` 2. **Install dependencies** + ```bash flutter pub get ``` 3. **Run the app** + ```bash flutter run ``` @@ -109,11 +132,13 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh ### Building for Release **Android APK:** + ```bash flutter build apk --release ``` **iOS:** + ```bash flutter build ios --release ``` @@ -125,7 +150,8 @@ lib/ ├── main.dart # App entry point ├── connector/ │ ├── meshcore_connector.dart # BLE communication & state management -│ └── meshcore_protocol.dart # Protocol definitions & frame parsing +│ ├── meshcore_protocol.dart # Protocol definitions & frame parsing +│ └── meshcore_uuids.dart # Device names and IDs (add prefixes here!) ├── screens/ │ ├── scanner_screen.dart # Device scanning (home screen) │ ├── contacts_screen.dart # Contact list @@ -152,25 +178,39 @@ lib/ ## BLE Protocol ### Nordic UART Service (NUS) + - **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e` - **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device) - **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device) ### Device Discovery -Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-` + +Devices are discovered by scanning for BLE advertisements with known MeshCore device name prefixes. These are currently: + - `MeshCore-` + - `Whisper-` + - `WisCore-` + - `HT-` + - `LowMesh_MC_` + +New device prefixes can be added in `lib/connector/meshcore_uuids.dart`. + ### Message Format + Messages are transmitted as binary frames using a custom protocol optimized for LoRa transmission. See `meshcore_protocol.dart` for frame structure definitions. ## Configuration ### App Settings + - **Theme**: System default, light, or dark mode +- **Language**: Use one of 15 languages (English, Chinese, French, Spanish, Portuguese, German, Dutch, Polish, Swedish, Italian, Slovak, Slovene, Bulgarian, Russian, Ukrainian) - **Notifications**: Configurable for messages, channels, and node advertisements - **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types - **Message Retry**: Automatic retry with configurable path clearing ### Device Settings + - **Radio Power**: Transmit power adjustment (10-30 dBm) - **Frequency**: LoRa frequency configuration - **Bandwidth**: Channel bandwidth selection @@ -182,22 +222,24 @@ Messages are transmitted as binary frames using a custom protocol optimized for This is an open-source project. Contributions are welcome! ### Development Guidelines + - Follow the Flutter style guide - Use Material 3 design components - Write clear commit messages - Test on both Android and iOS before submitting PRs ### Code Style + - Prefer `StatelessWidget` with `Consumer` for reactive UI - Use `const` constructors where possible - Keep functions small and focused - Avoid premature abstractions - +- Run dart format on all changes before submitting ## Support For issues, questions, or feature requests, please open an issue on GitHub: -https://github.com/zjs81/meshcore-open/issues + ## Donate @@ -205,6 +247,11 @@ If you find MeshCore Open useful and would like to support development, you can **Solana Address:** `F15YanjZj96YTBtKJYgNa8RLQLCZkx5CEwogPWkqXeoQ` + +**Monero Address:** `453TxnpUqjkJtXxzdjMsrgERNkBRXEGamPbpC45ENrvKAk9tH7kZbxWF82Hz66etgDZyXFPEBU2JUEqhLeJyWt9kBvTVy5m` + +**Bitcoin Address:** `bc1qh45x28v8dslcg4v4upmqd9g0mvc3lnyffmyzr5` + Your support helps maintain and improve this open-source project! ## Acknowledgments diff --git a/TESTFLIGHT_GUIDE.md b/TESTFLIGHT_GUIDE.md new file mode 100644 index 0000000..b092678 --- /dev/null +++ b/TESTFLIGHT_GUIDE.md @@ -0,0 +1,244 @@ +# TestFlight and App Store Deployment Guide + +## Prerequisites + +- [x] Apple Developer Account ($99/year) - [developer.apple.com](https://developer.apple.com) +- [x] Xcode installed +- [x] Apple Transporter app installed +- [x] App icons ready (1024x1024px) +- [x] Bundle ID configured: `com.monitormx.meshcoreopen` + +## Step 1: Register Bundle Identifier + +1. Go to [Apple Developer - Identifiers](https://developer.apple.com/account/resources/identifiers/list) +2. Click the **"+"** button +3. Select **"App IDs"** → Continue +4. Select **"App"** → Continue +5. Fill in: + - **Description**: Meshcore Open + - **Bundle ID**: Explicit - `com.monitormx.meshcoreopen` + - **Capabilities**: Leave defaults (or add as needed) +6. Click **Continue** → **Register** + +## Step 2: Create App in App Store Connect + +1. Go to [App Store Connect](https://appstoreconnect.apple.com) +2. Sign in with your Apple ID +3. Click **"My Apps"** +4. Click the **"+"** button → **"New App"** +5. Fill in the form: + - **Platforms**: iOS + - **Name**: Meshcore Open + - **Primary Language**: English (U.S.) + - **Bundle ID**: Select `com.monitormx.meshcoreopen` from dropdown + - **SKU**: `meshcore-open-001` (or any unique identifier) + - **User Access**: Full Access +6. Click **"Create"** + +## Step 3: Build the IPA + +Run these commands from the project directory: + +```bash +# Add CocoaPods to PATH +export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH" + +# Clean previous builds +../flutter/bin/flutter clean + +# Build IPA for App Store +../flutter/bin/flutter build ipa +``` + +The IPA will be created at: `build/ios/ipa/meshcore_open.ipa` + +## Step 4: Upload to App Store Connect via Transporter + +1. **Open Apple Transporter** + - Launch from Applications folder + - Sign in with your Apple ID + +2. **Upload the IPA** + - Drag and drop `build/ios/ipa/meshcore_open.ipa` into Transporter + - Click **"Deliver"** + - Wait for upload to complete (usually 1-5 minutes) + +3. **Processing** + - Apple will process your build (10-30 minutes) + - You'll receive an email when processing is complete + +## Step 5: Configure App Store Connect Metadata + +### App Information +1. In App Store Connect, go to your app +2. Fill in required information: + - **Subtitle**: Short description (30 chars max) + - **Privacy Policy URL**: Required for Bluetooth apps + - **Category**: Utilities or Productivity + - **Age Rating**: Complete questionnaire + +### App Store Listing +1. Go to **App Store** tab +2. Upload **Screenshots** (required): + - iPhone 6.7" display (1290 x 2796 pixels) - At least 1 screenshot + - iPhone 6.5" display (1242 x 2688 pixels) - At least 1 screenshot + - Optional: iPad screenshots + +3. Fill in **Description**: + ``` + Meshcore Open is a Flutter client for MeshCore LoRa mesh networking devices. + + Features: + - BLE connectivity to MeshCore devices + - Real-time mesh network communication + - Map visualization with OpenStreetMap + - Community management with QR code scanning + - Message tracking and retry system + + Connect to your MeshCore LoRa device and start communicating over the mesh network. + ``` + +4. **Keywords**: `lora,mesh,networking,bluetooth,communication` +5. **Support URL**: Your GitHub or website URL +6. **Marketing URL**: (Optional) + +### Version Information +1. **What's New in This Version**: + ``` + Initial release of Meshcore Open + + - BLE device connectivity + - Mesh network messaging + - Map integration + - Community features + ``` + +2. **Build**: Select the uploaded build once processing completes + +## Step 6: TestFlight Setup + +### Internal Testing (No Review Required) +1. Go to **TestFlight** tab in App Store Connect +2. Click **Internal Testing** → **"+"** to create a group +3. Name your group (e.g., "Internal Testers") +4. Add yourself as a tester using your email +5. Select the build you uploaded +6. Testers will receive an email with TestFlight invitation + +### External Testing (Requires Beta Review) +1. Click **External Testing** → **"+"** to create a group +2. Add build and testers +3. Fill in **Test Information**: + - **What to Test**: Brief description of features + - **Feedback Email**: Your email address +4. Click **Submit for Review** +5. Beta review typically takes 24-48 hours + +## Step 7: App Store Submission + +Once you're ready for public release: + +1. Go to **App Store** tab +2. Complete all required metadata (if not done) +3. Select your build +4. Fill in **App Review Information**: + - **Contact Information**: Your name, phone, email + - **Demo Account**: If app requires login + - **Notes**: Any special instructions for reviewers +5. Answer **Export Compliance** questions: + - Does your app use encryption? **Yes** (uses TLS/HTTPS) + - Is encryption registration required? **No** (standard encryption) +6. Click **Add for Review** +7. Review summary and click **Submit to App Review** + +## Step 8: After Submission + +- **App Review**: Typically 24-48 hours +- **Common Rejection Reasons**: + - Missing privacy policy + - Incomplete app information + - Crashes or bugs + - Misleading app description + +- **If Approved**: You can release immediately or schedule a release date +- **If Rejected**: Address issues and resubmit + +## Updating the App + +When you need to release an update: + +1. **Update version** in `pubspec.yaml`: + ```yaml + version: 0.5.0+6 # Increment version (0.5.0) and build number (+6) + ``` + +2. **Build new IPA**: + ```bash + export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH" + ../flutter/bin/flutter clean + ../flutter/bin/flutter build ipa + ``` + +3. **Upload via Transporter** (same process as above) + +4. **Create new version** in App Store Connect: + - Click **"+"** next to versions + - Select version number + - Update "What's New" text + - Select new build + - Submit for review + +## macOS Build (Bonus) + +To build for macOS: + +```bash +export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH" +../flutter/bin/flutter build macos --release +cd build/macos/Build/Products/Release +zip -r meshcore_open-macos.zip meshcore_open.app +``` + +Distribution: +- Share the zip file directly +- Users unzip and drag to Applications +- First run: Right-click → Open (to bypass Gatekeeper) + +## Troubleshooting + +### Build Errors +- **CocoaPods not found**: Ensure PATH includes `/opt/homebrew/lib/ruby/gems/4.0.0/bin` +- **No signing certificate**: Configure Team in Xcode (Signing & Capabilities) +- **Bundle ID mismatch**: Check `ios/Runner.xcodeproj/project.pbxproj` + +### Upload Errors +- **No profiles found**: Create app in App Store Connect first +- **Bundle ID not registered**: Register in Apple Developer portal +- **Authentication failed**: Use Transporter app instead of CLI + +### TestFlight Issues +- **Build not appearing**: Wait 10-30 minutes for processing +- **Can't add testers**: Check you have available slots (100 internal, 10,000 external) +- **TestFlight crashes**: Check device logs in Xcode → Devices & Simulators + +## Important Files + +- **iOS IPA**: `build/ios/ipa/meshcore_open.ipa` +- **macOS App**: `build/macos/Build/Products/Release/meshcore_open.app` +- **Bundle ID Config**: `ios/Runner.xcodeproj/project.pbxproj` +- **Version Info**: `pubspec.yaml` + +## Useful Links + +- [App Store Connect](https://appstoreconnect.apple.com) +- [Apple Developer Portal](https://developer.apple.com/account) +- [TestFlight Documentation](https://developer.apple.com/testflight/) +- [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) +- [Flutter iOS Deployment](https://docs.flutter.dev/deployment/ios) + +## Support + +For issues with: +- **App Store Process**: [Apple Developer Support](https://developer.apple.com/contact/) +- **Flutter Build Issues**: [Flutter GitHub](https://github.com/flutter/flutter/issues) +- **Meshcore Open App**: [GitHub Issues](https://github.com/wel97459/meshcore-open/issues) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 740451b..c8028e0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,16 +16,16 @@ if (keystorePropertiesFile.exists()) { android { namespace = "com.meshcore.meshcore_open" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "29.0.14206865" compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } defaultConfig { @@ -83,5 +83,5 @@ flutter { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 43cacc9..4ff626f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt index 4350b1e..9022c8b 100644 --- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt +++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt @@ -1,5 +1,18 @@ package com.meshcore.meshcore_open import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + private val usbFunctions by lazy { MeshcoreUsbFunctions(this) } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + usbFunctions.configureFlutterEngine(flutterEngine) + } + + override fun onDestroy() { + usbFunctions.dispose() + super.onDestroy() + } +} diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MeshcoreUsbFunctions.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MeshcoreUsbFunctions.kt new file mode 100644 index 0000000..279ba8a --- /dev/null +++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MeshcoreUsbFunctions.kt @@ -0,0 +1,582 @@ +package com.meshcore.meshcore_open + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.Locale +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class MeshcoreUsbFunctions( + private val activity: FlutterActivity, +) { + private companion object { + const val usbRecipientInterface = 0x01 + } + + private val usbMethodChannelName = "meshcore_open/android_usb_serial" + private val usbEventChannelName = "meshcore_open/android_usb_serial_events" + private val usbPermissionAction = "com.meshcore.meshcore_open.USB_PERMISSION" + + private val usbManager by lazy { + activity.getSystemService(Context.USB_SERVICE) as UsbManager + } + private val mainHandler = Handler(Looper.getMainLooper()) + private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor() + + @Volatile private var eventSink: EventChannel.EventSink? = null + @Volatile private var usbConnection: UsbDeviceConnection? = null + @Volatile private var usbInEndpoint: UsbEndpoint? = null + @Volatile private var usbOutEndpoint: UsbEndpoint? = null + @Volatile private var controlInterface: UsbInterface? = null + @Volatile private var dataInterface: UsbInterface? = null + private var readThread: Thread? = null + @Volatile private var isReading = false + @Volatile private var connectedDeviceName: String? = null + + private var pendingConnectResult: MethodChannel.Result? = null + private var pendingConnectPortName: String? = null + private var pendingConnectBaudRate: Int = 115200 + + private data class PortConfig( + val controlInterface: UsbInterface?, + val dataInterface: UsbInterface, + val inEndpoint: UsbEndpoint, + val outEndpoint: UsbEndpoint, + ) + + private val permissionReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + handleUsbDetached(intent) + return + } + usbPermissionAction -> Unit + else -> return + } + + val result = pendingConnectResult + val portName = pendingConnectPortName + pendingConnectResult = null + pendingConnectPortName = null + + if (result == null || portName == null) { + return + } + + val device = findUsbDevice(portName) + if (device == null) { + result.error( + "usb_device_missing", + null, + null, + ) + return + } + + val granted = + intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + if (!granted || !usbManager.hasPermission(device)) { + result.error("usb_permission_denied", null, null) + return + } + + openUsbDevice(device, pendingConnectBaudRate, result) + } + } + + fun configureFlutterEngine(flutterEngine: FlutterEngine) { + registerUsbPermissionReceiver() + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, usbMethodChannelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "listPorts" -> result.success(listUsbPorts()) + "connect" -> handleUsbConnect(call, result) + "write" -> handleUsbWrite(call, result) + "disconnect" -> { + scheduleCloseUsbConnection { + result.success(null) + } + } + else -> result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, usbEventChannelName) + .setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + }, + ) + } + + fun dispose() { + closeUsbConnection() + usbIoExecutor.shutdownNow() + try { + activity.unregisterReceiver(permissionReceiver) + } catch (_: IllegalArgumentException) { + } + } + + private fun registerUsbPermissionReceiver() { + val filter = + IntentFilter().apply { + addAction(usbPermissionAction) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + activity.registerReceiver(permissionReceiver, filter) + } + } + + private fun listUsbPorts(): List { + return usbManager.deviceList.values.map { device -> + val productName = device.productName ?: "USB Serial Device" + val vendorProduct = + String.format( + Locale.US, + "VID:%04X PID:%04X", + device.vendorId, + device.productId, + ) + "${device.deviceName} - $productName - $vendorProduct" + } + } + + private fun handleUsbConnect(call: MethodCall, result: MethodChannel.Result) { + val portName = call.argument("portName") + val baudRate = call.argument("baudRate") ?: 115200 + if (portName.isNullOrBlank()) { + result.error("usb_invalid_port", null, null) + return + } + + val device = findUsbDevice(portName) + if (device == null) { + result.error("usb_device_missing", null, null) + return + } + + if (usbManager.hasPermission(device)) { + openUsbDevice(device, baudRate, result) + return + } + + if (pendingConnectResult != null) { + result.error("usb_busy", null, null) + return + } + + pendingConnectResult = result + pendingConnectPortName = portName + pendingConnectBaudRate = baudRate + + val permissionIntent = PendingIntent.getBroadcast( + activity, + 0, + Intent(usbPermissionAction).setPackage(activity.packageName), + pendingIntentFlags(), + ) + usbManager.requestPermission(device, permissionIntent) + } + + private fun handleUsbWrite(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument("data") + val connection = usbConnection + val endpoint = usbOutEndpoint + if (data == null) { + result.error("usb_invalid_data", null, null) + return + } + if (connection == null || endpoint == null) { + result.error("usb_not_connected", null, null) + return + } + + usbIoExecutor.execute { + try { + writeToDevice(data) + mainHandler.post { result.success(null) } + } catch (error: Exception) { + mainHandler.post { + result.error("usb_write_failed", error.message, null) + } + } + } + } + + private fun findUsbDevice(portName: String): UsbDevice? { + val devices = usbManager.deviceList.values + val exactMatch = devices.firstOrNull { it.deviceName == portName } + if (exactMatch != null) { + return exactMatch + } + + val normalizedName = portName.substringBefore(" - ").trim() + return devices.firstOrNull { it.deviceName == normalizedName } + } + + private fun openUsbDevice( + device: UsbDevice, + baudRate: Int, + result: MethodChannel.Result, + ) { + usbIoExecutor.execute { + try { + closeUsbConnection() + + val config = resolvePortConfig(device) + if (config == null) { + mainHandler.post { + result.error( + "usb_driver_missing", + null, + null, + ) + } + return@execute + } + + val connection = usbManager.openDevice(device) + if (connection == null) { + mainHandler.post { + result.error( + "usb_open_failed", + null, + null, + ) + } + return@execute + } + + if (!connection.claimInterface(config.dataInterface, true)) { + connection.close() + mainHandler.post { + result.error( + "usb_open_failed", + null, + null, + ) + } + return@execute + } + + if (config.controlInterface != null && + config.controlInterface.id != config.dataInterface.id && + !connection.claimInterface(config.controlInterface, true) + ) { + connection.releaseInterface(config.dataInterface) + connection.close() + mainHandler.post { + result.error( + "usb_open_failed", + null, + null, + ) + } + return@execute + } + + usbConnection = connection + usbInEndpoint = config.inEndpoint + usbOutEndpoint = config.outEndpoint + controlInterface = config.controlInterface + dataInterface = config.dataInterface + + configureDevice(connection, config, baudRate) + + connectedDeviceName = device.deviceName + startReadLoop() + + mainHandler.post { + result.success(null) + } + } catch (error: Exception) { + closeUsbConnection() + mainHandler.post { + result.error("usb_connect_failed", error.message, null) + } + } + } + } + + private fun resolvePortConfig(device: UsbDevice): PortConfig? { + var preferredDataInterface: UsbInterface? = null + var preferredInEndpoint: UsbEndpoint? = null + var preferredOutEndpoint: UsbEndpoint? = null + var fallbackDataInterface: UsbInterface? = null + var fallbackInEndpoint: UsbEndpoint? = null + var fallbackOutEndpoint: UsbEndpoint? = null + var preferredControlInterface: UsbInterface? = null + + for (interfaceIndex in 0 until device.interfaceCount) { + val usbInterface = device.getInterface(interfaceIndex) + var inEndpoint: UsbEndpoint? = null + var outEndpoint: UsbEndpoint? = null + + for (endpointIndex in 0 until usbInterface.endpointCount) { + val endpoint = usbInterface.getEndpoint(endpointIndex) + if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) { + continue + } + when (endpoint.direction) { + UsbConstants.USB_DIR_IN -> if (inEndpoint == null) inEndpoint = endpoint + UsbConstants.USB_DIR_OUT -> if (outEndpoint == null) outEndpoint = endpoint + } + } + + val hasDataPair = inEndpoint != null && outEndpoint != null + when { + usbInterface.interfaceClass == UsbConstants.USB_CLASS_COMM && + preferredControlInterface == null -> { + preferredControlInterface = usbInterface + } + hasDataPair && + usbInterface.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA -> { + preferredDataInterface = usbInterface + preferredInEndpoint = inEndpoint + preferredOutEndpoint = outEndpoint + } + hasDataPair && fallbackDataInterface == null -> { + fallbackDataInterface = usbInterface + fallbackInEndpoint = inEndpoint + fallbackOutEndpoint = outEndpoint + } + } + } + + val dataInterface = preferredDataInterface ?: fallbackDataInterface ?: return null + val inEndpoint = preferredInEndpoint ?: fallbackInEndpoint ?: return null + val outEndpoint = preferredOutEndpoint ?: fallbackOutEndpoint ?: return null + return PortConfig(preferredControlInterface, dataInterface, inEndpoint, outEndpoint) + } + + private fun configureDevice( + connection: UsbDeviceConnection, + config: PortConfig, + baudRate: Int, + ) { + val control = config.controlInterface ?: return + val lineCoding = + byteArrayOf( + (baudRate and 0xFF).toByte(), + ((baudRate shr 8) and 0xFF).toByte(), + ((baudRate shr 16) and 0xFF).toByte(), + ((baudRate shr 24) and 0xFF).toByte(), + 0, // stop bits: 1 + 0, // parity: none + 8, // data bits + ) + + val lineCodingResult = + connection.controlTransfer( + UsbConstants.USB_DIR_OUT or + UsbConstants.USB_TYPE_CLASS or + usbRecipientInterface, + 0x20, + 0, + control.id, + lineCoding, + lineCoding.size, + 1000, + ) + if (lineCodingResult < 0) { + throw IllegalStateException("Failed to configure USB line coding") + } + + val controlLineResult = + connection.controlTransfer( + UsbConstants.USB_DIR_OUT or + UsbConstants.USB_TYPE_CLASS or + usbRecipientInterface, + 0x22, + 0x0001, // DTR on, RTS off + control.id, + null, + 0, + 1000, + ) + if (controlLineResult < 0) { + throw IllegalStateException("Failed to configure USB control line state") + } + } + + private fun startReadLoop() { + val connection = usbConnection ?: return + val endpoint = usbInEndpoint ?: return + + isReading = true + readThread = + Thread({ + val packetSize = endpoint.maxPacketSize.coerceAtLeast(64) + val buffer = ByteArray(packetSize * 4) + try { + while (isReading) { + val bytesRead = connection.bulkTransfer(endpoint, buffer, buffer.size, 250) + if (!isReading) { + break + } + if (bytesRead <= 0) { + continue + } + val packet = buffer.copyOf(bytesRead) + mainHandler.post { + eventSink?.success(packet) + } + } + } catch (error: Exception) { + if (isReading) { + mainHandler.post { + eventSink?.error( + "usb_io_error", + error.message ?: "USB serial I/O error", + null, + ) + } + scheduleCloseUsbConnection() + } + } + }, "MeshCoreUsbRead").also { thread -> + thread.isDaemon = true + thread.start() + } + } + + private fun writeToDevice(data: ByteArray) { + val connection = usbConnection ?: throw IllegalStateException("USB connection missing") + val endpoint = usbOutEndpoint ?: throw IllegalStateException("USB output endpoint missing") + var offset = 0 + val maxPacketSize = endpoint.maxPacketSize.coerceAtLeast(64) + while (offset < data.size) { + val chunkSize = minOf(maxPacketSize, data.size - offset) + val chunk = data.copyOfRange(offset, offset + chunkSize) + val bytesWritten = connection.bulkTransfer(endpoint, chunk, chunkSize, 1000) + if (bytesWritten != chunkSize) { + throw IllegalStateException("Short USB write: wrote $bytesWritten of $chunkSize bytes") + } + offset += chunkSize + } + } + + private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) { + usbIoExecutor.execute { + closeUsbConnection() + if (onComplete != null) { + mainHandler.post(onComplete) + } + } + } + + @Synchronized + private fun closeUsbConnection() { + isReading = false + readThread?.interrupt() + if (readThread != null && readThread !== Thread.currentThread()) { + try { + readThread?.join(300) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + readThread = null + + val connection = usbConnection + val claimedControl = controlInterface + val claimedData = dataInterface + + usbInEndpoint = null + usbOutEndpoint = null + controlInterface = null + dataInterface = null + usbConnection = null + + if (connection != null) { + if (claimedControl != null) { + try { + connection.releaseInterface(claimedControl) + } catch (_: Exception) { + } + } + if (claimedData != null && claimedData.id != claimedControl?.id) { + try { + connection.releaseInterface(claimedData) + } catch (_: Exception) { + } + } + try { + connection.close() + } catch (_: Exception) { + } + } + connectedDeviceName = null + } + + private fun handleUsbDetached(intent: Intent) { + val detachedDevice = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + + val detachedName = detachedDevice?.deviceName ?: return + + if (pendingConnectPortName == detachedName) { + pendingConnectResult?.error( + "usb_device_detached", + "USB device was removed before the connection completed", + null, + ) + pendingConnectResult = null + pendingConnectPortName = null + } + + if (connectedDeviceName == detachedName) { + scheduleCloseUsbConnection { + eventSink?.error( + "usb_device_detached", + "USB device was disconnected", + null, + ) + } + } + } + + private fun pendingIntentFlags(): Int { + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + return flags + } +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..2220133 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/assets/badges/badge_obtainium.png b/assets/badges/badge_obtainium.png new file mode 100644 index 0000000..cc3a0ed Binary files /dev/null and b/assets/badges/badge_obtainium.png differ diff --git a/assets/icons/done_all.svg b/assets/icons/done_all.svg new file mode 100644 index 0000000..bfeeec0 --- /dev/null +++ b/assets/icons/done_all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/BLE_PROTOCOL.md b/docs/BLE_PROTOCOL.md index 993c3ea..c17c3e7 100644 --- a/docs/BLE_PROTOCOL.md +++ b/docs/BLE_PROTOCOL.md @@ -21,7 +21,12 @@ The MeshCore BLE protocol implements a binary frame-based communication system u ### Connection Flow -1. **Scan** for devices with name prefix `MeshCore-` +1. **Scan** for devices with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`): + - `MeshCore-` + - `Whisper-` + - `WisCore-` + - `HT-` + - `LowMesh_MC_` 2. **Connect** with 15-second timeout 3. **Request MTU** of 185 bytes (falls back to default if unsupported) 4. **Discover services** and locate NUS characteristics diff --git a/docs/screenshots/signal-ui-consistency.png b/docs/screenshots/signal-ui-consistency.png new file mode 100644 index 0000000..2575945 Binary files /dev/null and b/docs/screenshots/signal-ui-consistency.png differ diff --git a/documentation/README.md b/documentation/README.md new file mode 100644 index 0000000..1367013 --- /dev/null +++ b/documentation/README.md @@ -0,0 +1,30 @@ +# MeshCore Open - Feature Documentation + +MeshCore Open is an open-source Flutter client for MeshCore LoRa mesh networking devices. This documentation covers every user-facing feature, how to access it, and what it does. + +## Table of Contents + +1. [Scanner & Connection](scanner-and-connection.md) - BLE scanning, USB serial, and TCP connection +2. [Navigation](navigation.md) - App flow, device screen, and quick-switch navigation +3. [Contacts](contacts.md) - Contact management, groups, discovery, and sharing +4. [Chat & Messaging](chat-and-messaging.md) - Direct messages, message status, reactions, and retries +5. [Channels](channels.md) - Broadcast channels, communities, and channel chat +6. [Map & Location](map-and-location.md) - Node map, path tracing, line-of-sight, and offline caching +7. [Settings](settings.md) - Device settings, app settings, radio configuration, and exports +8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences +9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors +10. [Additional Features](additional-features.md) - GIF picker, localization, debug logs, SMAZ compression, and more +11. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture + +## App Overview + +MeshCore Open connects to MeshCore LoRa mesh radios over BLE, USB, or TCP. Once connected, users can: + +- **Chat** with other mesh nodes via encrypted direct messages +- **Broadcast** on shared channels (public, hashtag, private, or community-scoped) +- **View nodes on a map** with GPS locations, predicted positions, and path traces +- **Manage repeaters** with CLI access, telemetry, neighbor info, and settings +- **Share contacts** via `meshcore://` URIs and QR codes +- **Configure radio settings** including frequency, power, bandwidth, and spreading factor +- **Cache offline maps** for use without internet connectivity +- **Analyze line-of-sight** between nodes with terrain elevation profiles diff --git a/documentation/additional-features.md b/documentation/additional-features.md new file mode 100644 index 0000000..f7b8319 --- /dev/null +++ b/documentation/additional-features.md @@ -0,0 +1,187 @@ +# Additional Features + +## GIF Picker (Giphy Integration) + +### How to Access +In any chat screen (direct or channel), tap the GIF button in the message input bar. + +### What the User Sees +A bottom sheet with a search field and a grid of GIF thumbnails. + +### Key Interactions +- On open, loads trending GIFs (G-rated, 25 results) +- Type to search and press the keyboard submit button (search triggers on submit, not on each keystroke). Clearing the search field reloads trending GIFs +- On network/API errors, a "Retry" button is shown in-place +- Tap a GIF to select it — the chat input shows an inline preview with an X button to dismiss +- Send the message to transmit the GIF reference (`g:`) +- Recipients see the GIF rendered inline via Giphy CDN +- "Powered by Giphy" attribution is always shown at the bottom of the picker +- The bottom sheet occupies 70% of screen height + +--- + +## Localization / Multi-Language Support + +### How to Access +App Settings → Appearance → Language + +### Supported Languages (15) +English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian + +### How It Works +- All UI strings go through Flutter's ARB localization system +- Language can follow the system locale or be explicitly overridden +- Changes take effect immediately + +--- + +## Discovered Contacts Screen + +### How to Access +From Contacts screen → overflow menu → "Discovered Contacts" + +### What the User Sees +A list of nodes heard passively over the air but not yet added as contacts. Each shows: +- Color-coded avatar (by type) +- Name +- Short public key +- Last-seen time + +### Key Interactions +- Search bar with debounced filtering +- Sort by last seen or name; filter by type +- **Tap**: Import the contact (adds to your contact list) +- **Long-press**: Add Contact, Copy `meshcore://` URI to clipboard, or Delete +- Overflow menu → "Delete All" (with confirmation) +- Already-known contacts and your own node are filtered out + +--- + +## SMAZ Compression + +### What It Is +An optional per-contact and per-channel text compression feature using the SMAZ algorithm (optimized for short English text). + +### How to Enable +- **Per contact**: Chat screen → info button → toggle "SMAZ compression" +- **Per channel**: Long-press channel → Edit → toggle "SMAZ compression" + +### How It Works +- When enabled, compression is applied using a "compress only if smaller" strategy — the message is only transmitted compressed if the encoded result is actually shorter than the original. Otherwise, the original text is sent uncompressed +- Compressed messages are transmitted with a `s:` prefix followed by base64-encoded data +- Recipients using MeshCore Open will decompress automatically. **Recipients using other software** that is not SMAZ-aware will see garbled `s:...` text +- The codec operates on ASCII. Non-ASCII / non-English text generally does not benefit from compression and may even expand. Best suited for short English messages +- Disabled by default + +--- + +## Community QR Scanner + +### How to Access +From Channels screen → "+" FAB → "Scan Community QR" + +### What the User Sees +A live QR scanner view with instruction text overlay. + +### Key Interactions +- Scan a community QR code shared by another member +- On valid scan: confirmation dialog showing community name and ID +- Option to "Add public channel to device" on join +- If already a member: shows an "Already a member" dialog +- Invalid QR: shows an orange error snackbar + +--- + +## Channel Message Path Viewing + +### How to Access +In a channel chat, tap a message bubble (mobile) or use the "Path" action (desktop). + +### What the User Sees +- Summary card: sender, time, repeat count, path type, observed hops +- "Other Observed Paths" section (if multiple paths detected) +- "Repeater Hops" section listing each hop with hex prefix, resolved name, and GPS coordinates + +### Actions +- **Radar icon**: Opens path trace map for live trace +- **Map icon**: Opens a map with hop markers and polyline +- **Path dropdown**: Switch between observed path variants (if multiple) + +--- + +## Debug Logging + +### BLE Debug Log +**Access**: Settings → BLE Debug Log + +Two views: +- **Frames**: Each BLE frame with direction, description, hex preview, timestamp. Long-press to copy hex. +- **Raw Log RX**: Decoded LoRa packets with route type, payload type, path bytes, and summary. + +### App Debug Log +**Access**: Settings → App Debug Log (must be enabled first in App Settings → Debug) + +Structured log entries with level (Info/Warning/Error), tag, message, and timestamp. + +Both logs support copy-all and clear operations. + +--- + +## Chrome Required Screen + +### When It Appears +Automatically shown on web platforms when a non-Chromium browser is detected. + +### What the User Sees +A full-screen informational page explaining that Web Bluetooth requires a Chromium-based browser. No interactive elements — purely informational. + +--- + +## Path History Service + +### What It Does (Background Service) +Maintains an in-memory LRU cache of up to 50 contacts, each with up to 100 route history entries, tracking: +- Hop count and trip time +- Success/failure counts and route weights +- Flood vs. direct discovery + +### Path Scoring +Paths are scored using a weighted formula: reliability (45%), route weight (20%), latency (25%), and freshness (10%). These weights are internal and not user-configurable. Paths whose weight drops to zero or below are automatically deleted. Flood deliveries that receive an ACK give a weight boost (+0.5) to the specific return path. + +Used internally for: +- **Auto route rotation**: Cycles through known paths using configurable weights on retries, with a diversity window to avoid re-using recently tried paths +- **Path selection**: Picks the best-scored path for each retry attempt +- **Flood statistics**: Tracks flood vs. direct discovery ratios + +--- + +## Message Retry Service + +### What It Does (Background Service) +Handles reliable delivery of outgoing direct messages: +1. Assigns a UUID and sends immediately. Only one message per contact can be in-flight at a time (avoids overflowing the firmware's 8-entry ACK table); subsequent messages are queued +2. Listens for ACK frames matched via SHA-256 hash of `[timestamp][attempt][text][sender_pubkey]` +3. On timeout, retries with exponential backoff: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s...) +4. Each retry may use a different path (via path history diversity window) +5. After max retries: marks failed but keeps a **30-second grace window** during which a late ACK can still resolve the message to "delivered". Optionally clears the contact's path +6. Reports RTT and path data for quality learning +7. Maintains an ACK hash history (last 50 entries) to handle duplicate ACKs + +### Configurable Settings (App Settings → Messaging) +- Max retries (2–10, default 5) +- Clear path on max retry (on/off) +- Auto route rotation with weight parameters + +--- + +## Timeout Prediction (ML) + +### What It Does (Background Service) +An ML-based service that predicts expected delivery timeouts: +- Collects delivery observations (path length, message size, time since last RX, delivery time) in a sliding window of up to 100 observations (oldest evicted first) +- Requires **10 minimum observations** before first training. After that, retrains every 5 new observations +- Applies a **1.5x safety margin** to raw predictions (the actual timeout issued is 1.5× the model's predicted delivery time) +- Features with zero variance are automatically excluded from training +- Blends per-contact statistics with ML predictions +- Falls back to `3000 + 3000 × pathLength` ms when insufficient data +- Observations are persisted to storage via a 2-second debounced timer (observations within 2s of app termination may be lost) diff --git a/documentation/ble-protocol.md b/documentation/ble-protocol.md new file mode 100644 index 0000000..ec24094 --- /dev/null +++ b/documentation/ble-protocol.md @@ -0,0 +1,254 @@ +# BLE Protocol & Data Layer + +This is a technical reference for the communication protocol and data architecture. + +## Transport Layer + +The app supports three transports, all sharing the same command/response protocol: + +| Transport | Method | Implementation | +|---|---|---| +| Bluetooth LE | Nordic UART Service (NUS) GATT | `flutter_blue_plus` | +| USB Serial | Packet-framed serial | `MeshCoreUsbManager` | +| TCP | Packet-framed socket | `MeshCoreTcpConnector` | + +### BLE (Nordic UART Service) + +- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e` +- **RX Characteristic** (write to device): `6e400002-b5a3-f393-e0a9-e50e24dcca9e` +- **TX Characteristic** (notify from device): `6e400003-b5a3-f393-e0a9-e50e24dcca9e` + +Raw `Uint8List` payloads are written directly to the RX characteristic. Writes use "write without response" if supported, falling back to "write with response". + +### USB and TCP Framing + +Both use a lightweight packet framing codec: + +``` +TX (host → device): [0x3C][len_lo][len_hi][payload...] +RX (device → host): [0x3E][len_lo][len_hi][payload...] +``` + +- Frame start: `0x3C` (`<`) for outgoing, `0x3E` (`>`) for incoming +- Length: 2-byte little-endian, payload only +- Max payload: 172 bytes +- TCP: `tcpNoDelay: true` (Nagle disabled), writes serialized to prevent interleaving +- USB: 10ms post-write delay between frames + +## Connection State Machine + +``` +enum MeshCoreConnectionState { + disconnected, + scanning, + connecting, + connected, + disconnecting, +} +``` + +## BLE Connection Lifecycle + +1. **Scan** with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`): + - `MeshCore-` + - `Whisper-` + - `WisCore-` + - `HT-` + - `LowMesh_MC_` +2. **Connect** with 15-second timeout +3. **Request MTU** 185 bytes (non-web only) +4. **Discover services** and locate NUS +5. **Enable TX notifications** (up to 3 attempts on native) +6. **Subscribe** to TX characteristic for incoming frames +7. **Initial sync**: device info query, time sync, channel sync + +## Auto-Reconnect (BLE Only) + +On unexpected disconnection, auto-reconnect with exponential backoff: +- Delays: 1s, 2s, 4s, 8s, 16s, 30s, 30s... +- Resets on successful connection +- Disabled for manual disconnects +- Not available for USB or TCP + +## Protocol Constants + +| Constant | Value | Description | +|---|---|---| +| Max frame size | 172 bytes | BLE/USB/TCP payload limit | +| Public key size | 32 bytes | Ed25519 public key | +| Max path size | 64 bytes | Maximum path data | +| Max name size | 32 bytes | Maximum node name | +| Max text payload | 160 bytes | Firmware `MAX_TEXT_LEN` | +| App protocol version | 3 | Sent in device query | +| Contact frame size | 148 bytes | Fixed-size contact record | + +## Command Codes (App → Device) + +| Code | Name | Description | +|------|------|-------------| +| 1 | CMD_APP_START | Announce app connection | +| 2 | CMD_SEND_TXT_MSG | Send direct text message | +| 3 | CMD_SEND_CHANNEL_TXT_MSG | Send channel text message | +| 4 | CMD_GET_CONTACTS | Request contact list | +| 5 | CMD_GET_DEVICE_TIME | Query device clock | +| 6 | CMD_SET_DEVICE_TIME | Set device clock | +| 7 | CMD_SEND_SELF_ADVERT | Broadcast own advertisement | +| 8 | CMD_SET_ADVERT_NAME | Set node name | +| 9 | CMD_ADD_UPDATE_CONTACT | Add or update a contact | +| 10 | CMD_SYNC_NEXT_MESSAGE | Request next queued message | +| 11 | CMD_SET_RADIO_PARAMS | Set radio parameters | +| 12 | CMD_SET_RADIO_TX_POWER | Set TX power | +| 13 | CMD_RESET_PATH | Reset contact path | +| 14 | CMD_SET_ADVERT_LATLON | Set advertised location | +| 15 | CMD_REMOVE_CONTACT | Remove a contact | +| 16 | CMD_SHARE_CONTACT | Share contact to mesh | +| 17 | CMD_EXPORT_CONTACT | Export contact as bytes | +| 18 | CMD_IMPORT_CONTACT | Import contact from bytes | +| 19 | CMD_REBOOT | Reboot device | +| 20 | CMD_GET_BATT_AND_STORAGE | Query battery and storage | +| 22 | CMD_DEVICE_QUERY | Query device info | +| 26 | CMD_SEND_LOGIN | Login to repeater/room | +| 27 | CMD_SEND_STATUS_REQ | Request repeater status | +| 30 | CMD_GET_CONTACT_BY_KEY | Get contact by public key | +| 31 | CMD_GET_CHANNEL | Get channel definition | +| 32 | CMD_SET_CHANNEL | Set channel name and PSK | +| 36 | CMD_SEND_TRACE_PATH | Request path trace | +| 38 | CMD_SET_OTHER_PARAMS | Set misc parameters | +| 39 | CMD_GET_TELEMETRY_REQ | Request sensor telemetry | +| 40 | CMD_GET_CUSTOM_VAR | Get custom variables | +| 41 | CMD_SET_CUSTOM_VAR | Set a custom variable | +| 50 | CMD_SEND_BINARY_REQ | Send binary request | +| 57 | CMD_SEND_ANON_REQ | Send anonymous request | +| 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration | +| 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration | + +## Response / Push Codes (Device → App) + +| Code | Name | Description | +|------|------|-------------| +| 0 | RESP_CODE_OK | Generic success | +| 1 | RESP_CODE_ERR | Generic error | +| 2 | RESP_CODE_CONTACTS_START | Contact list begins | +| 3 | RESP_CODE_CONTACT | Single contact data | +| 4 | RESP_CODE_END_OF_CONTACTS | Contact list complete | +| 5 | RESP_CODE_SELF_INFO | Device self-info response | +| 6 | RESP_CODE_SENT | Message transmitted; carries `[1]=is_flood, [2–5]=ack_hash, [6–9]=estimated_timeout_ms` | +| 7 | RESP_CODE_CONTACT_MSG_RECV | Incoming direct message (v2) | +| 8 | RESP_CODE_CHANNEL_MSG_RECV | Incoming channel message (v2) | +| 10 | RESP_CODE_NO_MORE_MESSAGES | No more queued messages | +| 11 | RESP_CODE_EXPORT_CONTACT | Exported contact data | +| 9 | RESP_CODE_CURR_TIME | Current device time | +| 12 | RESP_CODE_BATT_AND_STORAGE | Battery mV (uint16 LE) + storage used/total (uint32 LE each) | +| 13 | RESP_CODE_DEVICE_INFO | Firmware info | +| 16 | RESP_CODE_CONTACT_MSG_RECV_V3 | Incoming direct message (v3) | +| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) | +| 18 | RESP_CODE_CHANNEL_INFO | Channel definition | +| 21 | RESP_CODE_CUSTOM_VARS | Custom variables | +| 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags | +| 0x80 | PUSH_CODE_ADVERT | Known contact re-seen | +| 0x81 | PUSH_CODE_PATH_UPDATED | Better path found; carries the 32-byte public key of the updated contact | +| 0x82 | PUSH_CODE_SEND_CONFIRMED | Delivery ACK from remote; carries ACK hash (4 bytes) + trip time (4 bytes) | +| 0x83 | PUSH_CODE_MSG_WAITING | Offline messages queued | +| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Repeater/room login succeeded | +| 0x86 | PUSH_CODE_LOGIN_FAIL | Repeater/room login failed | +| 0x87 | PUSH_CODE_STATUS_RESPONSE | Repeater status response | +| 0x88 | PUSH_CODE_LOG_RX_DATA | Radio RX data with SNR (int8, units 1/4 dB), RSSI, and raw radio packet | +| 0x89 | PUSH_CODE_TRACE_DATA | Path trace result | +| 0x8A | PUSH_CODE_NEW_ADVERT | New node discovered | +| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Sensor telemetry data | +| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary data response | + +## Data Models + +### Contact +32-byte public key (primary identity), name, type (chat/repeater/room/sensor), flags, path data, GPS coordinates, last-seen timestamp. Parsed from 148-byte firmware frames with this layout: + +``` +[0] = resp_code +[1–32] = public key (32 bytes) +[33] = type (1=chat, 2=repeater, 3=room, 4=sensor) +[34] = flags (bit 0 = favorite) +[35] = path_length +[36–99] = path (64 bytes) +[100–131] = name (32 bytes, null-padded) +[132–135] = timestamp (uint32 LE) +[136–139] = latitude (int32 LE, × 1e-6 degrees) +[140–143] = longitude (int32 LE, × 1e-6 degrees) +[144–147] = last_modified (uint32 LE) +``` + +### Message (Direct) +Sender key, text, timestamp, outgoing flag, status (pending/sent/delivered/failed), message ID (UUID), retry count, ACK hash, trip time, path data, reactions. + +### Channel Message +Sender name, text, timestamp, status (pending/sent/failed), repeater hops, path variants, channel index, reactions, reply threading fields. + +### Channel +Index (0–7), name, 16-byte PSK, unread count. PSK derivation methods for hashtag (SHA-256) and community (HMAC-SHA256) channels. + +### Community +UUID, name, 32-byte secret, hashtag channel list. Shared via QR code. + +## Persistence + +All data is stored via `SharedPreferences` (JSON-serialized). No SQLite or other database. + +| Data | Storage Key Pattern | Scope | +|---|---|---| +| Contacts | `contacts` | Per device identity | +| Messages | `messages_` | Per device + contact | +| Channel Messages | `channel_messages_` | Per device + channel | +| Channels | `channels` | Per device identity | +| Channel Order | `channel_order_` | Per device identity | +| Contact Groups | `contact_groups` | Per device identity | +| Communities | `communities_v1` | Per device identity | +| Unread Counts | `contact_unread_count` | Per device identity | +| Discovered Contacts | `discovered_contacts` | Global | +| App Settings | `app_settings` | Global | +| Path History | `path_history_` | Per contact | + +## Auto-Add Configuration Bitmask + +Used by `CMD_SET_AUTO_ADD_CONFIG` (58) and `RESP_CODE_AUTO_ADD_CONFIG` (25): + +| Bit | Flag | Description | +|-----|------|-------------| +| 0 | 0x01 | Overwrite oldest contact when list is full | +| 1 | 0x02 | Auto-add chat users | +| 2 | 0x04 | Auto-add repeaters | +| 3 | 0x08 | Auto-add room servers | +| 4 | 0x10 | Auto-add sensors | + +## Radio Packet Payload Types + +Seen inside `PUSH_CODE_LOG_RX_DATA` raw packets: + +| Code | Type | +|------|------| +| 0x00 | REQ (request) | +| 0x01 | RESPONSE | +| 0x02 | TXTMSG (text message) | +| 0x03 | ACK | +| 0x04 | ADVERT | +| 0x05 | GRPTXT (group/channel text) | +| 0x06 | GRPDATA (group data) | +| 0x07 | ANONREQ (anonymous request) | +| 0x08 | PATH | +| 0x09 | TRACE | +| 0x0A | MULTIPART | +| 0x0B | CONTROL | +| 0x0F | RAW_CUSTOM | + +## State Management + +Uses Flutter `Provider` with `ChangeNotifier`. The central state holder is `MeshCoreConnector`, which owns all in-memory collections and fires debounced (50ms) `notifyListeners()` to update the UI. In-memory conversations are windowed to 200 messages per contact; older messages remain on disk and are loaded on demand. + +### Data Flow + +1. Raw frames arrive over BLE/USB/TCP +2. First byte is parsed as response/push code +3. Appropriate model factory (`fromFrame()`) parses the data +4. In-memory collections are updated +5. Storage stores are persisted (async) +6. `notifyListeners()` triggers UI rebuilds +7. Screens read current state via getters diff --git a/documentation/channels.md b/documentation/channels.md new file mode 100644 index 0000000..21fb52e --- /dev/null +++ b/documentation/channels.md @@ -0,0 +1,164 @@ +# Channels + +## Overview + +Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh. + +Up to 8 channels (indices 0–7) can be active simultaneously on one device. + +## How to Access + +QuickSwitchBar tab 1 (middle) from any main screen. + +## Channel Types + +| Type | Icon | Color | Description | +|---|---|---|---| +| Public | Globe | Green | Fixed well-known PSK; any device can join | +| Hashtag | Hash tag | Blue | PSK derived from the hashtag name via SHA-256; discoverable by convention | +| Private | Lock | Blue | Random PSK; requires out-of-band sharing of the 32-hex key | +| Community | Groups/Tag | Purple | PSK derived via HMAC-SHA256 from a community's shared secret | + +## Channels List Screen + +### What the User Sees + +- **Search bar** with live text filtering (300ms debounce) +- **Sort/filter button** +- **Scrollable list of channel cards**, each showing: + - Type icon with color coding (purple badge overlay for community channels) + - Channel name (or "Channel N" if unnamed) + - Subtitle: "Public channel", "Hashtag channel", "Private channel", or "Community channel - {name}" + - Unread badge (if messages are unread) + - Drag handle (when manual sort is active) +- **"+" FAB** to add a new channel +- **Overflow menu**: Disconnect, Manage Communities (only shown when at least one community exists), Settings + +If no channels exist, an empty state with an "Add Public Channel" shortcut is shown. If a search produces no results, a separate "no results" empty state with a search-off icon is shown. + +Pull-to-refresh (swipe down) forces a re-fetch of channels from the device firmware. + +### Sorting Options + +- **Manual** (default): Drag-and-drop reordering, persisted (drag handles are hidden when a search query is active) +- **A–Z**: Alphabetical +- **Latest messages**: Most recent first +- **Unread**: Most unread first + +## Adding a Channel + +Tap the "+" FAB to open a dialog with six options: + +1. **Create Private Channel** — Enter a name (max 31 characters); a random PSK is generated +2. **Join Private Channel** — Enter a name and a 32-hex PSK (non-hex characters like spaces and dashes are silently stripped, so pasted keys with formatting are accepted) +3. **Join Public Channel** — One tap; uses the well-known public PSK (only shown if no public channel exists) +4. **Join Hashtag Channel** — Enter a hashtag name; PSK is derived from the name. If communities exist, choose between regular hashtag (SHA-256) or community hashtag (HMAC) +5. **Scan Community QR** — Opens QR scanner to join a community +6. **Create Community** — Enter a name; generates a random 32-byte secret; optionally adds a community public channel; shows QR code for sharing + +## Channel Actions (Long-Press / Right-Click) + +| Action | Description | +|---|---| +| Edit | Change name, PSK (with a dice icon to generate a random PSK), or SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit) | +| Mute / Unmute | Toggle push notification suppression for this channel | +| Delete | Remove the channel from the device (confirmation required) | + +## Channel Chat + +Tap a channel card to open the channel chat screen. + +### App Bar + +- Type icon (public/private/hashtag) +- Channel name +- Subtitle: "{type} - {N} unread" + +### Message Display + +- Reverse-scrolling list (newest at bottom) +- **Incoming messages**: Colored avatar with sender's initial (or first emoji if name starts with one; color is deterministic from sender name hash), sender name in primary color, message bubble +- **Outgoing messages**: Primary container color bubble with a small status icon: pending (clock), sent (checkmark), or failed (red error circle) +- Automatic older-message loading on scroll-to-top +- Jump-to-bottom button when scrolled up +- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset text size +- **Message tracing mode** (when enabled in App Settings): Each bubble additionally shows path prefix bytes (`via XX,YY,...`), a timestamp, and a repeat count icon + +### Message Types in Chat + +- **Plain text** with linkified URLs +- **GIFs** (`g:{gifId}`) rendered inline via Giphy CDN +- **Location pins** (`m:{lat},{lon}|{label}|`) shown as tappable location cards +- **Reactions** displayed as emoji pills below target messages + +### Replies (Channel Chat Only) + +- **Mobile**: Swipe an **incoming** message left to trigger reply (with haptic feedback). You cannot swipe your own outgoing messages. Swipe reply is not available on desktop. +- **All platforms**: Long-press → "Reply" +- Reply banner appears above the input bar with the quoted message (tap X to cancel) +- Sent replies are prefixed `@[{senderName}] {text}` +- Received replies show a bordered quote block inside the bubble; tapping scrolls to the original. Reply previews render GIF thumbnails and location pin icons, not just text. + +### Message Path Viewing + +- **Mobile**: Tap a message bubble to view its routing path +- **Desktop**: Long-press/right-click → "Path" (tapping the bubble does nothing on desktop) +- Opens the Channel Message Path Screen (see [Additional Features](additional-features.md)) + +### Context Actions (Long-Press / Right-Click) + +| Action | Availability | Description | +|---|---|---| +| Reply | All messages | Triggers reply mode | +| Path | Desktop only | Opens message path view | +| Add Reaction | Incoming messages only | Opens emoji picker (cannot react to your own messages) | +| Copy | All messages | Copies text to clipboard | +| Delete | All messages | Removes locally (not from mesh) | + +### Message Path Viewing + +Tap a message bubble to open the Channel Message Path Screen, which shows: +- Each hop in the path as a visual chain +- Known contacts identified by name at each hop +- Observed vs. declared hop counts +- Alternative path variants (if received via multiple paths) +- Map view buttons for geographic path visualization + +## Communities + +Communities are a layer above channels that provide a private namespace. + +### What is a Community? + +A community has a name and a 32-byte random secret. Channel PSKs are derived from this secret: +- **Public channel**: `HMAC-SHA256(secret, "channel:v1:__public__")[:16]` +- **Hashtag channel**: `HMAC-SHA256(secret, "channel:v1:{hashtag}")[:16]` + +Outsiders who don't know the secret cannot discover or join community channels. + +### Sharing a Community + +Communities are shared via QR codes containing a JSON payload: +```json +{"v": 1, "type": "meshcore_community", "name": "...", "k": ""} +``` + +### Managing Communities + +From the channels screen overflow menu → "Manage Communities". Opens a draggable scrollable sheet (resizable 30–90% of screen height): + +- Each community shows its name and a short community ID (first 8 hex characters) +- **Tap a community** to directly show its QR code for sharing +- **Popup menu** per community: + - **Show QR** — displays the QR code for sharing with new members + - **Delete** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed) + +## How Channels Differ from Direct Messages + +| Aspect | Channels | Direct Messages | +|---|---|---| +| Addressing | Broadcast to all nodes with matching PSK | Point-to-point to a specific contact | +| Encryption | Shared PSK (symmetric) | Contact's public key (asymmetric) | +| Sender identity | Plain text prefix in payload | Verified via public key | +| Replies | Supported (swipe or long-press) | Not supported | +| Retry mechanism | No automatic retry | Exponential backoff with path rotation | diff --git a/documentation/chat-and-messaging.md b/documentation/chat-and-messaging.md new file mode 100644 index 0000000..22030d5 --- /dev/null +++ b/documentation/chat-and-messaging.md @@ -0,0 +1,120 @@ +# Chat & Messaging + +## Overview + +The app supports two chat modes: +- **Direct messages**: Encrypted point-to-point messages to individual contacts +- **Channel messages**: Broadcast messages to shared channels (see [Channels](channels.md)) + +This page covers direct messaging. For channel chat, see the Channels documentation. + +## How to Access + +From the Contacts screen, tap any Chat-type contact to open the ChatScreen. + +## Chat Screen Layout + +### App Bar + +- **Title**: Contact name +- **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details. +- **Action buttons**: + - **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing + - **Path management** (timeline icon): View recent paths with hop count, round-trip time, age, and success count. Paths are color-coded by direct repeater (green/yellow/red/blue for ranked repeaters, grey for unknown). Tap a path to activate it (the device verifies and confirms via snackbar), long-press to view full path details, set custom paths, or force flood mode. A warning banner appears when history reaches 100 entries. + - **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle + +### Message List + +- Scrollable list with newest messages at the bottom +- **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background +- **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name) +- Bubble width capped at 65% of screen width +- Hyperlinks rendered as tappable green underlined text +- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset +- **Jump to bottom**: Floating button appears when scrolled away from the bottom +- **Lazy loading**: Scrolling to top loads older messages from storage + +### Input Bar + +- **GIF button** (left): Opens GIF picker bottom sheet +- **Text field** (center): Auto-capitalization, enforces UTF-8 byte limit in real-time +- **Send button** (right): Submits the message +- On desktop: Enter/Numpad Enter also submits +- When a GIF is selected, the text field shows an inline GIF preview with a dismiss button + +## Message Types + +| Type | Wire Format | Display | +|---|---|---| +| Plain text | Raw UTF-8 string | Inline text with link detection | +| GIF | `g:` | Inline GIF image from Giphy CDN | +| Location pin | `m:,\|