mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
fix: init tabs advert quick add
ref:
This commit is contained in:
210
.github/workflows/release-build.yml
vendored
Normal file
210
.github/workflows/release-build.yml
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
name: Build And Upload Release Assets
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.35.6"
|
||||
APP_NAME: meshcore-sar
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
name: Build Android APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Prepare APK artifact
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
||||
cp build/app/outputs/flutter-apk/app-release.apk "${APP_NAME}-${TAG}-android.apk"
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-android
|
||||
path: "${{ env.APP_NAME }}-*-android.apk"
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
name: Build Linux App
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
clang \
|
||||
cmake \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
libgtk-3-dev \
|
||||
liblzma-dev
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Enable Linux desktop
|
||||
run: flutter config --enable-linux-desktop
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build Linux release
|
||||
run: flutter build linux --release
|
||||
|
||||
- name: Package Linux artifact
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
||||
tar -C build/linux/x64/release -czf "${APP_NAME}-${TAG}-linux.tar.gz" bundle
|
||||
|
||||
- name: Upload Linux artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux
|
||||
path: "${{ env.APP_NAME }}-*-linux.tar.gz"
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows App
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Enable Windows desktop
|
||||
shell: pwsh
|
||||
run: flutter config --enable-windows-desktop
|
||||
|
||||
- name: Install dependencies
|
||||
shell: pwsh
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build Windows release
|
||||
shell: pwsh
|
||||
run: flutter build windows --release
|
||||
|
||||
- name: Package Windows artifact
|
||||
shell: pwsh
|
||||
run: |
|
||||
$Tag = "${{ github.event.release.tag_name || github.ref_name }}"
|
||||
$Output = "${{ env.APP_NAME }}-$Tag-windows.zip"
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath $Output
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows
|
||||
path: "${{ env.APP_NAME }}-*-windows.zip"
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
name: Build macOS DMG
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Enable macOS desktop
|
||||
run: flutter config --enable-macos-desktop
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build macOS release
|
||||
run: flutter build macos --release
|
||||
|
||||
- name: Create DMG
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
||||
APP_PATH=$(find build/macos/Build/Products/Release -maxdepth 1 -name "*.app" -print -quit)
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "No .app bundle found in build output"
|
||||
exit 1
|
||||
fi
|
||||
hdiutil create -volname "MeshCore SAR" -srcfolder "$APP_PATH" -ov -format UDZO "${APP_NAME}-${TAG}-macos.dmg"
|
||||
|
||||
- name: Upload macOS artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: "${{ env.APP_NAME }}-*-macos.dmg"
|
||||
if-no-files-found: error
|
||||
|
||||
upload-release-assets:
|
||||
name: Upload Assets To Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-android
|
||||
- build-linux
|
||||
- build-windows
|
||||
- build-macos
|
||||
|
||||
steps:
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Upload assets to published release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
files: |
|
||||
dist/*.apk
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/*.dmg
|
||||
fail_on_unmatched_files: true
|
||||
310
AUTO_RECOVERY.md
310
AUTO_RECOVERY.md
@@ -1,310 +0,0 @@
|
||||
# Auto-Recovery System for ERR_CODE_NOT_FOUND
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When sending a message to a room contact that exists in the app's contact list but not in the companion radio's contact table, the radio responds with:
|
||||
```
|
||||
ERROR (0x01) with error code 2 (ERR_CODE_NOT_FOUND)
|
||||
```
|
||||
|
||||
This happens because:
|
||||
1. Room contacts may have been deleted from the radio
|
||||
2. New room contacts added to the app haven't been synced to the radio yet
|
||||
3. The radio was factory reset but the app still has cached contacts
|
||||
|
||||
## Solution: Automatic Contact Recovery
|
||||
|
||||
The system now automatically detects and recovers from `ERR_CODE_NOT_FOUND` errors by:
|
||||
|
||||
1. **Detecting the error** in `BleResponseHandler` (ble_response_handler.dart:584-587)
|
||||
2. **Tracking the failing contact** via `setLastContactPublicKey()`
|
||||
3. **Triggering auto-recovery** via `onContactNotFound` callback
|
||||
4. **Adding the missing contact** to the radio using `CMD_ADD_UPDATE_CONTACT`
|
||||
5. **Retrying the send operation** automatically after 300ms delay
|
||||
|
||||
## Implementation Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ User sends message to room contact │
|
||||
└─────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ConnectionProvider.sendTextMessage() │
|
||||
│ - Tracks pending operation: _PendingSendOperation │
|
||||
│ - Contains: contactPublicKey, text, messageId, contact │
|
||||
└─────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MeshCoreBleService.sendTextMessage() │
|
||||
│ - Calls: responseHandler.setLastContactPublicKey() │
|
||||
│ - Sends: CMD_SEND_TXT_MSG (0x02) │
|
||||
└─────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Radio processes command │
|
||||
│ ❌ Contact not found in radio's contact table │
|
||||
│ → Responds: RESP_CODE_ERR (0x01) with errCode=2 │
|
||||
└─────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ BleResponseHandler._handleError() │
|
||||
│ - Detects: errorCode == 2 (ERR_CODE_NOT_FOUND) │
|
||||
│ - Triggers: onContactNotFound(lastContactPublicKey) │
|
||||
└─────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ConnectionProvider.onContactNotFound callback │
|
||||
│ 1. Looks up pending operation by public key │
|
||||
│ 2. Calls: bleService.addOrUpdateContact(contact) │
|
||||
│ → Sends: CMD_ADD_UPDATE_CONTACT (0x09) │
|
||||
│ 3. Waits 300ms for contact to be added │
|
||||
│ 4. Retries: bleService.sendTextMessage() │
|
||||
│ → Sends: CMD_SEND_TXT_MSG (0x02) again │
|
||||
│ 5. Clears pending operation on success │
|
||||
└─────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Radio processes retry │
|
||||
│ ✅ Contact now exists in radio's contact table │
|
||||
│ → Responds: RESP_CODE_SENT (0x06) with ACK tag │
|
||||
└─────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Message sent successfully │
|
||||
│ → UI shows "sent" status │
|
||||
│ → Normal delivery confirmation flow continues │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. BleResponseHandler (lib/services/ble/ble_response_handler.dart)
|
||||
|
||||
**New Callbacks:**
|
||||
```dart
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
```
|
||||
|
||||
**Error Detection:**
|
||||
```dart
|
||||
void _handleError(BufferReader reader) {
|
||||
final errorCode = FrameParser.parseError(reader);
|
||||
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
|
||||
onContactNotFound?.call(_lastContactPublicKey);
|
||||
}
|
||||
onError?.call(errorMsg, errorCode: errorCode);
|
||||
}
|
||||
```
|
||||
|
||||
**Tracking:**
|
||||
```dart
|
||||
void setLastContactPublicKey(Uint8List? publicKey) {
|
||||
_lastContactPublicKey = publicKey;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. MeshCoreBleService (lib/services/meshcore_ble_service.dart)
|
||||
|
||||
**Updated Typedef:**
|
||||
```dart
|
||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
```
|
||||
|
||||
**Callback Forwarding:**
|
||||
```dart
|
||||
_responseHandler.onError = (error, {int? errorCode}) {
|
||||
onError?.call(error, errorCode: errorCode);
|
||||
};
|
||||
_responseHandler.onContactNotFound = (contactPublicKey) {
|
||||
onContactNotFound?.call(contactPublicKey);
|
||||
};
|
||||
```
|
||||
|
||||
**Contact Tracking:**
|
||||
```dart
|
||||
Future<void> sendTextMessage({...}) async {
|
||||
// Track the last contact for auto-recovery if contact not found
|
||||
_responseHandler.setLastContactPublicKey(contactPublicKey);
|
||||
await _commandSender.writeData(...);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. ConnectionProvider (lib/providers/connection_provider.dart)
|
||||
|
||||
**Pending Operation Tracking:**
|
||||
```dart
|
||||
class _PendingSendOperation {
|
||||
final Uint8List contactPublicKey;
|
||||
final String text;
|
||||
final String? messageId;
|
||||
final Contact? contact;
|
||||
final int retryAttempt;
|
||||
}
|
||||
|
||||
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
||||
```
|
||||
|
||||
**Auto-Recovery Logic:**
|
||||
```dart
|
||||
_bleService.onContactNotFound = (contactPublicKey) async {
|
||||
// 1. Look up pending operation
|
||||
final pendingOp = _pendingSendOperations[operationId];
|
||||
|
||||
// 2. Add contact to radio
|
||||
await _bleService.addOrUpdateContact(pendingOp.contact!);
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
// 3. Retry send
|
||||
await _bleService.sendTextMessage(
|
||||
contactPublicKey: pendingOp.contactPublicKey,
|
||||
text: pendingOp.text,
|
||||
attempt: pendingOp.retryAttempt,
|
||||
);
|
||||
|
||||
// 4. Cleanup
|
||||
_pendingSendOperations.remove(operationId);
|
||||
};
|
||||
```
|
||||
|
||||
**Send Message Tracking:**
|
||||
```dart
|
||||
Future<bool> sendTextMessage({...}) async {
|
||||
// Track operation before sending
|
||||
if (contact != null) {
|
||||
_pendingSendOperations[operationId] = _PendingSendOperation(...);
|
||||
}
|
||||
|
||||
await _bleService.sendTextMessage(...);
|
||||
|
||||
// Clear after 500ms (if no error occurs)
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
_pendingSendOperations.remove(operationId);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### Scenario 1: Room contact deleted from radio
|
||||
1. User has room "SAR-Command" in app's contact list
|
||||
2. Room was deleted from companion radio
|
||||
3. User sends SAR marker to room
|
||||
4. **Expected:** System automatically adds room and sends message
|
||||
5. **Verify:** Message appears as "sent" in UI
|
||||
|
||||
### Scenario 2: New room added to app
|
||||
1. User manually adds new room contact to app
|
||||
2. Room not yet in companion radio's contact table
|
||||
3. User sends message to new room
|
||||
4. **Expected:** System automatically adds room and sends message
|
||||
5. **Verify:** Message appears as "sent" in UI
|
||||
|
||||
### Scenario 3: Factory reset radio
|
||||
1. Radio was factory reset (all contacts cleared)
|
||||
2. App still has cached contacts
|
||||
3. User sends message to any room
|
||||
4. **Expected:** System automatically re-adds contact and sends message
|
||||
5. **Verify:** Message appears as "sent" in UI
|
||||
|
||||
## Console Output Example
|
||||
|
||||
**Before (failed send):**
|
||||
```
|
||||
📤 [TX] Sending command: SEND_TXT_MSG (0x02)
|
||||
Data size: 46 bytes
|
||||
📥 [RX] Received: ERROR (0x01)
|
||||
❌ [Error] Not found
|
||||
⚠️ [Provider] BLE error received: Not found
|
||||
```
|
||||
|
||||
**After (auto-recovery):**
|
||||
```
|
||||
📤 [TX] Sending command: SEND_TXT_MSG (0x02)
|
||||
Data size: 46 bytes
|
||||
📝 Tracked pending operation for auto-recovery: 8f:a0:f2:68:d0:c1
|
||||
📥 [RX] Received: ERROR (0x01)
|
||||
❌ [Error] Not found
|
||||
⚠️ [Error] Contact not found in radio - attempting auto-recovery
|
||||
🔧 [Provider] Contact not found error detected - initiating auto-recovery
|
||||
📋 Found pending operation for: SAR-Command
|
||||
📤 Step 1: Adding contact to radio...
|
||||
📝 [BLE] Adding/updating contact on companion radio:
|
||||
Name: SAR-Command
|
||||
Public key prefix: 8f:a0:f2:68:d0:c1
|
||||
Type: room (3)
|
||||
📤 [TX] Sending command: ADD_UPDATE_CONTACT (0x09)
|
||||
📥 [RX] Received: OK (0x00)
|
||||
✅ Contact added successfully
|
||||
🔄 Step 2: Retrying message send...
|
||||
📤 [TX] Sending command: SEND_TXT_MSG (0x02)
|
||||
📥 [RX] Received: SENT (0x06)
|
||||
✅ Auto-recovery completed - message resent
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### If contact is null
|
||||
```
|
||||
⚠️ No pending operation found for recovery: 8f:a0:f2:68:d0:c1
|
||||
```
|
||||
This happens if the send was called without a Contact object. Cannot auto-recover.
|
||||
|
||||
### If add contact fails
|
||||
```
|
||||
❌ Auto-recovery failed: BLE write error
|
||||
```
|
||||
Error is logged and operation is cleared. User must manually retry.
|
||||
|
||||
### If retry send fails
|
||||
Same as above - error logged, operation cleared.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Zero user intervention** - automatic recovery
|
||||
2. **Transparent operation** - user sees "sent" status as expected
|
||||
3. **Robust handling** - works for all ERR_CODE_NOT_FOUND scenarios
|
||||
4. **Minimal latency** - 300ms delay is imperceptible to user
|
||||
5. **Clean architecture** - isolated to ConnectionProvider layer
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Batch recovery** - if multiple contacts missing, add all at once
|
||||
2. **Proactive sync** - periodically sync app contacts to radio
|
||||
3. **Smart caching** - track which contacts have been successfully added
|
||||
4. **UI feedback** - optional: show "Adding contact..." toast during recovery
|
||||
5. **Telemetry** - track auto-recovery success rate for monitoring
|
||||
|
||||
## Related Files
|
||||
|
||||
- `lib/services/ble/ble_response_handler.dart` - Error detection
|
||||
- `lib/services/meshcore_ble_service.dart` - Service layer coordination
|
||||
- `lib/providers/connection_provider.dart` - Auto-recovery orchestration
|
||||
- `lib/services/protocol/frame_parser.dart` - Error code parsing
|
||||
- `lib/services/meshcore_constants.dart` - Error code constants
|
||||
|
||||
## MeshCore Protocol Reference
|
||||
|
||||
**Error Codes (ERR_CODE):**
|
||||
- 1: ERR_CODE_UNSUPPORTED_CMD
|
||||
- **2: ERR_CODE_NOT_FOUND** ← This is what we handle
|
||||
- 3: ERR_CODE_TABLE_FULL
|
||||
- 4: ERR_CODE_BAD_STATE
|
||||
- 5: ERR_CODE_FILE_IO_ERROR
|
||||
- 6: ERR_CODE_ILLEGAL_ARG
|
||||
|
||||
**Recovery Commands:**
|
||||
- CMD_ADD_UPDATE_CONTACT (9): Adds contact to radio's contact table
|
||||
- CMD_SEND_TXT_MSG (2): Sends message to contact (retried after add)
|
||||
|
||||
## Conclusion
|
||||
|
||||
The auto-recovery system provides a seamless user experience by automatically handling missing contacts in the radio's contact table. Users can now send messages to room contacts without worrying about synchronization issues, and the system will intelligently recover from `ERR_CODE_NOT_FOUND` errors.
|
||||
289
BLOG_POST.md
289
BLOG_POST.md
@@ -1,289 +0,0 @@
|
||||
# MeshCore SAR: Off-Grid Communication for Search & Rescue Operations
|
||||
|
||||
When the grid goes down, lives are on the line. MeshCore SAR is a revolutionary mobile application that enables search and rescue teams to communicate, coordinate, and share critical information—even when cellular networks and internet connectivity are completely unavailable.
|
||||
|
||||
Built on cutting-edge mesh networking technology, MeshCore SAR transforms ordinary smartphones into powerful off-grid communication devices using low-power radio hardware. Whether you're coordinating a wilderness rescue, managing a disaster response, or operating in remote areas, MeshCore SAR keeps your team connected when it matters most.
|
||||
|
||||
## Why MeshCore SAR?
|
||||
|
||||
Traditional communication systems fail when you need them most:
|
||||
- **Cellular networks** collapse during disasters or don't exist in remote wilderness
|
||||
- **Satellite phones** are expensive and have limited messaging capabilities
|
||||
- **Radio systems** require licensing and lack modern features like GPS integration
|
||||
|
||||
MeshCore SAR solves these problems by creating a resilient, self-healing mesh network that:
|
||||
- ✅ **Works completely off-grid** - No cellular, WiFi, or internet required
|
||||
- ✅ **Extends range through mesh routing** - Messages hop through nearby devices
|
||||
- ✅ **Integrates GPS tracking** - Real-time location sharing on offline maps
|
||||
- ✅ **Specializes in SAR operations** - Purpose-built features for emergency response
|
||||
- ✅ **Uses affordable hardware** - Low-cost LoRa radios via Bluetooth
|
||||
|
||||
---
|
||||
|
||||
## 🗨️ Messages: Reliable Communication When Networks Fail
|
||||
|
||||
[*Image placeholder: Messages screen showing conversation with delivery status*]
|
||||
|
||||
At the heart of MeshCore SAR is a robust messaging system designed for mission-critical communication.
|
||||
|
||||
### Key Features:
|
||||
|
||||
**🎯 Multiple Communication Modes**
|
||||
- **Direct Messages**: Private one-to-one communication with team members
|
||||
- **Public Channel**: Broadcast updates to all nearby devices
|
||||
- **Rooms**: Persistent message storage for coordination centers
|
||||
|
||||
**📡 Smart Message Delivery**
|
||||
- **Intelligent routing**: Messages automatically find the best path through the mesh network
|
||||
- **Delivery confirmation**: Know when your message reaches its destination with ACK tracking
|
||||
- **Automatic retries**: Failed messages retry automatically with progressive timeouts
|
||||
- **Flood fallback**: Critical messages use broadcast mode if routing fails
|
||||
|
||||
**🚨 SAR Marker Messages**
|
||||
Send location-tagged alerts with a simple message format:
|
||||
- **🧑 Found Person**: `S:🧑:37.7749,-122.4194:Survivor located, needs medical attention`
|
||||
- **🔥 Fire Location**: `S:🔥:40.7128,-74.0060:Wildfire spreading rapidly northeast`
|
||||
- **🏕️ Staging Area**: `S:🏕️:34.0522,-118.2437:Base camp established with supplies`
|
||||
|
||||
These special messages automatically appear as markers on the map, making critical information instantly visual for the entire team.
|
||||
|
||||
**📊 Message Status Tracking**
|
||||
Every message shows its delivery status:
|
||||
- ⏳ **Sending** - Message is being transmitted
|
||||
- ✅ **Sent** - Message queued with expected acknowledgment
|
||||
- ✔️✔️ **Delivered** - Confirmation received with round-trip time
|
||||
- 🔄 **Retrying** - Automatic retry in progress
|
||||
- ❌ **Failed** - Delivery unsuccessful after all attempts
|
||||
|
||||
**🌍 Multilingual Support**
|
||||
Full localization in English, Croatian (Hrvatski), and Slovenian (Slovenščina) ensures teams can communicate in their native language.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contacts: Know Your Team's Status and Location
|
||||
|
||||
[*Image placeholder: Contacts list showing team members with GPS locations and battery levels*]
|
||||
|
||||
MeshCore SAR's contact system goes beyond simple names and numbers—it provides real-time situational awareness for your entire team.
|
||||
|
||||
### Contact Intelligence:
|
||||
|
||||
**📍 Real-Time Location Tracking**
|
||||
- GPS coordinates automatically broadcast at configurable intervals
|
||||
- Location history tracking (last 100 positions per contact)
|
||||
- Distance and bearing calculations from your position
|
||||
- "Last seen" timestamps for situational awareness
|
||||
|
||||
**🔋 Battery Monitoring**
|
||||
- Battery percentage displayed for each team member
|
||||
- Voltage telemetry via Cayenne LPP format
|
||||
- Early warning when team members need to conserve power
|
||||
|
||||
**🛤️ Mesh Network Routing**
|
||||
The app shows routing information for each contact:
|
||||
- **Direct (0 hops)**: Connected directly to your radio
|
||||
- **Good path (1-2 hops)**: Reliable routing through 1-2 intermediate devices
|
||||
- **Medium/Long path (3-5+ hops)**: Extended range through multiple hops
|
||||
- **No path (flood mode)**: Messages broadcast to entire network
|
||||
|
||||
**👔 Role-Based Identification**
|
||||
- Add emoji prefixes to names (🧑🏻🚒 for firefighter, 👮 for police, 🏥 for medical)
|
||||
- Instant visual identification on map and in contact lists
|
||||
- Customizable display names
|
||||
|
||||
**📡 Contact Types**
|
||||
- **Chat** (Team Members): Standard team members shown on map
|
||||
- **Repeater**: Network infrastructure nodes that extend range
|
||||
- **Room**: Message servers with persistent storage and login capabilities
|
||||
|
||||
**📞 Contact Sharing**
|
||||
- Export contacts as business cards
|
||||
- Share contacts directly over the mesh network
|
||||
- Import contacts from other team members
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Map: Offline Navigation and Tactical Awareness
|
||||
|
||||
[*Image placeholder: Map screen showing team members, SAR markers, and offline terrain*]
|
||||
|
||||
The map is where everything comes together—combining team locations, SAR events, and offline navigation into a single, comprehensive tactical display.
|
||||
|
||||
### Map Features:
|
||||
|
||||
**🗺️ Offline Vector Maps**
|
||||
- **MBTiles format**: Lightweight vector maps that work completely offline
|
||||
- **Multiple layers**: Street maps (OpenStreetMap), topographic (OpenTopoMap), satellite imagery (ESRI)
|
||||
- **High zoom levels**: Street-level detail up to zoom level 19
|
||||
- **Smart caching**: Tiles cached locally for 30 days
|
||||
|
||||
**📍 Team Member Tracking**
|
||||
Each team member appears on the map with:
|
||||
- Blue circle markers with role emoji
|
||||
- Battery level badge (green/yellow/red indicators)
|
||||
- Distance from your location
|
||||
- Tap to view detailed information and message directly
|
||||
|
||||
**🚨 SAR Event Markers**
|
||||
Critical events appear as color-coded markers:
|
||||
- 🟢 **Green** - Found Person
|
||||
- 🔴 **Red** - Fire Location
|
||||
- 🔵 **Blue** - Staging Area
|
||||
- 🟣 **Purple** - Object
|
||||
- Time elapsed since report
|
||||
- Tap to navigate and view details
|
||||
|
||||
**✏️ Map Drawing Tools**
|
||||
Collaborative tactical planning:
|
||||
- **Line drawings**: Sketch routes, boundaries, or directions
|
||||
- **Rectangle areas**: Mark zones, perimeters, or sectors
|
||||
- **8 color palette**: Red, blue, green, yellow, orange, purple, pink, cyan
|
||||
- **Share drawings**: Send to channel or specific rooms via mesh network
|
||||
- **Collaborative editing**: All team members see drawings in real-time
|
||||
- **Ultra-compact format**: Efficient JSON encoding reduces bandwidth usage by 37%
|
||||
|
||||
**🧭 Detailed Compass Dialog**
|
||||
Ultra-compact location display:
|
||||
- Current GPS coordinates
|
||||
- Toggle between Decimal Degrees (DD) and Degrees/Minutes/Seconds (DMS)
|
||||
- Tap outside to close (no buttons needed)
|
||||
- Perfect for quick location checks
|
||||
|
||||
**📡 User Location Tracking**
|
||||
- Blue pulsing circle shows your current position
|
||||
- Navigation icon for direction
|
||||
- Tap to center and track your movement
|
||||
- Configurable accuracy settings
|
||||
|
||||
**📊 Map Legend**
|
||||
Collapsible legend in top-right corner:
|
||||
- Team member count
|
||||
- SAR marker count by type
|
||||
- Quick reference for marker colors
|
||||
|
||||
**🎯 Smart Navigation**
|
||||
- Tap any SAR marker in messages to navigate on map
|
||||
- Automatic tab switching
|
||||
- Map centers and zooms to selected location
|
||||
- Clears navigation after viewing
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Innovation
|
||||
|
||||
### Mesh Network Protocol
|
||||
- **MeshCore Protocol**: Open-source, battle-tested mesh networking
|
||||
- **LoRa Radio**: Long-range, low-power wireless technology
|
||||
- **BLE Connection**: Smartphone connects to companion radio via Bluetooth
|
||||
- **Intelligent routing**: Self-healing paths through the network
|
||||
- **Flood mode fallback**: Guaranteed delivery for critical messages
|
||||
|
||||
### Smart Features
|
||||
- **Adaptive location broadcasting**: Only sends updates when you move significantly
|
||||
- **Progressive retry logic**: Failed messages retry with increasing timeouts
|
||||
- **Cayenne LPP telemetry**: Standardized sensor data format
|
||||
- **Contact synchronization**: Automatic contact list updates
|
||||
- **Message persistence**: Rooms store messages for later retrieval
|
||||
|
||||
### Built for Reliability
|
||||
- **Provider-based architecture**: Efficient state management
|
||||
- **Automatic reconnection**: Handles radio disconnections gracefully
|
||||
- **Message queue management**: Synchronizes messages in order
|
||||
- **Battery optimization**: Configurable tracking intervals
|
||||
- **Memory management**: Automatic history cleanup
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Real-World Applications
|
||||
|
||||
**Search & Rescue Operations**
|
||||
- Wilderness rescue coordination
|
||||
- Missing person searches
|
||||
- Cave rescue operations
|
||||
- Mountain rescue teams
|
||||
|
||||
**Disaster Response**
|
||||
- Hurricane and flood response
|
||||
- Earthquake emergency communication
|
||||
- Infrastructure failure scenarios
|
||||
- Mass casualty incidents
|
||||
|
||||
**Remote Operations**
|
||||
- Forestry operations
|
||||
- Mining site communication
|
||||
- Border patrol and security
|
||||
- Wildlife management
|
||||
|
||||
**Training & Exercises**
|
||||
- Team coordination drills
|
||||
- Radio discipline training
|
||||
- Navigation exercises
|
||||
- Emergency preparedness
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
MeshCore SAR works with affordable LoRa companion radios that connect to your smartphone via Bluetooth. The app handles all the complexity—you just:
|
||||
|
||||
1. **Connect** your radio via Bluetooth
|
||||
2. **Add contacts** to your team
|
||||
3. **Start messaging** and tracking locations
|
||||
4. **Download maps** for your area
|
||||
5. **Coordinate** your mission
|
||||
|
||||
No cellular service. No internet. No limits.
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Open Source & Community-Driven
|
||||
|
||||
MeshCore SAR is built on the open-source MeshCore protocol, fostering a community of developers and users who contribute to its continuous improvement. Whether you're a first responder, amateur radio enthusiast, or outdoor adventurer, you're part of a global network working to keep people connected when it matters most.
|
||||
|
||||
---
|
||||
|
||||
## 💡 The Future of Off-Grid Communication
|
||||
|
||||
In a world increasingly dependent on fragile infrastructure, MeshCore SAR represents a paradigm shift: resilient, decentralized communication that works when everything else fails. As climate change drives more frequent disasters and teams operate in increasingly remote locations, mesh networking isn't just an alternative—it's essential.
|
||||
|
||||
**MeshCore SAR isn't just an app. It's a lifeline.**
|
||||
|
||||
---
|
||||
|
||||
*MeshCore SAR is compatible with iOS 13+ and Android API 21+. Requires compatible LoRa companion radio hardware.*
|
||||
|
||||
**Repository**: [github.com/meshcore-dev/meshcore.js](https://github.com/meshcore-dev/meshcore.js)
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
*[Placeholder sections for images]*
|
||||
|
||||
### Messages
|
||||
*Screenshot showing conversation with delivery status, SAR marker messages, and message options*
|
||||
|
||||
### Contacts
|
||||
*Screenshot showing contact list with GPS locations, battery levels, and routing information*
|
||||
|
||||
### Map - Team View
|
||||
*Screenshot showing map with multiple team members, distance indicators, and user location*
|
||||
|
||||
### Map - SAR Markers
|
||||
*Screenshot showing map with various SAR markers (person, fire, staging area) and color coding*
|
||||
|
||||
### Map - Drawing Tools
|
||||
*Screenshot showing map with line and rectangle drawings, color palette, and toolbar*
|
||||
|
||||
### Map - Offline Layers
|
||||
*Screenshot showing different map layers (street, topographic, satellite) selection*
|
||||
|
||||
### Settings
|
||||
*Screenshot showing connection status, radio parameters, and location tracking settings*
|
||||
|
||||
### Contact Details
|
||||
*Screenshot showing detailed contact information, telemetry data, and message history*
|
||||
|
||||
---
|
||||
|
||||
**Ready to experience off-grid communication?** Connect your radio and join the mesh network today.
|
||||
897
CLAUDE.md
897
CLAUDE.md
@@ -1,897 +0,0 @@
|
||||
# CLAUDE.md - MeshCore SAR Technical Reference
|
||||
|
||||
AI assistant guide for the MeshCore SAR Flutter application.
|
||||
|
||||
## Table of Contents
|
||||
1. [Critical Development Rules](#critical-development-rules)
|
||||
2. [Quick Reference](#quick-reference)
|
||||
3. [Project Structure](#project-structure)
|
||||
4. [Protocol Reference](#protocol-reference)
|
||||
5. [Architecture](#architecture)
|
||||
6. [Common Tasks](#common-tasks)
|
||||
7. [Build & Troubleshooting](#build--troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Critical Development Rules
|
||||
|
||||
### Flutter Process Management
|
||||
**NEVER run or kill Flutter processes:**
|
||||
- ❌ DO NOT execute `flutter run` command
|
||||
- ❌ DO NOT kill Flutter processes (`pkill flutter`, `killall flutter`)
|
||||
- ✅ User manages Flutter development server - only make code changes
|
||||
- ✅ Hot reload happens automatically when files are saved
|
||||
|
||||
### Key Architecture Constraints
|
||||
- **Echo Detection**: Uses DJB2-style hash (NO crypto dependency needed)
|
||||
- **Channel Messages**: NO ACKs (fire-and-forget flood routing)
|
||||
- **Direct Messages**: Automatic ACKs via `PUSH_CODE_SEND_CONFIRMED`
|
||||
- **SAR Markers**: MUST be sent to rooms, NOT public channel
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Project**: Flutter Mobile App (iOS 13+, Android API 21+)
|
||||
**Architecture**: Provider pattern + BLE communication
|
||||
**Protocol**: MeshCore BLE Companion Radio (Little Endian)
|
||||
**Repository**: https://github.com/meshcore-dev/meshcore.js
|
||||
|
||||
### BLE UUIDs
|
||||
```
|
||||
Service: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E
|
||||
RX (write): 6E400002-B5A3-F393-E0A9-E50E24DCCA9E
|
||||
TX (notify): 6E400003-B5A3-F393-E0A9-E50E24DCCA9E
|
||||
```
|
||||
|
||||
### Key Dependencies
|
||||
```yaml
|
||||
flutter_blue_plus: ^2.0.0 # BLE communication
|
||||
flutter_map: ^8.2.2 # Mapping + WMS support
|
||||
provider: ^6.1.0 # State management
|
||||
geolocator: ^14.0.2 # GPS tracking
|
||||
proj4dart: ^2.1.0 # Coordinate transformations (EPSG:3794)
|
||||
flutter_map_tile_caching: ^10.1.1 # Offline tile caching
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── l10n/ # Internationalization (en, hr, sl)
|
||||
│ ├── app_localizations.dart # Generated (DO NOT EDIT)
|
||||
│ └── app_*.arb # Translation files
|
||||
├── models/ # Data models
|
||||
│ ├── contact.dart, message.dart, sar_marker.dart
|
||||
│ ├── map_drawing.dart # Drawing shapes
|
||||
│ └── sent_message_tracker.dart # Echo detection
|
||||
├── services/ # Business logic
|
||||
│ ├── meshcore_ble_service.dart # BLE coordinator
|
||||
│ ├── protocol/ # Frame parsing & building
|
||||
│ ├── ble/ # Connection, commands, responses
|
||||
│ ├── location_tracking_service.dart # GPS + broadcast
|
||||
│ ├── map_marker_service.dart # Marker generation
|
||||
│ ├── tile_cache_service.dart # Offline tile caching (WMS + standard)
|
||||
│ └── validation_service.dart # Form validation
|
||||
├── providers/ # State management
|
||||
│ ├── connection_provider.dart # BLE state
|
||||
│ ├── contacts_provider.dart # Contact list
|
||||
│ ├── messages_provider.dart # Messages + SAR
|
||||
│ ├── map_provider.dart # Map navigation
|
||||
│ ├── drawing_provider.dart # Map drawings
|
||||
│ └── app_provider.dart # Coordinator
|
||||
├── screens/ # UI screens
|
||||
│ └── (home, messages, contacts, map, settings, etc.)
|
||||
├── widgets/ # Reusable components
|
||||
│ ├── map_markers.dart
|
||||
│ └── map/ # Map-specific widgets
|
||||
└── utils/ # Utilities
|
||||
├── sar_message_parser.dart
|
||||
├── drawing_message_parser.dart
|
||||
└── slovenian_crs.dart # EPSG:3794 CRS for WMS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protocol Reference
|
||||
|
||||
### Message Formats
|
||||
|
||||
#### SAR Marker Format
|
||||
```
|
||||
S:<emoji>:<latitude>,<longitude>:<optional_message>
|
||||
|
||||
Emojis:
|
||||
🧑 or 👤 → Found Person
|
||||
🔥 → Fire Location
|
||||
🏕️ or ⛺ → Staging Area
|
||||
|
||||
Examples:
|
||||
S:🧑:37.7749,-122.4194
|
||||
S:🔥:40.7128,-74.0060:Large wildfire spreading rapidly
|
||||
```
|
||||
|
||||
#### Map Drawing Format
|
||||
```
|
||||
D:<json>
|
||||
|
||||
Line: D:{"t":0,"c":0,"p":[lat1,lon1,lat2,lon2]}
|
||||
Rectangle: D:{"t":1,"c":1,"b":[topLat,topLon,botLat,botLon]}
|
||||
|
||||
Fields:
|
||||
t = type (0=line, 1=rectangle)
|
||||
c = color index (0-7: red,blue,green,yellow,orange,purple,pink,cyan)
|
||||
p = points array (flat)
|
||||
b = bounds array (rectangles only)
|
||||
```
|
||||
|
||||
#### Cayenne LPP Format
|
||||
```
|
||||
[Channel][Type][Data...]
|
||||
|
||||
Types:
|
||||
0x88 (136) → GPS: lat/lon/alt (int32/10000, int32/10000, int32/100)
|
||||
0x67 (103) → Temperature (int16/10 for °C)
|
||||
0x02 (2) → Analog Input (uint16/100 for volts/battery)
|
||||
```
|
||||
|
||||
### Command Quick Reference
|
||||
|
||||
| Code | Command | Response | Description |
|
||||
|------|---------|----------|-------------|
|
||||
| 1 | CMD_APP_START | RESP_CODE_SELF_INFO (5) | First command after connection |
|
||||
| 2 | CMD_SEND_TXT_MSG | RESP_CODE_SENT (6) | Send DM to contact (with ACK) |
|
||||
| 3 | CMD_SEND_CHANNEL_TXT_MSG | RESP_CODE_SENT (6) | Broadcast to channel (NO ACK) |
|
||||
| 4 | CMD_GET_CONTACTS | RESP_CODE_CONTACTS_START (2) | Sync contact list |
|
||||
| 10 | CMD_SYNC_NEXT_MESSAGE | RESP_CODE_CONTACT_MSG_RECV (7) | Pull next message from queue |
|
||||
| 22 | CMD_DEVICE_QUERY | RESP_CODE_DEVICE_INFO (13) | Get device firmware/hardware info |
|
||||
| 26 | CMD_SEND_LOGIN | PUSH_CODE_LOGIN_SUCCESS (0x85) | Login to room server |
|
||||
|
||||
### Push Notifications (Async)
|
||||
|
||||
| Code | Name | Purpose |
|
||||
|------|------|---------|
|
||||
| 0x80 | PUSH_CODE_ADVERT | New advertisement received |
|
||||
| 0x82 | PUSH_CODE_SEND_CONFIRMED | Message ACK received (DMs only) |
|
||||
| 0x83 | PUSH_CODE_MSG_WAITING | New message in queue → sync it |
|
||||
| 0x88 | PUSH_CODE_LOG_RX_DATA | **Raw packet capture (always-on diagnostic)** |
|
||||
| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Room login successful |
|
||||
| 0x86 | PUSH_CODE_LOGIN_FAIL | Room login failed |
|
||||
|
||||
### Constants
|
||||
|
||||
#### Contact Types (ADV_TYPE)
|
||||
```
|
||||
0 = ADV_TYPE_NONE # Unknown/invalid
|
||||
1 = ADV_TYPE_CHAT # Team member (shown on map)
|
||||
2 = ADV_TYPE_REPEATER # Network repeater
|
||||
3 = ADV_TYPE_ROOM # Communication room/server
|
||||
```
|
||||
|
||||
#### Message Types (TXT_TYPE)
|
||||
```
|
||||
0 = TXT_TYPE_PLAIN # Plain text
|
||||
1 = TXT_TYPE_CLI_DATA # CLI command
|
||||
2 = TXT_TYPE_SIGNED_PLAIN # Text + 4-byte pubkey signature
|
||||
```
|
||||
|
||||
#### Error Codes (ERR_CODE)
|
||||
```
|
||||
1 = ERR_CODE_UNSUPPORTED_CMD
|
||||
2 = ERR_CODE_NOT_FOUND
|
||||
3 = ERR_CODE_TABLE_FULL
|
||||
4 = ERR_CODE_BAD_STATE
|
||||
5 = ERR_CODE_FILE_IO_ERROR
|
||||
6 = ERR_CODE_ILLEGAL_ARG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Provider Hierarchy
|
||||
```
|
||||
MultiProvider
|
||||
├── ConnectionProvider # BLE connection state
|
||||
├── ContactsProvider # Contact list management
|
||||
├── MessagesProvider # Messages + SAR markers
|
||||
├── MapProvider # Map navigation state
|
||||
├── DrawingProvider # Map drawing state
|
||||
└── AppProvider # Coordinator (wires everything)
|
||||
```
|
||||
|
||||
### Event Flow
|
||||
```
|
||||
BLE Device → MeshCoreBleService → ConnectionProvider → AppProvider
|
||||
↓
|
||||
ContactsProvider
|
||||
MessagesProvider
|
||||
DrawingProvider
|
||||
↓
|
||||
UI
|
||||
```
|
||||
|
||||
### BLE Keepalive for iOS Background Mode
|
||||
|
||||
**Purpose**: Dual-purpose timer that prevents iOS from terminating BLE connections AND provides fallback message syncing.
|
||||
|
||||
**Implementation** (`lib/services/meshcore_ble_service.dart:654-681`):
|
||||
- Timer sends `syncNextMessage()` command every 20 seconds
|
||||
- Automatically starts when connected, stops when disconnected
|
||||
- Logs: "💚 [BLE] Keepalive: Connection maintained & messages synced"
|
||||
- Handles errors gracefully (iOS may throttle commands temporarily)
|
||||
|
||||
**Dual Purpose**:
|
||||
1. **Connection Keepalive**: iOS aggressively kills idle BLE connections to save battery. Periodic commands signal active usage and prevent ~30 second disconnection timeout.
|
||||
2. **Fallback Message Sync**: Provides redundancy when `PUSH_CODE_MSG_WAITING` (0x83) push notifications fail to trigger. Device responds with `RESP_CODE_NO_MORE_MSG` if queue is empty.
|
||||
|
||||
**Connection Flow**:
|
||||
- Upon connection, `_sendDeviceQuery()` immediately calls `syncNextMessage()` to fetch any messages received while disconnected
|
||||
- Keepalive timer then continues periodic syncing for reliability
|
||||
|
||||
**Technical Notes**:
|
||||
- TX characteristic (6E400003) is notify-only, not readable
|
||||
- RX characteristic (6E400002) is write-only
|
||||
- Uses `CMD_SYNC_NEXT_MESSAGE` (10) which is idempotent and safe to call repeatedly
|
||||
|
||||
**Alternative**: Location broadcasts also keep connection alive, but keepalive provides redundancy for stationary scenarios and ensures message delivery.
|
||||
|
||||
### Contact Path Status
|
||||
|
||||
**outPathLen** indicates routing mode:
|
||||
- **-1 (0xFF)**: Path unknown → **Flood mode** (broadcast to all neighbors)
|
||||
- **0**: Direct connection → **Direct mode** (zero hops, best quality)
|
||||
- **1-64**: Multi-hop path → **Direct mode** (uses learned routing)
|
||||
|
||||
**Map Display**: Only `ContactType.chat` contacts with valid GPS coordinates shown.
|
||||
|
||||
### Channels vs. Rooms
|
||||
|
||||
**Channels** (numeric identifiers):
|
||||
- Channel 0 = "Public Channel" (default broadcast)
|
||||
- **Ephemeral** - messages NOT persisted
|
||||
- Uses `CMD_SEND_CHANNEL_TXT_MSG` (3)
|
||||
- **NO ACKs** - fire-and-forget flood routing
|
||||
- Pre-configured with secret: `8b3387e9c5cdea6ac9e5edbaa115cd72` (hex)
|
||||
|
||||
**Rooms** (ADV_TYPE_ROOM contacts):
|
||||
- Named contacts with public keys
|
||||
- **Persistent storage** on room server
|
||||
- Uses `CMD_SEND_TXT_MSG` (2) with room's public key
|
||||
- **Automatic ACKs** when messages stored
|
||||
- Login via `CMD_SEND_LOGIN` (26) to receive stored messages
|
||||
|
||||
**SAR Routing**: SAR markers MUST go to rooms for reliable delivery.
|
||||
|
||||
### Room Login Protocol
|
||||
|
||||
```
|
||||
1. Send CMD_SEND_LOGIN (26)
|
||||
↓
|
||||
2. Radio generates sender_timestamp & sync_since
|
||||
↓
|
||||
3. Room validates password, stores sync_since
|
||||
↓
|
||||
4. Receive PUSH_CODE_LOGIN_SUCCESS (0x85) or PUSH_CODE_LOGIN_FAIL (0x86)
|
||||
↓
|
||||
5. Room auto-pushes messages (1200ms intervals)
|
||||
↓
|
||||
6. Receive PUSH_CODE_MSG_WAITING (0x83) → call CMD_SYNC_NEXT_MESSAGE (10)
|
||||
```
|
||||
|
||||
**CRITICAL**: Do NOT call `syncAllMessages()` after login. Wait for push notifications.
|
||||
|
||||
---
|
||||
|
||||
## Echo Detection Feature
|
||||
|
||||
### Overview
|
||||
**Status**: ✅ Fully implemented and production-ready
|
||||
**Purpose**: Detect when broadcast messages are rebroadcast by mesh nodes
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Packet Identification** (via `PUSH_CODE_LOG_RX_DATA` 0x88):
|
||||
```
|
||||
Packet Structure (PAYLOAD_TYPE_GRP_TXT 0x05):
|
||||
[Byte 0] = Header (route + payload type + version)
|
||||
[Byte 1] = Path length
|
||||
[Byte 2] = Sender's node hash (first byte of public key) ✅
|
||||
[Byte 3+] = Rest of path + encrypted payload
|
||||
```
|
||||
|
||||
2. **On Connection**:
|
||||
- Receive `RESP_CODE_SELF_INFO` with our public key
|
||||
- Extract **our node hash** (byte 0 of public key)
|
||||
- Store for packet matching
|
||||
|
||||
3. **Sending a Message**:
|
||||
- Call `trackSentMessage(messageId)` when user sends channel message
|
||||
- Status = "pending" (waiting for packet capture)
|
||||
|
||||
4. **Packet Capture** (50-200ms later):
|
||||
- Radio sends `PUSH_CODE_LOG_RX_DATA` with raw packet
|
||||
- Extract sender hash from packet[2]
|
||||
- If sender hash == our node hash → **This is our packet!**
|
||||
- Calculate DJB2-style hash of entire packet (no crypto dependency)
|
||||
- Store tracker by packet hash for echo detection
|
||||
|
||||
5. **Echo Detection**:
|
||||
- Future `PUSH_CODE_LOG_RX_DATA` packets arrive
|
||||
- Calculate packet hash and lookup in tracker map (O(1))
|
||||
- If match found → **Echo detected!** Increment counter
|
||||
- Track SNR/RSSI signature for path diversity
|
||||
- UI auto-updates to show "Rebroadcast by X nodes"
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**Files**:
|
||||
- `lib/models/sent_message_tracker.dart` - Tracker model
|
||||
- `lib/models/message.dart` - echoCount, firstEchoAt fields
|
||||
- `lib/services/ble/ble_response_handler.dart` - Detection engine
|
||||
- `lib/services/meshcore_ble_service.dart` - Callback wiring
|
||||
- `lib/providers/connection_provider.dart` - Provider callback
|
||||
- `lib/providers/messages_provider.dart` - handleMessageEcho()
|
||||
|
||||
**Performance**:
|
||||
- Packet ID: O(1) - byte comparison at offset 2
|
||||
- Hash calc: O(n) where n = packet length (~38-200 bytes)
|
||||
- Echo lookup: O(1) via HashMap
|
||||
- Memory: ~150 bytes/message, max 100 messages = ~15KB
|
||||
- TTL: 5-minute expiry, auto-cleanup
|
||||
|
||||
**Limitations**:
|
||||
- Echo count ≠ exact receiver count (one node can echo multiple times)
|
||||
- Only detects echoes while app connected
|
||||
- Network topology dependent (dense networks → more echoes)
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New BLE Command
|
||||
|
||||
1. Add code to `lib/services/meshcore_constants.dart`
|
||||
2. Build frame in `lib/services/protocol/frame_builder.dart`
|
||||
3. Add API method in `lib/services/meshcore_ble_service.dart`
|
||||
4. Parse response in `lib/services/protocol/frame_parser.dart`
|
||||
5. Handle in `lib/services/ble/ble_response_handler.dart`
|
||||
6. Add callback in `lib/services/meshcore_ble_service.dart`
|
||||
|
||||
### Adding a New SAR Marker Type
|
||||
|
||||
1. Update enum in `lib/models/sar_marker.dart`
|
||||
2. Add parser logic in `lib/utils/sar_message_parser.dart`
|
||||
3. Add color mapping in `lib/widgets/map_markers.dart`
|
||||
4. Update handling in `lib/providers/messages_provider.dart`
|
||||
|
||||
### Adding Localized Strings
|
||||
|
||||
1. **Add to `lib/l10n/app_en.arb`**:
|
||||
```json
|
||||
{
|
||||
"myNewString": "My new text",
|
||||
"@myNewString": {
|
||||
"description": "What this string is for"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add translations to `app_hr.arb` and `app_sl.arb`**
|
||||
|
||||
3. **Generate**: `flutter gen-l10n`
|
||||
|
||||
4. **Use in code**:
|
||||
```dart
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
Text(AppLocalizations.of(context)!.myNewString)
|
||||
```
|
||||
|
||||
**IMPORTANT**: Always use relative import path `'../l10n/app_localizations.dart'`
|
||||
|
||||
### Importing/Exporting Map Tiles
|
||||
|
||||
The app supports importing and exporting cached map tiles using the `flutter_map_tile_caching` library's archive format (`.fmtc` files).
|
||||
|
||||
**Export Workflow**:
|
||||
1. Navigate to Map Management screen
|
||||
2. Tap "Export Tiles to File"
|
||||
3. Archive is created in temporary directory with gzip compression
|
||||
4. System share sheet appears (iOS/Android)
|
||||
5. Choose where to save: Files app, email, cloud storage, etc.
|
||||
6. File named: `meshcore_tiles_<timestamp>.fmtc`
|
||||
|
||||
**Import Workflow**:
|
||||
1. Navigate to Map Management screen
|
||||
2. Tap "Import Tiles from File"
|
||||
3. Select `.fmtc` archive file
|
||||
4. Tiles are merged with existing cache
|
||||
5. Cache statistics refreshed automatically
|
||||
|
||||
**API Usage**:
|
||||
|
||||
```dart
|
||||
// Export current cache to file
|
||||
final tileCount = await tileCacheService.exportStore(
|
||||
'/path/to/export.fmtc',
|
||||
);
|
||||
|
||||
// Import tiles from archive (with merge strategy)
|
||||
final result = await tileCacheService.importStore(
|
||||
'/path/to/import.fmtc',
|
||||
storeNames: null, // null = import all stores
|
||||
strategy: ImportConflictStrategy.merge, // default: merge
|
||||
);
|
||||
|
||||
// Preview stores in archive before importing
|
||||
final stores = await tileCacheService.listArchiveStores(
|
||||
'/path/to/archive.fmtc',
|
||||
);
|
||||
```
|
||||
|
||||
**Use Cases**:
|
||||
- **Backup**: Export tiles before clearing cache or reinstalling app
|
||||
- **Sharing**: Pre-download maps once, distribute to team devices
|
||||
- **Disaster Recovery**: Restore offline maps after device reset
|
||||
- **Bandwidth Saving**: Reduce cellular data usage by sharing cached tiles
|
||||
|
||||
**Technical Details**:
|
||||
- Archive format: `.fmtc` (FMTC native format)
|
||||
- Compression: gzip (built-in by FMTC)
|
||||
- Import strategy: Merge (tiles combined with existing cache)
|
||||
- Export location: Temporary directory → system share sheet
|
||||
- Import source: User-selected via `file_picker` package
|
||||
- Conflict handling: Automatic merge of tile stores
|
||||
- Cross-platform: Works on Android and iOS using native share mechanisms
|
||||
|
||||
**File**: `lib/services/tile_cache_service.dart` - Export/import methods
|
||||
**UI**: `lib/screens/map_management_screen.dart` - Import/export card
|
||||
|
||||
### Map Drawing Workflow
|
||||
|
||||
```
|
||||
User selects mode → DrawingProvider.setDrawingMode()
|
||||
↓
|
||||
User taps map → DrawingLayer captures touches
|
||||
↓
|
||||
Preview rendered → DrawingProvider.getPreviewDrawing()
|
||||
↓
|
||||
User completes → Saved to DrawingProvider._drawings
|
||||
↓
|
||||
User shares → DrawingToolbar._shareDrawingsToChannel/Room()
|
||||
↓
|
||||
BLE send → ConnectionProvider.sendChannelMessage/sendTextMessage()
|
||||
↓
|
||||
Receiver parses → DrawingMessageParser.parseDrawingMessage()
|
||||
↓
|
||||
Display → DrawingProvider.addReceivedDrawing()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build & Troubleshooting
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Dependencies
|
||||
flutter pub get
|
||||
flutter gen-l10n # After ARB file changes
|
||||
|
||||
# Development
|
||||
flutter run # Debug mode (user controls this)
|
||||
# Hot reload: save file | Hot restart: press 'R' in terminal
|
||||
|
||||
# Code Quality
|
||||
flutter analyze
|
||||
flutter test
|
||||
dart format lib/
|
||||
flutter clean
|
||||
|
||||
# Release
|
||||
flutter build ios --release
|
||||
flutter build ipa
|
||||
flutter build apk --release
|
||||
flutter build appbundle --release
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**BLE Connection Failed**:
|
||||
- Check Bluetooth enabled and permissions granted
|
||||
- Verify device in range (<10m)
|
||||
- Check service UUID matches: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
|
||||
|
||||
**MissingPluginException** (after adding dependencies):
|
||||
```bash
|
||||
cd ios && pod install && cd ..
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
**iOS Pod Install Fails**:
|
||||
```bash
|
||||
cd ios
|
||||
rm Podfile.lock
|
||||
rm -rf Pods/
|
||||
pod install --repo-update
|
||||
cd ..
|
||||
```
|
||||
|
||||
**Android Gradle Timeout** - Add to `android/gradle.properties`:
|
||||
```
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.jvmargs=-Xmx4096m
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
**BLE**:
|
||||
- Buffer partial packets
|
||||
- Throttle telemetry (max 1/sec per contact)
|
||||
- Use `notifyListeners()` sparingly
|
||||
|
||||
**Map**:
|
||||
- Cluster markers if >100 visible
|
||||
- Use `RepaintBoundary` for marker widgets
|
||||
- Implement marker virtualization for large datasets
|
||||
|
||||
**Memory**:
|
||||
- Dispose controllers in `dispose()` methods
|
||||
- Limit message history to 1000 messages
|
||||
- Set tile cache size limits
|
||||
|
||||
---
|
||||
|
||||
## Services Reference
|
||||
|
||||
### LocationTrackingService (Singleton)
|
||||
**Purpose**: GPS tracking for map display and trail recording
|
||||
|
||||
**Callbacks**: `onPositionUpdate`, `onError`, `onBroadcastSent`, `onTrackingStateChanged`
|
||||
|
||||
**Broadcasting**:
|
||||
- **Automatic broadcasting DISABLED** - No automatic position adverts to mesh network
|
||||
- Manual broadcasting available via `broadcastLocationNow()` method
|
||||
- User must explicitly use the advert button to broadcast location
|
||||
- GPS tracking continues for map display, currentPosition tracking, and trail recording
|
||||
|
||||
**File**: `lib/services/location_tracking_service.dart`
|
||||
|
||||
### MapMarkerService (Singleton)
|
||||
**Purpose**: Map marker generation + geodesic calculations
|
||||
|
||||
**Features**:
|
||||
- Pure functions (testable)
|
||||
- Contact markers with battery badge, distance
|
||||
- SAR markers (color-coded by type)
|
||||
- Distance/bearing calculations (Haversine)
|
||||
- "Time ago" formatting
|
||||
|
||||
**File**: `lib/services/map_marker_service.dart` (518 lines)
|
||||
|
||||
### ValidationService (Singleton)
|
||||
**Purpose**: Form validation + input parsing
|
||||
|
||||
**Returns**: Structured `ValidationResult<T>` or `ParseResult<T>`
|
||||
|
||||
**Validates**:
|
||||
- Coordinates (lat: -90 to +90, lon: -180 to +180)
|
||||
- Radio params (freq: 137-1020 MHz, bw: 7.8-500 kHz, sf: 5-12, cr: 5-8, tx: -9 to +22 dBm)
|
||||
- Text/names, zoom levels (0-19)
|
||||
|
||||
**File**: `lib/services/validation_service.dart` (511 lines)
|
||||
|
||||
---
|
||||
|
||||
## Map Implementation
|
||||
|
||||
### Tile Layers
|
||||
1. **OpenStreetMap** (default) - Max zoom 19
|
||||
2. **OpenTopoMap** - Max zoom 17, topographic
|
||||
3. **ESRI World Imagery** - Max zoom 19, satellite
|
||||
|
||||
### WMS Support
|
||||
**Purpose**: Integration with Web Map Service (WMS) providers for specialized mapping data
|
||||
|
||||
**Slovenian CRS (EPSG:3794)**:
|
||||
- Projection: Transverse Mercator (Slovenia 1996 / Slovene National Grid)
|
||||
- Ellipsoid: GRS80
|
||||
- Usage: Slovenian government WMS/WMTS services (prostor.zgs.gov.si)
|
||||
- Zoom levels: 0-15 (GeoWebCache tile matrix)
|
||||
- Bounds: X: 373217.65-695777.65m, Y: 31118.30-246158.30m
|
||||
- Origin: Top-left (373217.65, 246158.30)
|
||||
- Resolutions: Calculated from scale denominators (420m/px at zoom 0 to 0.028m/px at zoom 15)
|
||||
- **Language Filter**: Only shown when app language is Slovenian (sl) or Croatian (hr)
|
||||
|
||||
**Tile Caching**:
|
||||
- All WMS layers (base + overlays) use `flutter_map_tile_caching`
|
||||
- Cache strategy: `cacheFirst` (offline-first with 30-day validity)
|
||||
- Same caching infrastructure as standard tile layers
|
||||
|
||||
**Files**:
|
||||
- `lib/utils/slovenian_crs.dart` - EPSG:3794 CRS definition
|
||||
- `lib/services/tile_cache_service.dart` - getTileProviderForWms() method
|
||||
|
||||
**Example**:
|
||||
```dart
|
||||
import 'package:meshcore_sar_app/utils/slovenian_crs.dart';
|
||||
|
||||
// All WMS layers automatically use the cached tile provider
|
||||
TileLayer(
|
||||
wmsOptions: WMSTileLayerOptions(
|
||||
baseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?',
|
||||
layers: ['pregledovalnik:DOF_2024'],
|
||||
format: 'image/jpeg',
|
||||
crs: slovenianCrs,
|
||||
),
|
||||
tileProvider: tileCacheService.getTileProviderForWms(layer),
|
||||
)
|
||||
```
|
||||
|
||||
### WMS Implementation Details
|
||||
|
||||
#### Overview
|
||||
The app integrates Slovenian government WMS (Web Map Service) layers using a custom EPSG:3794 coordinate reference system. This enables high-resolution aerial imagery and specialized overlays (cadastral parcels, forest roads) for SAR operations in Slovenia.
|
||||
|
||||
**Language-Based Filtering**: WMS layers are only shown to users when the app language is set to Slovenian (sl) or Croatian (hr), since these layers only cover Slovenia geographically and are irrelevant to users in other regions.
|
||||
|
||||
#### Architecture
|
||||
|
||||
**Layer Types**:
|
||||
1. **Base Layer**: Slovenian Aerial Imagery 2024 (DOF_2024)
|
||||
- Source: `https://prostor.zgs.gov.si/geowebcache/service/wms`
|
||||
- Format: JPEG (better compression for aerial photos)
|
||||
- Transparency: False (opaque base layer)
|
||||
- Max zoom: 15 (GeoWebCache limit)
|
||||
|
||||
2. **Overlay Layers**: Cadastral Parcels, Forest Roads
|
||||
- Format: PNG (supports transparency)
|
||||
- Transparency: True (overlays on base layer)
|
||||
- Max zoom: 19
|
||||
|
||||
**Coordinate System (EPSG:3794)**:
|
||||
- Official name: Slovenia 1996 / Slovene National Grid
|
||||
- Projection: Transverse Mercator
|
||||
- Parameters: `+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000 +ellps=GRS80`
|
||||
- Why needed: Slovenian government services use this instead of standard Web Mercator (EPSG:3857)
|
||||
|
||||
**Tile Grid Alignment**:
|
||||
The critical challenge with WMS layers is aligning the client-side tile grid with the server's GeoWebCache configuration. Misalignment causes 400 Bad Request errors.
|
||||
|
||||
**Correct Configuration** (`lib/utils/slovenian_crs.dart`):
|
||||
```dart
|
||||
// Origin MUST match WMTS TileMatrixSet TopLeftCorner
|
||||
final origin = Point<double>(373217.6542445397, 246158.298050262);
|
||||
|
||||
// Bounds MUST match WMS capabilities extent
|
||||
final bounds = Rect.fromLTRB(
|
||||
373217.65, // min X (west)
|
||||
31118.30, // min Y (south)
|
||||
695777.65, // max X (east)
|
||||
246158.30, // max Y (north)
|
||||
);
|
||||
|
||||
// Resolutions MUST be calculated from scale denominators
|
||||
// Formula: resolution = scaleDenominator * 0.00028 (OGC standard)
|
||||
final resolutions = [
|
||||
420.0, // Zoom 0: 1,500,000 * 0.00028
|
||||
280.0, // Zoom 1: 1,000,000 * 0.00028
|
||||
// ... through zoom 15
|
||||
];
|
||||
```
|
||||
|
||||
**How to Get Correct Values**:
|
||||
1. Query WMTS GetCapabilities:
|
||||
```bash
|
||||
curl "https://prostor.zgs.gov.si/geowebcache/service/wmts?REQUEST=GetCapabilities&SERVICE=WMTS"
|
||||
```
|
||||
2. Find `<TileMatrixSet>` for EPSG:3794
|
||||
3. Extract `<TopLeftCorner>` (origin)
|
||||
4. Extract `<ScaleDenominator>` for each `<TileMatrix>` (convert to resolutions)
|
||||
5. Query WMS GetCapabilities for bounds:
|
||||
```bash
|
||||
curl "https://prostor.zgs.gov.si/geowebcache/service/wms?REQUEST=GetCapabilities&SERVICE=WMS"
|
||||
```
|
||||
|
||||
#### Caching Strategy
|
||||
|
||||
**Implementation** (`lib/services/tile_cache_service.dart`):
|
||||
```dart
|
||||
FMTCTileProvider getTileProviderForWms(MapLayer layer) {
|
||||
return _store.getTileProvider(
|
||||
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
|
||||
cachedValidDuration: const Duration(days: 30),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
1. **Cache First**: Check local cache before network request
|
||||
2. **30-Day Validity**: Tiles expire after 30 days (suitable for aerial imagery that updates infrequently)
|
||||
3. **Automatic Caching**: All viewed tiles automatically saved to ObjectBox database
|
||||
4. **Offline Support**: Cached tiles available when device offline
|
||||
|
||||
**Storage Location**:
|
||||
- Backend: ObjectBox (embedded database)
|
||||
- Store name: 'meshcore_tiles' (shared with standard tile layers)
|
||||
- Format: Binary tile data + metadata (URL, timestamp, headers)
|
||||
|
||||
#### Usage in Map
|
||||
|
||||
**Language Filtering** (`lib/screens/map_tab.dart`):
|
||||
|
||||
The following UI elements are only visible when `localeName == 'sl' || localeName == 'hr'`:
|
||||
1. **Slovenian Aerial 2024 layer** in the base layer selector
|
||||
2. **Cadastral Parcels overlay toggle** in map options
|
||||
3. **Forest Roads overlay toggle** in map options
|
||||
4. **WMS Overlays section divider** and header
|
||||
|
||||
```dart
|
||||
// Implementation pattern
|
||||
final locale = AppLocalizations.of(context)!.localeName;
|
||||
if (locale == 'sl' || locale == 'hr') {
|
||||
// Show WMS layer option
|
||||
ListTile(
|
||||
title: Text(_slovenianAerialLayer.name),
|
||||
// ...
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This ensures users in other regions (English, German, French, Spanish, Italian) don't see geographically irrelevant layers.
|
||||
|
||||
**Base Layer** (`lib/screens/map_tab.dart`):
|
||||
```dart
|
||||
if (_currentLayer.isWms && _currentLayer.crs != null) {
|
||||
TileLayer(
|
||||
wmsOptions: WMSTileLayerOptions(
|
||||
baseUrl: _currentLayer.wmsBaseUrl!,
|
||||
layers: _currentLayer.wmsLayers ?? [],
|
||||
format: _currentLayer.wmsFormat ?? 'image/jpeg',
|
||||
crs: _currentLayer.crs!, // EPSG:3794
|
||||
),
|
||||
tileProvider: _tileCache.getTileProviderForWms(_currentLayer),
|
||||
maxZoom: _currentLayer.maxZoom, // 15 for Slovenian layers
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Map Options**:
|
||||
```dart
|
||||
MapOptions(
|
||||
// CRITICAL: Use layer's CRS, not default EPSG:3857
|
||||
crs: _currentLayer.crs ?? const Epsg3857(),
|
||||
// ... other options
|
||||
)
|
||||
```
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**RangeError: Invalid value: Not in inclusive range 0..15: 16**:
|
||||
- **Cause**: Map zoom level exceeds the number of resolutions defined in Slovenian CRS
|
||||
- **Fix**: Zoom level is automatically clamped when switching to WMS layers
|
||||
- **Implementation**:
|
||||
- Layer switching clamps zoom to `layer.maxZoom` if current zoom exceeds it
|
||||
- Layer loading from settings clamps `_savedMapZoom` to layer's maximum
|
||||
- Prevents crash when switching from high-zoom layer (19+) to WMS layer (15 max)
|
||||
|
||||
**400 Bad Request Errors**:
|
||||
- **Cause**: Tile grid misalignment (wrong origin, bounds, or resolutions)
|
||||
- **Fix**: Verify values match WMTS GetCapabilities exactly
|
||||
- **Debug**: Check WMS URL in error logs for out-of-bounds coordinates
|
||||
|
||||
**Tiles Not Caching**:
|
||||
- **Cause**: Using wrong tile provider (e.g., NetworkTileProvider instead of FMTC)
|
||||
- **Fix**: Ensure `getTileProviderForWms()` is used, not `NetworkTileProvider()` or custom providers
|
||||
- **Verify**: Check `tile_cache_service.dart:75` is being called
|
||||
|
||||
**Layer Not Appearing**:
|
||||
- **Cause 1**: Layer name mismatch (e.g., `DOF_2024` vs `pregledovalnik:DOF_2024`)
|
||||
- **Cause 2**: Wrong CRS in MapOptions (using EPSG:3857 instead of EPSG:3794)
|
||||
- **Fix**: Verify layer name in WMS GetCapabilities, ensure `crs: _currentLayer.crs` in MapOptions
|
||||
|
||||
**Performance Issues**:
|
||||
- **Issue**: Slow tile loading on first view
|
||||
- **Expected**: WMS tile generation is slower than pre-rendered tiles (100-500ms per tile)
|
||||
- **Mitigation**: Pre-download regions using Map Management screen
|
||||
|
||||
#### Adding New WMS Layers
|
||||
|
||||
1. **Find Layer in GetCapabilities**:
|
||||
```bash
|
||||
curl "https://prostor.zgs.gov.si/geowebcache/service/wms?REQUEST=GetCapabilities" | grep "<Name>"
|
||||
```
|
||||
|
||||
2. **Add to MapLayer** (`lib/models/map_layer.dart`):
|
||||
```dart
|
||||
static MapLayer getMyNewLayer(Crs slovenianCrs) {
|
||||
return MapLayer(
|
||||
type: MapLayerType.wmsBase, // or create new enum value
|
||||
name: 'My New Layer',
|
||||
urlTemplate: '', // Not used for WMS
|
||||
attribution: '© Data Provider',
|
||||
maxZoom: 15, // Match GeoWebCache capability
|
||||
isWms: true,
|
||||
wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?',
|
||||
wmsLayers: const ['workspace:layername'],
|
||||
wmsFormat: 'image/png', // or 'image/jpeg'
|
||||
wmsTransparent: true, // true for overlays, false for base
|
||||
crs: slovenianCrs,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
3. **Verify CRS Support**: Ensure layer supports EPSG:3794 in GetCapabilities
|
||||
|
||||
4. **Test**: Check for 400 errors, verify tiles load correctly
|
||||
|
||||
#### Technical Notes
|
||||
|
||||
**Why Not Use WMTS Instead of WMS?**
|
||||
- `flutter_map` has excellent WMS support via `WMSTileLayerOptions`
|
||||
- WMS and WMTS use same GeoWebCache backend (identical tiles)
|
||||
- WMS is simpler to configure (no manual tile URL template)
|
||||
- Caching abstracts the protocol difference
|
||||
|
||||
**Proj4dart Integration**:
|
||||
- Handles coordinate transformation from EPSG:4326 (GPS) to EPSG:3794 (map)
|
||||
- Projection registered once at app startup: `proj4.Projection.add('EPSG:3794', ...)`
|
||||
- Flutter Map uses it automatically when `crs: slovenianCrs` is set
|
||||
|
||||
**Memory Considerations**:
|
||||
- Each CRS instance stores transformation matrices and bounds
|
||||
- Use singleton pattern: `final Crs slovenianCrs = getSlovenianCrs();`
|
||||
- Shared across all WMS layers
|
||||
|
||||
### Offline Caching
|
||||
- Backend: `flutter_map_tile_caching` + ObjectBox
|
||||
- Behavior: `CacheBehavior.cacheFirst`, 30-day validity
|
||||
- Downloads: `RectangleRegion(bounds).download.startForeground()`
|
||||
|
||||
### Marker Types
|
||||
**Team Member**: Blue circle, battery badge, name, distance, tap for details
|
||||
**SAR Event**: Color-coded (green=person, red=fire, orange=staging), time ago, tap for details
|
||||
|
||||
### Navigation Flow
|
||||
```
|
||||
Messages tab → tap SAR marker
|
||||
↓
|
||||
MapProvider.navigateToLocation()
|
||||
↓
|
||||
Switch to Map tab
|
||||
↓
|
||||
MapTab._handleMapNavigation() → animate to location
|
||||
↓
|
||||
MapProvider.clearNavigation()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Flutter Documentation](https://docs.flutter.dev/)
|
||||
- [flutter_blue_plus API](https://pub.dev/documentation/flutter_blue_plus/)
|
||||
- [flutter_map Documentation](https://docs.fleaflet.dev/)
|
||||
- [MeshCore Protocol](https://github.com/meshcore-dev/meshcore.js)
|
||||
- [MeshCore FAQ](https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md)
|
||||
- [Cayenne LPP Specification](https://developers.mydevices.com/cayenne/docs/lora/#lora-cayenne-low-power-payload)
|
||||
- [Provider Package](https://pub.dev/packages/provider)
|
||||
- [EPSG:3794 Reference](https://epsg.io/3794) - Slovenian CRS definition
|
||||
- [Proj4dart Package](https://pub.dev/packages/proj4dart) - Coordinate transformation library
|
||||
- [OGC WMS Specification](https://www.ogc.org/standards/wms) - Web Map Service standard
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **BLE**: No authentication in current protocol - consider encryption for production
|
||||
- **Permissions**: Request minimum required permissions only
|
||||
- **Logging**: No sensitive data in logs
|
||||
- **Network**: HTTPS for all tile sources
|
||||
- **Raw Packets**: `PUSH_CODE_LOG_RX_DATA` exposes all radio traffic (diagnostic feature)
|
||||
@@ -1,211 +0,0 @@
|
||||
# Vector Map Tiles Implementation - Technical Notes
|
||||
|
||||
## Successfully Implemented! ✅
|
||||
|
||||
The vector map tiles with MBTiles support has been successfully implemented and the app builds without errors.
|
||||
|
||||
## Final Package Versions
|
||||
|
||||
```yaml
|
||||
vector_map_tiles: ^9.0.0-beta.8 # flutter_map 8.x compatible!
|
||||
vector_map_tiles_mbtiles: 1.2.1 # from git repository (latest)
|
||||
vector_tile_renderer: ^6.0.0
|
||||
mbtiles: ^0.4.2
|
||||
file_picker: ^8.3.7
|
||||
http: 1.5.0
|
||||
```
|
||||
|
||||
### Why Git Dependency?
|
||||
|
||||
The published version of `vector_map_tiles_mbtiles` on pub.dev doesn't support `vector_map_tiles` v9 beta yet. The git version from the flutter_map_plugins repository is compatible:
|
||||
|
||||
```yaml
|
||||
vector_map_tiles_mbtiles:
|
||||
git:
|
||||
url: https://github.com/josxha/flutter_map_plugins.git
|
||||
path: vector_map_tiles_mbtiles
|
||||
```
|
||||
|
||||
## API Compatibility Issues Resolved
|
||||
|
||||
### 1. Name Collision: Theme
|
||||
|
||||
**Problem**: Both Flutter Material and vector_tile_renderer export a `Theme` class.
|
||||
|
||||
**Solution**: Import vector_tile_renderer with alias:
|
||||
```dart
|
||||
import 'package:vector_tile_renderer/vector_tile_renderer.dart' as vtr;
|
||||
|
||||
// Usage
|
||||
vtr.Theme? _vectorTheme;
|
||||
final theme = vtr.ThemeReader().read(styleJson);
|
||||
```
|
||||
|
||||
### 2. Name Collision: TileLayer
|
||||
|
||||
**Problem**: Both flutter_map and vector_tile_renderer export `TileLayer`.
|
||||
|
||||
**Solution**: Import flutter_map with alias for explicit TileLayer usage:
|
||||
```dart
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:flutter_map/flutter_map.dart'; // Keep non-aliased for other classes
|
||||
|
||||
// Usage
|
||||
flutter_map.TileLayer(...)
|
||||
```
|
||||
|
||||
### 3. MBTiles API Changes
|
||||
|
||||
**Problem**: The `mbtiles` package v0.4.2 changed from Map-based to object-based API.
|
||||
|
||||
**Old API (v0.3.x)**:
|
||||
```dart
|
||||
final metadata = await mbtiles.getMetadata();
|
||||
final name = metadata['name']; // Map access
|
||||
final minZoom = metadata['minzoom'];
|
||||
```
|
||||
|
||||
**New API (v0.4.2)**:
|
||||
```dart
|
||||
final metadata = await mbtiles.getMetadata();
|
||||
final name = metadata.name; // Object property
|
||||
final minZoom = metadata.minZoom?.toInt(); // Returns double?
|
||||
```
|
||||
|
||||
**Key Changes**:
|
||||
- `getMetadata()` returns `MbTilesMetadata` object, not `Map<String, dynamic>`
|
||||
- Properties like `minZoom`, `maxZoom` are now `double?` instead of `int?`
|
||||
- `bounds` is now `MbTilesBounds` object with no direct property access
|
||||
- `type` is now `TileLayerType?` enum instead of `String?`
|
||||
- Some properties removed: `attribution`, `center`, `json`
|
||||
|
||||
**Our Solution**:
|
||||
```dart
|
||||
final metadata = await mbtiles.getMetadata();
|
||||
|
||||
// Convert types appropriately
|
||||
return MbtilesMetadata(
|
||||
name: metadata.name ?? _getFileNameWithoutExtension(file),
|
||||
description: metadata.description,
|
||||
version: metadata.version?.toString(), // double? to String?
|
||||
attribution: null, // Not available in new API
|
||||
bounds: metadata.bounds.toString(), // Object to String
|
||||
center: null, // Not available in new API
|
||||
minZoom: metadata.minZoom?.toInt(), // double? to int?
|
||||
maxZoom: metadata.maxZoom?.toInt(), // double? to int?
|
||||
format: metadata.format,
|
||||
type: metadata.type?.name, // TileLayerType? to String?
|
||||
json: null, // Not available in new API
|
||||
file: file,
|
||||
fileSize: fileSize,
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Type Mismatch: maximumZoom
|
||||
|
||||
**Problem**: `VectorTileLayer.maximumZoom` expects `double`, not `int`.
|
||||
|
||||
**Solution**: Remove `.toInt()` call:
|
||||
```dart
|
||||
VectorTileLayer(
|
||||
theme: _vectorTheme!,
|
||||
tileProviders: TileProviders({...}),
|
||||
maximumZoom: _currentLayer.maxZoom, // Already double
|
||||
)
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── services/
|
||||
│ ├── mbtiles_service.dart (280 lines) - NEW
|
||||
│ └── tile_cache_service.dart (+20 lines)
|
||||
├── models/
|
||||
│ └── map_layer.dart (+40 lines)
|
||||
├── screens/
|
||||
│ ├── map_tab.dart (+50 lines)
|
||||
│ └── map_management_screen.dart (+180 lines)
|
||||
└── l10n/
|
||||
└── app_en.arb (+80 lines)
|
||||
|
||||
Total: ~650 new lines of code
|
||||
```
|
||||
|
||||
## Build Status
|
||||
|
||||
- ✅ iOS: Build successful (28.7MB)
|
||||
- ⏳ Android: Not tested yet
|
||||
- ⏳ Runtime: Not tested with actual MBTiles file
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Before Runtime Testing
|
||||
|
||||
- [x] Code compiles without errors
|
||||
- [x] All imports resolved
|
||||
- [x] API compatibility verified
|
||||
- [ ] Import MBTiles file
|
||||
- [ ] Switch to vector layer
|
||||
- [ ] Verify style loading
|
||||
- [ ] Verify vector rendering
|
||||
- [ ] Test offline mode
|
||||
- [ ] Test file deletion
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **Missing Metadata**: `attribution`, `center`, and `json` fields are not available in mbtiles v0.4.2
|
||||
2. **Bounds Format**: Bounds are stored as string representation of MbTilesBounds object
|
||||
3. **Schema Detection**: Limited to checking description and name for "shortbread" or "openmaptiles" keywords
|
||||
|
||||
### Recommendations for Production
|
||||
|
||||
1. **Add Error Handling**: Wrap vector tile rendering in try-catch to fall back to raster
|
||||
2. **Cache Styles**: Persist downloaded styles to avoid re-downloading
|
||||
3. **Validate MBTiles**: Add file format validation before import
|
||||
4. **Add Tests**: Unit tests for MbtilesService, integration tests for rendering
|
||||
5. **Performance Monitoring**: Track render times and memory usage
|
||||
|
||||
## Quick Start for Testing
|
||||
|
||||
1. **Download Test File**:
|
||||
```bash
|
||||
wget https://geodata.maptiler.download/extracts/osm/v3.11/2020-02-10/europe/osm-2020-02-10-v3.11_europe_slovenia.mbtiles
|
||||
```
|
||||
|
||||
2. **Run App**:
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
3. **Import File**:
|
||||
- Settings → Map Management
|
||||
- Tap "Import MBTiles File"
|
||||
- Select downloaded file
|
||||
|
||||
4. **Switch Layer**:
|
||||
- Map tab → Layers button
|
||||
- Select "Slovenia" (or imported name)
|
||||
- Wait for style download
|
||||
|
||||
5. **Verify**:
|
||||
- Check map renders vector tiles
|
||||
- Test zooming (over-zoom should work)
|
||||
- Test panning
|
||||
- Toggle airplane mode (should still work)
|
||||
|
||||
## Support
|
||||
|
||||
For issues related to:
|
||||
- **Package compatibility**: Check flutter_map_plugins repository
|
||||
- **MBTiles format**: See MBTiles specification
|
||||
- **Vector styles**: Check versatiles.org documentation
|
||||
- **App-specific issues**: See CLAUDE.md and VECTOR_MAPS.md
|
||||
|
||||
## References
|
||||
|
||||
- [flutter_map v8 migration guide](https://docs.fleaflet.dev/)
|
||||
- [vector_map_tiles documentation](https://pub.dev/packages/vector_map_tiles)
|
||||
- [mbtiles package](https://pub.dev/packages/mbtiles)
|
||||
- [MBTiles spec](https://github.com/mapbox/mbtiles-spec)
|
||||
- [Shortbread schema](https://shortbread-tiles.org/)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,578 +0,0 @@
|
||||
# MeshCore Public Channel Message Structure & Detection Research
|
||||
|
||||
## 1. Public Channel Message Creation Flow
|
||||
|
||||
### 1.1 Message Generation (BaseChatMesh::sendGroupMessage)
|
||||
|
||||
File: `/Users/dz0ny/meshcore-sar/MeshCore/src/helpers/BaseChatMesh.cpp` (lines 379-398)
|
||||
|
||||
```cpp
|
||||
bool BaseChatMesh::sendGroupMessage(uint32_t timestamp,
|
||||
mesh::GroupChannel& channel,
|
||||
const char* sender_name,
|
||||
const char* text,
|
||||
int text_len) {
|
||||
uint8_t temp[5+MAX_TEXT_LEN+32];
|
||||
|
||||
// Step 1: Add timestamp (4 bytes, little-endian)
|
||||
memcpy(temp, ×tamp, 4);
|
||||
|
||||
// Step 2: Add txt_type flag (1 byte) - 0 = TXT_TYPE_PLAIN
|
||||
temp[4] = 0;
|
||||
|
||||
// Step 3: Format message as "sender_name: message_text"
|
||||
sprintf((char *)&temp[5], "%s: ", sender_name);
|
||||
char *ep = strchr((char *)&temp[5], 0);
|
||||
int prefix_len = ep - (char *)&temp[5];
|
||||
|
||||
if (text_len + prefix_len > MAX_TEXT_LEN)
|
||||
text_len = MAX_TEXT_LEN - prefix_len;
|
||||
memcpy(ep, text, text_len);
|
||||
ep[text_len] = 0;
|
||||
|
||||
// Step 4: Create encrypted packet
|
||||
auto pkt = createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel, temp, 5 + prefix_len + text_len);
|
||||
if (pkt) {
|
||||
sendFlood(pkt);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Unencrypted data format: `[4-byte timestamp][1-byte txt_type][variable "name: text"]`
|
||||
- txt_type = 0x00 for plain text
|
||||
- Message includes sender name in plaintext
|
||||
- No message ID or checksum in plaintext data
|
||||
|
||||
### 1.2 Packet Encryption (Mesh::createGroupDatagram)
|
||||
|
||||
File: `/Users/dz0ny/meshcore-sar/MeshCore/src/Mesh.cpp` (lines 509-527)
|
||||
|
||||
```cpp
|
||||
Packet* Mesh::createGroupDatagram(uint8_t type, const GroupChannel& channel,
|
||||
const uint8_t* data, size_t data_len) {
|
||||
if (!(type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA))
|
||||
return NULL;
|
||||
if (data_len + 1 + CIPHER_BLOCK_SIZE-1 > MAX_PACKET_PAYLOAD)
|
||||
return NULL;
|
||||
|
||||
Packet* packet = obtainNewPacket();
|
||||
if (packet == NULL) return NULL;
|
||||
|
||||
packet->header = (type << PH_TYPE_SHIFT); // ROUTE_TYPE_* set later
|
||||
|
||||
int len = 0;
|
||||
// Step 1: Add channel hash (1 byte)
|
||||
memcpy(&packet->payload[len], channel.hash, PATH_HASH_SIZE);
|
||||
len += PATH_HASH_SIZE;
|
||||
|
||||
// Step 2: Encrypt plaintext data + add MAC
|
||||
len += Utils::encryptThenMAC(channel.secret, &packet->payload[len],
|
||||
data, data_len);
|
||||
|
||||
packet->payload_len = len;
|
||||
return packet;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Payload structure: `[1-byte channel_hash][2-byte MAC][16+ bytes encrypted data]`
|
||||
- PATH_HASH_SIZE = 1 byte
|
||||
- CIPHER_MAC_SIZE = 2 bytes (V1 protocol)
|
||||
- CIPHER_BLOCK_SIZE = 16 bytes (AES128)
|
||||
- Uses AES128-ECB encryption with HMAC-SHA256 truncated to 2 bytes
|
||||
|
||||
### 1.3 Encryption Algorithm (Utils::encryptThenMAC)
|
||||
|
||||
File: `/Users/dz0ny/meshcore-sar/MeshCore/src/Utils.cpp` (lines 63-72)
|
||||
|
||||
```cpp
|
||||
int Utils::encryptThenMAC(const uint8_t* shared_secret, uint8_t* dest,
|
||||
const uint8_t* src, int src_len) {
|
||||
// Step 1: Encrypt plaintext
|
||||
int enc_len = encrypt(shared_secret, dest + CIPHER_MAC_SIZE, src, src_len);
|
||||
|
||||
// Step 2: Calculate HMAC-SHA256 over ciphertext
|
||||
SHA256 sha;
|
||||
sha.resetHMAC(shared_secret, PUB_KEY_SIZE);
|
||||
sha.update(dest + CIPHER_MAC_SIZE, enc_len);
|
||||
sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, dest, CIPHER_MAC_SIZE);
|
||||
|
||||
return CIPHER_MAC_SIZE + enc_len;
|
||||
}
|
||||
```
|
||||
|
||||
**Encryption Details:**
|
||||
- Plaintext padded with zero bytes to 16-byte block boundary
|
||||
- AES128 in ECB mode (Electronic Code Book)
|
||||
- HMAC-SHA256 truncated to 2 bytes
|
||||
- Order: HMAC-SHA256(SHA256_HMAC(shared_secret, ciphertext)) -> 2 bytes
|
||||
- Shared secret = channel.secret (pre-shared key for the channel)
|
||||
|
||||
### 1.4 Complete Wire Format for Group Message
|
||||
|
||||
```
|
||||
[1 byte] = packet header (type=0x05 PAYLOAD_TYPE_GRP_TXT, route type)
|
||||
[1 byte] = channel_hash (identifies which channel)
|
||||
[2 bytes] = MAC (HMAC-SHA256 truncated to 2 bytes)
|
||||
[16+ bytes] = AES128 encrypted data:
|
||||
[4 bytes] = timestamp (little-endian)
|
||||
[1 byte] = txt_type (0x00 for plain)
|
||||
[variable] = "sender_name: message_text"
|
||||
[0-15 bytes] = zero padding to reach 16-byte boundary
|
||||
```
|
||||
|
||||
**Example for "Alice: Hello":**
|
||||
```
|
||||
Plaintext (13 bytes before padding):
|
||||
00 01 02 03 <- timestamp (example)
|
||||
00 <- txt_type = 0
|
||||
41 6C 69 63 65 3A 20 48 65 6C 6C 6F <- "Alice: Hello"
|
||||
|
||||
After padding to 16 bytes:
|
||||
00 01 02 03 00 41 6C 69 63 65 3A 20 48 65 6C 6C 6F
|
||||
|
||||
After AES128 encryption (16 bytes):
|
||||
[16 random-looking bytes]
|
||||
|
||||
Final packet:
|
||||
[header] [channel_hash] [2-byte MAC] [16-byte ciphertext]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Packet Reception & Decryption Flow
|
||||
|
||||
### 2.1 Receiving Group Messages (Mesh::onRecvPacket)
|
||||
|
||||
File: `/Users/dz0ny/meshcore-sar/MeshCore/src/Mesh.cpp` (lines 196-220)
|
||||
|
||||
```cpp
|
||||
case PAYLOAD_TYPE_GRP_TXT: {
|
||||
int i = 0;
|
||||
uint8_t channel_hash = pkt->payload[i++]; // Extract 1-byte hash
|
||||
|
||||
uint8_t* macAndData = &pkt->payload[i]; // Points to MAC + encrypted data
|
||||
|
||||
if (i + 2 >= pkt->payload_len) {
|
||||
// incomplete data
|
||||
} else if (!_tables->hasSeen(pkt)) { // Check if we've already processed this
|
||||
// Search for all matching channels
|
||||
GroupChannel channels[2];
|
||||
int num = searchChannelsByHash(&channel_hash, channels, 2);
|
||||
|
||||
// Try to decrypt with each matching channel
|
||||
for (int j = 0; j < num; j++) {
|
||||
uint8_t data[MAX_PACKET_PAYLOAD];
|
||||
// Verify MAC, then decrypt
|
||||
int len = Utils::MACThenDecrypt(channels[j].secret, data,
|
||||
macAndData, pkt->payload_len - i);
|
||||
if (len > 0) { // MAC verified - success!
|
||||
onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len);
|
||||
break;
|
||||
}
|
||||
}
|
||||
action = routeRecvPacket(pkt);
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Processing Decrypted Group Data (BaseChatMesh::onGroupDataRecv)
|
||||
|
||||
File: `/Users/dz0ny/meshcore-sar/MeshCore/src/helpers/BaseChatMesh.cpp` (lines 298-310)
|
||||
|
||||
```cpp
|
||||
void BaseChatMesh::onGroupDataRecv(mesh::Packet* packet, uint8_t type,
|
||||
const mesh::GroupChannel& channel,
|
||||
uint8_t* data, size_t len) {
|
||||
uint8_t txt_type = data[4]; // Extract txt_type from decrypted data
|
||||
|
||||
if (type == PAYLOAD_TYPE_GRP_TXT && len > 5 && (txt_type >> 2) == 0) {
|
||||
uint32_t timestamp;
|
||||
memcpy(×tamp, data, 4); // Extract timestamp
|
||||
|
||||
// Null-terminate the message
|
||||
data[len] = 0;
|
||||
|
||||
// Notify UI
|
||||
onChannelMessageRecv(channel, packet, timestamp,
|
||||
(const char *)&data[5]); // Pass message text
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Packet Deduplication & Matching Mechanism
|
||||
|
||||
### 3.1 Packet Hash Calculation
|
||||
|
||||
File: `/Users/dz0ny/meshcore-sar/MeshCore/src/Packet.cpp` (lines 17-26)
|
||||
|
||||
```cpp
|
||||
void Packet::calculatePacketHash(uint8_t* hash) const {
|
||||
SHA256 sha;
|
||||
uint8_t t = getPayloadType();
|
||||
sha.update(&t, 1);
|
||||
|
||||
// Special handling for TRACE packets
|
||||
if (t == PAYLOAD_TYPE_TRACE) {
|
||||
sha.update(&path_len, sizeof(path_len));
|
||||
}
|
||||
|
||||
// Hash includes payload type + entire payload
|
||||
sha.update(payload, payload_len);
|
||||
sha.finalize(hash, MAX_HASH_SIZE); // Truncate to 8 bytes
|
||||
}
|
||||
```
|
||||
|
||||
**Hash = SHA256(payload_type || full_payload) -> 8 bytes**
|
||||
|
||||
### 3.2 Duplicate Detection (MeshTables::hasSeen)
|
||||
|
||||
The `hasSeen()` function maintains a table of recently seen packets:
|
||||
|
||||
- When we **send** a packet: `_tables->hasSeen(packet)` marks it as seen
|
||||
- When we **receive** a packet: check `!_tables->hasSeen(pkt)` to avoid reprocessing
|
||||
- Prevents duplicate processing via different network paths
|
||||
|
||||
**Implementation in Mesh::sendFlood (line 600):**
|
||||
```cpp
|
||||
_tables->hasSeen(packet); // mark this packet as already sent in case
|
||||
// it is rebroadcast back to us
|
||||
```
|
||||
|
||||
**Implementation in Mesh::sendDirect (line 633):**
|
||||
```cpp
|
||||
_tables->hasSeen(packet); // mark this packet as already sent in case
|
||||
// it is rebroadcast back to us
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Echo Detection: Can We Match Sent vs Received Packets?
|
||||
|
||||
### 4.1 What Makes a Packet Unique?
|
||||
|
||||
**Encrypted packets (the wire format) are NOT directly matchable:**
|
||||
- MAC uses HMAC-SHA256 truncated to 2 bytes - collision resistance but NOT deterministic
|
||||
- Ciphertext appears random due to AES128-ECB
|
||||
- Each encryption run produces different ciphertext (due to random key derivation?)
|
||||
|
||||
**Wait - actually they ARE the same:**
|
||||
- AES128-ECB is deterministic: same plaintext + key = same ciphertext
|
||||
- HMAC-SHA256 is deterministic: same data + key = same MAC
|
||||
- **Therefore: Same plaintext + same channel secret = identical encrypted packet**
|
||||
|
||||
### 4.2 How to Match Sent vs Received
|
||||
|
||||
```
|
||||
Sent packet generation:
|
||||
1. User sends: "Alice: Hello World"
|
||||
2. Timestamp T is captured
|
||||
3. Plaintext: [T || 0x00 || "Alice: Hello World"]
|
||||
4. Channel secret S is used
|
||||
5. AES128(S, plaintext) -> ciphertext C
|
||||
6. MAC = HMAC-SHA256(S, C) -> M
|
||||
7. Packet = [channel_hash || M || C]
|
||||
|
||||
If the same packet echoes back:
|
||||
- Exact same plaintext
|
||||
- Exact same channel secret
|
||||
- Exact same AES128 result
|
||||
- Exact same MAC
|
||||
- Exact same final packet
|
||||
```
|
||||
|
||||
### 4.3 Matching Strategy
|
||||
|
||||
**Option 1: Full Packet Comparison (Strongest)**
|
||||
```
|
||||
Store sent packet payload:
|
||||
sent_payload = [channel_hash || MAC || ciphertext]
|
||||
|
||||
When receive PAYLOAD_TYPE_GRP_TXT:
|
||||
if (received_payload == sent_payload) {
|
||||
// This is OUR message echoed back!
|
||||
// Someone received and rebroadcast it
|
||||
}
|
||||
```
|
||||
|
||||
**Option 2: Payload Hash Matching**
|
||||
```
|
||||
Calculate hash:
|
||||
sent_hash = SHA256(PAYLOAD_TYPE_GRP_TXT || full_payload) -> 8 bytes
|
||||
|
||||
The mesh already does this for deduplication!
|
||||
Packet::calculatePacketHash() is used in MeshTables::hasSeen()
|
||||
|
||||
If packet hash matches = guaranteed same packet
|
||||
```
|
||||
|
||||
**Option 3: Plaintext Content Matching (Weakest)**
|
||||
```
|
||||
Store plaintext:
|
||||
timestamp + "Alice: Hello World"
|
||||
|
||||
When receive decrypted plaintext:
|
||||
if (timestamp + sender_name + text) matches {
|
||||
// Likely same message
|
||||
// But doesn't prove it came from us (collision risk)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Matching Challenges & Solutions
|
||||
|
||||
| Challenge | Issue | Solution |
|
||||
|-----------|-------|----------|
|
||||
| **Timestamp uniqueness** | Same timestamp in plaintext | Use `getRTCClock()->getCurrentTimeUnique()` when sending - increases counter if time doesn't advance |
|
||||
| **Sender name collision** | Multiple "Alice"s in mesh | Combine timestamp + sender name + text content for match |
|
||||
| **Text content match** | Same text sent by different user | Timestamp makes it unique (getRTCClock()->getCurrentTimeUnique()) |
|
||||
| **Encrypted packet change** | Doesn't change if plaintext unchanged | AES128-ECB is deterministic - if plaintext same, ciphertext same |
|
||||
| **MAC truncation** | 2-byte MAC seems short | HMAC-SHA256 with shared secret - same data = same MAC, truncation doesn't affect determinism |
|
||||
|
||||
---
|
||||
|
||||
## 5. Flutter App - Packet Interception Points
|
||||
|
||||
### 5.1 BLE Response Handler (ble_response_handler.dart)
|
||||
|
||||
File: `/Users/dz0ny/meshcore-sar/meshcore_sar_app/lib/services/ble/ble_response_handler.dart`
|
||||
|
||||
```dart
|
||||
void _onDataReceived(List<int> data) {
|
||||
// All RX data comes here
|
||||
// Packets are parsed and routed to frame_parser
|
||||
|
||||
// Store packet logs for debugging:
|
||||
final log = BlePacketLog(
|
||||
timestamp: DateTime.now(),
|
||||
direction: PacketDirection.incoming,
|
||||
rawData: Uint8List.fromList(data),
|
||||
responseCode: responseCode,
|
||||
decodedInfo: decodedInfo,
|
||||
);
|
||||
_packetLogs.add(log);
|
||||
}
|
||||
```
|
||||
|
||||
**Access point for intercepting raw packets:**
|
||||
- All RX data (including echoed messages) flows through `_onDataReceived()`
|
||||
- Raw packet data is stored in `_packetLogs`
|
||||
- Can extract and compare encrypted payloads here
|
||||
|
||||
### 5.2 Frame Parser Integration
|
||||
|
||||
File: `/Users/dz0ny/meshcore-sar/meshcore_sar_app/lib/services/protocol/frame_parser.dart`
|
||||
|
||||
The frame parser processes:
|
||||
- PUSH_CODE values
|
||||
- Response codes
|
||||
- Extracts message content from decrypted payloads
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Strategy for Echo Detection
|
||||
|
||||
### 6.1 Store Sent Messages
|
||||
|
||||
```dart
|
||||
// In MessagesProvider or new EchoDetectionService
|
||||
class SentMessageRecord {
|
||||
final DateTime sentTime;
|
||||
final Uint8List encryptedPayload; // [channel_hash || MAC || ciphertext]
|
||||
final String plaintext; // "Alice: Hello"
|
||||
final uint32_t timestamp; // From packet
|
||||
final uint8_t channelHash;
|
||||
final Uint8List mac; // 2 bytes
|
||||
final Uint8List ciphertext; // 16+ bytes
|
||||
|
||||
String get key => '${sentTime.millisecondsSinceEpoch}_${plaintext.hashCode}';
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Intercept Sent Packets
|
||||
|
||||
In `meshcore_ble_service.dart`, before sending:
|
||||
|
||||
```dart
|
||||
// When sendChannelMessage() is called
|
||||
Future<void> sendChannelMessage(String channelName, String messageText) async {
|
||||
// Create message record
|
||||
final record = SentMessageRecord(
|
||||
sentTime: DateTime.now(),
|
||||
plaintext: messageText,
|
||||
// ... other fields
|
||||
);
|
||||
|
||||
// Store for echo detection
|
||||
_sentMessages.add(record);
|
||||
|
||||
// Send via BLE
|
||||
// The BLE layer will encrypt and generate the final packet
|
||||
// We need to intercept AFTER encryption
|
||||
}
|
||||
```
|
||||
|
||||
**Better approach: Intercept at frame builder level**
|
||||
|
||||
In `frame_builder.dart`, capture the encrypted payload:
|
||||
|
||||
```dart
|
||||
Uint8List buildChannelMessage(
|
||||
String channelName,
|
||||
String senderName,
|
||||
String messageText,
|
||||
Uint8List channelSecret,
|
||||
Uint8List channelHash,
|
||||
) {
|
||||
// Existing build logic...
|
||||
final encryptedPayload = [
|
||||
...channelHash,
|
||||
...mac,
|
||||
...ciphertext,
|
||||
];
|
||||
|
||||
// Store for echo detection
|
||||
_sentPackets.add({
|
||||
'timestamp': sentTime,
|
||||
'payload': encryptedPayload,
|
||||
'plaintext': messageText,
|
||||
});
|
||||
|
||||
return Uint8List.fromList(encryptedPayload);
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Detect Echo in Response Handler
|
||||
|
||||
In `ble_response_handler.dart`, when receiving PAYLOAD_TYPE_GRP_TXT:
|
||||
|
||||
```dart
|
||||
void _handleGroupMessage(Uint8List payload) {
|
||||
// payload = [channel_hash || MAC || ciphertext]
|
||||
|
||||
// Check if this matches any sent message
|
||||
for (var sent in _sentPackets) {
|
||||
if (listEquals(sent['payload'], payload)) {
|
||||
// ECHO DETECTED!
|
||||
print('🔄 ECHO: Our message was rebroadcast by other node!');
|
||||
_echoDetectionCallbacks.forEach((cb) => cb(sent['plaintext']));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not an echo - process normally
|
||||
_processNewGroupMessage(payload);
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 Key Insight: Timing
|
||||
|
||||
The echo will arrive **at different times**:
|
||||
- **Sent**: T=0ms
|
||||
- **Echo received**: T=100-5000ms (depending on network/hops)
|
||||
- Time gap confirms it's an echo, not just local reflection
|
||||
|
||||
---
|
||||
|
||||
## 7. Constants Reference
|
||||
|
||||
From `/Users/dz0ny/meshcore-sar/MeshCore/src/MeshCore.h`:
|
||||
|
||||
```cpp
|
||||
#define PUB_KEY_SIZE 32
|
||||
#define CIPHER_KEY_SIZE 16
|
||||
#define CIPHER_BLOCK_SIZE 16
|
||||
#define CIPHER_MAC_SIZE 2 // V1 protocol, truncated HMAC-SHA256
|
||||
#define PATH_HASH_SIZE 1 // Channel hash size
|
||||
#define MAX_PACKET_PAYLOAD 184 // Maximum payload in a packet
|
||||
#define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // 160 bytes
|
||||
```
|
||||
|
||||
Payload type codes:
|
||||
```cpp
|
||||
#define PAYLOAD_TYPE_GRP_TXT 0x05 // Group text message
|
||||
#define PAYLOAD_TYPE_ADVERT 0x04 // Advertisement
|
||||
#define PAYLOAD_TYPE_TXT_MSG 0x02 // Direct text message
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Packet Structure Summary
|
||||
|
||||
### 8.1 Wire Format (Full Packet)
|
||||
|
||||
```
|
||||
[1 byte] PACKET HEADER
|
||||
├─ [2 bits] Route type (0=FLOOD+TRANSPORT, 1=FLOOD, 2=DIRECT, 3=DIRECT+TRANSPORT)
|
||||
├─ [4 bits] Payload type (0x05 for GRP_TXT)
|
||||
└─ [2 bits] Payload version (0=V1)
|
||||
|
||||
[0-4 bytes] TRANSPORT CODES (optional, only if route type = 0 or 3)
|
||||
|
||||
[1 byte] PATH_LEN (or omitted for flood mode)
|
||||
|
||||
[0-64 bytes] PATH (route information)
|
||||
|
||||
[1+ bytes] PAYLOAD (encrypted message)
|
||||
├─ [1 byte] Channel hash
|
||||
├─ [2 bytes] MAC (HMAC-SHA256 truncated)
|
||||
└─ [16+ bytes] AES128 encrypted data
|
||||
```
|
||||
|
||||
### 8.2 Plaintext Structure (Inside Encryption)
|
||||
|
||||
```
|
||||
[4 bytes] TIMESTAMP (uint32_t, little-endian)
|
||||
[1 byte] TXT_TYPE (0=plain, 1=CLI_DATA, 2=signed)
|
||||
[variable] MESSAGE ("sender: text")
|
||||
[0-15 bytes] ZERO PADDING (to reach 16-byte boundary)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Conclusion: Echo Detection Feasibility
|
||||
|
||||
### Can We Detect Our Own Broadcast Echo?
|
||||
|
||||
**YES - With High Confidence**
|
||||
|
||||
**Methods:**
|
||||
1. **Full Payload Matching (Recommended)**
|
||||
- Store encrypted payload `[channel_hash || MAC || ciphertext]` after sending
|
||||
- Compare received encrypted payloads
|
||||
- 100% accurate if payload matches exactly
|
||||
- No false positives due to deterministic encryption
|
||||
|
||||
2. **Plaintext + Timestamp Matching**
|
||||
- Use `getRTCClock()->getCurrentTimeUnique()` to ensure unique timestamp
|
||||
- Store plaintext: `"sender_name: message_text"` + timestamp
|
||||
- Match against decrypted received messages
|
||||
- Very high confidence (timestamp uniqueness)
|
||||
|
||||
3. **Packet Hash Matching**
|
||||
- Calculate `SHA256(PAYLOAD_TYPE_GRP_TXT || payload) -> 8 bytes`
|
||||
- Store sent packet hash
|
||||
- Compare with received packet hash
|
||||
- Collision probability: negligible
|
||||
|
||||
### Why It Works:
|
||||
- AES128-ECB is **deterministic**: same plaintext + key = identical ciphertext
|
||||
- HMAC-SHA256 is **deterministic**: same data + key = identical MAC
|
||||
- Timestamp uniqueness prevents collisions from same sender
|
||||
|
||||
### When Echo Occurs:
|
||||
- Another node receives our packet
|
||||
- Rebroadcasts it (forwarding/relaying)
|
||||
- We receive it back via different path
|
||||
- Encrypted payload is **identical** to what we sent
|
||||
|
||||
### Implementation Effort:
|
||||
- **Low**: Store 18-50 bytes per sent message (hash + payload subset)
|
||||
- **Fast**: Binary comparison or hash lookup
|
||||
- **Reliable**: No dependencies on network topology or timing
|
||||
|
||||
1163
MESHCORE_PROTOCOL.md
1163
MESHCORE_PROTOCOL.md
File diff suppressed because it is too large
Load Diff
@@ -1,216 +0,0 @@
|
||||
# MeshCore Quick Reference Card
|
||||
|
||||
Quick lookup for MeshCore protocol constants and structures.
|
||||
|
||||
## BLE Service (App ↔ Device)
|
||||
|
||||
**Service UUID:** `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
|
||||
- **RX:** `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (Write - Commands)
|
||||
- **TX:** `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` (Notify - Responses)
|
||||
|
||||
### BLE Commands (RX)
|
||||
|
||||
| Code | Command | Format |
|
||||
|------|---------|--------|
|
||||
| `0x04` | Get Contacts | `[0x04]` |
|
||||
| `0x02` | Send Message | `[0x02][32B pubkey][2B len][text]` |
|
||||
| `0x27` | Get Telemetry | `[0x27][32B pubkey]` |
|
||||
|
||||
### BLE Responses (TX)
|
||||
|
||||
| Code | Response | Format |
|
||||
|------|----------|--------|
|
||||
| `0x03` | Contact Info | `[0x03][32B pubkey][1B type][64B name][4B lat][4B lon]` |
|
||||
| `0x07` | Message | `[0x07][1B type][4B src][4B dest][2B len][text]` |
|
||||
| `0x8B` | Telemetry | `[0x8B][4B pubkey][Cayenne LPP data]` |
|
||||
|
||||
---
|
||||
|
||||
## Mesh Packet Structure (LoRa Network)
|
||||
|
||||
```
|
||||
[Header: 1B] [Path Len: 1B] [Path: 0-64B] [Payload: 0-184B]
|
||||
```
|
||||
|
||||
### Header Encoding
|
||||
|
||||
```
|
||||
Bits: [Ver:2][Type:4][Route:2]
|
||||
Route = header & 0x03
|
||||
Type = (header >> 2) & 0x0F
|
||||
Ver = (header >> 6) & 0x03
|
||||
```
|
||||
|
||||
### Route Types
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| `0x01` | FLOOD | Broadcast to all nodes |
|
||||
| `0x02` | DIRECT | Point-to-point via path |
|
||||
|
||||
### Payload Types
|
||||
|
||||
| Code | Name | Encrypted | Structure |
|
||||
|------|------|-----------|-----------|
|
||||
| `0x00` | REQ | ✓ | `[1B dest][1B src][2B MAC][encrypted]` |
|
||||
| `0x01` | RESPONSE | ✓ | `[1B dest][1B src][2B MAC][encrypted]` |
|
||||
| `0x02` | TXT_MSG | ✓ | `[1B dest][1B src][2B MAC][encrypted]` |
|
||||
| `0x03` | ACK | ✗ | `[ack data]` |
|
||||
| `0x04` | ADVERT | ✗ | `[32B pubkey][4B ts][app][64B sig]` |
|
||||
| `0x05` | GRP_TXT | ✓ | `[1B chan][2B MAC][encrypted]` |
|
||||
| `0x06` | GRP_DATA | ✓ | `[1B chan][2B MAC][encrypted]` |
|
||||
| `0x07` | ANON_REQ | ✓ | `[1B dest][32B ephemeral][2B MAC][enc]` |
|
||||
| `0x08` | PATH | ✓ | `[1B dest][1B src][2B MAC][encrypted]` |
|
||||
| `0x09` | TRACE | ✗ | `[trace data]` |
|
||||
| `0x0F` | RAW_CUSTOM | ? | Application-defined |
|
||||
|
||||
---
|
||||
|
||||
## Advertisement App Data
|
||||
|
||||
**Format:** `[Flags:1B][Lat:4B?][Lon:4B?][Battery:1B?][Temp:1B?][Name:NB?]`
|
||||
|
||||
### Flags Byte
|
||||
|
||||
```
|
||||
Bits: [Name:1][Temp:1][Batt:1][LatLon:1][Type:4]
|
||||
Type = flags & 0x0F
|
||||
Has GPS = flags & 0x10
|
||||
Has Batt = flags & 0x20
|
||||
Has Temp = flags & 0x40
|
||||
Has Name = flags & 0x80
|
||||
```
|
||||
|
||||
### Contact Types
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| `0x00` | NONE | Unknown |
|
||||
| `0x01` | CHAT | Team member (shown on map) |
|
||||
| `0x02` | REPEATER | Network node |
|
||||
| `0x03` | ROOM | Group channel |
|
||||
|
||||
---
|
||||
|
||||
## Cayenne LPP (Telemetry)
|
||||
|
||||
**Format:** `[Channel:1B][Type:1B][Data]`
|
||||
|
||||
| Type | Code | Data | Decoding |
|
||||
|------|------|------|----------|
|
||||
| GPS | `0x88` | 12B | lat/lon ÷ 10000, alt ÷ 100 |
|
||||
| Temp | `0x67` | 2B | int16 ÷ 10 for °C |
|
||||
| Analog | `0x02` | 2B | uint16 ÷ 100 for volts |
|
||||
|
||||
**Example:**
|
||||
```
|
||||
[01][88][A0C20600][30670200][2C010000]
|
||||
^ch ^gps ^-lat-^ ^-lon-^ ^-alt-^
|
||||
GPS: 44.304°N, 15.7488°E, 3.00m
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constants
|
||||
|
||||
### Size Limits
|
||||
- Max Packet Payload: 184 bytes
|
||||
- Max Path Size: 64 bytes
|
||||
- Max Advert Data: 32 bytes
|
||||
- Public Key: 32 bytes
|
||||
- Private Key: 64 bytes
|
||||
- Signature: 64 bytes
|
||||
- MAC: 2 bytes
|
||||
- Cipher Block: 16 bytes
|
||||
|
||||
### Coordinate Encoding
|
||||
```dart
|
||||
// Encode
|
||||
int32 encoded = (double degrees * 10000).toInt();
|
||||
|
||||
// Decode
|
||||
double degrees = encoded / 10000.0;
|
||||
|
||||
// Precision: 4 decimal places (~11m accuracy)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Parse BLE Contact Response
|
||||
```dart
|
||||
final pubkey = data.sublist(1, 33); // 32 bytes
|
||||
final type = data[33]; // 0-3
|
||||
final name = data.sublist(34, 98); // 64 bytes
|
||||
final lat = ByteData.view(data.buffer)
|
||||
.getInt32(98, Endian.little) / 10000.0;
|
||||
final lon = ByteData.view(data.buffer)
|
||||
.getInt32(102, Endian.little) / 10000.0;
|
||||
```
|
||||
|
||||
### Parse Mesh Packet Header
|
||||
```dart
|
||||
final header = packet[0];
|
||||
final routeType = header & 0x03;
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
final version = (header >> 6) & 0x03;
|
||||
final isFlood = routeType == 0x01;
|
||||
final isTxtMsg = payloadType == 0x02;
|
||||
```
|
||||
|
||||
### Parse Advertisement Flags
|
||||
```dart
|
||||
final flags = appData[0];
|
||||
final contactType = flags & 0x0F;
|
||||
final hasGPS = (flags & 0x10) != 0;
|
||||
final hasBattery = (flags & 0x20) != 0;
|
||||
final hasTemp = (flags & 0x40) != 0;
|
||||
final hasName = (flags & 0x80) != 0;
|
||||
```
|
||||
|
||||
### Parse Cayenne LPP GPS
|
||||
```dart
|
||||
if (data[1] == 0x88) { // GPS type
|
||||
final lat = ByteData.view(data.buffer)
|
||||
.getInt32(2, Endian.little) / 10000.0;
|
||||
final lon = ByteData.view(data.buffer)
|
||||
.getInt32(6, Endian.little) / 10000.0;
|
||||
final alt = ByteData.view(data.buffer)
|
||||
.getInt32(10, Endian.little) / 100.0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **Always verify signatures** on ADVERT packets
|
||||
2. **Validate MAC** before decrypting encrypted payloads
|
||||
3. **Check timestamps** to prevent replay attacks
|
||||
4. **Sanitize strings** before display (max length, UTF-8 validation)
|
||||
5. **Rate limit** packet processing to prevent DoS
|
||||
6. Use **constant-time comparison** for MAC validation
|
||||
|
||||
---
|
||||
|
||||
## Flutter Implementation
|
||||
|
||||
**Main Files:**
|
||||
- `lib/services/meshcore_ble_service.dart` - BLE protocol
|
||||
- `lib/services/buffer_reader.dart` - Binary parsing
|
||||
- `lib/services/buffer_writer.dart` - Binary encoding
|
||||
- `lib/services/cayenne_lpp_parser.dart` - Telemetry decoding
|
||||
|
||||
---
|
||||
|
||||
## Additional Documentation
|
||||
|
||||
- **[MESHCORE_PROTOCOL.md](MESHCORE_PROTOCOL.md)** - Complete mesh packet protocol specification
|
||||
- **[MESHCORE_BLE_PROTOCOL.md](MESHCORE_BLE_PROTOCOL.md)** - Complete BLE command/response protocol
|
||||
- **[CLAUDE.md](CLAUDE.md)** - Project overview and development guide
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-14
|
||||
1322
MESSAGES.md
1322
MESSAGES.md
File diff suppressed because it is too large
Load Diff
@@ -1,268 +0,0 @@
|
||||
# Fixing "Not Found" Error When Logging Into Room
|
||||
|
||||
## Your Current Situation
|
||||
|
||||
You're seeing this sequence:
|
||||
```
|
||||
✅ Room "Repetitor" found in app contacts
|
||||
📤 Sending SEND_LOGIN command
|
||||
❌ Companion radio responds: ERR_CODE_NOT_FOUND (2)
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
Your **Flutter app** and the **companion radio firmware** maintain **separate contact lists**:
|
||||
|
||||
```
|
||||
┌─────────────────────────┐ ┌──────────────────────────┐
|
||||
│ Flutter App │ │ Companion Radio │
|
||||
│ (ContactsProvider) │ │ (Firmware Storage) │
|
||||
├─────────────────────────┤ ├──────────────────────────┤
|
||||
│ │ │ │
|
||||
│ ✅ Repetitor │ │ ❌ Repetitor │
|
||||
│ 15:59:89:54:b4:d4 │ BLE │ (NOT FOUND!) │
|
||||
│ │ <───> │ │
|
||||
│ Other contacts... │ │ Other contacts... │
|
||||
│ │ │ │
|
||||
└─────────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
When you call `getContacts()`:
|
||||
- The companion radio sends you a **snapshot** of its contact table
|
||||
- Your app stores these contacts locally
|
||||
- But if the radio's contact table changes, your app doesn't know
|
||||
|
||||
**The problem:** The room "Repetitor" exists in your app (from an old sync), but NOT in the radio's firmware anymore.
|
||||
|
||||
## Why This Happens
|
||||
|
||||
1. **Companion radio was factory reset** - Erased all contacts
|
||||
2. **Contact was manually removed** - Via serial console or config tool
|
||||
3. **Room never advertised** - Contact was temporary, never saved persistently
|
||||
4. **Firmware bug** - Contact wasn't properly persisted to flash storage
|
||||
|
||||
## Solutions
|
||||
|
||||
### Solution 1: Force Re-Sync Contacts (Quick Test)
|
||||
|
||||
This will clear your app's contacts and re-fetch from the radio:
|
||||
|
||||
```dart
|
||||
// In your app, add a button to force full sync:
|
||||
await contactsProvider.clearContacts();
|
||||
await connectionProvider.getContacts();
|
||||
await Future.delayed(Duration(milliseconds: 1000));
|
||||
|
||||
// Now check what rooms exist:
|
||||
final rooms = contactsProvider.rooms;
|
||||
print('Rooms on device: ${rooms.length}');
|
||||
for (final room in rooms) {
|
||||
print(' - ${room.advName}');
|
||||
}
|
||||
```
|
||||
|
||||
If "Repetitor" is NOT in the list after this sync, then the radio truly doesn't have it.
|
||||
|
||||
### Solution 2: Wait for Room to Advertise (Automatic)
|
||||
|
||||
If the room server is running and broadcasting:
|
||||
|
||||
1. The companion radio will receive the advertisement over LoRa
|
||||
2. If `manual_add_contacts=0`, you'll automatically receive `PUSH_CODE_NEW_ADVERT` (0x8A)
|
||||
3. The room will be added to both the radio AND your app
|
||||
4. Then you can login
|
||||
|
||||
**Expected flow:**
|
||||
```
|
||||
Room broadcasts → Companion receives → PUSH_CODE_NEW_ADVERT → Contact added → Login works
|
||||
```
|
||||
|
||||
### Solution 3: Manually Add Room Contact (CMD_ADD_UPDATE_CONTACT)
|
||||
|
||||
This requires implementing `CMD_ADD_UPDATE_CONTACT` (command code 9) in your app.
|
||||
|
||||
**Add this to `MeshCoreBleService`:**
|
||||
|
||||
```dart
|
||||
/// Manually add or update a contact on the companion radio
|
||||
///
|
||||
/// This is useful when you need to add a room that hasn't advertised yet,
|
||||
/// or restore a contact that was deleted from the radio's table.
|
||||
///
|
||||
/// Protocol format (CMD_ADD_UPDATE_CONTACT):
|
||||
/// - 1 byte: command code (9)
|
||||
/// - 32 bytes: public key
|
||||
/// - 1 byte: type (ADV_TYPE_*)
|
||||
/// - 1 byte: flags
|
||||
/// - 1 byte: out path length (signed)
|
||||
/// - 64 bytes: out path
|
||||
/// - 32 bytes: advertised name (null-terminated)
|
||||
/// - 4 bytes: last advert timestamp (uint32)
|
||||
/// - 4 bytes: (optional) advert latitude * 1E6 (int32)
|
||||
/// - 4 bytes: (optional) advert longitude * 1E6 (int32)
|
||||
Future<void> addOrUpdateContact(Contact contact) async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09
|
||||
writer.writeBytes(contact.publicKey); // 32 bytes
|
||||
writer.writeByte(contact.type.value); // ADV_TYPE_*
|
||||
writer.writeByte(contact.flags); // flags
|
||||
writer.writeInt8(contact.outPathLen); // path length (signed byte)
|
||||
writer.writeBytes(contact.outPath); // 64 bytes
|
||||
|
||||
// Write name as null-terminated string in 32-byte field
|
||||
final nameBytes = Uint8List(32);
|
||||
final encoded = utf8.encode(contact.advName);
|
||||
final copyLen = encoded.length > 31 ? 31 : encoded.length;
|
||||
nameBytes.setRange(0, copyLen, encoded);
|
||||
writer.writeBytes(nameBytes);
|
||||
|
||||
writer.writeUInt32LE(contact.lastAdvert); // timestamp
|
||||
writer.writeInt32LE(contact.advLat); // latitude * 1E6
|
||||
writer.writeInt32LE(contact.advLon); // longitude * 1E6
|
||||
|
||||
await _writeData(writer.toBytes());
|
||||
|
||||
print('✅ [BLE] Sent CMD_ADD_UPDATE_CONTACT for ${contact.advName}');
|
||||
print(' This adds the contact to the radio\'s internal table');
|
||||
}
|
||||
```
|
||||
|
||||
Add the constant:
|
||||
```dart
|
||||
// In meshcore_constants.dart
|
||||
static const int cmdAddUpdateContact = 9;
|
||||
```
|
||||
|
||||
Then in your app, before login:
|
||||
```dart
|
||||
// Add the room contact to the radio's table
|
||||
await connectionProvider.bleService.addOrUpdateContact(widget.contact);
|
||||
|
||||
// Small delay to allow radio to save
|
||||
await Future.delayed(Duration(milliseconds: 300));
|
||||
|
||||
// Now login should work
|
||||
await connectionProvider.loginToRoom(...);
|
||||
```
|
||||
|
||||
### Solution 4: Import Room Contact Card
|
||||
|
||||
If you have the room's "business card" (from `CMD_EXPORT_CONTACT`):
|
||||
|
||||
1. Get the card data (usually starts with `meshcore://`)
|
||||
2. Implement `CMD_IMPORT_CONTACT` (command code 18)
|
||||
3. Import the card
|
||||
|
||||
```dart
|
||||
Future<void> importContact(String cardData) async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdImportContact); // 0x12
|
||||
writer.writeString(cardData); // meshcore:// card data
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
```
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
**Step 1:** Force re-sync to see current state
|
||||
```dart
|
||||
await contactsProvider.clearContacts();
|
||||
await connectionProvider.getContacts();
|
||||
```
|
||||
|
||||
**Step 2:** Check if room exists
|
||||
```dart
|
||||
final roomExists = contactsProvider.rooms.any(
|
||||
(r) => r.publicKeyPrefix.matches(targetPrefix)
|
||||
);
|
||||
```
|
||||
|
||||
**Step 3a:** If room doesn't exist → **Implement CMD_ADD_UPDATE_CONTACT** (Solution 3)
|
||||
|
||||
**Step 3b:** Or wait for room to advertise (Solution 2)
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Immediate Fix (Easiest)
|
||||
1. ✅ Add contact sync verification (already done!)
|
||||
2. ✅ Show helpful error messages (already done!)
|
||||
|
||||
### Short Term (Recommended)
|
||||
3. 🔧 **Implement `CMD_ADD_UPDATE_CONTACT`** - This lets you manually restore contacts
|
||||
4. 🔧 **Implement `CMD_EXPORT_CONTACT`** - This lets you backup/share room contacts
|
||||
|
||||
### Long Term (Optional)
|
||||
5. 📱 Add UI to manually add rooms by public key
|
||||
6. 💾 Cache room contacts in SharedPreferences
|
||||
7. 🔄 Auto-restore cached rooms on connect
|
||||
|
||||
## Testing Your Fix
|
||||
|
||||
1. **Clear app contacts:**
|
||||
```dart
|
||||
await contactsProvider.clearContacts();
|
||||
```
|
||||
|
||||
2. **Force fresh sync:**
|
||||
```dart
|
||||
await connectionProvider.getContacts();
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
```
|
||||
|
||||
3. **List actual rooms on device:**
|
||||
```dart
|
||||
final rooms = contactsProvider.rooms;
|
||||
print('═══════════════════════════════');
|
||||
print('Rooms on companion radio: ${rooms.length}');
|
||||
for (final room in rooms) {
|
||||
print('📍 ${room.advName}');
|
||||
print(' PK: ${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
print(' Type: ${room.type}');
|
||||
}
|
||||
print('═══════════════════════════════');
|
||||
```
|
||||
|
||||
4. **If "Repetitor" is NOT in the list:**
|
||||
- The radio truly doesn't have it
|
||||
- You need to add it using CMD_ADD_UPDATE_CONTACT
|
||||
- Or wait for it to advertise
|
||||
|
||||
## Expected Logs After Fix
|
||||
|
||||
**Before (Current - Broken):**
|
||||
```
|
||||
🔍 Checking room "Repetitor"...
|
||||
App contacts: ✅ Found
|
||||
Radio contacts: ❌ Not found
|
||||
📤 Sending LOGIN
|
||||
❌ ERROR: Not found
|
||||
```
|
||||
|
||||
**After (Fixed - Option 1: Re-sync):**
|
||||
```
|
||||
🔍 Checking room "Repetitor"...
|
||||
📤 Clearing app contacts
|
||||
📤 Syncing from radio
|
||||
📥 Got 5 contacts
|
||||
Room "Repetitor": ❌ NOT on radio
|
||||
⚠️ Room needs to be added to radio first
|
||||
```
|
||||
|
||||
**After (Fixed - Option 2: Manual add):**
|
||||
```
|
||||
🔍 Checking room "Repetitor"...
|
||||
App contacts: ✅ Found
|
||||
Radio contacts: ❌ Not found
|
||||
📤 Sending CMD_ADD_UPDATE_CONTACT
|
||||
✅ Contact added to radio
|
||||
📤 Sending LOGIN
|
||||
✅ LOGIN_SUCCESS
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Try Solution 1 (force re-sync) to confirm the issue
|
||||
2. Implement Solution 3 (CMD_ADD_UPDATE_CONTACT) for permanent fix
|
||||
3. Test by adding the room contact manually before login
|
||||
|
||||
Would you like me to implement `CMD_ADD_UPDATE_CONTACT` for you?
|
||||
@@ -1,851 +0,0 @@
|
||||
# Room Login Implementation Review
|
||||
|
||||
**Date**: 2025-01-14
|
||||
**Reviewer**: AI Code Analysis
|
||||
**Status**: ✅ PRODUCTION READY
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After comprehensive review of the room login implementation for both **cold start** (automatic login) and **user-initiated login** (manual login via UI), the implementation is **CORRECT** and follows the MeshCore protocol specifications exactly.
|
||||
|
||||
### Key Findings
|
||||
|
||||
- ✅ **Protocol Compliance**: All BLE frame formats are correct
|
||||
- ✅ **Cold Start Works**: Auto-login on connection is properly implemented
|
||||
- ✅ **User Login Works**: Manual login with pre-flight checks
|
||||
- ✅ **No Premature Sync**: Code does NOT call `syncAllMessages()` immediately after login
|
||||
- ✅ **Message Push Handling**: Correctly waits for `PUSH_CODE_MSG_WAITING` notifications
|
||||
- ⚠️ **CMD_ADD_UPDATE_CONTACT Already Implemented**: Code already has this functionality!
|
||||
|
||||
---
|
||||
|
||||
## 1. Cold Start Auto-Login Review
|
||||
|
||||
### 1.1 Entry Point
|
||||
|
||||
**File**: `lib/providers/app_provider.dart`
|
||||
**Method**: `initialize()` → `_autoLoginToRooms()` (lines 88-153)
|
||||
|
||||
### 1.2 Flow Diagram
|
||||
|
||||
```
|
||||
App Starts
|
||||
↓
|
||||
connect(device)
|
||||
↓
|
||||
initialize() ← Called after connection established
|
||||
↓
|
||||
├─ syncDeviceTime()
|
||||
├─ getContacts()
|
||||
├─ delay(500ms)
|
||||
└─ _autoLoginToRooms()
|
||||
↓
|
||||
├─ Get all rooms (exclude "Public Channel")
|
||||
├─ For each room:
|
||||
│ ├─ Load saved password (or "hello" default)
|
||||
│ ├─ _loginToRoomWithCallback()
|
||||
│ │ ├─ Setup temporary callbacks
|
||||
│ │ ├─ connectionProvider.loginToRoom()
|
||||
│ │ ├─ Wait for PUSH_CODE_LOGIN_SUCCESS/FAIL
|
||||
│ │ └─ Restore original callbacks
|
||||
│ └─ Delay 300ms between logins
|
||||
└─ _syncMessages() ← Syncs pre-existing messages from device queue
|
||||
```
|
||||
|
||||
### 1.3 Code Review
|
||||
|
||||
#### ✅ Password Loading (lines 131-137)
|
||||
|
||||
```dart
|
||||
for (final room in rooms) {
|
||||
try {
|
||||
// Load saved password for this room
|
||||
final roomKey = 'room_password_${room.publicKeyHex}';
|
||||
final savedPassword = prefs.getString(roomKey) ?? 'hello';
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- Uses SharedPreferences with room-specific keys
|
||||
- Falls back to "hello" if no saved password
|
||||
- Correct implementation
|
||||
|
||||
#### ✅ Login with Callback Wrapper (lines 142-145)
|
||||
|
||||
```dart
|
||||
// Set up one-time callbacks for this room login
|
||||
await _loginToRoomWithCallback(room, savedPassword);
|
||||
|
||||
// Small delay between logins to avoid overwhelming the device
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- Uses temporary callbacks per room (prevents callback mixing)
|
||||
- 300ms spacing prevents BLE command queue overflow
|
||||
- Correct implementation
|
||||
|
||||
#### ✅ SUCCESS Handler - NO Premature Sync! (lines 165-173)
|
||||
|
||||
```dart
|
||||
connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
|
||||
// Restore original callbacks
|
||||
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||
connectionProvider.onLoginFail = originalOnFail;
|
||||
|
||||
debugPrint('✅ [AppProvider] Auto-login successful for ${room.advName}');
|
||||
debugPrint('📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING');
|
||||
|
||||
completer.complete(true);
|
||||
};
|
||||
```
|
||||
|
||||
**CRITICAL ANALYSIS**:
|
||||
- ❌ **DOES NOT** call `syncAllMessages()`
|
||||
- ❌ **DOES NOT** call `syncNextMessage()`
|
||||
- ✅ **DOES** print message about automatic message push
|
||||
- ✅ **CORRECT** per protocol specification
|
||||
|
||||
#### ✅ Message Waiting Handler (connection_provider.dart:125-129)
|
||||
|
||||
```dart
|
||||
_bleService.onMessageWaiting = () {
|
||||
print('📥 [Provider] Received MsgWaiting push - auto-fetching messages');
|
||||
// Automatically fetch messages when push notification received
|
||||
syncAllMessages();
|
||||
};
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- Only syncs when `PUSH_CODE_MSG_WAITING` (0x83) is received
|
||||
- This is triggered by room server pushing messages
|
||||
- Correct implementation per protocol
|
||||
|
||||
---
|
||||
|
||||
## 2. User-Initiated Login Review
|
||||
|
||||
### 2.1 Entry Point
|
||||
|
||||
**File**: `lib/screens/contacts_tab.dart`
|
||||
**Method**: `_RoomLoginSheetState._loginToRoom()` (lines 983-1201)
|
||||
|
||||
### 2.2 Flow Diagram
|
||||
|
||||
```
|
||||
User clicks "Login to Room"
|
||||
↓
|
||||
_RoomLoginSheet shown
|
||||
↓
|
||||
Load saved password (or "hello")
|
||||
↓
|
||||
User clicks "Login" button
|
||||
↓
|
||||
_loginToRoom()
|
||||
↓
|
||||
├─ 🕐 CLOCK DRIFT CHECK (lines 1006-1015)
|
||||
│ └─ getDeviceTime() - diagnostic only
|
||||
│
|
||||
├─ 🔍 PRE-LOGIN CHECK (lines 1018-1113)
|
||||
│ ├─ Check: Room in ContactsProvider?
|
||||
│ │ ├─ YES → Continue
|
||||
│ │ └─ NO → Sync contacts from device
|
||||
│ │ ├─ getContacts()
|
||||
│ │ ├─ Wait 800ms
|
||||
│ │ ├─ Check again
|
||||
│ │ │ ├─ Found → Continue
|
||||
│ │ │ └─ Not Found → ADD MANUALLY
|
||||
│ │ │ ├─ addOrUpdateContact(room)
|
||||
│ │ │ ├─ Wait 500ms for flash write
|
||||
│ │ │ └─ Continue
|
||||
│ │ └─ Log available rooms for debugging
|
||||
│ │
|
||||
├─ 💾 SAVE PASSWORD (line 1116)
|
||||
│ └─ SharedPreferences.setString(roomKey, password)
|
||||
│
|
||||
├─ 🔧 SETUP CALLBACKS (lines 1118-1161)
|
||||
│ ├─ Store original callbacks
|
||||
│ ├─ Set temporary onLoginSuccess
|
||||
│ │ └─ Does NOT call syncAllMessages() ← CRITICAL
|
||||
│ └─ Set temporary onLoginFail
|
||||
│
|
||||
├─ 📤 SEND LOGIN REQUEST (lines 1165-1168)
|
||||
│ └─ connectionProvider.loginToRoom()
|
||||
│
|
||||
└─ 📥 WAIT FOR RESPONSE
|
||||
├─ PUSH_CODE_LOGIN_SUCCESS (0x85)
|
||||
│ └─ Show: "Logged in successfully! Waiting for room messages..."
|
||||
└─ PUSH_CODE_LOGIN_FAIL (0x86)
|
||||
└─ Show: "Login failed - incorrect password"
|
||||
```
|
||||
|
||||
### 2.3 Code Review
|
||||
|
||||
#### ✅ Clock Drift Check (lines 1006-1015)
|
||||
|
||||
```dart
|
||||
// 🕐 CLOCK DRIFT CHECK: Get device time to detect synchronization issues
|
||||
print('🕐 [RoomLogin] Checking for clock drift between app and radio...');
|
||||
try {
|
||||
await connectionProvider.getDeviceTime();
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
} catch (e) {
|
||||
print('⚠️ [RoomLogin] Failed to get device time: $e');
|
||||
// Don't fail login - this is just a diagnostic check
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- Diagnostic check only, doesn't fail on error
|
||||
- Response logged in `meshcore_ble_service.dart:1015-1048`
|
||||
- Good practice for troubleshooting
|
||||
- Correct implementation
|
||||
|
||||
#### ✅ Pre-Login Contact Check (lines 1018-1113)
|
||||
|
||||
```dart
|
||||
// 🔍 PRE-LOGIN CHECK: Ensure room contact exists in device
|
||||
print('🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...');
|
||||
|
||||
bool roomExists = contactsProvider.rooms.any(
|
||||
(room) => room.publicKeyHex == widget.contact.publicKeyHex,
|
||||
);
|
||||
|
||||
if (!roomExists) {
|
||||
// Try syncing contacts
|
||||
await connectionProvider.getContacts();
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
// Check again
|
||||
roomExists = contactsProvider.rooms.any(...);
|
||||
|
||||
if (!roomExists) {
|
||||
// Manually add the room contact to the radio
|
||||
try {
|
||||
await connectionProvider.addOrUpdateContact(widget.contact);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
} catch (e) {
|
||||
// Show error snackbar and exit
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- ✅ Checks if room exists before login
|
||||
- ✅ Attempts sync if not found
|
||||
- ✅ Falls back to manual add via `CMD_ADD_UPDATE_CONTACT`
|
||||
- ✅ Shows user-friendly error messages
|
||||
- ✅ Solves `ERR_CODE_NOT_FOUND` issue
|
||||
- **EXCELLENT** implementation
|
||||
|
||||
#### ✅ Login Success Handler - NO Premature Sync! (lines 1125-1143)
|
||||
|
||||
```dart
|
||||
connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
|
||||
// Restore original callback
|
||||
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||
connectionProvider.onLoginFail = originalOnFail;
|
||||
|
||||
print('✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin');
|
||||
print('📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING');
|
||||
print(' Messages will be fetched when onMessageWaiting callback is triggered');
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Logged in successfully! Waiting for room messages...'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**CRITICAL ANALYSIS**:
|
||||
- ❌ **DOES NOT** call `syncAllMessages()`
|
||||
- ❌ **DOES NOT** call `syncNextMessage()`
|
||||
- ✅ **DOES** print detailed message about automatic push
|
||||
- ✅ **DOES** show user-friendly success message
|
||||
- ✅ **CORRECT** per protocol specification
|
||||
|
||||
---
|
||||
|
||||
## 3. Protocol Compliance Review
|
||||
|
||||
### 3.1 CMD_SEND_LOGIN Implementation
|
||||
|
||||
**File**: `lib/services/meshcore_ble_service.dart:1390-1416`
|
||||
|
||||
```dart
|
||||
Future<void> loginToRoom({
|
||||
required Uint8List roomPublicKey,
|
||||
required String password,
|
||||
int syncSince = 0, // 0 = get all messages
|
||||
}) async {
|
||||
if (password.length > 15) {
|
||||
throw ArgumentError('Password exceeds 15 character limit');
|
||||
}
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // epoch seconds
|
||||
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A (26)
|
||||
writer.writeUInt32LE(now); // sender timestamp
|
||||
writer.writeUInt32LE(syncSince); // sync since
|
||||
writer.writeBytes(roomPublicKey); // 32 bytes
|
||||
writer.writeString(password); // max 15 bytes
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Frame Structure Verification
|
||||
|
||||
**Documented Format** (MESSAGES.md):
|
||||
```
|
||||
[0x1A] - Command code (26)
|
||||
[4 bytes] - Sender timestamp (uint32 LE)
|
||||
[4 bytes] - Sync since (uint32 LE)
|
||||
[32 bytes] - Room public key
|
||||
[N bytes] - Password (max 15, null-terminated)
|
||||
```
|
||||
|
||||
**Actual Implementation**:
|
||||
```
|
||||
Byte 0: 0x1A ✅ Correct
|
||||
Bytes 1-4: now (uint32 LE) ✅ Correct
|
||||
Bytes 5-8: syncSince (uint32 LE) ✅ Correct
|
||||
Bytes 9-40: roomPublicKey (32) ✅ Correct
|
||||
Bytes 41+: password (UTF-8) ✅ Correct
|
||||
```
|
||||
|
||||
**VERDICT**: ✅ 100% Protocol Compliant
|
||||
|
||||
### 3.3 Response Handlers
|
||||
|
||||
#### PUSH_CODE_LOGIN_SUCCESS (0x85)
|
||||
|
||||
**File**: `meshcore_ble_service.dart:942-981`
|
||||
|
||||
```dart
|
||||
void _handleLoginSuccess(BufferReader reader) {
|
||||
if (reader.remainingBytesCount >= 11) {
|
||||
final permissions = reader.readByte();
|
||||
final isAdmin = (permissions & 0x01) != 0;
|
||||
final publicKeyPrefix = reader.readBytes(6);
|
||||
final tag = reader.readInt32LE();
|
||||
|
||||
// V7+ new permissions byte (optional)
|
||||
int? newPermissions;
|
||||
if (reader.hasRemaining) {
|
||||
newPermissions = reader.readByte();
|
||||
}
|
||||
|
||||
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- ✅ Parses all documented fields
|
||||
- ✅ Handles optional V7+ permissions
|
||||
- ✅ Calls callback with correct parameters
|
||||
- ✅ **DOES NOT** call any message sync methods
|
||||
- **CORRECT** implementation
|
||||
|
||||
#### PUSH_CODE_LOGIN_FAIL (0x86)
|
||||
|
||||
**File**: `meshcore_ble_service.dart:983-1009`
|
||||
|
||||
```dart
|
||||
void _handleLoginFail(BufferReader reader) {
|
||||
if (reader.remainingBytesCount >= 7) {
|
||||
final reserved = reader.readByte();
|
||||
final publicKeyPrefix = reader.readBytes(6);
|
||||
onLoginFail?.call(publicKeyPrefix);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- ✅ Parses reserved byte + 6-byte prefix
|
||||
- ✅ Calls callback
|
||||
- **CORRECT** implementation
|
||||
|
||||
---
|
||||
|
||||
## 4. Message Push Protocol Review
|
||||
|
||||
### 4.1 Room Server Behavior (Reference)
|
||||
|
||||
**Source**: `/Users/dz0ny/meshcore-sar/MeshCore/examples/simple_repeater/MyMesh.cpp`
|
||||
|
||||
```cpp
|
||||
// Login handler (line 324)
|
||||
client->extra.room.sync_since = sender_sync_since;
|
||||
|
||||
// Set delay before first push (line 346)
|
||||
next_push = futureMillis(PUSH_NOTIFY_DELAY_MILLIS); // 2000ms
|
||||
|
||||
// Round-robin polling loop (lines 498-542)
|
||||
if (post->timestamp > client->extra.room.sync_since) {
|
||||
pushPostToClient(client, post); // Send PAYLOAD_TYPE_TXT_MSG
|
||||
// Wait for ACK...
|
||||
client->extra.room.sync_since = post->timestamp;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Flutter App Message Reception
|
||||
|
||||
**File**: `connection_provider.dart:125-129`
|
||||
|
||||
```dart
|
||||
_bleService.onMessageWaiting = () {
|
||||
print('📥 [Provider] Received MsgWaiting push - auto-fetching messages');
|
||||
syncAllMessages();
|
||||
};
|
||||
```
|
||||
|
||||
### 4.3 Protocol Flow
|
||||
|
||||
```
|
||||
Room Server Companion Radio Flutter App
|
||||
| | |
|
||||
| LOGIN_SUCCESS | |
|
||||
|------------------------->|-------------------->|
|
||||
| | | ✅ onLoginSuccess() called
|
||||
| | | ❌ Does NOT call syncAllMessages()
|
||||
| | |
|
||||
| [Wait 2000ms] | |
|
||||
| | |
|
||||
| PAYLOAD_TYPE_TXT_MSG | |
|
||||
|------------------------->| |
|
||||
| (direct to client) | |
|
||||
| | |
|
||||
| | PUSH_CODE_MSG_WAITING|
|
||||
| |-------------------->| ✅ Now sync is called!
|
||||
| | |
|
||||
| |<---- CMD_SYNC_NEXT --|
|
||||
| | |
|
||||
| |---- CONTACT_MSG ---->|
|
||||
| | |
|
||||
|<------ ACK -------------| |
|
||||
| | |
|
||||
| [Next message...] | |
|
||||
```
|
||||
|
||||
**VERDICT**: ✅ Implementation matches protocol exactly
|
||||
|
||||
---
|
||||
|
||||
## 5. CMD_ADD_UPDATE_CONTACT Implementation
|
||||
|
||||
### 5.1 Discovery
|
||||
|
||||
**File**: `lib/services/meshcore_ble_service.dart:1123-1172`
|
||||
|
||||
```dart
|
||||
/// Manually add or update a contact on the companion radio
|
||||
Future<void> addOrUpdateContact(Contact contact) async {
|
||||
print('📝 [BLE] Adding/updating contact on companion radio:');
|
||||
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09
|
||||
writer.writeBytes(contact.publicKey); // 32 bytes
|
||||
writer.writeByte(contact.type.value);
|
||||
writer.writeByte(contact.flags);
|
||||
writer.writeInt8(contact.outPathLen);
|
||||
writer.writeBytes(contact.outPath); // 64 bytes
|
||||
|
||||
// Write name as null-terminated string in 32-byte field
|
||||
final nameBytes = Uint8List(32);
|
||||
final encoded = utf8.encode(contact.advName);
|
||||
final copyLen = encoded.length > 31 ? 31 : encoded.length;
|
||||
nameBytes.setRange(0, copyLen, encoded);
|
||||
writer.writeBytes(nameBytes);
|
||||
|
||||
writer.writeUInt32LE(contact.lastAdvert);
|
||||
writer.writeInt32LE(contact.advLat);
|
||||
writer.writeInt32LE(contact.advLon);
|
||||
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Usage in User Login (contacts_tab.dart:1050-1060)
|
||||
|
||||
```dart
|
||||
// Manually add the room contact to the radio's flash storage
|
||||
await connectionProvider.addOrUpdateContact(widget.contact);
|
||||
|
||||
print('✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT');
|
||||
print(' Waiting 500ms for radio to save to flash...');
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
```
|
||||
|
||||
**VERDICT**: ✅ Already fully implemented and working!
|
||||
|
||||
---
|
||||
|
||||
## 6. State Management Review
|
||||
|
||||
### 6.1 RoomLoginState Model
|
||||
|
||||
**File**: `lib/models/room_login_state.dart`
|
||||
|
||||
```dart
|
||||
class RoomLoginState {
|
||||
final Uint8List publicKeyPrefix;
|
||||
final bool isLoggedIn;
|
||||
final bool isAdmin;
|
||||
final int permissions;
|
||||
final int? tag;
|
||||
final DateTime? loginTime;
|
||||
final bool hasPassword;
|
||||
|
||||
factory RoomLoginState.loggedIn({...}) { ... }
|
||||
factory RoomLoginState.loggedOut({...}) { ... }
|
||||
|
||||
String get publicKeyPrefixHex { ... }
|
||||
Duration? get loginDuration { ... }
|
||||
String? get loginDurationFormatted { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- ✅ Tracks all necessary login state
|
||||
- ✅ Provides helper methods
|
||||
- ✅ Immutable design
|
||||
- **EXCELLENT** implementation
|
||||
|
||||
### 6.2 State Tracking (connection_provider.dart:49-51, 131-164)
|
||||
|
||||
```dart
|
||||
// Room login state tracking
|
||||
final Map<String, RoomLoginState> _roomLoginStates = {};
|
||||
Map<String, RoomLoginState> get roomLoginStates => Map.unmodifiable(_roomLoginStates);
|
||||
|
||||
// In onLoginSuccess callback:
|
||||
final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
final hasPassword = await _hasPasswordForRoom(publicKeyPrefix);
|
||||
_roomLoginStates[prefixHex] = RoomLoginState.loggedIn(
|
||||
publicKeyPrefix: publicKeyPrefix,
|
||||
permissions: permissions,
|
||||
isAdmin: isAdmin,
|
||||
tag: tag,
|
||||
hasPassword: hasPassword,
|
||||
);
|
||||
notifyListeners();
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- ✅ Stores state per room (by public key prefix)
|
||||
- ✅ Checks for saved password
|
||||
- ✅ Notifies UI of state changes
|
||||
- **CORRECT** implementation
|
||||
|
||||
### 6.3 UI Integration (contacts_tab.dart:157-284)
|
||||
|
||||
Room login badges shown in ContactsTab:
|
||||
```dart
|
||||
// Room login status indicator badge
|
||||
if (contact.type == ContactType.room && roomLoginState != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _getRoomStatusColor(roomLoginState),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(_getRoomStatusIcon(roomLoginState), ...),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
- ✅ Visual indicator of login status
|
||||
- ✅ Different colors for logged in/out/admin
|
||||
- ✅ Shows login duration
|
||||
- **EXCELLENT** UX
|
||||
|
||||
---
|
||||
|
||||
## 7. Issues and Recommendations
|
||||
|
||||
### 7.1 Issues Found
|
||||
|
||||
**NONE** - Implementation is correct!
|
||||
|
||||
### 7.2 Enhancements Recommended
|
||||
|
||||
#### Priority 1: Login Timeout
|
||||
|
||||
Currently, if room never responds, app waits forever.
|
||||
|
||||
**Recommendation**:
|
||||
```dart
|
||||
// In connection_provider.dart loginToRoom()
|
||||
Future<bool> loginToRoom({
|
||||
required Uint8List roomPublicKey,
|
||||
required String password,
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final completer = Completer<bool>();
|
||||
|
||||
// Setup callbacks...
|
||||
|
||||
// Send login
|
||||
await _bleService.loginToRoom(...);
|
||||
|
||||
// Start timeout
|
||||
final timeoutFuture = Future.delayed(timeout, () {
|
||||
if (!completer.isCompleted) {
|
||||
print('⏱️ Login timeout - no response from room');
|
||||
// Restore callbacks
|
||||
completer.complete(false);
|
||||
}
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
```
|
||||
|
||||
#### Priority 2: Room State Persistence
|
||||
|
||||
**Current**: Login state lost on app restart
|
||||
**Recommendation**: Save to SharedPreferences
|
||||
|
||||
```dart
|
||||
// Save on login success
|
||||
await prefs.setBool('room_logged_in_${roomId}', true);
|
||||
await prefs.setInt('room_login_time_${roomId}', DateTime.now().millisecondsSinceEpoch);
|
||||
|
||||
// Restore on app start
|
||||
if (prefs.getBool('room_logged_in_${roomId}') == true) {
|
||||
final loginTime = prefs.getInt('room_login_time_${roomId}');
|
||||
// Show UI indicator that we were logged in
|
||||
// Note: Room login is session-based, so we need to re-login
|
||||
}
|
||||
```
|
||||
|
||||
#### Priority 3: "Waiting for Messages" UI
|
||||
|
||||
**Current**: User sees empty messages list after login
|
||||
**Recommendation**: Show loading indicator
|
||||
|
||||
```dart
|
||||
// In messages_tab.dart
|
||||
if (roomLoginState?.isLoggedIn == true && messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column([
|
||||
CircularProgressIndicator(),
|
||||
Text('Logged into ${room.name}'),
|
||||
Text('Waiting for room to push messages...'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing Recommendations
|
||||
|
||||
### 8.1 Cold Start Auto-Login
|
||||
|
||||
✅ Test: First connection (no saved passwords)
|
||||
- [ ] Uses "hello" as default
|
||||
- [ ] Saves password after successful login
|
||||
|
||||
✅ Test: Reconnection (has saved passwords)
|
||||
- [ ] Auto-logs into all rooms
|
||||
- [ ] Shows success messages
|
||||
- [ ] Receives pushed messages
|
||||
|
||||
✅ Test: Wrong saved password
|
||||
- [ ] Login fails gracefully
|
||||
- [ ] User can manually enter correct password
|
||||
|
||||
### 8.2 User-Initiated Login
|
||||
|
||||
✅ Test: Room not in device contacts
|
||||
- [ ] Syncs contacts first
|
||||
- [ ] Adds room manually if still not found
|
||||
- [ ] Login succeeds after adding
|
||||
|
||||
✅ Test: Clock drift > 60 seconds
|
||||
- [ ] Warning logged
|
||||
- [ ] Login may fail (room rejects old timestamps)
|
||||
|
||||
✅ Test: Multiple rapid logins
|
||||
- [ ] No callback mixing
|
||||
- [ ] No memory leaks
|
||||
- [ ] Proper cleanup
|
||||
|
||||
### 8.3 Message Push
|
||||
|
||||
✅ Test: Room has messages waiting
|
||||
- [ ] Receives PUSH_CODE_MSG_WAITING
|
||||
- [ ] Messages synced automatically
|
||||
- [ ] All messages received
|
||||
|
||||
✅ Test: Login with syncSince parameter
|
||||
- [ ] Only new messages pushed
|
||||
- [ ] Old messages not re-sent
|
||||
|
||||
---
|
||||
|
||||
## 9. Conclusion
|
||||
|
||||
### Summary
|
||||
|
||||
The room login implementation is **production-ready and protocol-compliant**.
|
||||
|
||||
### What Works Perfectly ✅
|
||||
|
||||
1. ✅ **Protocol Compliance**: All frame formats correct
|
||||
2. ✅ **Cold Start Auto-Login**: Properly implemented
|
||||
3. ✅ **User-Initiated Login**: Comprehensive pre-flight checks
|
||||
4. ✅ **No Premature Sync**: Waits for `PUSH_CODE_MSG_WAITING`
|
||||
5. ✅ **Contact Management**: `CMD_ADD_UPDATE_CONTACT` already implemented!
|
||||
6. ✅ **State Tracking**: `RoomLoginState` model is excellent
|
||||
7. ✅ **Error Handling**: User-friendly messages
|
||||
8. ✅ **Callback Management**: Proper cleanup and restoration
|
||||
|
||||
### Recommended Enhancements 📝
|
||||
|
||||
1. **Login timeout handling** (10 second timeout)
|
||||
2. **Room state persistence** (survive app restart)
|
||||
3. **"Waiting for messages" UI** (loading indicator)
|
||||
|
||||
### Final Verdict
|
||||
|
||||
**✅ APPROVED FOR PRODUCTION**
|
||||
|
||||
No critical bugs found. All enhancements are optional improvements, not bug fixes.
|
||||
|
||||
The implementation demonstrates excellent understanding of the MeshCore protocol and follows all best practices.
|
||||
|
||||
---
|
||||
|
||||
## 10. Understanding LOG_RX_DATA Push Notifications
|
||||
|
||||
### 10.1 What is LOG_RX_DATA (0x88)?
|
||||
|
||||
`LOG_RX_DATA` (push code 0x88) is a **diagnostic push notification** that reports **raw over-the-air packets** received by the radio.
|
||||
|
||||
**Key Points**:
|
||||
- **NOT** an application-layer message
|
||||
- **Encrypted over-the-air packet data** captured by the radio
|
||||
- Used for debugging and monitoring network activity
|
||||
- Contains the actual LoRa PHY layer packets
|
||||
|
||||
### 10.2 Frame Format
|
||||
|
||||
```
|
||||
[Push Code: 1 byte] = 0x88
|
||||
[Data: N bytes] = Raw over-the-air packet (encrypted)
|
||||
```
|
||||
|
||||
The data payload contains:
|
||||
1. **First 4 bytes**: Airtime or packet metadata (varies)
|
||||
2. **Remaining bytes**: Encrypted packet payload from LoRa
|
||||
|
||||
**Important**: The data is encrypted with the mesh network's shared key, so it appears as high-entropy random bytes.
|
||||
|
||||
### 10.3 Observed LOG_RX_DATA During Message Send
|
||||
|
||||
Example from logs:
|
||||
|
||||
```
|
||||
flutter: 📥 [RX] Received: LOG_RX_DATA (0x88)
|
||||
flutter: Data size: 9 bytes
|
||||
flutter: Hex: 88 32 a7 0e 00 e2 d8 94 3a
|
||||
```
|
||||
|
||||
This is an **ACK packet** being captured over the air:
|
||||
- **Bytes 0-3**: `32 a7 0e 00` = Airtime/metadata (960306 when interpreted as uint32 LE)
|
||||
- **Bytes 4-7**: `e2 d8 94 3a` = **ACK code** matching the sent message's expected ACK (982833378)
|
||||
|
||||
This confirms the radio received the acknowledgment packet over the air.
|
||||
|
||||
### 10.4 More Complex LOG_RX_DATA Packets
|
||||
|
||||
```
|
||||
flutter: 📥 [RX] Received: LOG_RX_DATA (0x88)
|
||||
flutter: Data size: 73 bytes
|
||||
flutter: Hex: 88 25 a4 0a 00 11 15 43 9c 7e 51 ce 2b ...
|
||||
```
|
||||
|
||||
This is likely the **original message packet** being retransmitted or repeated by another node:
|
||||
- **Bytes 0-3**: Airtime metadata
|
||||
- **Bytes 4-35**: **Sender public key** (32 bytes) = `11 15 43 9c 7e 51 ...`
|
||||
- **Remaining**: Encrypted payload containing the message
|
||||
|
||||
### 10.5 Why Multiple LOG_RX_DATA Packets?
|
||||
|
||||
When you send a message, you may see **multiple LOG_RX_DATA** notifications because:
|
||||
|
||||
1. **Your own transmission** is captured (loopback from radio)
|
||||
2. **Repeater nodes re-broadcast** your message (mesh forwarding)
|
||||
3. **ACK packets** are captured (confirmation from recipient)
|
||||
4. **Path return packets** might be captured (route discovery)
|
||||
|
||||
**This is normal behavior** - it shows the mesh network is working correctly.
|
||||
|
||||
### 10.6 Should You Handle LOG_RX_DATA?
|
||||
|
||||
**No** - LOG_RX_DATA is **diagnostic only**. Your app should:
|
||||
|
||||
✅ **Ignore** LOG_RX_DATA push notifications
|
||||
✅ **Focus on** application-layer responses:
|
||||
- `RESP_CODE_SENT` (0x06) - Message queued for transmission
|
||||
- `PUSH_CODE_SEND_CONFIRMED` (0x82) - Delivery confirmed
|
||||
- `RESP_CODE_CONTACT_MSG_RECV` (0x07) - Received message from contact
|
||||
- `PUSH_CODE_MSG_WAITING` (0x83) - New message notification
|
||||
|
||||
❌ **Do not parse** LOG_RX_DATA payload - it's encrypted mesh-layer data
|
||||
|
||||
### 10.7 Implementation Recommendation
|
||||
|
||||
Your current implementation already handles LOG_RX_DATA correctly:
|
||||
|
||||
```dart
|
||||
case 0x88: // LOG_RX_DATA
|
||||
debugPrint('📥 [RX] Received raw over-the-air packet (diagnostic)');
|
||||
// Log for debugging, but don't parse - it's encrypted mesh data
|
||||
break;
|
||||
```
|
||||
|
||||
The hex dump analysis you're seeing is helpful for debugging but doesn't need to trigger any action in your app.
|
||||
|
||||
### 10.8 Summary
|
||||
|
||||
1. **LOG_RX_DATA = Diagnostic tool** showing raw encrypted LoRa packets
|
||||
2. **Multiple LOG_RX_DATA packets are normal** (loopback, repeaters, ACKs)
|
||||
3. **High entropy is expected** (encrypted with mesh network key)
|
||||
4. **Your app should ignore these** - focus on application-layer responses
|
||||
5. **The encrypted "repeats" are the mesh network working** - forwarding messages, sending ACKs, establishing routes
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `lib/providers/app_provider.dart:88-153` | Cold start auto-login |
|
||||
| `lib/providers/connection_provider.dart:125-129` | Message waiting handler |
|
||||
| `lib/providers/connection_provider.dart:696-715` | loginToRoom() API |
|
||||
| `lib/screens/contacts_tab.dart:983-1201` | User-initiated login UI |
|
||||
| `lib/services/meshcore_ble_service.dart:1390-1416` | CMD_SEND_LOGIN |
|
||||
| `lib/services/meshcore_ble_service.dart:1123-1172` | CMD_ADD_UPDATE_CONTACT |
|
||||
| `lib/services/meshcore_ble_service.dart:942-981` | PUSH_CODE_LOGIN_SUCCESS handler |
|
||||
| `lib/models/room_login_state.dart` | Login state model |
|
||||
| `MESSAGES.md:Section 5` | Protocol documentation |
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Review Date**: 2025-01-14
|
||||
**Next Review**: After implementing recommended enhancements
|
||||
367
SCREENSHOTS.md
367
SCREENSHOTS.md
@@ -1,367 +0,0 @@
|
||||
# Screenshot Automation Guide
|
||||
|
||||
Comprehensive guide for capturing App Store screenshots for MeshCore SAR app using Flutter integration tests.
|
||||
|
||||
## Overview
|
||||
|
||||
This project includes automated screenshot capture for:
|
||||
- **App Store submission** (iOS + Android)
|
||||
- **Documentation and training materials**
|
||||
- **Marketing assets**
|
||||
- **Multiple devices and screen sizes**
|
||||
- **Multiple locales** (English, Croatian, Slovenian)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Flutter SDK** installed and configured
|
||||
2. **iOS**: Xcode with simulators installed
|
||||
3. **Android**: Android Studio with emulators configured
|
||||
4. **Dependencies installed**:
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### Take Screenshots (All Devices)
|
||||
|
||||
```bash
|
||||
./scripts/take_screenshots.sh
|
||||
```
|
||||
|
||||
Screenshots will be saved to `screenshots/` directory.
|
||||
|
||||
## Detailed Usage
|
||||
|
||||
### Command Line Options
|
||||
|
||||
```bash
|
||||
# All devices (iOS + Android)
|
||||
./scripts/take_screenshots.sh
|
||||
|
||||
# iOS devices only
|
||||
./scripts/take_screenshots.sh --ios
|
||||
|
||||
# Android devices only
|
||||
./scripts/take_screenshots.sh --android
|
||||
|
||||
# Specific device
|
||||
./scripts/take_screenshots.sh --device "iPhone 15 Pro Max"
|
||||
|
||||
# List available devices
|
||||
./scripts/take_screenshots.sh --list
|
||||
|
||||
# Show help
|
||||
./scripts/take_screenshots.sh --help
|
||||
```
|
||||
|
||||
### Manual Test Execution
|
||||
|
||||
You can also run the integration test manually:
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
flutter drive \
|
||||
--driver=test_driver/integration_test.dart \
|
||||
--target=integration_test/app_screenshots_test.dart \
|
||||
-d "iPhone 15 Pro Max"
|
||||
|
||||
# Android (start emulator first)
|
||||
flutter drive \
|
||||
--driver=test_driver/integration_test.dart \
|
||||
--target=integration_test/app_screenshots_test.dart \
|
||||
-d emulator-5554
|
||||
```
|
||||
|
||||
## Screenshot Coverage
|
||||
|
||||
The automated test captures the following screens:
|
||||
|
||||
1. **Home Screen (Disconnected)** - Initial state showing connect button
|
||||
2. **Messages List** - Messages with SAR markers displayed
|
||||
3. **SAR Marker Detail** - Detailed view of a SAR event
|
||||
4. **Contacts List** - Team members and repeaters
|
||||
5. **Contact Detail** - Individual contact information
|
||||
6. **Map View** - Map with team markers and SAR markers
|
||||
7. **Map Legend** - Legend showing marker types
|
||||
8. **Settings Screen** - App settings and preferences
|
||||
|
||||
## Device Configurations
|
||||
|
||||
### iOS Devices (App Store Requirements)
|
||||
|
||||
The script is configured for App Store screenshot requirements:
|
||||
|
||||
| Device | Screen Size | Resolution | Required for App Store |
|
||||
|--------|-------------|------------|----------------------|
|
||||
| iPhone 15 Pro Max | 6.7" | 1290x2796 | ✅ Yes (primary) |
|
||||
| iPhone 14 Pro Max | 6.7" | 1290x2796 | ✅ Yes (backup) |
|
||||
| iPhone 8 Plus | 5.5" | 1242x2208 | ✅ Yes (smaller size) |
|
||||
|
||||
**App Store Notes:**
|
||||
- 6.7" display is **required** as of 2024
|
||||
- 5.5" display provides compatibility with older devices
|
||||
- Screenshots must be in PNG or JPEG format
|
||||
- Maximum 10 screenshots per device size
|
||||
|
||||
### Android Devices (Google Play Requirements)
|
||||
|
||||
| Device | Type | Resolution | Required for Play Store |
|
||||
|--------|------|------------|------------------------|
|
||||
| Pixel 7 Pro | Phone | 1440x3120 | ✅ Recommended |
|
||||
| Pixel Tablet | Tablet | 2560x1600 | ✅ Recommended |
|
||||
|
||||
**Google Play Notes:**
|
||||
- Phone screenshots: 16:9 or 9:16 ratio recommended
|
||||
- Tablet screenshots: Optional but recommended
|
||||
- Minimum 2 screenshots, maximum 8 per device type
|
||||
- PNG or JPEG format accepted
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
meshcore_sar_app/
|
||||
├── integration_test/
|
||||
│ ├── app_screenshots_test.dart # Main screenshot test
|
||||
│ └── helpers/
|
||||
│ ├── mock_data.dart # Mock contacts, messages, markers
|
||||
│ └── screenshot_helper.dart # Screenshot utilities
|
||||
├── test_driver/
|
||||
│ └── integration_test.dart # Integration test driver
|
||||
├── scripts/
|
||||
│ └── take_screenshots.sh # Automated screenshot script
|
||||
└── screenshots/ # Output directory
|
||||
├── ios/
|
||||
│ ├── iPhone_15_Pro_Max/
|
||||
│ ├── iPhone_14_Pro_Max/
|
||||
│ └── iPhone_8_Plus/
|
||||
└── android/
|
||||
├── pixel_7_pro/
|
||||
└── pixel_tablet/
|
||||
```
|
||||
|
||||
## Mock Data
|
||||
|
||||
The test uses predictable mock data for consistent screenshots:
|
||||
|
||||
### Contacts (6 total)
|
||||
- **Alpha Team Lead** - Battery: 3850mV, 1 hop, -45 dBm
|
||||
- **Bravo Scout** - Battery: 3700mV, 2 hops, -68 dBm
|
||||
- **Charlie Base** - Battery: 4100mV, 0 hops, -35 dBm
|
||||
- **Delta Medic** - Battery: 3600mV, 3 hops, -75 dBm
|
||||
- **Mountain Repeater 1** - Repeater type
|
||||
- **SAR Command Room** - Room type
|
||||
|
||||
### Messages (8 total)
|
||||
- Team communications
|
||||
- SAR marker messages
|
||||
- Public channel broadcasts
|
||||
|
||||
### SAR Markers (3 total)
|
||||
- 🧑 **Found Person** at 46.0589, 14.5078 (Bravo Scout)
|
||||
- 🏕️ **Staging Area** at 46.0549, 14.5038 (Charlie Base)
|
||||
- 🔥 **Fire Location** at 46.0620, 14.5120 (Alpha Team Lead)
|
||||
|
||||
All mock data is defined in `integration_test/helpers/mock_data.dart`.
|
||||
|
||||
## Customization
|
||||
|
||||
### Adding More Screens
|
||||
|
||||
Edit `integration_test/app_screenshots_test.dart`:
|
||||
|
||||
```dart
|
||||
// Navigate to your screen
|
||||
await tester.tapAndSettle(find.text('Your Screen'));
|
||||
|
||||
// Take screenshot
|
||||
await screenshotHelper.takeScreenshot(
|
||||
tester,
|
||||
'your_screen_name',
|
||||
);
|
||||
```
|
||||
|
||||
### Changing Mock Data
|
||||
|
||||
Edit `integration_test/helpers/mock_data.dart`:
|
||||
|
||||
```dart
|
||||
static List<Contact> getMockContacts() {
|
||||
return [
|
||||
Contact(
|
||||
publicKey: '0x...',
|
||||
name: 'Your Contact Name',
|
||||
// ... more fields
|
||||
),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Devices
|
||||
|
||||
Edit `scripts/take_screenshots.sh`:
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
IOS_DEVICES=(
|
||||
"iPhone 15 Pro Max"
|
||||
"Your Device Name"
|
||||
)
|
||||
|
||||
# Android
|
||||
ANDROID_DEVICES=(
|
||||
"pixel_7_pro"
|
||||
"your_emulator_name"
|
||||
)
|
||||
```
|
||||
|
||||
## Localization
|
||||
|
||||
To capture screenshots in different languages:
|
||||
|
||||
1. **Set system language** on simulator/emulator
|
||||
2. **Run the screenshot script**
|
||||
3. **Organize by locale** in output directory
|
||||
|
||||
Example for Croatian screenshots:
|
||||
|
||||
```bash
|
||||
# 1. Set iOS simulator to Croatian
|
||||
xcrun simctl spawn booted defaults write "Apple Global Domain" AppleLanguages -array hr
|
||||
|
||||
# 2. Run screenshots
|
||||
./scripts/take_screenshots.sh --ios
|
||||
|
||||
# 3. Move to locale-specific folder
|
||||
mkdir -p screenshots/ios/hr-HR
|
||||
mv screenshots/ios/iPhone_15_Pro_Max/* screenshots/ios/hr-HR/
|
||||
```
|
||||
|
||||
For automation, you can modify the script to handle locale switching.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Simulator Not Found
|
||||
|
||||
```bash
|
||||
# List available simulators
|
||||
xcrun simctl list devices available
|
||||
|
||||
# Create new simulator
|
||||
xcrun simctl create "iPhone 15 Pro Max" "iPhone 15 Pro Max"
|
||||
```
|
||||
|
||||
### Emulator Issues
|
||||
|
||||
```bash
|
||||
# List available emulators
|
||||
emulator -list-avds
|
||||
|
||||
# Create new emulator (use Android Studio AVD Manager)
|
||||
# Or via command line:
|
||||
avdmanager create avd -n pixel_7_pro -k "system-images;android-33;google_apis;x86_64"
|
||||
```
|
||||
|
||||
### Screenshots Not Appearing
|
||||
|
||||
1. Check test output for errors
|
||||
2. Verify `integration_test/app_screenshots_test.dart` runs successfully
|
||||
3. Check `ScreenshotHelper` is calling `binding.takeScreenshot()`
|
||||
4. Ensure output directory has write permissions
|
||||
|
||||
### Test Times Out
|
||||
|
||||
```bash
|
||||
# Increase timeout in test
|
||||
await tester.pumpAndSettle(const Duration(seconds: 10));
|
||||
|
||||
# Or modify flutter drive timeout
|
||||
flutter drive --timeout=120s ...
|
||||
```
|
||||
|
||||
### BLE/Permissions Errors in Tests
|
||||
|
||||
Integration tests run in a sandboxed environment. The test uses **mock data** instead of real BLE connections, so:
|
||||
|
||||
- ✅ No actual BLE device needed
|
||||
- ✅ No location permissions required
|
||||
- ✅ Predictable, repeatable screenshots
|
||||
- ❌ Cannot test actual BLE connectivity (use manual testing for that)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For App Store Screenshots
|
||||
|
||||
1. **Use the largest device first** (iPhone 15 Pro Max, Pixel 7 Pro)
|
||||
2. **Highlight key features** in each screenshot
|
||||
3. **Add localization** for target markets
|
||||
4. **Keep consistent ordering** across all devices
|
||||
5. **Review before submission** - ensure no sensitive data visible
|
||||
|
||||
### For Quality Screenshots
|
||||
|
||||
1. **Clean state** - Use mock data for predictable content
|
||||
2. **Good lighting** - Ensure sufficient contrast in UI
|
||||
3. **Meaningful content** - Show realistic usage scenarios
|
||||
4. **No debug info** - Disable debug banners/overlays
|
||||
5. **Proper timing** - Wait for animations to complete
|
||||
|
||||
### File Organization
|
||||
|
||||
Recommended structure for App Store submission:
|
||||
|
||||
```
|
||||
screenshots/
|
||||
├── en-US/ # English (default)
|
||||
│ ├── 6.7-inch/ # iPhone 15 Pro Max
|
||||
│ │ ├── 01_home.png
|
||||
│ │ ├── 02_messages.png
|
||||
│ │ └── ...
|
||||
│ ├── 5.5-inch/ # iPhone 8 Plus
|
||||
│ └── android-phone/ # Pixel 7 Pro
|
||||
├── hr-HR/ # Croatian
|
||||
│ └── ...
|
||||
└── sl-SI/ # Slovenian
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Advanced: CI/CD Integration
|
||||
|
||||
For automated screenshot generation in CI/CD pipelines:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/screenshots.yml
|
||||
name: Generate Screenshots
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
jobs:
|
||||
screenshots:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
- name: Take screenshots
|
||||
run: ./scripts/take_screenshots.sh --ios
|
||||
- name: Upload screenshots
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: screenshots
|
||||
path: screenshots/
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Flutter Integration Testing](https://docs.flutter.dev/testing/integration-tests)
|
||||
- [App Store Screenshot Requirements](https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/)
|
||||
- [Google Play Screenshot Requirements](https://support.google.com/googleplay/android-developer/answer/9866151)
|
||||
- [MeshCore SAR Documentation](./CLAUDE.md)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check the [Troubleshooting](#troubleshooting) section
|
||||
2. Review Flutter integration test docs
|
||||
3. Open an issue in the project repository
|
||||
219
TEST_COVERAGE.md
219
TEST_COVERAGE.md
@@ -1,219 +0,0 @@
|
||||
# Critical String Formatting Test Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the comprehensive test coverage for ensuring S: (SAR marker) and D: (drawing) messages are always sent as **raw strings**, never as object representations.
|
||||
|
||||
## Test Results
|
||||
|
||||
✅ **All 45 tests passing**
|
||||
|
||||
- 17 tests for Drawing Message Parser
|
||||
- 28 tests for SAR Message Parser
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Production Code
|
||||
1. `lib/utils/drawing_message_parser.dart` - Added explicit `.toString()` for JSON encoding
|
||||
2. `lib/utils/sar_message_parser.dart` - Added explicit `.toString()` for coordinates
|
||||
3. `lib/screens/messages_tab.dart` - Added explicit `.toString()` for color index
|
||||
|
||||
### Test Files (New)
|
||||
1. `test/utils/drawing_message_parser_test.dart` - 17 comprehensive tests
|
||||
2. `test/utils/sar_message_parser_test.dart` - 28 comprehensive tests
|
||||
|
||||
## Test Categories
|
||||
|
||||
### Drawing Message Tests (`drawing_message_parser_test.dart`)
|
||||
|
||||
#### Critical String Safety Tests
|
||||
- ✅ Returns String type, not Object
|
||||
- ✅ Does NOT contain "Instance of" or "Object"
|
||||
- ✅ Does NOT contain object class names
|
||||
- ✅ Produces parseable JSON after D: prefix
|
||||
- ✅ Coordinates are numbers in JSON, not strings
|
||||
- ✅ JSON is compact and properly encoded
|
||||
|
||||
#### Format Validation Tests
|
||||
- ✅ Line drawings (type 0) format correctly
|
||||
- ✅ Rectangle drawings (type 1) format correctly
|
||||
- ✅ Color indices (0-7) preserved as integers
|
||||
- ✅ Coordinates rounded to 5 decimal places
|
||||
- ✅ Empty points arrays handled
|
||||
- ✅ Large/extreme coordinate values work
|
||||
|
||||
#### Round-Trip Tests
|
||||
- ✅ Create → Parse → Create produces consistent output
|
||||
- ✅ Metadata extraction works correctly
|
||||
- ✅ Type and color names extracted properly
|
||||
|
||||
#### Security Tests
|
||||
- ✅ Sender metadata NOT included in network JSON
|
||||
- ✅ Only compact fields (t, c, p/b) in output
|
||||
- ✅ Special characters don't break format
|
||||
|
||||
### SAR Message Tests (`sar_message_parser_test.dart`)
|
||||
|
||||
#### Critical String Safety Tests
|
||||
- ✅ Returns String type, not Object
|
||||
- ✅ Does NOT contain "Instance of", "Object", or "LatLng"
|
||||
- ✅ Coordinates converted to string form
|
||||
- ✅ Color index converted to string form
|
||||
- ✅ Emoji preserved as UTF-8 character, not code points
|
||||
|
||||
#### Format Validation Tests
|
||||
- ✅ New format: `S:<emoji>:<colorIndex>:<lat>,<lon>:<notes>`
|
||||
- ✅ Color index defaults to 0 when null
|
||||
- ✅ All emoji types (🧑, 🔥, 🏕️) work correctly
|
||||
- ✅ Notes with special characters preserved
|
||||
- ✅ Empty/null notes handled gracefully
|
||||
|
||||
#### Coordinate Validation Tests
|
||||
- ✅ Negative coordinates work
|
||||
- ✅ Extreme valid coordinates (±90°, ±180°) work
|
||||
- ✅ Invalid coordinates (>90°, >180°) rejected
|
||||
- ✅ Zero coordinates (0.0, 0.0) work
|
||||
- ✅ Coordinate precision maintained
|
||||
|
||||
#### Round-Trip Tests
|
||||
- ✅ Create → Parse → Create preserves format
|
||||
- ✅ Backward compatible with old format (no color index)
|
||||
- ✅ Multi-line notes extracted correctly
|
||||
|
||||
#### Specification Compliance
|
||||
- ✅ CLAUDE.md format specification followed
|
||||
- ✅ Message format is compact (<50 chars base)
|
||||
- ✅ No extra whitespace or newlines
|
||||
|
||||
## Critical Safety Checks
|
||||
|
||||
Every test verifies these critical properties:
|
||||
|
||||
### For Drawing Messages (D:)
|
||||
```dart
|
||||
// ✅ Must be String type
|
||||
expect(message, isA<String>());
|
||||
|
||||
// ✅ Must start with D: prefix
|
||||
expect(message, startsWith('D:'));
|
||||
|
||||
// ✅ Must NOT contain object representations
|
||||
expect(message, isNot(contains('Instance of')));
|
||||
expect(message, isNot(contains('Object')));
|
||||
|
||||
// ✅ JSON must be valid after prefix
|
||||
final jsonStr = message.substring(2);
|
||||
expect(() => jsonDecode(jsonStr), returnsNormally);
|
||||
```
|
||||
|
||||
### For SAR Messages (S:)
|
||||
```dart
|
||||
// ✅ Must be String type
|
||||
expect(message, isA<String>());
|
||||
|
||||
// ✅ Must start with S: prefix
|
||||
expect(message, startsWith('S:'));
|
||||
|
||||
// ✅ Must NOT contain object representations
|
||||
expect(message, isNot(contains('Instance of')));
|
||||
expect(message, isNot(contains('LatLng')));
|
||||
|
||||
// ✅ Coordinates must be string-formatted numbers
|
||||
expect(message, contains('37.7749')); // Not "LatLng(37.7749, ...)"
|
||||
```
|
||||
|
||||
## Example Outputs Verified
|
||||
|
||||
### Drawing Messages
|
||||
```
|
||||
Line: D:{"t":0,"c":1,"p":[37.7749,-122.4194,37.775,-122.4195]}
|
||||
Rectangle: D:{"t":1,"c":2,"b":[45.5231,-122.6765,45.51,-122.66]}
|
||||
```
|
||||
|
||||
### SAR Messages
|
||||
```
|
||||
Person: S:🧑:2:37.7749,-122.4194:Found alive
|
||||
Fire: S:🔥:0:40.7128,-74.006:Large wildfire
|
||||
Staging: S:🏕️:4:51.5074,-0.1278:Command center
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all utils tests
|
||||
```bash
|
||||
flutter test test/utils/
|
||||
```
|
||||
|
||||
### Run individual test files
|
||||
```bash
|
||||
flutter test test/utils/drawing_message_parser_test.dart
|
||||
flutter test test/utils/sar_message_parser_test.dart
|
||||
```
|
||||
|
||||
### Run with detailed output
|
||||
```bash
|
||||
flutter test test/utils/ --reporter=expanded
|
||||
```
|
||||
|
||||
## Code Changes Summary
|
||||
|
||||
### 1. Drawing Message Parser
|
||||
```dart
|
||||
// BEFORE
|
||||
final jsonStr = jsonEncode(json);
|
||||
|
||||
// AFTER
|
||||
final jsonStr = jsonEncode(json).toString(); // Explicit string conversion
|
||||
```
|
||||
|
||||
### 2. SAR Message Parser
|
||||
```dart
|
||||
// BEFORE
|
||||
final text = 'S:${type.emoji}:$colorIdx:${location.latitude},${location.longitude}';
|
||||
|
||||
// AFTER
|
||||
final text = 'S:${type.emoji}:$colorIdx:${location.latitude.toString()},${location.longitude.toString()}';
|
||||
```
|
||||
|
||||
### 3. Messages Tab SAR Creation
|
||||
```dart
|
||||
// BEFORE
|
||||
'S:$emoji:$colorIndex:${position.latitude.toStringAsFixed(5)},...'
|
||||
|
||||
// AFTER
|
||||
'S:$emoji:${colorIndex.toString()}:${position.latitude.toStringAsFixed(5)},...'
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Without explicit `.toString()` calls, edge cases could cause:
|
||||
|
||||
1. **Object Leakage**: `LatLng` objects interpolated as `"Instance of 'LatLng'"`
|
||||
2. **Type Coercion Failures**: JSON encoding returning non-String types
|
||||
3. **Network Failures**: Receivers unable to parse malformed messages
|
||||
4. **Data Loss**: Coordinates lost if object representation sent
|
||||
|
||||
## Confidence Level
|
||||
|
||||
🟢 **HIGH CONFIDENCE** - All critical paths tested with:
|
||||
- Type safety verification
|
||||
- Format validation
|
||||
- Round-trip parsing
|
||||
- Edge case coverage
|
||||
- Backward compatibility
|
||||
- Specification compliance
|
||||
|
||||
## Maintenance
|
||||
|
||||
When modifying message formats:
|
||||
|
||||
1. ✅ Run `flutter test test/utils/`
|
||||
2. ✅ Verify all 45 tests pass
|
||||
3. ✅ Add new tests for new message types
|
||||
4. ✅ Update CLAUDE.md if format changes
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `CLAUDE.md` - Protocol specification
|
||||
- `lib/utils/drawing_message_parser.dart` - Drawing message implementation
|
||||
- `lib/utils/sar_message_parser.dart` - SAR message implementation
|
||||
File diff suppressed because it is too large
Load Diff
265
VECTOR_MAPS.md
265
VECTOR_MAPS.md
@@ -1,265 +0,0 @@
|
||||
# Vector Map Tiles with MBTiles - User Guide
|
||||
|
||||
This guide explains how to use offline vector map tiles in MeshCore SAR app.
|
||||
|
||||
## Overview
|
||||
|
||||
The app now supports **offline vector map tiles** using the MBTiles format. Vector tiles provide:
|
||||
|
||||
- ✅ **True Offline Maps**: Work without any internet connection after initial import
|
||||
- ✅ **Smaller File Sizes**: ~70% smaller than raster tiles (e.g., 150MB vs 500MB)
|
||||
- ✅ **Better Performance**: Smooth zooming with over-zooming support
|
||||
- ✅ **Customizable Styles**: Change map appearance without re-downloading tiles
|
||||
- ✅ **SAR-Optimized**: Topographic styles ideal for search & rescue operations
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Download MBTiles File
|
||||
|
||||
Download a vector tile MBTiles file for your region. Recommended sources:
|
||||
|
||||
**Option A: Geofabrik (via MapTiler)** - Recommended for Slovenia
|
||||
```
|
||||
URL: https://geodata.maptiler.download/extracts/osm/v3.11/2020-02-10/europe/osm-2020-02-10-v3.11_europe_slovenia.mbtiles
|
||||
Schema: Shortbread
|
||||
Size: ~150MB (Slovenia)
|
||||
```
|
||||
|
||||
**Option B: Protomaps** - Global coverage
|
||||
```
|
||||
URL: https://maps.protomaps.com/builds/
|
||||
Format: PMTiles (can be converted to MBTiles)
|
||||
```
|
||||
|
||||
**Option C: OpenMapTiles** - Self-hosted
|
||||
```
|
||||
URL: https://openmaptiles.org/downloads/
|
||||
Schema: OpenMapTiles
|
||||
Requires: Account (free tier available)
|
||||
```
|
||||
|
||||
### 2. Import MBTiles File
|
||||
|
||||
1. Open **Settings** → **Map Management**
|
||||
2. Scroll to **"Offline Vector Maps"** section
|
||||
3. Tap **"Import MBTiles File"**
|
||||
4. Select your downloaded `.mbtiles` file
|
||||
5. Wait for import to complete
|
||||
|
||||
### 3. Select Vector Layer
|
||||
|
||||
1. Go to the **Map** tab
|
||||
2. Tap the **Layers** button (bottom-right)
|
||||
3. Select your imported vector map from the list
|
||||
4. The app will automatically download the appropriate style
|
||||
|
||||
### 4. Enjoy Offline Maps!
|
||||
|
||||
Your vector maps now work completely offline. No internet required!
|
||||
|
||||
## Detailed Features
|
||||
|
||||
### File Management
|
||||
|
||||
The **Map Management** screen shows:
|
||||
|
||||
- **File Name**: Name from MBTiles metadata
|
||||
- **File Size**: Human-readable (KB/MB/GB)
|
||||
- **Format**: PBF (vector) or PNG/JPG (raster)
|
||||
- **Zoom Levels**: Min and max zoom supported
|
||||
- **Geographic Bounds**: Coverage area coordinates
|
||||
- **Vector Schema**: Shortbread, OpenMapTiles, or Unknown
|
||||
|
||||
**Actions:**
|
||||
- **Expand Card**: Tap to see full metadata
|
||||
- **Delete**: Tap delete button (confirmation required)
|
||||
- **Refresh**: Tap refresh icon to reload list
|
||||
|
||||
### Supported Vector Schemas
|
||||
|
||||
#### Shortbread (Geofabrik)
|
||||
- **Best for**: European regions, SAR operations
|
||||
- **Style Source**: versatiles.org
|
||||
- **Compatible MBTiles**: Geofabrik extracts
|
||||
- **Compression**: Gzipped PBF data
|
||||
|
||||
#### OpenMapTiles
|
||||
- **Best for**: Global coverage, detailed mapping
|
||||
- **Style Source**: openmaptiles.org or custom
|
||||
- **Compatible MBTiles**: OpenMapTiles downloads
|
||||
- **Compression**: Standard PBF data
|
||||
|
||||
### Map Styles
|
||||
|
||||
Vector tile styles are automatically downloaded from:
|
||||
|
||||
**Versatiles (Shortbread):**
|
||||
```
|
||||
https://tiles.versatiles.org/assets/styles/colorful.json
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Topographic contours
|
||||
- Road classification
|
||||
- Building outlines
|
||||
- Natural features (forests, water)
|
||||
- POI markers
|
||||
|
||||
### Technical Details
|
||||
|
||||
#### Storage Location
|
||||
```
|
||||
iOS: /Documents/offline_maps/
|
||||
Android: /data/data/com.meshcore.sar/files/offline_maps/
|
||||
```
|
||||
|
||||
#### Supported Formats
|
||||
- **Vector**: PBF (Protocol Buffer Format), MVT (Mapbox Vector Tile)
|
||||
- **Compression**: Auto-detected gzip compression
|
||||
- **Schema**: Shortbread, OpenMapTiles, or custom
|
||||
|
||||
#### Performance
|
||||
|
||||
**Slovenia Example (Geofabrik):**
|
||||
- File Size: ~150MB
|
||||
- Zoom Levels: 0-14
|
||||
- Tile Count: ~500,000 tiles
|
||||
- Load Time: <2 seconds
|
||||
|
||||
**Comparison with Raster:**
|
||||
- Raster (same area, zoom 0-16): ~500MB
|
||||
- Vector advantage: **70% smaller**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Import Fails
|
||||
|
||||
**Error**: "Failed to import MBTiles file"
|
||||
|
||||
**Solutions:**
|
||||
1. Verify file is valid MBTiles format (use `mbtiles` CLI to validate)
|
||||
2. Check file permissions (ensure app can read the file)
|
||||
3. Ensure sufficient storage space available
|
||||
4. Try re-downloading the MBTiles file
|
||||
|
||||
### Style Not Loading
|
||||
|
||||
**Error**: "Failed to load map style"
|
||||
|
||||
**Solutions:**
|
||||
1. Check internet connection (required for first-time style download)
|
||||
2. Wait and retry (remote servers may be temporarily down)
|
||||
3. Clear app cache and restart
|
||||
4. Verify MBTiles schema matches style (Shortbread vs OpenMapTiles)
|
||||
|
||||
### Map Not Displaying
|
||||
|
||||
**Symptoms**: Blank map or only showing other layers
|
||||
|
||||
**Solutions:**
|
||||
1. Verify layer is selected in layer picker
|
||||
2. Check zoom level is within MBTiles zoom range
|
||||
3. Pan to area covered by MBTiles bounds
|
||||
4. Restart app to reload layers
|
||||
|
||||
### Black Screen on Map
|
||||
|
||||
**Cause**: Vector theme not loaded yet
|
||||
|
||||
**Solution:** Wait for style download to complete (loading indicator shows progress)
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Using Custom Styles
|
||||
|
||||
To use custom vector tile styles:
|
||||
|
||||
1. Host your style JSON on a web server
|
||||
2. Modify `MapLayer.fromMbtilesFile()` to use your style URL
|
||||
3. Ensure style schema matches your MBTiles schema
|
||||
|
||||
Example style URL format:
|
||||
```
|
||||
https://your-server.com/styles/custom-sar-style.json
|
||||
```
|
||||
|
||||
### Converting Other Formats
|
||||
|
||||
**PMTiles → MBTiles:**
|
||||
```bash
|
||||
# Using tippecanoe
|
||||
pmtiles extract region.pmtiles region.mbtiles
|
||||
```
|
||||
|
||||
**Shapefile → MBTiles:**
|
||||
```bash
|
||||
# Using tippecanoe
|
||||
tippecanoe -o output.mbtiles input.shp
|
||||
```
|
||||
|
||||
### Generating Custom MBTiles
|
||||
|
||||
Use **Tilemaker** to generate MBTiles from OSM data:
|
||||
|
||||
```bash
|
||||
# Download OSM extract
|
||||
wget https://download.geofabrik.de/europe/slovenia-latest.osm.pbf
|
||||
|
||||
# Generate MBTiles with Shortbread schema
|
||||
tilemaker --input slovenia-latest.osm.pbf \
|
||||
--output slovenia-custom.mbtiles \
|
||||
--config shortbread.json \
|
||||
--process shortbread.lua
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
### Documentation
|
||||
- [Vector Map Tiles Package](https://pub.dev/packages/vector_map_tiles)
|
||||
- [MBTiles Specification](https://github.com/mapbox/mbtiles-spec)
|
||||
- [Shortbread Schema](https://shortbread-tiles.org/)
|
||||
- [Versatiles Styles](https://versatiles.org/)
|
||||
|
||||
### Tools
|
||||
- [Tilemaker](https://github.com/systemed/tilemaker) - Generate MBTiles from OSM
|
||||
- [MBTiles CLI](https://github.com/mapbox/mbtiles-spec) - Validate and inspect
|
||||
- [Tippecanoe](https://github.com/felt/tippecanoe) - Convert and optimize tiles
|
||||
|
||||
### Data Sources
|
||||
- [Geofabrik](https://download.geofabrik.de/) - OSM extracts
|
||||
- [Protomaps](https://protomaps.com/) - Pre-generated PMTiles
|
||||
- [OpenMapTiles](https://openmaptiles.org/) - Commercial and free options
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I use multiple MBTiles files at once?**
|
||||
A: Yes! Import multiple files and switch between them using the layer picker.
|
||||
|
||||
**Q: Do I need internet after importing?**
|
||||
A: Only for the first-time style download. After that, fully offline.
|
||||
|
||||
**Q: What's the maximum file size?**
|
||||
A: No hard limit. Tested with files up to 2GB successfully.
|
||||
|
||||
**Q: Can I share MBTiles files between devices?**
|
||||
A: Yes! Export the `.mbtiles` file and import on another device.
|
||||
|
||||
**Q: Do vector tiles work on iOS and Android?**
|
||||
A: Yes! Fully supported on both platforms.
|
||||
|
||||
**Q: How do I update map data?**
|
||||
A: Download a new MBTiles file with updated data and import it.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- GitHub Issues: [meshcore-sar/issues](https://github.com/meshcore-dev/meshcore-sar/issues)
|
||||
- Documentation: See CLAUDE.md for technical details
|
||||
- Community: Join the MeshCore Slack/Discord
|
||||
|
||||
## License
|
||||
|
||||
Vector map tiles implementation uses:
|
||||
- `vector_map_tiles` - MIT License
|
||||
- `vector_map_tiles_mbtiles` - MIT License
|
||||
- Map data copyright OpenStreetMap contributors
|
||||
@@ -1,463 +0,0 @@
|
||||
# Implementation Plan: Generic WMS Server Support
|
||||
|
||||
## Overview
|
||||
Refactor the hardcoded Slovenian WMS implementation to support adding custom WMS servers while preserving existing functionality.
|
||||
|
||||
## Goals
|
||||
1. **Add WMS Server Management** - UI to add/edit/delete custom WMS servers via GetCapabilities URL
|
||||
2. **Layer Selection** - Allow users to select base layers and overlays from any WMS server
|
||||
3. **Preserve Slovenian Layers** - Keep existing EPSG:3794 support and language filtering
|
||||
4. **Dynamic CRS** - Support common coordinate systems (EPSG:3857, 4326, 3794)
|
||||
5. **Base Layer Switching** - Allow switching base layers on top of other layers
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Models & Parsing (Foundation)
|
||||
|
||||
**1.1 Create WMS Server Model** (`lib/models/wms_server.dart`)
|
||||
- `WmsServer` class with id, name, capabilitiesUrl, version, layers list
|
||||
- `WmsLayer` class with name, title, abstract, styles, CRS support, bounds
|
||||
- JSON serialization for persistence
|
||||
|
||||
**1.2 Implement WMS Capabilities Parser** (`lib/services/wms_capabilities_parser.dart`)
|
||||
- Add `xml: ^7.0.0` dependency to pubspec.yaml
|
||||
- Parse WMS 1.3.0 GetCapabilities XML
|
||||
- Extract service metadata, supported formats, CRS list
|
||||
- Recursively parse layer hierarchy (handle groups vs leaf layers)
|
||||
- Extract bounding boxes, styles, and metadata
|
||||
|
||||
**1.3 Create CRS Factory** (`lib/utils/crs_factory.dart`)
|
||||
- Registry for common CRS: EPSG:3857 (Web Mercator), EPSG:4326 (WGS84), EPSG:3794 (Slovenian)
|
||||
- Cache CRS instances to avoid re-creating projection objects
|
||||
- Support for retrieving CRS by EPSG code
|
||||
|
||||
**1.4 Create Storage Repository** (`lib/services/wms_server_repository.dart`)
|
||||
- Store custom WMS servers in SharedPreferences as JSON
|
||||
- CRUD operations: save, load, delete servers
|
||||
- Persist layer visibility state per server/layer
|
||||
|
||||
### Phase 2: UI Implementation
|
||||
|
||||
**2.1 WMS Server Management Screen** (`lib/screens/wms_server_management_screen.dart`)
|
||||
- List of saved WMS servers (edit/delete actions)
|
||||
- "Add WMS Server" button → capabilities URL input dialog
|
||||
- Show loading indicator while fetching GetCapabilities
|
||||
- Parse and preview available layers
|
||||
- Layer picker with checkboxes (base layers vs overlays)
|
||||
- Validate CRS compatibility (warn if unsupported)
|
||||
- Save button stores server + selected layers
|
||||
|
||||
**2.2 Refactor Layer Selector** (`lib/screens/map_tab.dart`)
|
||||
- Keep existing sections: Standard Online Layers, Built-in WMS (Slovenian - language filtered), MBTiles
|
||||
- Add new section: "Custom WMS Servers" (expandable)
|
||||
- Each custom server shows its base layers as selectable tiles
|
||||
- "Manage WMS Servers" button at bottom → opens management screen
|
||||
|
||||
**2.3 Overlay Management**
|
||||
- Extend existing cadastral/forest roads overlay pattern to custom WMS layers
|
||||
- Store overlay visibility per server/layer combination
|
||||
- Add overlay toggles to layer selector when custom WMS base layer active
|
||||
|
||||
### Phase 3: Integration & Testing
|
||||
|
||||
**3.1 Refactor MapLayer Model** (`lib/models/map_layer.dart`)
|
||||
- Add fields: `wmsServerId` (reference to custom server), `crsCode` (EPSG string)
|
||||
- Add `isBuiltIn` flag to distinguish Slovenian layers from custom
|
||||
- Factory method: `MapLayer.fromWmsServer(WmsServer, WmsLayer, Crs)`
|
||||
- Keep existing Slovenian layer factories unchanged
|
||||
|
||||
**3.2 Update MapProvider** (`lib/providers/map_provider.dart`)
|
||||
- Load custom WMS servers on init
|
||||
- Handle base layer switching with CRS changes
|
||||
- Automatically clamp zoom level when switching to layers with lower maxZoom
|
||||
- Persist selected custom WMS layer to SharedPreferences
|
||||
|
||||
**3.3 Tile Caching Integration**
|
||||
- Verify existing `getTileProviderForWms()` works with custom servers
|
||||
- No changes needed (already uses `flutter_map`'s WMS tile URL generation)
|
||||
|
||||
**3.4 Testing**
|
||||
- Test with known WMS servers: NASA GIBS, USGS, OpenStreetMap WMS
|
||||
- Test Slovenian layers still work (no regression)
|
||||
- Test CRS switching (3857 ↔ 4326 ↔ 3794)
|
||||
- Test offline caching with custom WMS tiles
|
||||
- Test error cases: invalid URL, timeout, unsupported CRS, malformed XML
|
||||
|
||||
### Phase 4: Polish & Documentation
|
||||
|
||||
**4.1 Localization**
|
||||
- Add strings to `lib/l10n/app_*.arb` files:
|
||||
- "Add WMS Server", "Capabilities URL", "Custom WMS Servers"
|
||||
- Error messages: "Invalid URL", "Parsing failed", "Unsupported CRS"
|
||||
- Generate with `flutter gen-l10n`
|
||||
|
||||
**4.2 Error Handling**
|
||||
- Network timeout (30s) with user-friendly error
|
||||
- XML parsing errors with "Invalid GetCapabilities response"
|
||||
- Unsupported CRS warning with fallback suggestion
|
||||
- Handle missing layer names, invalid bounds gracefully
|
||||
|
||||
**4.3 Update Documentation** (`CLAUDE.md`)
|
||||
- Document WMS server management workflow
|
||||
- Add custom WMS section to "Common Tasks"
|
||||
- Update architecture diagram with new models/services
|
||||
- Add troubleshooting section for common WMS issues
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### Preservation of Slovenian Layers
|
||||
- **Approach**: Keep existing code paths intact, add `isBuiltIn: true` flag
|
||||
- **Language Filtering**: Remains unchanged (sl/hr only see Slovenian layers)
|
||||
- **CRS**: EPSG:3794 remains as singleton in `slovenian_crs.dart`
|
||||
|
||||
### Storage Strategy
|
||||
- **Choice**: SharedPreferences (JSON serialization)
|
||||
- **Rationale**: Estimated 700KB for 10 servers × 50 layers (under 1MB limit)
|
||||
- **Migration Path**: Can move to SQLite if users need >10 servers
|
||||
|
||||
### CRS Support (Initial Release)
|
||||
- **Supported**: EPSG:3857 (Web Mercator), EPSG:4326 (WGS84), EPSG:3794 (Slovenian)
|
||||
- **Unsupported**: Show warning, prevent selection
|
||||
- **Future**: Add manual CRS configuration for advanced users
|
||||
|
||||
### WMS Version Support
|
||||
- **Phase 1**: WMS 1.3.0 only (most common)
|
||||
- **Future**: Add WMS 1.1.1 (requires axis order handling)
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
**New Files**:
|
||||
- `lib/models/wms_server.dart`
|
||||
- `lib/services/wms_capabilities_parser.dart`
|
||||
- `lib/services/wms_server_repository.dart`
|
||||
- `lib/utils/crs_factory.dart`
|
||||
- `lib/screens/wms_server_management_screen.dart`
|
||||
|
||||
**Modified Files**:
|
||||
- `lib/models/map_layer.dart` (add fields, factory method)
|
||||
- `lib/screens/map_tab.dart` (layer selector UI)
|
||||
- `lib/providers/map_provider.dart` (load custom servers, persistence)
|
||||
- `lib/l10n/app_*.arb` (localized strings)
|
||||
- `pubspec.yaml` (add `xml: ^7.0.0`)
|
||||
- `CLAUDE.md` (documentation update)
|
||||
|
||||
**Unchanged Files** (critical for backward compatibility):
|
||||
- `lib/utils/slovenian_crs.dart` ✅
|
||||
- `lib/services/tile_cache_service.dart` ✅
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Slow GetCapabilities responses (5-30s) | Show loading indicator, 30s timeout, cache for 24h |
|
||||
| Unsupported CRS breaks map | Validate CRS before saving, show warning, fallback to 3857 |
|
||||
| Tile matrix mismatch causes 400 errors | Document limitation, suggest WMTS if available |
|
||||
| Breaking existing Slovenian functionality | Keep all existing code paths, add `isBuiltIn` flag, extensive testing |
|
||||
|
||||
## Estimated Timeline
|
||||
- **Phase 1**: 1-2 weeks (models, parser, storage)
|
||||
- **Phase 2**: 1 week (UI screens)
|
||||
- **Phase 3**: 1-2 weeks (integration, testing)
|
||||
- **Phase 4**: 1 week (polish, docs)
|
||||
- **Total**: 4-6 weeks (single developer, full-time)
|
||||
|
||||
## Success Criteria
|
||||
✅ Users can add custom WMS servers via GetCapabilities URL
|
||||
✅ Users can select base layers and overlays from custom servers
|
||||
✅ Slovenian layers continue to work with language filtering
|
||||
✅ Base layer switching works (including CRS changes)
|
||||
✅ Custom WMS tiles are cached for offline use
|
||||
✅ Clear error messages for invalid/unsupported servers
|
||||
✅ No regressions in existing map functionality
|
||||
|
||||
---
|
||||
|
||||
## Detailed Implementation Notes
|
||||
|
||||
### WMS Server Model Structure
|
||||
|
||||
```dart
|
||||
class WmsServer {
|
||||
final String id; // UUID for persistence
|
||||
final String name; // User-friendly name
|
||||
final String capabilitiesUrl; // GetCapabilities endpoint
|
||||
final String version; // WMS version (1.1.1, 1.3.0)
|
||||
final List<WmsLayer> availableLayers;
|
||||
final List<String> supportedCrs;
|
||||
final DateTime lastUpdated; // Cache invalidation
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
factory WmsServer.fromJson(Map<String, dynamic> json);
|
||||
}
|
||||
|
||||
class WmsLayer {
|
||||
final String name; // Layer identifier
|
||||
final String title; // Human-readable title
|
||||
final String? abstract;
|
||||
final List<String> styles;
|
||||
final LatLngBounds? boundingBox;
|
||||
final List<String> supportedCrs;
|
||||
final bool supportsTransparency;
|
||||
final String? legendUrl;
|
||||
}
|
||||
```
|
||||
|
||||
### WMS GetCapabilities XML Parsing
|
||||
|
||||
Key elements to extract:
|
||||
|
||||
```xml
|
||||
<WMS_Capabilities version="1.3.0">
|
||||
<Service>
|
||||
<Title>Server Name</Title>
|
||||
</Service>
|
||||
<Capability>
|
||||
<Layer>
|
||||
<Title>Root Layer</Title>
|
||||
<CRS>EPSG:4326</CRS>
|
||||
<CRS>EPSG:3857</CRS>
|
||||
<Layer queryable="1">
|
||||
<Name>layer_name</Name>
|
||||
<Title>Layer Title</Title>
|
||||
<CRS>EPSG:3857</CRS>
|
||||
<EX_GeographicBoundingBox>
|
||||
<westBoundLongitude>-180</westBoundLongitude>
|
||||
<eastBoundLongitude>180</eastBoundLongitude>
|
||||
<southBoundLatitude>-90</southBoundLatitude>
|
||||
<northBoundLatitude>90</northBoundLatitude>
|
||||
</EX_GeographicBoundingBox>
|
||||
<Style>
|
||||
<Name>default</Name>
|
||||
</Style>
|
||||
</Layer>
|
||||
</Layer>
|
||||
</Capability>
|
||||
</WMS_Capabilities>
|
||||
```
|
||||
|
||||
### CRS Factory Implementation
|
||||
|
||||
```dart
|
||||
class CrsFactory {
|
||||
static final Map<String, Crs> _crsCache = {
|
||||
'EPSG:3794': getSlovenianCrs(),
|
||||
'EPSG:3857': const Epsg3857(),
|
||||
'EPSG:4326': const Epsg4326(),
|
||||
};
|
||||
|
||||
static Crs? getCrs(String epsgCode) {
|
||||
return _crsCache[epsgCode];
|
||||
}
|
||||
|
||||
static bool isSupported(String epsgCode) {
|
||||
return _crsCache.containsKey(epsgCode);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Storage JSON Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"name": "My WMS Server",
|
||||
"capabilitiesUrl": "https://example.com/wms?SERVICE=WMS&REQUEST=GetCapabilities",
|
||||
"version": "1.3.0",
|
||||
"lastUpdated": "2025-10-28T12:00:00Z",
|
||||
"layers": [
|
||||
{
|
||||
"name": "layer_name",
|
||||
"title": "Layer Title",
|
||||
"abstract": "Description...",
|
||||
"supportedCrs": ["EPSG:3857", "EPSG:4326"],
|
||||
"boundingBox": {
|
||||
"south": -90, "west": -180,
|
||||
"north": 90, "east": 180
|
||||
},
|
||||
"styles": ["default"],
|
||||
"supportsTransparency": true
|
||||
}
|
||||
],
|
||||
"supportedCrs": ["EPSG:3857", "EPSG:4326"]
|
||||
}
|
||||
```
|
||||
|
||||
### UI Flow Diagrams
|
||||
|
||||
**Adding a WMS Server**:
|
||||
1. User taps "Manage WMS Servers" in Map Management screen
|
||||
2. User taps "Add WMS Server" button
|
||||
3. Dialog appears with text field for capabilities URL
|
||||
4. User enters URL (e.g., `https://prostor.zgs.gov.si/geoserver/wms?SERVICE=WMS&REQUEST=GetCapabilities`)
|
||||
5. App fetches and parses GetCapabilities
|
||||
6. Layer picker shows available layers with checkboxes
|
||||
7. User selects layers to use as base layers or overlays
|
||||
8. User taps "Save" → server stored in SharedPreferences
|
||||
9. Layers appear in map layer selector
|
||||
|
||||
**Using a Custom WMS Layer**:
|
||||
1. User opens layer selector in Map tab
|
||||
2. User scrolls to "Custom WMS Servers" section
|
||||
3. User taps custom layer → map switches to that layer
|
||||
4. If CRS differs from previous layer, map CRS updates
|
||||
5. If zoom level exceeds layer's maxZoom, zoom is clamped
|
||||
6. Overlay toggles appear if custom server has overlay layers
|
||||
|
||||
### Testing Checklist
|
||||
|
||||
**Functional Tests**:
|
||||
- [ ] Add WMS server with valid GetCapabilities URL
|
||||
- [ ] Parse layers with nested hierarchy (group layers)
|
||||
- [ ] Select base layer from custom WMS server
|
||||
- [ ] Switch between standard tile layer and custom WMS layer
|
||||
- [ ] Switch between custom WMS layers with different CRS
|
||||
- [ ] Toggle overlay layers from custom WMS server
|
||||
- [ ] Delete custom WMS server
|
||||
- [ ] Edit custom WMS server (re-fetch capabilities)
|
||||
- [ ] Persist selected custom WMS layer across app restarts
|
||||
|
||||
**Error Handling Tests**:
|
||||
- [ ] Invalid URL (malformed)
|
||||
- [ ] Network timeout (30s)
|
||||
- [ ] Invalid XML (not a GetCapabilities response)
|
||||
- [ ] Empty layer list
|
||||
- [ ] Unsupported CRS (show warning, prevent selection)
|
||||
- [ ] Missing required fields (layer name, title)
|
||||
|
||||
**Regression Tests**:
|
||||
- [ ] Slovenian aerial imagery still loads
|
||||
- [ ] Slovenian overlays (cadastral, forest roads) still work
|
||||
- [ ] Language filtering (sl/hr) still hides WMS layers for other locales
|
||||
- [ ] EPSG:3794 CRS still works correctly
|
||||
- [ ] Standard tile layers (OSM, OpenTopoMap) still work
|
||||
- [ ] MBTiles offline layers still work
|
||||
- [ ] Tile caching still works for WMS tiles
|
||||
|
||||
**Performance Tests**:
|
||||
- [ ] GetCapabilities fetch completes within 30s
|
||||
- [ ] Large layer lists (>100 layers) render without lag
|
||||
- [ ] Switching layers is smooth (no UI freeze)
|
||||
- [ ] SharedPreferences storage under 1MB for 10 servers
|
||||
|
||||
### Example WMS Servers for Testing
|
||||
|
||||
**Public WMS Servers**:
|
||||
1. **NASA GIBS** (satellite imagery):
|
||||
- URL: `https://gibs.earthdata.nasa.gov/wms/epsg4326/best/wms.cgi?SERVICE=WMS&REQUEST=GetCapabilities`
|
||||
- CRS: EPSG:4326
|
||||
- Layers: MODIS, VIIRS, Landsat
|
||||
|
||||
2. **USGS National Map** (US topographic):
|
||||
- URL: `https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer?SERVICE=WMS&REQUEST=GetCapabilities`
|
||||
- CRS: EPSG:3857
|
||||
- Layers: US Topo
|
||||
|
||||
3. **OpenStreetMap WMS** (reference):
|
||||
- URL: `https://ows.terrestris.de/osm/service?SERVICE=WMS&REQUEST=GetCapabilities`
|
||||
- CRS: EPSG:3857, EPSG:4326
|
||||
- Layers: OSM-WMS
|
||||
|
||||
4. **Slovenian Government** (current built-in):
|
||||
- URL: `https://prostor.zgs.gov.si/geowebcache/service/wms?SERVICE=WMS&REQUEST=GetCapabilities`
|
||||
- CRS: EPSG:3794, EPSG:3857
|
||||
- Layers: DOF_2024, kn_parcele, gozdne_ceste
|
||||
|
||||
### Critical Gotchas
|
||||
|
||||
**WMS Version Differences**:
|
||||
- WMS 1.1.1 uses `<SRS>`, WMS 1.3.0 uses `<CRS>`
|
||||
- EPSG:4326 axis order differs (lon,lat vs lat,lon)
|
||||
- bbox parameter order changes between versions
|
||||
|
||||
**Layer Inheritance**:
|
||||
- Child layers inherit CRS from parent if not specified
|
||||
- Root layer CRS applies to all children unless overridden
|
||||
|
||||
**Group vs Leaf Layers**:
|
||||
- Only layers with `<Name>` can be requested in GetMap
|
||||
- Layers without `<Name>` are groups (organizational only)
|
||||
|
||||
**Namespace Prefixes**:
|
||||
- Layer names may include workspace namespace (e.g., `pregledovalnik:DOF_2024`)
|
||||
- Must be included in GetMap request exactly as in GetCapabilities
|
||||
|
||||
**Tile Grid Alignment**:
|
||||
- WMS uses arbitrary bounding boxes (not aligned tile grids)
|
||||
- Works for dynamic rendering but may have caching issues
|
||||
- WMTS is better for tile caching but requires separate implementation
|
||||
|
||||
### Future Enhancements (Post-MVP)
|
||||
|
||||
**Phase 5: Advanced Features** (not in initial scope):
|
||||
- WMS 1.1.1 support (axis order handling)
|
||||
- WMTS support (better tile caching)
|
||||
- Custom CRS registration (manual proj4 definition input)
|
||||
- Layer groups (hierarchical tree view)
|
||||
- Legend display for overlay layers
|
||||
- GetFeatureInfo support (tap on map to query layer attributes)
|
||||
- Layer metadata viewer (abstract, attribution, keywords)
|
||||
- Batch import/export of WMS server configurations
|
||||
|
||||
**Phase 6: Performance Optimization**:
|
||||
- Background GetCapabilities refresh (update layer list without blocking UI)
|
||||
- Lazy loading of layer metadata (only fetch when user expands server)
|
||||
- Thumbnail preview for layers (GetMap with small bbox)
|
||||
- Server health check (ping before adding)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **WMS 1.3.0 Specification**: https://www.ogc.org/standards/wms
|
||||
- **EPSG Registry**: https://epsg.io/
|
||||
- **Proj4 Definitions**: https://proj4.org/
|
||||
- **flutter_map WMS Docs**: https://docs.fleaflet.dev/layers/tile-layer/wms-tile-layer
|
||||
- **xml package**: https://pub.dev/packages/xml
|
||||
- **Slovenian WMS**: https://prostor.zgs.gov.si/geowebcache/service/wms?REQUEST=GetCapabilities&SERVICE=WMS
|
||||
- **NASA GIBS**: https://wiki.earthdata.nasa.gov/display/GIBS/GIBS+API+for+Developers
|
||||
- **OGC WMS Best Practices**: https://www.ogc.org/standards/wms
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Add `xml: ^7.0.0` to pubspec.yaml
|
||||
- [ ] Create `lib/models/wms_server.dart` with JSON serialization
|
||||
- [ ] Create `lib/services/wms_capabilities_parser.dart` with XML parsing
|
||||
- [ ] Create `lib/utils/crs_factory.dart` with CRS registry
|
||||
- [ ] Create `lib/services/wms_server_repository.dart` with SharedPreferences storage
|
||||
- [ ] Write unit tests for capabilities parser
|
||||
|
||||
### Phase 2: UI
|
||||
- [ ] Create `lib/screens/wms_server_management_screen.dart`
|
||||
- [ ] Add "Manage WMS Servers" button to Map Management screen
|
||||
- [ ] Implement "Add WMS Server" dialog with URL input
|
||||
- [ ] Implement layer picker with checkbox selection
|
||||
- [ ] Add custom WMS section to layer selector in `map_tab.dart`
|
||||
- [ ] Add "Manage WMS Servers" button to layer selector
|
||||
|
||||
### Phase 3: Integration
|
||||
- [ ] Add `wmsServerId`, `crsCode`, `isBuiltIn` fields to `MapLayer`
|
||||
- [ ] Add `MapLayer.fromWmsServer()` factory method
|
||||
- [ ] Update `MapProvider` to load custom WMS servers on init
|
||||
- [ ] Update layer switching logic to handle CRS changes
|
||||
- [ ] Update zoom clamping logic for custom layers
|
||||
- [ ] Add overlay management for custom WMS layers
|
||||
- [ ] Verify tile caching works with custom WMS
|
||||
|
||||
### Phase 4: Polish
|
||||
- [ ] Add localized strings to all `app_*.arb` files (en, hr, sl, de, es, fr, it)
|
||||
- [ ] Add error handling with user-friendly messages
|
||||
- [ ] Add loading indicators for GetCapabilities fetch
|
||||
- [ ] Add CRS compatibility warnings
|
||||
- [ ] Update `CLAUDE.md` with WMS documentation
|
||||
- [ ] Test with multiple public WMS servers
|
||||
- [ ] Regression test Slovenian layers
|
||||
- [ ] Performance test with large layer lists
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2025-10-28
|
||||
**Status**: Ready for Implementation
|
||||
@@ -1,726 +0,0 @@
|
||||
# iOS Background Execution Guide
|
||||
|
||||
Complete guide for running MeshCore SAR in the background on iOS devices.
|
||||
|
||||
## Current Configuration Status
|
||||
|
||||
### ✅ Already Configured
|
||||
|
||||
Your app is **already set up** for background execution with the following capabilities:
|
||||
|
||||
#### 1. Info.plist Background Modes ([Info.plist:67-74](../ios/Runner/Info.plist#L67-L74))
|
||||
|
||||
```xml
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>location</string> <!-- GPS tracking in background -->
|
||||
<string>bluetooth-central</string> <!-- BLE communication in background -->
|
||||
<string>processing</string> <!-- Background processing tasks -->
|
||||
<string>external-accessory</string> <!-- External accessory communication -->
|
||||
<string>fetch</string> <!-- Background fetch updates -->
|
||||
</array>
|
||||
```
|
||||
|
||||
#### 2. Location Permissions ([Info.plist:53-60](../ios/Runner/Info.plist#L53-L60))
|
||||
|
||||
```xml
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>MeshCore SAR needs location access for offline map functionality during field operations</string>
|
||||
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>MeshCore SAR needs location access to display team members and SAR markers on the map</string>
|
||||
|
||||
<key>NSLocationTemporaryPreciseUsageDescription</key>
|
||||
<string>MeshCore SAR needs precise location for accurate positioning in SAR operations</string>
|
||||
```
|
||||
|
||||
#### 3. Bluetooth Permissions ([Info.plist:49-52](../ios/Runner/Info.plist#L49-L52))
|
||||
|
||||
```xml
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
||||
```
|
||||
|
||||
#### 4. Background Task Identifiers ([Info.plist:5-8](../ios/Runner/Info.plist#L5-L8))
|
||||
|
||||
```xml
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>dev.flutter.background.refresh</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How iOS Background Modes Work
|
||||
|
||||
### 1. Location Background Mode (`location`)
|
||||
|
||||
**What it does:**
|
||||
- Keeps GPS active when app is backgrounded
|
||||
- Delivers location updates to your app
|
||||
- Shows blue status bar indicator: "MeshCore SAR is using your location"
|
||||
|
||||
**How to use:**
|
||||
```dart
|
||||
// Request "Always" permission (required for background location)
|
||||
await Geolocator.requestPermission();
|
||||
|
||||
// Start tracking with distance filter
|
||||
final settings = LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: 10, // meters
|
||||
);
|
||||
|
||||
final stream = Geolocator.getPositionStream(locationSettings: settings);
|
||||
stream.listen((position) {
|
||||
// This callback runs even when app is in background!
|
||||
debugPrint('Background position: ${position.latitude}, ${position.longitude}');
|
||||
});
|
||||
```
|
||||
|
||||
**Current implementation:** [background_location_service.dart:69-75](../lib/services/background_location_service.dart#L69-L75)
|
||||
|
||||
**Battery impact:** Medium-High (depends on accuracy and distance filter)
|
||||
|
||||
**iOS limitations:**
|
||||
- User must grant "Always Allow" location permission
|
||||
- iOS shows blue banner when app uses background location
|
||||
- Location updates may be deferred to save battery
|
||||
- Maximum accuracy may be reduced after some time
|
||||
|
||||
---
|
||||
|
||||
### 2. Bluetooth Central Background Mode (`bluetooth-central`)
|
||||
|
||||
**What it does:**
|
||||
- Keeps BLE connections alive when app is backgrounded
|
||||
- Receives BLE notifications and read responses
|
||||
- Can scan for known devices (limited)
|
||||
|
||||
**How to use:**
|
||||
```dart
|
||||
// flutter_blue_plus automatically uses background mode
|
||||
await device.connect(); // Connection stays alive in background
|
||||
|
||||
// Subscribe to characteristics - notifications work in background
|
||||
await characteristic.setNotifyValue(true);
|
||||
characteristic.lastValueStream.listen((data) {
|
||||
// This callback runs even when app is in background!
|
||||
debugPrint('Background BLE data: $data');
|
||||
});
|
||||
```
|
||||
|
||||
**Current implementation:**
|
||||
- [meshcore_ble_service.dart](../lib/services/meshcore_ble_service.dart) - BLE communication
|
||||
- Connection and notifications already support background mode
|
||||
|
||||
**Battery impact:** Low-Medium
|
||||
|
||||
**iOS limitations:**
|
||||
- Cannot start new connections from background (must be initiated in foreground)
|
||||
- BLE scanning in background only finds previously connected devices
|
||||
- Scanning is slower and less frequent in background
|
||||
- Connection timeouts may be more aggressive
|
||||
|
||||
---
|
||||
|
||||
### 3. Processing Background Mode (`processing`)
|
||||
|
||||
**What it does:**
|
||||
- Schedules background tasks to run when system conditions are optimal
|
||||
- Used for non-urgent work (data sync, cache cleanup, etc.)
|
||||
- System decides when to run tasks (not guaranteed)
|
||||
|
||||
**How to use:**
|
||||
Requires `workmanager` package:
|
||||
```yaml
|
||||
dependencies:
|
||||
workmanager: ^0.5.0
|
||||
```
|
||||
|
||||
```dart
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
void callbackDispatcher() {
|
||||
Workmanager().executeTask((task, inputData) {
|
||||
// This runs in background (system decides when)
|
||||
debugPrint('Background task: $task');
|
||||
return Future.value(true);
|
||||
});
|
||||
}
|
||||
|
||||
void main() {
|
||||
Workmanager().initialize(callbackDispatcher);
|
||||
|
||||
Workmanager().registerPeriodicTask(
|
||||
'mesh-sync',
|
||||
'meshSync',
|
||||
frequency: Duration(hours: 1),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Current implementation:** Not currently used (task identifier registered but no handler)
|
||||
|
||||
**Battery impact:** Low (system schedules intelligently)
|
||||
|
||||
**iOS limitations:**
|
||||
- Only runs when device is idle, plugged in, or has sufficient battery
|
||||
- Minimum 15-minute intervals
|
||||
- No guarantees on execution time
|
||||
- May not run at all if battery is low
|
||||
|
||||
---
|
||||
|
||||
### 4. Fetch Background Mode (`fetch`)
|
||||
|
||||
**What it does:**
|
||||
- Allows app to wake up periodically to fetch new content
|
||||
- System learns usage patterns and schedules fetch intelligently
|
||||
- More frequent than processing mode, but still not real-time
|
||||
|
||||
**How to use:**
|
||||
Requires `background_fetch` package:
|
||||
```yaml
|
||||
dependencies:
|
||||
background_fetch: ^1.3.0
|
||||
```
|
||||
|
||||
```dart
|
||||
import 'package:background_fetch/background_fetch.dart';
|
||||
|
||||
void backgroundFetchHandler(String taskId) async {
|
||||
debugPrint('Background fetch: $taskId');
|
||||
|
||||
// Fetch new messages, sync data, etc.
|
||||
await syncMessages();
|
||||
|
||||
BackgroundFetch.finish(taskId);
|
||||
}
|
||||
|
||||
void initBackgroundFetch() {
|
||||
BackgroundFetch.configure(
|
||||
BackgroundFetchConfig(
|
||||
minimumFetchInterval: 15, // minutes
|
||||
stopOnTerminate: false,
|
||||
enableHeadless: true,
|
||||
),
|
||||
backgroundFetchHandler,
|
||||
).then((status) {
|
||||
debugPrint('Background fetch status: $status');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Current implementation:** Not currently used
|
||||
|
||||
**Battery impact:** Low-Medium
|
||||
|
||||
**iOS limitations:**
|
||||
- System decides when to wake app (typically every 15-30 minutes)
|
||||
- No guarantees on timing
|
||||
- May not run if battery is low
|
||||
- Requires network activity to "train" iOS
|
||||
|
||||
---
|
||||
|
||||
## Best Practices for SAR Operations
|
||||
|
||||
### Recommended Configuration
|
||||
|
||||
For a **Search & Rescue application**, prioritize real-time updates:
|
||||
|
||||
#### 1. Use Location Background Mode Exclusively
|
||||
|
||||
**Why:** Only location mode provides continuous updates while backgrounded.
|
||||
|
||||
**Implementation:**
|
||||
```dart
|
||||
// Start location tracking when BLE connects
|
||||
await BackgroundLocationService().startTracking(distanceThreshold: 10.0);
|
||||
|
||||
// Location updates automatically trigger mesh broadcasts
|
||||
// See: background_location_service.dart:75-135
|
||||
```
|
||||
|
||||
**User experience:**
|
||||
- Blue status bar shows "using location"
|
||||
- Reassures users that tracking is active
|
||||
- Critical for SAR where real-time location is life-or-death
|
||||
|
||||
#### 2. Request "Always Allow" Location Permission
|
||||
|
||||
**Why:** "When In Use" permission is revoked when app enters background.
|
||||
|
||||
**Implementation:**
|
||||
```dart
|
||||
// Check permission
|
||||
final permission = await Geolocator.checkPermission();
|
||||
|
||||
if (permission != LocationPermission.always) {
|
||||
// Show explanation to user
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Background Location Required'),
|
||||
content: Text(
|
||||
'For SAR operations, we need "Always Allow" permission to track '
|
||||
'your location even when the app is in the background. This ensures '
|
||||
'your team can always see your position.'
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Geolocator.openLocationSettings();
|
||||
},
|
||||
child: Text('Open Settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**User education:**
|
||||
- Explain why "Always" is needed (safety, team coordination)
|
||||
- Show value: "Your team can find you in an emergency"
|
||||
- Emphasize battery impact and mitigation strategies
|
||||
|
||||
#### 3. Optimize Battery Life
|
||||
|
||||
**Distance Filter:**
|
||||
```dart
|
||||
// Don't send updates for small movements
|
||||
const minDistance = 10.0; // meters
|
||||
|
||||
// Adjust based on terrain and operation type:
|
||||
// - Urban search: 5m (frequent position changes)
|
||||
// - Wilderness: 20m (sparse updates OK)
|
||||
// - Vehicle-based: 50m (high speed, less precision needed)
|
||||
```
|
||||
|
||||
**Accuracy vs. Battery:**
|
||||
```dart
|
||||
// High accuracy (GPS + GLONASS + Galileo)
|
||||
LocationAccuracy.best // Best for SAR, but drains battery
|
||||
|
||||
// Balanced (GPS only)
|
||||
LocationAccuracy.high // Good compromise
|
||||
|
||||
// Low accuracy (WiFi/cell towers)
|
||||
LocationAccuracy.low // NOT recommended for SAR
|
||||
```
|
||||
|
||||
**Current implementation:** Uses `LocationAccuracy.best` ([background_location_service.dart:72](../lib/services/background_location_service.dart#L72))
|
||||
|
||||
#### 4. Handle Background BLE Properly
|
||||
|
||||
**Connection Strategy:**
|
||||
```dart
|
||||
// Connect in foreground
|
||||
await device.connect(autoConnect: true);
|
||||
|
||||
// Keep connection alive
|
||||
device.connectionState.listen((state) {
|
||||
if (state == BluetoothConnectionState.disconnected) {
|
||||
// Attempt reconnect (only works if app is in foreground or shortly after background)
|
||||
Future.delayed(Duration(seconds: 5), () {
|
||||
device.connect(autoConnect: true);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Characteristic Notifications:**
|
||||
```dart
|
||||
// Subscribe to notifications (works in background)
|
||||
await characteristic.setNotifyValue(true);
|
||||
|
||||
// Process incoming data
|
||||
characteristic.lastValueStream.listen((data) {
|
||||
// Parse MeshCore frames even when backgrounded
|
||||
final frame = FrameParser.parse(data);
|
||||
// Update UI, save to database, trigger notifications
|
||||
});
|
||||
```
|
||||
|
||||
**Current implementation:** [meshcore_ble_service.dart](../lib/services/meshcore_ble_service.dart) already handles this correctly.
|
||||
|
||||
---
|
||||
|
||||
## iOS Background Limitations
|
||||
|
||||
### What Works in Background
|
||||
|
||||
✅ **Location Updates** - Continuous GPS tracking
|
||||
✅ **BLE Notifications** - Receive data from connected device
|
||||
✅ **BLE Reads/Writes** - Communicate with connected device
|
||||
✅ **Local Notifications** - Display alerts to user
|
||||
✅ **Audio Playback** - Play alert sounds
|
||||
✅ **Network Requests** - Sync data, send telemetry
|
||||
|
||||
### What Doesn't Work in Background
|
||||
|
||||
❌ **BLE Scanning** - Cannot discover new devices (limited scanning only for known UUIDs)
|
||||
❌ **New BLE Connections** - Cannot initiate connections (must be done in foreground)
|
||||
❌ **Heavy Processing** - CPU throttled, may cause crashes
|
||||
❌ **Camera/Photos** - Cannot access camera or photo library
|
||||
❌ **Screen Rendering** - UI doesn't update (use local notifications instead)
|
||||
|
||||
### System Throttling
|
||||
|
||||
**iOS aggressively throttles background apps:**
|
||||
|
||||
| Time in Background | GPS Accuracy | BLE Performance | CPU Quota |
|
||||
|--------------------|--------------|-----------------|-----------|
|
||||
| 0-10 seconds | Full | Full | 100% |
|
||||
| 10 seconds - 3 minutes | Full | Full | 80% |
|
||||
| 3-10 minutes | Reduced | Full | 50% |
|
||||
| 10+ minutes | Deferred | Throttled | 20% |
|
||||
| 30+ minutes | Significant deferral | Slow | 10% |
|
||||
|
||||
**Mitigation:**
|
||||
- Keep BLE data payloads small
|
||||
- Batch location broadcasts (don't send every update)
|
||||
- Use background tasks for non-critical work
|
||||
|
||||
---
|
||||
|
||||
## Testing Background Execution
|
||||
|
||||
### 1. Xcode Console Monitoring
|
||||
|
||||
```bash
|
||||
# Open Console.app and filter by your app
|
||||
# Look for debug prints with timestamps
|
||||
|
||||
# Expected logs when backgrounded:
|
||||
📍 [BackgroundLocation] New position: 37.7749, -122.4194
|
||||
📤 [BackgroundLocation] Updating device location...
|
||||
📡 [BackgroundLocation] Broadcasting self advertisement...
|
||||
✅ [BackgroundLocation] Location update sent successfully
|
||||
```
|
||||
|
||||
### 2. Xcode Debug Navigator
|
||||
|
||||
1. Run app from Xcode
|
||||
2. Press Home button to background app
|
||||
3. In Xcode: Debug → View Debugging → Background Tasks
|
||||
4. Verify "location" task is active
|
||||
|
||||
### 3. Background Simulation
|
||||
|
||||
```bash
|
||||
# Simulate location changes in Simulator
|
||||
xcrun simctl location <device-id> set 37.7749 -122.4194
|
||||
|
||||
# Simulate BLE data (requires real device)
|
||||
# Send data via nRF Connect or LightBlue
|
||||
```
|
||||
|
||||
### 4. Real-World Testing
|
||||
|
||||
**Recommended test procedure:**
|
||||
1. Start app in foreground
|
||||
2. Connect to MeshCore device
|
||||
3. Start location tracking
|
||||
4. **Lock screen** (simulates background)
|
||||
5. Walk 50+ meters
|
||||
6. Unlock and check:
|
||||
- Location updates in logs
|
||||
- Mesh broadcasts sent
|
||||
- Battery usage acceptable
|
||||
|
||||
**Important:** iOS treats locked screen differently than backgrounded app!
|
||||
- Locked = Full background capabilities
|
||||
- Backgrounded (app switcher) = Throttled after 3 minutes
|
||||
- Terminated = No background execution (must use background fetch)
|
||||
|
||||
---
|
||||
|
||||
## User-Facing Settings
|
||||
|
||||
### Recommended Settings Screen
|
||||
|
||||
```dart
|
||||
class BackgroundTrackingSettings extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: Text('Background Location Tracking'),
|
||||
subtitle: Text('Keep tracking your location when app is closed'),
|
||||
value: _isEnabled,
|
||||
onChanged: (enabled) {
|
||||
if (enabled) {
|
||||
_startBackgroundTracking();
|
||||
} else {
|
||||
_stopBackgroundTracking();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
ListTile(
|
||||
title: Text('Update Distance'),
|
||||
subtitle: Text('Send location update every $_distance meters'),
|
||||
trailing: Text('$_distance m'),
|
||||
onTap: () => _showDistanceSlider(),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
title: Text('Battery Impact'),
|
||||
subtitle: Text(_getBatteryImpactText()),
|
||||
trailing: Icon(_getBatteryIcon()),
|
||||
),
|
||||
|
||||
// Permission check
|
||||
if (!_hasAlwaysPermission)
|
||||
ListTile(
|
||||
title: Text('⚠️ Permission Required'),
|
||||
subtitle: Text('Tap to enable "Always Allow" location access'),
|
||||
onTap: () => Geolocator.openLocationSettings(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Battery Impact Indicators
|
||||
|
||||
```dart
|
||||
String _getBatteryImpactText() {
|
||||
if (_distance <= 5) return 'High - Frequent updates';
|
||||
if (_distance <= 20) return 'Medium - Balanced';
|
||||
if (_distance <= 50) return 'Low - Sparse updates';
|
||||
return 'Minimal - Only major movements';
|
||||
}
|
||||
|
||||
IconData _getBatteryIcon() {
|
||||
if (_distance <= 5) return Icons.battery_alert;
|
||||
if (_distance <= 20) return Icons.battery_std;
|
||||
return Icons.battery_full;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Location Updates Stop After 10 Minutes
|
||||
|
||||
**Symptom:** GPS stops updating after app is backgrounded for ~10 minutes
|
||||
|
||||
**Cause:** iOS defers location updates to save battery
|
||||
|
||||
**Solution:**
|
||||
```dart
|
||||
// Request "Always" permission (not just "When In Use")
|
||||
await Geolocator.requestPermission();
|
||||
|
||||
// In Info.plist, ensure you have:
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Your explanation here</string>
|
||||
|
||||
// Use smaller distance filter to signal importance
|
||||
LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: 5, // smaller = less deferral
|
||||
)
|
||||
```
|
||||
|
||||
### BLE Connection Drops in Background
|
||||
|
||||
**Symptom:** BLE device disconnects after ~30 seconds in background
|
||||
|
||||
**Cause:** iOS terminates idle BLE connections to save power
|
||||
|
||||
**Solution:**
|
||||
```dart
|
||||
// Keep connection alive by polling
|
||||
Timer.periodic(Duration(seconds: 20), (timer) async {
|
||||
if (device.isConnected) {
|
||||
// Read a characteristic to keep connection alive
|
||||
await characteristic.read();
|
||||
}
|
||||
});
|
||||
|
||||
// Or send keepalive messages
|
||||
Timer.periodic(Duration(seconds: 30), (timer) async {
|
||||
if (device.isConnected) {
|
||||
await bleService.sendSelfAdvert(floodMode: false);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Current implementation:**
|
||||
- Keepalive timer reads TX characteristic every 20 seconds ([meshcore_ble_service.dart:648-677](../lib/services/meshcore_ble_service.dart#L648-L677))
|
||||
- Location updates also keep connection alive ([background_location_service.dart:112-121](../lib/services/background_location_service.dart#L112-L121))
|
||||
- Timer automatically starts when connected, stops when disconnected
|
||||
- Logs connection health: "💚 [BLE] Keepalive: Connection maintained"
|
||||
|
||||
### Blue Status Bar Annoying Users
|
||||
|
||||
**Symptom:** Users complain about persistent blue "using location" banner
|
||||
|
||||
**Explanation:** This is **intentional** iOS behavior for user privacy
|
||||
|
||||
**Options:**
|
||||
1. **Keep it** - Shows app is working, builds trust
|
||||
2. **Educate users** - Explain it's a safety feature
|
||||
3. **Toggle option** - Let users disable background tracking if not in active SAR operation
|
||||
|
||||
**DO NOT try to hide it** - This is an Apple HIG violation and will get your app rejected
|
||||
|
||||
### Notifications Not Appearing in Background
|
||||
|
||||
**Symptom:** Local notifications don't show when app is backgrounded
|
||||
|
||||
**Cause:** Permission not granted or critical alerts not enabled
|
||||
|
||||
**Solution:**
|
||||
```dart
|
||||
// Request critical notification permission (bypasses silent mode)
|
||||
await IOSFlutterLocalNotificationsPlugin().requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
critical: true, // Important for SAR alerts
|
||||
);
|
||||
```
|
||||
|
||||
**Current implementation:** Already configured ([notification_service.dart:92-99](../lib/services/notification_service.dart#L92-L99))
|
||||
|
||||
---
|
||||
|
||||
## Battery Optimization Recommendations
|
||||
|
||||
### 1. Adaptive Distance Thresholds
|
||||
|
||||
```dart
|
||||
// Adjust based on movement speed
|
||||
class AdaptiveLocationTracking {
|
||||
double _distanceThreshold = 10.0;
|
||||
|
||||
void _adjustThreshold(Position position) {
|
||||
// If moving fast (in vehicle), use larger threshold
|
||||
if (position.speed > 5.0) { // 5 m/s = 18 km/h
|
||||
_distanceThreshold = 50.0;
|
||||
}
|
||||
// If stationary, use very large threshold
|
||||
else if (position.speed < 0.5) {
|
||||
_distanceThreshold = 100.0;
|
||||
}
|
||||
// If walking, use small threshold
|
||||
else {
|
||||
_distanceThreshold = 10.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Time-Based Throttling
|
||||
|
||||
```dart
|
||||
// Don't broadcast more than once per minute
|
||||
DateTime? _lastBroadcast;
|
||||
|
||||
void _handleLocationUpdate(Position position) async {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (_lastBroadcast != null) {
|
||||
final elapsed = now.difference(_lastBroadcast!);
|
||||
if (elapsed < Duration(seconds: 60)) {
|
||||
return; // Skip this update
|
||||
}
|
||||
}
|
||||
|
||||
await _broadcastLocation(position);
|
||||
_lastBroadcast = now;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Operation Mode Profiles
|
||||
|
||||
```dart
|
||||
enum OperationMode {
|
||||
active, // Full tracking, 5m threshold, 30s min interval
|
||||
standby, // Medium tracking, 20m threshold, 2m min interval
|
||||
idle, // Sparse tracking, 100m threshold, 10m min interval
|
||||
}
|
||||
|
||||
class LocationProfileManager {
|
||||
void applyProfile(OperationMode mode) {
|
||||
switch (mode) {
|
||||
case OperationMode.active:
|
||||
_distanceThreshold = 5.0;
|
||||
_minTimeInterval = 30;
|
||||
break;
|
||||
case OperationMode.standby:
|
||||
_distanceThreshold = 20.0;
|
||||
_minTimeInterval = 120;
|
||||
break;
|
||||
case OperationMode.idle:
|
||||
_distanceThreshold = 100.0;
|
||||
_minTimeInterval = 600;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### ✅ Your App is Ready for Background Execution
|
||||
|
||||
**Current capabilities:**
|
||||
- ✅ Location tracking in background
|
||||
- ✅ BLE communication in background
|
||||
- ✅ Background task scheduling (configured but not used)
|
||||
- ✅ Background fetch (configured but not used)
|
||||
- ✅ All required permissions in Info.plist
|
||||
|
||||
**What works now:**
|
||||
1. User starts app → connects to MeshCore device
|
||||
2. User enables location tracking → `BackgroundLocationService` starts
|
||||
3. User backgrounds app → Location updates continue
|
||||
4. GPS updates trigger → Mesh broadcasts sent via BLE
|
||||
5. BLE notifications received → Messages processed, notifications shown
|
||||
6. User returns to app → Full state restored
|
||||
|
||||
**Limitations:**
|
||||
- BLE connection must be initiated in foreground
|
||||
- Location updates may be deferred after 10+ minutes
|
||||
- CPU/network throttled after 3 minutes in background
|
||||
- Cannot scan for new BLE devices in background
|
||||
|
||||
**Battery life:**
|
||||
- Current config: **Medium impact** (~10-15% battery per hour of active tracking)
|
||||
- With optimizations: **Low-Medium impact** (~5-10% per hour)
|
||||
- Comparable to navigation apps (Google Maps, Waze)
|
||||
|
||||
### Next Steps (Optional Enhancements)
|
||||
|
||||
1. **Add Operation Mode Profiles** - Let users choose Active/Standby/Idle mode
|
||||
2. **Implement Adaptive Thresholds** - Adjust tracking based on speed
|
||||
3. **Add Battery Monitoring** - Show real-time battery impact
|
||||
4. **Background Fetch Integration** - Sync messages when app is terminated
|
||||
5. **Keepalive Optimization** - Fine-tune BLE connection retention
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Apple Background Execution Guide](https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background)
|
||||
- [iOS Location Background Mode](https://developer.apple.com/documentation/corelocation/getting_the_user_s_location/handling_location_events_in_the_background)
|
||||
- [iOS Bluetooth Background Mode](https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html)
|
||||
- [flutter_blue_plus Background Mode](https://pub.dev/packages/flutter_blue_plus#background-mode)
|
||||
- [Geolocator Package](https://pub.dev/packages/geolocator)
|
||||
- [Local Notifications Package](https://pub.dev/packages/flutter_local_notifications)
|
||||
|
||||
**Last Updated:** 2025-10-30
|
||||
**App Version:** 48 (iOS build 48)
|
||||
@@ -1,304 +0,0 @@
|
||||
# Slovenian WMS Layers - SAR Application Analysis
|
||||
|
||||
**Total Layers Found: 108**
|
||||
|
||||
## HIGH PRIORITY - Critical for SAR Operations
|
||||
|
||||
### Aerial Imagery & Base Maps (5 layers)
|
||||
1. **pregledovalnik:DOF_2024** - Digital Orthophoto 2024 (Latest aerial imagery)
|
||||
- **USE CASE**: Primary visual reference, current terrain conditions
|
||||
- **ALREADY IMPLEMENTED** in the app
|
||||
|
||||
2. **pregledovalnik:DOF25** - Digital Orthophoto 25cm resolution
|
||||
- **USE CASE**: High-resolution aerial imagery for detailed terrain analysis
|
||||
|
||||
3. **pregledovalnik:DOF_IR** - Infrared Orthophoto
|
||||
- **USE CASE**: Thermal/infrared imagery for detecting heat signatures, useful for night searches
|
||||
|
||||
4. **pregledovalnik:DTK25** - Topographic Map 1:25,000
|
||||
- **USE CASE**: Traditional topographic reference with contours, trails, landmarks
|
||||
|
||||
5. **pregledovalnik:dof025_2022_2024** - Combined orthophoto 2022-2024
|
||||
- **USE CASE**: Multi-year aerial imagery comparison
|
||||
|
||||
### Administrative Boundaries (6 layers)
|
||||
6. **pregledovalnik:NEP_RPE_OBCINE** - Municipalities (občine)
|
||||
- **USE CASE**: Jurisdiction boundaries for coordinating with local authorities
|
||||
- **RECOMMENDED FOR OVERLAY**
|
||||
|
||||
7. **pregledovalnik:NEP_RPE_NASELJA** - Settlements
|
||||
- **USE CASE**: Identify populated areas, evacuation points, staging areas
|
||||
- **RECOMMENDED FOR OVERLAY**
|
||||
|
||||
8. **pregledovalnik:NEP_HISNE_STEVILKE** - House Numbers
|
||||
- **USE CASE**: Precise location identification for emergency response
|
||||
|
||||
9. **pregledovalnik:NEP_RPE_UPRAVNE_ENOTE** - Administrative Units
|
||||
- **USE CASE**: Regional administration boundaries
|
||||
|
||||
10. **pregledovalnik:NEP_RPE_STATISTICNE_REGIJE** - Statistical Regions
|
||||
- **USE CASE**: Broader regional planning
|
||||
|
||||
11. **pregledovalnik:drzavna_meja** - State Border
|
||||
- **USE CASE**: International coordination for cross-border operations
|
||||
|
||||
### Roads & Transportation (4 layers)
|
||||
12. **pregledovalnik:KGI_LINIJE_CESTE_G** - Roads
|
||||
- **USE CASE**: Primary access routes, evacuation routes, vehicle navigation
|
||||
- **HIGH PRIORITY OVERLAY**
|
||||
|
||||
13. **pregledovalnik:gozdne_ceste** - Forest Roads
|
||||
- **USE CASE**: Access to remote forest areas, critical for SAR vehicles
|
||||
- **HIGH PRIORITY OVERLAY**
|
||||
|
||||
14. **pregledovalnik:LINIJE_ZELEZNICE_G** - Railways
|
||||
- **USE CASE**: Alternative access routes, landmarks, coordination with rail authorities
|
||||
|
||||
15. **pregledovalnik:KGI_LINIJE_PLANINSKE_POTI_G** - Mountain/Hiking Trails
|
||||
- **USE CASE**: Common search areas, lost hiker routes, access to remote areas
|
||||
- **HIGH PRIORITY OVERLAY**
|
||||
|
||||
### Fire & Emergency Hazards (5 layers)
|
||||
16. **pregledovalnik:gozdni_pozari** - Forest Fires (historical)
|
||||
- **USE CASE**: Historical fire locations, high-risk areas
|
||||
- **RECOMMENDED FOR OVERLAY**
|
||||
|
||||
17. **pregledovalnik:pozarna_ogrozenost** - Fire Hazard/Risk Areas
|
||||
- **USE CASE**: Identify high fire risk zones, plan safe evacuation routes
|
||||
- **HIGH PRIORITY OVERLAY**
|
||||
|
||||
18. **pregledovalnik:pozarisce_goriski_kras** - Fire Site - Goriški Kras
|
||||
- **USE CASE**: Specific fire hazard area in Karst region
|
||||
|
||||
19. **pregledovalnik:pozarisce_kras** - Fire Site - Kras
|
||||
- **USE CASE**: Karst region fire zones
|
||||
|
||||
20. **pregledovalnik:protipozarne_preseke** - Firebreaks
|
||||
- **USE CASE**: Fire containment lines, safe zones
|
||||
|
||||
### Geographic Names & Navigation (1 layer)
|
||||
21. **pregledovalnik:zemljepisna_imena** - Geographic Names
|
||||
- **USE CASE**: Place names for communication, location identification
|
||||
- **RECOMMENDED FOR OVERLAY**
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM PRIORITY - Useful for Planning & Context
|
||||
|
||||
### Protected Areas & Natural Features (9 layers)
|
||||
22. **pregledovalnik:natura2000** - Natura 2000 Protected Areas
|
||||
- **USE CASE**: Environmental restrictions, sensitive areas
|
||||
|
||||
23. **pregledovalnik:zavarovana_obmocja_poligoni** - Protected Areas (polygons)
|
||||
- **USE CASE**: National parks, nature reserves, access restrictions
|
||||
|
||||
24. **pregledovalnik:zavarovana_obmocja_tocke** - Protected Areas (points)
|
||||
- **USE CASE**: Point-based protected sites
|
||||
|
||||
25. **pregledovalnik:zavarovana_obmocja_conacija** - Protected Areas (zoning)
|
||||
- **USE CASE**: Zoning within protected areas
|
||||
|
||||
26. **pregledovalnik:naravne_vrednote_poligoni** - Natural Heritage (polygons)
|
||||
- **USE CASE**: Notable natural features, landmarks
|
||||
|
||||
27. **pregledovalnik:naravne_vrednote_tocke** - Natural Heritage (points)
|
||||
- **USE CASE**: Specific natural landmarks (caves, waterfalls, etc.)
|
||||
|
||||
28. **pregledovalnik:epo_poligoni** - Single Trees/Monuments (polygons)
|
||||
- **USE CASE**: Notable landmark trees
|
||||
|
||||
29. **pregledovalnik:epo_tocke** - Single Trees/Monuments (points)
|
||||
- **USE CASE**: Point landmarks
|
||||
|
||||
30. **pregledovalnik:gozdni_rezervati** - Forest Reserves
|
||||
- **USE CASE**: Old-growth forests, restricted access areas
|
||||
|
||||
### Cadastral & Land Parcels (2 layers)
|
||||
31. **pregledovalnik:kn_parcele** - Cadastral Parcels
|
||||
- **USE CASE**: Land ownership boundaries, legal jurisdictions
|
||||
|
||||
32. **pregledovalnik:KN_KATASTRSKE_OBCINE** - Cadastral Municipalities
|
||||
- **USE CASE**: Cadastral administrative units
|
||||
|
||||
### Terrain & Elevation (2 layers)
|
||||
33. **pregledovalnik:DMK** - Digital Cartographic Model
|
||||
- **USE CASE**: Contours, terrain features, elevation data
|
||||
|
||||
34. **pregledovalnik:DMR** - Digital Relief Model
|
||||
- **USE CASE**: Shaded relief, terrain visualization
|
||||
|
||||
### Forest Management Areas (10 layers)
|
||||
35. **pregledovalnik:gge** - Forest Management Units (GGE)
|
||||
- **USE CASE**: Forest administrative units, contact local foresters
|
||||
|
||||
36. **pregledovalnik:ggo** - Forest Management Districts (GGO)
|
||||
- **USE CASE**: Larger forest districts
|
||||
|
||||
37. **pregledovalnik:revirji** - Forest Ranger Districts
|
||||
- **USE CASE**: Local ranger contact areas
|
||||
|
||||
38. **pregledovalnik:krajevne_enote** - Local Forest Units
|
||||
- **USE CASE**: Smallest administrative forest units
|
||||
|
||||
39. **pregledovalnik:odseki** - Forest Compartments
|
||||
- **USE CASE**: Forest subdivisions for management
|
||||
|
||||
40. **pregledovalnik:odseki_gozdni** - Forest Compartments (variant)
|
||||
- **USE CASE**: Alternative compartment layer
|
||||
|
||||
41. **pregledovalnik:sestoji** - Forest Stands
|
||||
- **USE CASE**: Specific tree stand types, vegetation density
|
||||
|
||||
42. **pregledovalnik:sestoji_druga_gozdna_zemljisca** - Other Forest Lands
|
||||
- **USE CASE**: Non-productive forest areas
|
||||
|
||||
43. **pregledovalnik:varovalni_gozdovi** - Protection Forests
|
||||
- **USE CASE**: Forests with protective function (avalanche, erosion)
|
||||
|
||||
44. **pregledovalnik:lovisca** - Hunting Grounds
|
||||
- **USE CASE**: Contact with hunting associations for local knowledge
|
||||
|
||||
### Historical Disasters (6 layers)
|
||||
45. **pregledovalnik:vetrolom_2017** - Windthrow 2017
|
||||
- **USE CASE**: Storm damage areas, difficult terrain
|
||||
|
||||
46. **pregledovalnik:vetrolom_2018** - Windthrow 2018
|
||||
- **USE CASE**: Storm damage areas from 2018
|
||||
|
||||
47. **pregledovalnik:zled_2014** - Ice Storm 2014
|
||||
- **USE CASE**: Ice damage areas
|
||||
|
||||
48. **pregledovalnik:zled_drugi_gozdovi_2014** - Ice Storm 2014 (other forests)
|
||||
- **USE CASE**: Ice damage in secondary forests
|
||||
|
||||
49. **pregledovalnik:podlubniki_2015_2019** - Bark Beetle Damage 2015-2019
|
||||
- **USE CASE**: Dead wood areas, fire hazard, difficult terrain
|
||||
|
||||
50. **pregledovalnik:krcitve** - Clearcuts
|
||||
- **USE CASE**: Open areas, recent harvest sites, potential staging areas
|
||||
|
||||
### Agricultural & Land Use (2 layers)
|
||||
51. **pregledovalnik:povrsine_v_zarascanju** - Overgrown Areas
|
||||
- **USE CASE**: Abandoned agricultural land, changing terrain
|
||||
|
||||
52. **pregledovalnik:skupna_kmetijska_politika_2023_2027** - Common Agricultural Policy
|
||||
- **USE CASE**: Agricultural land use planning
|
||||
|
||||
---
|
||||
|
||||
## LOW PRIORITY - Technical/Specialized Layers
|
||||
|
||||
### Forest Function Layers (ON21 series - 45 layers)
|
||||
These are highly specialized forest function layers from the 2021 forest management plan. Each has variants for lines (_l), polygons (_p), and points (_t):
|
||||
|
||||
**Categories:**
|
||||
- **Biotska** (Biodiversity): on21_fun_biotska_l/p/t
|
||||
- **Druge gozdne dobrine** (Other forest goods): on21_fun_druge_gozdne_dobrine_l/p/t
|
||||
- **Estetska** (Aesthetic): on21_fun_estetska_l/p/t
|
||||
- **Hidroloska** (Hydrological): on21_fun_hidroloska_l/p/t
|
||||
- **Higiensko-zdravstvena** (Health/hygiene): on21_fun_higiensko_zdravstvena_p
|
||||
- **Klimatska** (Climate): on21_fun_klimatska_l/p
|
||||
- **Kulturna** (Cultural): on21_fun_kulturna_l/p/t
|
||||
- **Lesnoproizvodna** (Timber production): on21_fun_lesnoproizvodna_p
|
||||
- **Lovnogospodarska** (Hunting management): on21_fun_lovnogospodarska_p/t
|
||||
- **Obrambna** (Defense): on21_fun_obrambna_p/t
|
||||
- **Poucna** (Educational): on21_fun_poucna_l/p/t
|
||||
- **Raziskovalna** (Research): on21_fun_raziskovalna_p/t
|
||||
- **Rekreacijska** (Recreation): on21_fun_rekreacijska_l/p/t
|
||||
- **Skupaj** (Combined): on21_fun_skupaj_l/p/t
|
||||
- **Turisticna** (Tourism): on21_fun_turisticna_l/p/t
|
||||
- **Varovalna** (Protection): on21_fun_varovalna_p
|
||||
- **Varovanja naravnih vrednot** (Natural heritage protection): on21_fun_varovanja_naravnih_vrednot_l/p/t
|
||||
- **Zascitna** (Conservation): on21_fun_zascitna_l/p
|
||||
|
||||
**USE CASE**: Very specialized forest planning data. May be useful for:
|
||||
- **Rekreacijska**: Popular recreation areas (lost hikers)
|
||||
- **Turisticna**: Tourist areas (search priority)
|
||||
- **Varovalna**: Avalanche/erosion protection forests (hazard awareness)
|
||||
|
||||
### Miscellaneous Technical (6 layers)
|
||||
- **pregledovalnik:conacija_gp** - Zonation (technical)
|
||||
- **pregledovalnik:evrd** - Single-tree selection forests
|
||||
- **pregledovalnik:koridorji** - Corridors (ecological)
|
||||
- **pregledovalnik:luo** - Forest landscape units
|
||||
- **pregledovalnik:pobude_gge** - GGE initiatives
|
||||
- **pregledovalnik:provenience** - Seed provenance areas
|
||||
- **pregledovalnik:uvhvvr** - High conservation value forests
|
||||
- **pregledovalnik:gozdni_sklad_ekocelice** - Forest fund eco-cells
|
||||
- **pregledovalnik:gozdni_sklad_habitatna_drevesa** - Habitat trees
|
||||
|
||||
### Layer Groups (2 layers)
|
||||
- **pregledovalnik:ttn_group** - Group layer (container)
|
||||
- **pregledovalnik:zemljevid_group** - Map group layer (container)
|
||||
|
||||
---
|
||||
|
||||
## RECOMMENDED IMPLEMENTATION PLAN
|
||||
|
||||
### Phase 1: Critical Overlays (Immediate)
|
||||
Add these layers as toggleable overlays in the Map Options screen:
|
||||
|
||||
1. **Forest Roads** (`gozdne_ceste`) - PNG, transparent
|
||||
- Critical for vehicle access in remote areas
|
||||
|
||||
2. **Hiking/Mountain Trails** (`KGI_LINIJE_PLANINSKE_POTI_G`) - PNG, transparent
|
||||
- Common search areas for lost hikers
|
||||
|
||||
3. **Fire Hazard Zones** (`pozarna_ogrozenost`) - PNG, transparent
|
||||
- Safety planning, risk assessment
|
||||
|
||||
4. **Settlements** (`NEP_RPE_NASELJA`) - PNG, transparent
|
||||
- Populated areas, staging areas
|
||||
|
||||
5. **Municipalities** (`NEP_RPE_OBCINE`) - PNG, transparent
|
||||
- Administrative boundaries
|
||||
|
||||
### Phase 2: Additional Useful Layers
|
||||
6. **Geographic Names** (`zemljepisna_imena`)
|
||||
7. **Forest Fires Historical** (`gozdni_pozari`)
|
||||
8. **Protected Areas** (`zavarovana_obmocja_poligoni`)
|
||||
9. **Topographic Map** (`DTK25`) - Alternative base layer
|
||||
10. **Infrared Imagery** (`DOF_IR`) - Alternative base layer
|
||||
|
||||
### Phase 3: Specialized Layers (On Demand)
|
||||
11. **Windthrow/Disaster Areas** (for post-disaster operations)
|
||||
12. **Recreation/Tourism Areas** (for prioritizing search areas)
|
||||
13. **Protection Forests** (avalanche/erosion hazard awareness)
|
||||
|
||||
---
|
||||
|
||||
## TECHNICAL NOTES
|
||||
|
||||
### CRS Compatibility
|
||||
- All layers from `prostor.zgs.gov.si` support **EPSG:3794** (Slovenian National Grid)
|
||||
- The app already has the correct CRS implementation in `lib/utils/slovenian_crs.dart`
|
||||
|
||||
### Recommended Formats
|
||||
- **Base Layers**: JPEG (better compression for imagery)
|
||||
- **Overlays**: PNG with transparency=true (for stacking)
|
||||
|
||||
### Caching Strategy
|
||||
- All layers should use the existing FMTC caching infrastructure
|
||||
- 30-day validity is appropriate for most layers
|
||||
- Consider longer validity for static layers (administrative boundaries)
|
||||
|
||||
### Performance Considerations
|
||||
- Limit active overlays to 3-4 simultaneously to avoid performance issues
|
||||
- Use appropriate zoom level restrictions (some layers only useful at close zoom)
|
||||
- Consider pre-downloading critical layers for offline SAR operations
|
||||
|
||||
---
|
||||
|
||||
## LAYER NAMING CONVENTIONS
|
||||
|
||||
**Slovenian Terms Reference:**
|
||||
- **DOF** = Digitalni Ortofoto (Digital Orthophoto)
|
||||
- **DTK** = Državna Topografska Karta (State Topographic Map)
|
||||
- **DMK** = Digitalni Kartografski Model (Digital Cartographic Model)
|
||||
- **DMR** = Digitalni Model Reliefa (Digital Relief Model)
|
||||
- **NEP** = Nacionalni Evidenčni Portal (National Registry Portal)
|
||||
- **RPE** = Register Prostorskih Enot (Spatial Units Register)
|
||||
- **GGE** = Gozdnogospodarska Enota (Forest Management Unit)
|
||||
- **GGO** = Gozdnogospodarska Območje (Forest Management District)
|
||||
- **ON21** = Območni Načrt 2021 (Regional Plan 2021)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
ENABLE_DEBUG_DYLIB = NO
|
||||
|
||||
@@ -61,9 +61,9 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- SDWebImage (5.21.3):
|
||||
- SDWebImage/Core (= 5.21.3)
|
||||
- SDWebImage/Core (5.21.3)
|
||||
- SDWebImage (5.21.7):
|
||||
- SDWebImage/Core (= 5.21.7)
|
||||
- SDWebImage/Core (5.21.7)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -155,7 +155,7 @@ SPEC CHECKSUMS:
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
|
||||
@@ -20,7 +20,8 @@ class AppProvider with ChangeNotifier {
|
||||
final DrawingProvider drawingProvider;
|
||||
final ChannelsProvider channelsProvider;
|
||||
final TileCacheService tileCacheService;
|
||||
final LocationTrackingService locationTrackingService = LocationTrackingService();
|
||||
final LocationTrackingService locationTrackingService =
|
||||
LocationTrackingService();
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
@@ -61,7 +62,9 @@ class AppProvider with ChangeNotifier {
|
||||
// Give DrawingProvider a moment to finish loading too
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
debugPrint('🔄 [AppProvider] Early sync: syncing drawings from messages...');
|
||||
debugPrint(
|
||||
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
|
||||
);
|
||||
messagesProvider.syncDrawingsWithProvider(drawingProvider);
|
||||
}
|
||||
|
||||
@@ -129,7 +132,9 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
// Setup callbacks
|
||||
locationTrackingService.onPositionUpdate = (position) {
|
||||
debugPrint('📍 [AppProvider] Position updated: ${position.latitude}, ${position.longitude}');
|
||||
debugPrint(
|
||||
'📍 [AppProvider] Position updated: ${position.latitude}, ${position.longitude}',
|
||||
);
|
||||
};
|
||||
|
||||
locationTrackingService.onBroadcastSent = (position) {
|
||||
@@ -141,7 +146,9 @@ class AppProvider with ChangeNotifier {
|
||||
};
|
||||
|
||||
locationTrackingService.onTrackingStateChanged = (isTracking) {
|
||||
debugPrint('🔄 [AppProvider] Location tracking state: ${isTracking ? "started" : "stopped"}');
|
||||
debugPrint(
|
||||
'🔄 [AppProvider] Location tracking state: ${isTracking ? "started" : "stopped"}',
|
||||
);
|
||||
};
|
||||
|
||||
debugPrint('✅ [AppProvider] Location tracking service initialized');
|
||||
@@ -187,87 +194,103 @@ class AppProvider with ChangeNotifier {
|
||||
};
|
||||
|
||||
// When channel info is received
|
||||
connectionProvider.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
|
||||
try {
|
||||
debugPrint('🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"');
|
||||
|
||||
// Check if this is a channel deletion (empty name)
|
||||
if (channelName.isEmpty && channelIdx != 0) {
|
||||
debugPrint(' 🗑️ Channel $channelIdx deleted - removing from providers');
|
||||
|
||||
// Remove from ChannelsProvider
|
||||
channelsProvider.removeChannel(channelIdx);
|
||||
debugPrint(' ✅ Removed from ChannelsProvider');
|
||||
|
||||
// Remove from ContactsProvider using pseudo public key
|
||||
final publicKeyBytes = Uint8List(32);
|
||||
publicKeyBytes[0] = 0xFF; // Special marker for channels
|
||||
publicKeyBytes[1] = channelIdx; // Channel index
|
||||
final publicKeyHex = publicKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
|
||||
contactsProvider.removeContact(publicKeyHex);
|
||||
debugPrint(' ✅ Removed from ContactsProvider');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Add/update in ChannelsProvider
|
||||
channelsProvider.addOrUpdateChannel(
|
||||
index: channelIdx,
|
||||
name: channelName,
|
||||
secret: secret,
|
||||
flags: flags,
|
||||
);
|
||||
debugPrint(' ✅ Added to ChannelsProvider');
|
||||
|
||||
// Also add as Contact to ContactsProvider (for UI display)
|
||||
// Skip if it's public channel (already exists)
|
||||
debugPrint('📻 [AppProvider] Channel $channelIdx: "$channelName" (isEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})');
|
||||
|
||||
if (channelName.isNotEmpty && channelIdx != 0) {
|
||||
debugPrint(' ✅ Adding channel $channelIdx to ContactsProvider as Contact');
|
||||
|
||||
// Create a pseudo public key for the channel based on its index
|
||||
// Use channel index as a unique identifier (pad to 32 bytes)
|
||||
final publicKeyBytes = Uint8List(32);
|
||||
publicKeyBytes[0] = 0xFF; // Special marker for channels
|
||||
publicKeyBytes[1] = channelIdx; // Channel index
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
contactsProvider.addOrUpdateContact(
|
||||
Contact(
|
||||
publicKey: publicKeyBytes,
|
||||
type: ContactType.channel,
|
||||
flags: flags ?? 0,
|
||||
outPathLen: -1, // Flood mode for channels
|
||||
outPath: Uint8List(0), // Empty path for channels
|
||||
advName: channelName,
|
||||
lastAdvert: now,
|
||||
advLat: 0, // Channels don't have location
|
||||
advLon: 0,
|
||||
lastMod: now,
|
||||
isNew: false, // Don't mark channels as new
|
||||
),
|
||||
);
|
||||
|
||||
debugPrint(' ✅ Channel contact added. Total channels in ContactsProvider: ${contactsProvider.channels.length}');
|
||||
} else {
|
||||
debugPrint(' ⏭️ Skipping channel $channelIdx (empty: ${channelName.isEmpty}, isPublic: ${channelIdx == 0})');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e');
|
||||
debugPrint(' Stack trace: $stackTrace');
|
||||
}
|
||||
};
|
||||
connectionProvider.onChannelInfoReceived =
|
||||
(int channelIdx, String channelName, Uint8List secret, int? flags) {
|
||||
try {
|
||||
debugPrint(
|
||||
'🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"',
|
||||
);
|
||||
|
||||
// Check if this is a channel deletion (empty name)
|
||||
if (channelName.isEmpty && channelIdx != 0) {
|
||||
debugPrint(
|
||||
' 🗑️ Channel $channelIdx deleted - removing from providers',
|
||||
);
|
||||
|
||||
// Remove from ChannelsProvider
|
||||
channelsProvider.removeChannel(channelIdx);
|
||||
debugPrint(' ✅ Removed from ChannelsProvider');
|
||||
|
||||
// Remove from ContactsProvider using pseudo public key
|
||||
final publicKeyBytes = Uint8List(32);
|
||||
publicKeyBytes[0] = 0xFF; // Special marker for channels
|
||||
publicKeyBytes[1] = channelIdx; // Channel index
|
||||
final publicKeyHex = publicKeyBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
|
||||
contactsProvider.removeContact(publicKeyHex);
|
||||
debugPrint(' ✅ Removed from ContactsProvider');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Add/update in ChannelsProvider
|
||||
channelsProvider.addOrUpdateChannel(
|
||||
index: channelIdx,
|
||||
name: channelName,
|
||||
secret: secret,
|
||||
flags: flags,
|
||||
);
|
||||
debugPrint(' ✅ Added to ChannelsProvider');
|
||||
|
||||
// Also add as Contact to ContactsProvider (for UI display)
|
||||
// Skip if it's public channel (already exists)
|
||||
debugPrint(
|
||||
'📻 [AppProvider] Channel $channelIdx: "$channelName" (isEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})',
|
||||
);
|
||||
|
||||
if (channelName.isNotEmpty && channelIdx != 0) {
|
||||
debugPrint(
|
||||
' ✅ Adding channel $channelIdx to ContactsProvider as Contact',
|
||||
);
|
||||
|
||||
// Create a pseudo public key for the channel based on its index
|
||||
// Use channel index as a unique identifier (pad to 32 bytes)
|
||||
final publicKeyBytes = Uint8List(32);
|
||||
publicKeyBytes[0] = 0xFF; // Special marker for channels
|
||||
publicKeyBytes[1] = channelIdx; // Channel index
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
contactsProvider.addOrUpdateContact(
|
||||
Contact(
|
||||
publicKey: publicKeyBytes,
|
||||
type: ContactType.channel,
|
||||
flags: flags ?? 0,
|
||||
outPathLen: -1, // Flood mode for channels
|
||||
outPath: Uint8List(0), // Empty path for channels
|
||||
advName: channelName,
|
||||
lastAdvert: now,
|
||||
advLat: 0, // Channels don't have location
|
||||
advLon: 0,
|
||||
lastMod: now,
|
||||
isNew: false, // Don't mark channels as new
|
||||
),
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
' ✅ Channel contact added. Total channels in ContactsProvider: ${contactsProvider.channels.length}',
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
' ⏭️ Skipping channel $channelIdx (empty: ${channelName.isEmpty}, isPublic: ${channelIdx == 0})',
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e');
|
||||
debugPrint(' Stack trace: $stackTrace');
|
||||
}
|
||||
};
|
||||
|
||||
// When a message is received
|
||||
connectionProvider.onMessageReceived = (message) {
|
||||
// Enrich message with sender name from contacts first
|
||||
Message enrichedMessage = message;
|
||||
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
||||
final contact = contactsProvider
|
||||
.findContactByKey(message.senderPublicKeyPrefix!);
|
||||
final contact = contactsProvider.findContactByKey(
|
||||
message.senderPublicKeyPrefix!,
|
||||
);
|
||||
if (contact != null) {
|
||||
enrichedMessage = message.copyWith(senderName: contact.advName);
|
||||
}
|
||||
@@ -281,10 +304,13 @@ class AppProvider with ChangeNotifier {
|
||||
final drawing = DrawingMessageParser.parseDrawingMessage(
|
||||
enrichedMessage.text,
|
||||
senderName: senderName,
|
||||
messageId: enrichedMessage.id, // Pass message ID for navigation linking
|
||||
messageId:
|
||||
enrichedMessage.id, // Pass message ID for navigation linking
|
||||
);
|
||||
if (drawing != null) {
|
||||
debugPrint('🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}');
|
||||
debugPrint(
|
||||
'🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}',
|
||||
);
|
||||
debugPrint(' Drawing linked to message ID: ${enrichedMessage.id}');
|
||||
drawingProvider.addReceivedDrawing(drawing);
|
||||
|
||||
@@ -318,7 +344,8 @@ class AppProvider with ChangeNotifier {
|
||||
final contact = contactsProvider.contacts.firstWhere(
|
||||
(c) => c.advName == name,
|
||||
);
|
||||
return contact.publicKeyHex.isNotEmpty && contact.publicKeyHex.length >= 12
|
||||
return contact.publicKeyHex.isNotEmpty &&
|
||||
contact.publicKeyHex.length >= 12
|
||||
? contact.publicKeyHex.substring(0, 12)
|
||||
: '';
|
||||
} catch (e) {
|
||||
@@ -335,7 +362,9 @@ class AppProvider with ChangeNotifier {
|
||||
// When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B)
|
||||
// Used by older firmware versions for telemetry responses
|
||||
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
|
||||
debugPrint('📊 [AppProvider] Telemetry response (0x8B) received - updating contact');
|
||||
debugPrint(
|
||||
'📊 [AppProvider] Telemetry response (0x8B) received - updating contact',
|
||||
);
|
||||
contactsProvider.updateTelemetry(publicKey, lppData);
|
||||
};
|
||||
|
||||
@@ -343,7 +372,9 @@ class AppProvider with ChangeNotifier {
|
||||
// Used by newer firmware versions for telemetry and other binary data
|
||||
// BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility
|
||||
connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
|
||||
debugPrint('📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry');
|
||||
debugPrint(
|
||||
'📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry',
|
||||
);
|
||||
// Binary response tag 0 = telemetry data (Cayenne LPP format)
|
||||
// Other tags may be used for different data types in the future
|
||||
contactsProvider.updateTelemetry(publicKeyPrefix, responseData);
|
||||
@@ -351,7 +382,9 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
// When a contact's routing path is updated in the mesh network
|
||||
connectionProvider.onPathUpdated = (publicKey) {
|
||||
debugPrint('🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
|
||||
debugPrint(
|
||||
'🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
|
||||
);
|
||||
// Trigger a single contact fetch to get the updated path information
|
||||
// This is much more efficient than fetching all contacts
|
||||
// This happens asynchronously to avoid blocking the event handler
|
||||
@@ -365,11 +398,15 @@ class AppProvider with ChangeNotifier {
|
||||
// When an advertisement is received (PUSH_CODE_ADVERT 0x80)
|
||||
// This may be sent by the radio for existing contacts instead of PUSH_CODE_NEW_ADVERT (0x8A)
|
||||
connectionProvider.onAdvertReceived = (publicKey) {
|
||||
debugPrint('📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
|
||||
debugPrint(
|
||||
'📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
|
||||
);
|
||||
// Check if this is an existing contact that might have updated location
|
||||
final contact = contactsProvider.findContactByKey(publicKey);
|
||||
if (contact != null) {
|
||||
debugPrint(' Existing contact "${contact.advName}" - fetching updated contact info (optimized)');
|
||||
debugPrint(
|
||||
' Existing contact "${contact.advName}" - fetching updated contact info (optimized)',
|
||||
);
|
||||
// Trigger a single contact fetch to get the updated contact information
|
||||
// This is much more efficient than fetching all contacts
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
@@ -378,44 +415,88 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
debugPrint(' New contact - waiting for PUSH_CODE_NEW_ADVERT (0x8A) with full details');
|
||||
contactsProvider.addPendingAdvert(
|
||||
publicKey,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
debugPrint(
|
||||
' Unknown contact - added to pending adverts list and waiting for details',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// When firmware deletes a contact due to contacts table overflow (PUSH_CODE_CONTACT_DELETED 0x8F)
|
||||
connectionProvider.onContactDeleted = (publicKey) {
|
||||
final keyHex = publicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
final contact = contactsProvider.findContactByKey(publicKey);
|
||||
final name = contact?.advName ?? keyHex.substring(0, 12);
|
||||
debugPrint('⚠️ [AppProvider] Contact deleted by firmware: $name');
|
||||
contactsProvider.removeContact(keyHex);
|
||||
messagesProvider.logSystemMessage(
|
||||
text: 'Contact "$name" was removed — device contacts table is full',
|
||||
level: 'warning',
|
||||
);
|
||||
};
|
||||
|
||||
// When firmware reports contacts storage is full (PUSH_CODE_CONTACTS_FULL 0x90)
|
||||
connectionProvider.onContactsFull = () {
|
||||
debugPrint('⚠️ [AppProvider] Contacts storage full');
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
'Device contacts storage is full. New contacts will overwrite old ones.',
|
||||
level: 'warning',
|
||||
);
|
||||
};
|
||||
|
||||
// When a message is sent (RESP_CODE_SENT received)
|
||||
connectionProvider.onMessageSent = (messageId, expectedAckTag, suggestedTimeoutMs) {
|
||||
debugPrint('📤 [AppProvider] Message sent - Message ID: $messageId, ACK tag: $expectedAckTag');
|
||||
messagesProvider.markMessageSent(messageId, expectedAckTag, suggestedTimeoutMs);
|
||||
connectionProvider
|
||||
.onMessageSent = (messageId, expectedAckTag, suggestedTimeoutMs) {
|
||||
debugPrint(
|
||||
'📤 [AppProvider] Message sent - Message ID: $messageId, ACK tag: $expectedAckTag',
|
||||
);
|
||||
messagesProvider.markMessageSent(
|
||||
messageId,
|
||||
expectedAckTag,
|
||||
suggestedTimeoutMs,
|
||||
);
|
||||
};
|
||||
|
||||
// When a message is delivered (PUSH_CODE_SEND_CONFIRMED received)
|
||||
connectionProvider.onMessageDelivered = (ackCode, roundTripTimeMs) {
|
||||
debugPrint('✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
|
||||
debugPrint(
|
||||
'✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||||
);
|
||||
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
|
||||
};
|
||||
|
||||
// When an echo is detected for a public channel message (PUSH_CODE_LOG_RX_DATA matched)
|
||||
connectionProvider.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
|
||||
debugPrint('🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount');
|
||||
connectionProvider
|
||||
.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
|
||||
debugPrint(
|
||||
'🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount',
|
||||
);
|
||||
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
|
||||
};
|
||||
|
||||
// Wire up MessagesProvider's sendMessageCallback for retry logic
|
||||
messagesProvider.sendMessageCallback = ({
|
||||
required contactPublicKey,
|
||||
required text,
|
||||
required messageId,
|
||||
required contact,
|
||||
retryAttempt = 0,
|
||||
}) async {
|
||||
return await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: retryAttempt,
|
||||
);
|
||||
};
|
||||
messagesProvider.sendMessageCallback =
|
||||
({
|
||||
required contactPublicKey,
|
||||
required text,
|
||||
required messageId,
|
||||
required contact,
|
||||
retryAttempt = 0,
|
||||
}) async {
|
||||
return await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: retryAttempt,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/// Initialize the app (load contacts, sync time, etc.)
|
||||
@@ -446,19 +527,25 @@ class AppProvider with ChangeNotifier {
|
||||
// In simple mode: only sync first 5 channels for faster startup
|
||||
// In normal mode: sync all channels (up to device max)
|
||||
final channelsToSync = _isSimpleMode ? 5 : null;
|
||||
debugPrint('📻 [AppProvider] Syncing channels${_isSimpleMode ? ' (simple mode: max 5)' : ''}...');
|
||||
debugPrint(
|
||||
'📻 [AppProvider] Syncing channels${_isSimpleMode ? ' (simple mode: max 5)' : ''}...',
|
||||
);
|
||||
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
||||
debugPrint('✅ [AppProvider] Channel sync complete');
|
||||
|
||||
// Configure the default public channel (channel 0)
|
||||
// This must be done before sending any channel messages
|
||||
// Note: Some firmware versions may have this pre-configured
|
||||
debugPrint('📻 [AppProvider] Configuring default public channel (channel 0)...');
|
||||
debugPrint(
|
||||
'📻 [AppProvider] Configuring default public channel (channel 0)...',
|
||||
);
|
||||
try {
|
||||
await connectionProvider.configureDefaultPublicChannel();
|
||||
debugPrint('✅ [AppProvider] Public channel configured successfully');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [AppProvider] Public channel configuration failed (may already be configured): $e');
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Public channel configuration failed (may already be configured): $e',
|
||||
);
|
||||
// Continue anyway - channel might already be configured in firmware
|
||||
}
|
||||
|
||||
@@ -467,19 +554,27 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
// FALLBACK: Sync messages once after connection to catch any missed push notifications
|
||||
// This handles the case where messages arrived while the app was disconnected
|
||||
debugPrint('🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)');
|
||||
debugPrint(
|
||||
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
|
||||
);
|
||||
final initialMessageCount = await connectionProvider.syncAllMessages();
|
||||
debugPrint('📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)');
|
||||
debugPrint(
|
||||
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
|
||||
);
|
||||
|
||||
// Note: Future messages are synced automatically via PUSH_CODE_MSG_WAITING events
|
||||
|
||||
// Start location tracking AFTER all initialization is complete
|
||||
debugPrint('📍 [AppProvider] Starting location tracking after successful initialization');
|
||||
debugPrint(
|
||||
'📍 [AppProvider] Starting location tracking after successful initialization',
|
||||
);
|
||||
await _startLocationTracking();
|
||||
|
||||
// Sync drawing messages with DrawingProvider
|
||||
// This restores any drawings that may be missing from storage
|
||||
debugPrint('🎨 [AppProvider] Syncing drawing messages with DrawingProvider...');
|
||||
debugPrint(
|
||||
'🎨 [AppProvider] Syncing drawing messages with DrawingProvider...',
|
||||
);
|
||||
messagesProvider.syncDrawingsWithProvider(drawingProvider);
|
||||
|
||||
notifyListeners();
|
||||
@@ -503,7 +598,9 @@ class AppProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...');
|
||||
debugPrint(
|
||||
'📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...',
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -513,7 +610,9 @@ class AppProvider with ChangeNotifier {
|
||||
final roomKey = 'room_password_${room.publicKeyHex}';
|
||||
final savedPassword = prefs.getString(roomKey) ?? 'hello';
|
||||
|
||||
debugPrint('🔑 [AppProvider] Auto-logging into room: ${room.advName}');
|
||||
debugPrint(
|
||||
'🔑 [AppProvider] Auto-logging into room: ${room.advName}',
|
||||
);
|
||||
|
||||
// Set up one-time callbacks for this room login
|
||||
await _loginToRoomWithCallback(room, savedPassword);
|
||||
@@ -521,7 +620,9 @@ class AppProvider with ChangeNotifier {
|
||||
// Small delay between logins to avoid overwhelming the device
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
} catch (e) {
|
||||
debugPrint('❌ [AppProvider] Failed to auto-login to ${room.advName}: $e');
|
||||
debugPrint(
|
||||
'❌ [AppProvider] Failed to auto-login to ${room.advName}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -539,13 +640,16 @@ class AppProvider with ChangeNotifier {
|
||||
final originalOnFail = connectionProvider.onLoginFail;
|
||||
|
||||
// Set up temporary callbacks
|
||||
connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
|
||||
connectionProvider
|
||||
.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
|
||||
// Restore original callbacks
|
||||
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||
connectionProvider.onLoginFail = originalOnFail;
|
||||
|
||||
debugPrint('✅ [AppProvider] Auto-login successful for ${room.advName}');
|
||||
debugPrint('📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING');
|
||||
debugPrint(
|
||||
'📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING',
|
||||
);
|
||||
|
||||
completer.complete(true);
|
||||
};
|
||||
@@ -555,7 +659,9 @@ class AppProvider with ChangeNotifier {
|
||||
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||
connectionProvider.onLoginFail = originalOnFail;
|
||||
|
||||
debugPrint('❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)');
|
||||
debugPrint(
|
||||
'❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)',
|
||||
);
|
||||
completer.complete(false);
|
||||
};
|
||||
|
||||
@@ -581,7 +687,9 @@ class AppProvider with ChangeNotifier {
|
||||
// Restore callbacks on error
|
||||
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||
connectionProvider.onLoginFail = originalOnFail;
|
||||
debugPrint('❌ [AppProvider] Error during auto-login to ${room.advName}: $e');
|
||||
debugPrint(
|
||||
'❌ [AppProvider] Error during auto-login to ${room.advName}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,11 +703,11 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
// Sync contacts
|
||||
await connectionProvider.getContacts();
|
||||
|
||||
|
||||
// Sync channels (respect simple mode settings)
|
||||
final channelsToSync = _isSimpleMode ? 5 : null;
|
||||
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
||||
|
||||
|
||||
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -614,9 +722,13 @@ class AppProvider with ChangeNotifier {
|
||||
if (!connectionProvider.deviceInfo.isConnected) return 0;
|
||||
|
||||
try {
|
||||
debugPrint('🔄 [AppProvider] Manual message sync requested (user initiated)');
|
||||
debugPrint(
|
||||
'🔄 [AppProvider] Manual message sync requested (user initiated)',
|
||||
);
|
||||
final messageCount = await connectionProvider.syncAllMessages();
|
||||
debugPrint('✅ [AppProvider] Manual sync completed: $messageCount messages');
|
||||
debugPrint(
|
||||
'✅ [AppProvider] Manual sync completed: $messageCount messages',
|
||||
);
|
||||
notifyListeners();
|
||||
return messageCount;
|
||||
} catch (e) {
|
||||
@@ -634,7 +746,9 @@ class AppProvider with ChangeNotifier {
|
||||
// Location tracking will be started AFTER initialization completes
|
||||
if (!isConnected && wasTracking) {
|
||||
// Connection lost - stop location tracking
|
||||
debugPrint('🔴 [AppProvider] BLE disconnected - stopping location tracking');
|
||||
debugPrint(
|
||||
'🔴 [AppProvider] BLE disconnected - stopping location tracking',
|
||||
);
|
||||
_stopLocationTracking();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
Function(int channelIdx, String channelName, Uint8List secret, int? flags)? onChannelInfoReceived;
|
||||
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
|
||||
onBinaryResponse;
|
||||
Function(Uint8List publicKey)? onContactDeleted;
|
||||
VoidCallback? onContactsFull;
|
||||
Function(Uint8List publicKey)? onAdvertReceived;
|
||||
Function(Uint8List publicKey)? onPathUpdated;
|
||||
Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag)?
|
||||
@@ -319,6 +321,16 @@ class ConnectionProvider with ChangeNotifier {
|
||||
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
|
||||
};
|
||||
|
||||
_bleService.onContactDeleted = (publicKey) {
|
||||
debugPrint('⚠️ [Provider] Contact deleted by firmware (contacts full overwrite)');
|
||||
onContactDeleted?.call(publicKey);
|
||||
};
|
||||
|
||||
_bleService.onContactsFull = () {
|
||||
debugPrint('⚠️ [Provider] Contacts storage is full');
|
||||
onContactsFull?.call();
|
||||
};
|
||||
|
||||
_bleService.onMessageReceived = (message) {
|
||||
// Parse SAR markers
|
||||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||
|
||||
@@ -4,9 +4,32 @@ import '../services/cayenne_lpp_parser.dart';
|
||||
import '../services/contact_storage_service.dart';
|
||||
import '../utils/key_comparison.dart';
|
||||
|
||||
class PendingAdvert {
|
||||
final Uint8List publicKey;
|
||||
final DateTime receivedAt;
|
||||
|
||||
const PendingAdvert({required this.publicKey, required this.receivedAt});
|
||||
|
||||
String get publicKeyHex =>
|
||||
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
|
||||
String get shortDisplayKey {
|
||||
final prefix = publicKey.length >= 6 ? publicKey.sublist(0, 6) : publicKey;
|
||||
return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
}
|
||||
|
||||
PendingAdvert copyWith({Uint8List? publicKey, DateTime? receivedAt}) {
|
||||
return PendingAdvert(
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
receivedAt: receivedAt ?? this.receivedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Contacts Provider - manages contact list and telemetry
|
||||
class ContactsProvider with ChangeNotifier {
|
||||
final Map<String, Contact> _contacts = {};
|
||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||
final ContactStorageService _storageService = ContactStorageService();
|
||||
bool _isInitialized = false;
|
||||
|
||||
@@ -156,6 +179,9 @@ class ContactsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
List<Contact> get contacts => _contacts.values.toList();
|
||||
List<PendingAdvert> get pendingAdverts =>
|
||||
_pendingAdverts.values.toList()
|
||||
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||
|
||||
List<Contact> get chatContacts =>
|
||||
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
|
||||
@@ -195,11 +221,12 @@ class ContactsProvider with ChangeNotifier {
|
||||
/// Add or update a contact
|
||||
/// Excludes contacts that match the device's own public key
|
||||
void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) {
|
||||
debugPrint('📝 [ContactsProvider] addOrUpdateContact called: ${contact.advName} (type: ${contact.type.displayName}, key: ${contact.publicKeyHex.substring(0, 8)}...)');
|
||||
|
||||
debugPrint(
|
||||
'📝 [ContactsProvider] addOrUpdateContact called: ${contact.advName} (type: ${contact.type.displayName}, key: ${contact.publicKeyHex.substring(0, 8)}...)',
|
||||
);
|
||||
|
||||
// Don't add contacts that match our device's public key
|
||||
if (devicePublicKey != null &&
|
||||
contact.publicKey.matches(devicePublicKey)) {
|
||||
if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) {
|
||||
debugPrint(
|
||||
'ℹ️ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}',
|
||||
);
|
||||
@@ -208,7 +235,9 @@ class ContactsProvider with ChangeNotifier {
|
||||
|
||||
// Check if this is a new contact
|
||||
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
|
||||
debugPrint(' isNew: $isNewContact, total contacts before: ${_contacts.length}');
|
||||
debugPrint(
|
||||
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
||||
);
|
||||
|
||||
Contact updatedContact;
|
||||
if (isNewContact) {
|
||||
@@ -246,7 +275,10 @@ class ContactsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
_contacts[contact.publicKeyHex] = updatedContact;
|
||||
debugPrint(' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}');
|
||||
_pendingAdverts.remove(contact.publicKeyHex);
|
||||
debugPrint(
|
||||
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
||||
);
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
debugPrint(' 🔔 notifyListeners() called');
|
||||
@@ -267,6 +299,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
continue;
|
||||
}
|
||||
_contacts[contact.publicKeyHex] = contact;
|
||||
_pendingAdverts.remove(contact.publicKeyHex);
|
||||
}
|
||||
if (excluded > 0) {
|
||||
debugPrint(
|
||||
@@ -303,8 +336,8 @@ class ContactsProvider with ChangeNotifier {
|
||||
|
||||
// Update contact with new telemetry AND last seen time
|
||||
// lastAdvert is Unix timestamp in seconds
|
||||
final currentTimestamp =
|
||||
(DateTime.now().millisecondsSinceEpoch / 1000).round();
|
||||
final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000)
|
||||
.round();
|
||||
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
|
||||
debugPrint(' New lastAdvert: $currentTimestamp');
|
||||
|
||||
@@ -351,6 +384,34 @@ class ContactsProvider with ChangeNotifier {
|
||||
return _contacts[keyHex];
|
||||
}
|
||||
|
||||
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
||||
/// Excludes self key and existing contacts.
|
||||
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||||
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final keyHex = publicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
if (_contacts.containsKey(keyHex)) {
|
||||
_pendingAdverts.remove(keyHex);
|
||||
return;
|
||||
}
|
||||
|
||||
final existing = _pendingAdverts[keyHex];
|
||||
final now = DateTime.now();
|
||||
if (existing != null) {
|
||||
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
|
||||
} else {
|
||||
_pendingAdverts[keyHex] = PendingAdvert(
|
||||
publicKey: Uint8List.fromList(publicKey),
|
||||
receivedAt: now,
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Find contact by name
|
||||
Contact? findContactByName(String name) {
|
||||
return contacts.firstWhere(
|
||||
@@ -404,6 +465,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
/// Clear all contacts
|
||||
void clearContacts() {
|
||||
_contacts.clear();
|
||||
_pendingAdverts.clear();
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -425,6 +487,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
|
||||
// Then remove from local storage
|
||||
_contacts.remove(publicKeyHex);
|
||||
_pendingAdverts.remove(publicKeyHex);
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class ContactsTab extends StatefulWidget {
|
||||
|
||||
class _ContactsTabState extends State<ContactsTab> {
|
||||
Position? _currentPosition;
|
||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -59,6 +60,25 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
await _getCurrentLocation();
|
||||
}
|
||||
|
||||
Future<void> _handleResolveAdvert(PendingAdvert advert) async {
|
||||
final keyHex = advert.publicKeyHex;
|
||||
if (_resolvingAdvertKeys.contains(keyHex)) return;
|
||||
|
||||
setState(() {
|
||||
_resolvingAdvertKeys.add(keyHex);
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<ConnectionProvider>().getContact(advert.publicKey);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_resolvingAdvertKeys.remove(keyHex);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate distance between two points in meters
|
||||
double _calculateDistanceInMeters(
|
||||
double lat1,
|
||||
@@ -90,6 +110,15 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatRelativeTime(BuildContext context, DateTime when) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final diff = DateTime.now().difference(when);
|
||||
if (diff.inMinutes < 1) return l10n.justNow;
|
||||
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
||||
return l10n.daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
/// Show the add channel dialog
|
||||
Future<void> _showAddChannelDialog(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -140,11 +169,14 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
final repeaters = contactsProvider.repeaters;
|
||||
final rooms = contactsProvider.rooms;
|
||||
final channels = contactsProvider.channels;
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
|
||||
// Check if there are any displayable contacts (excluding channels)
|
||||
final hasDisplayableContacts = chatContacts.isNotEmpty ||
|
||||
final hasDisplayableContacts =
|
||||
chatContacts.isNotEmpty ||
|
||||
repeaters.isNotEmpty ||
|
||||
rooms.isNotEmpty;
|
||||
rooms.isNotEmpty ||
|
||||
pendingAdverts.isNotEmpty;
|
||||
|
||||
if (!hasDisplayableContacts) {
|
||||
return Center(
|
||||
@@ -177,6 +209,27 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
// Pending adverts (public key only; quick resolve)
|
||||
if (pendingAdverts.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: l10n.pending,
|
||||
count: pendingAdverts.length,
|
||||
icon: Icons.person_add_alt_1,
|
||||
),
|
||||
...pendingAdverts.map(
|
||||
(advert) => _PendingAdvertTile(
|
||||
advert: advert,
|
||||
subtitle:
|
||||
'${l10n.publicKey}: ${advert.publicKeyHex}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||
isResolving: _resolvingAdvertKeys.contains(
|
||||
advert.publicKeyHex,
|
||||
),
|
||||
onResolve: () => _handleResolveAdvert(advert),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Team Members (Chat contacts)
|
||||
if (chatContacts.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
@@ -280,6 +333,46 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingAdvertTile extends StatelessWidget {
|
||||
final PendingAdvert advert;
|
||||
final String subtitle;
|
||||
final bool isResolving;
|
||||
final VoidCallback onResolve;
|
||||
|
||||
const _PendingAdvertTile({
|
||||
required this.advert,
|
||||
required this.subtitle,
|
||||
required this.isResolving,
|
||||
required this.onResolve,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)),
|
||||
title: Text(
|
||||
advert.shortDisplayKey,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isResolving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
tooltip: 'Quick add',
|
||||
onPressed: onResolve,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@@ -53,6 +53,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize synchronously so first build always has a valid controller.
|
||||
_initTabController();
|
||||
_loadMapEnabledAndInitTabs();
|
||||
_loadRxTxPreference();
|
||||
|
||||
@@ -67,12 +69,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
Future<void> _loadMapEnabledAndInitTabs() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final mapEnabled = prefs.getBool('map_enabled') ?? true;
|
||||
if (mapEnabled != _isMapEnabled) {
|
||||
_isMapEnabled = mapEnabled;
|
||||
}
|
||||
_initTabController();
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
if (!mounted) return;
|
||||
if (_isMapEnabled != mapEnabled) {
|
||||
_updateTabController(mapEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +285,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
// Determine if we should hide the UI (only in fullscreen on map tab)
|
||||
final shouldHideUI = _isMapEnabled && _isMapFullscreen && _currentIndex == 2;
|
||||
final shouldHideUI =
|
||||
_isMapEnabled && _isMapFullscreen && _currentIndex == 2;
|
||||
|
||||
return Scaffold(
|
||||
appBar: shouldHideUI
|
||||
@@ -296,7 +296,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
actions: [
|
||||
Consumer<ConnectionProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final isConnected = provider.deviceInfo.isConnected || provider.isSseClientConnected;
|
||||
final isConnected =
|
||||
provider.deviceInfo.isConnected ||
|
||||
provider.isSseClientConnected;
|
||||
if (isConnected) {
|
||||
return IconButton(
|
||||
onPressed: () async {
|
||||
@@ -540,11 +542,14 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
color: isSseConnected
|
||||
? Colors.green
|
||||
: (deviceInfo.signalRssi != null
|
||||
? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!)
|
||||
: Colors.grey),
|
||||
? BatteryDisplayHelper.getSignalColor(
|
||||
deviceInfo.signalRssi!,
|
||||
)
|
||||
: Colors.grey),
|
||||
size: 13,
|
||||
),
|
||||
if (isBleConnected && deviceInfo.signalRssi != null) ...[
|
||||
if (isBleConnected &&
|
||||
deviceInfo.signalRssi != null) ...[
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${deviceInfo.signalRssi}',
|
||||
@@ -569,7 +574,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (deviceInfo.batteryPercent != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
BatteryDisplayHelper.getBatteryIcon(deviceInfo.batteryPercent!),
|
||||
BatteryDisplayHelper.getBatteryIcon(
|
||||
deviceInfo.batteryPercent!,
|
||||
),
|
||||
color: BatteryDisplayHelper.getBatteryColor(
|
||||
deviceInfo.batteryPercent!,
|
||||
),
|
||||
|
||||
@@ -98,6 +98,8 @@ class BleResponseHandler {
|
||||
OnChannelInfoCallback? onChannelInfoReceived;
|
||||
OnMessageEchoDetectedCallback? onMessageEchoDetected;
|
||||
VoidCallback? onRxActivity;
|
||||
void Function(Uint8List publicKey)? onContactDeleted;
|
||||
VoidCallback? onContactsFull;
|
||||
|
||||
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
|
||||
Uint8List? _lastContactPublicKey;
|
||||
@@ -183,6 +185,14 @@ class BleResponseHandler {
|
||||
debugPrint(' → Handling ChannelMessage');
|
||||
_handleChannelMessage(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respContactMsgRecvV3:
|
||||
debugPrint(' → Handling ContactMessage V3');
|
||||
_handleContactMessageV3(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respChannelMsgRecvV3:
|
||||
debugPrint(' → Handling ChannelMessage V3');
|
||||
_handleChannelMessageV3(reader);
|
||||
break;
|
||||
case MeshCoreConstants.pushTelemetryResponse:
|
||||
debugPrint(' → Handling TelemetryResponse');
|
||||
_handleTelemetryResponse(reader);
|
||||
@@ -251,6 +261,20 @@ class BleResponseHandler {
|
||||
debugPrint(' → Response: No More Messages');
|
||||
onNoMoreMessages?.call();
|
||||
break;
|
||||
case MeshCoreConstants.pushPathDiscoveryResponse:
|
||||
debugPrint(' → Path discovery response (not yet handled)');
|
||||
break;
|
||||
case MeshCoreConstants.pushControlData:
|
||||
debugPrint(' → Control data push (not yet handled)');
|
||||
break;
|
||||
case MeshCoreConstants.pushContactDeleted:
|
||||
debugPrint(' → Handling ContactDeleted push');
|
||||
_handleContactDeleted(reader);
|
||||
break;
|
||||
case MeshCoreConstants.pushContactsFull:
|
||||
debugPrint(' → Contacts storage full');
|
||||
onContactsFull?.call();
|
||||
break;
|
||||
case MeshCoreConstants.respOk:
|
||||
debugPrint(' → Response: OK');
|
||||
// Complete any pending ACK command
|
||||
@@ -349,6 +373,43 @@ class BleResponseHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ContactMessage V3 response (firmware ver >= 3, has SNR header)
|
||||
void _handleContactMessageV3(BufferReader reader) {
|
||||
try {
|
||||
final message = FrameParser.parseContactMessageV3(reader);
|
||||
debugPrint(' ✅ [ContactMessage V3] Parsed successfully');
|
||||
onMessageReceived?.call(message);
|
||||
} catch (e) {
|
||||
debugPrint(' ❌ [ContactMessage V3] Parsing error: $e');
|
||||
onError?.call('Contact message V3 parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ChannelMessage V3 response (firmware ver >= 3, has SNR header)
|
||||
void _handleChannelMessageV3(BufferReader reader) {
|
||||
try {
|
||||
final message = FrameParser.parseChannelMessageV3(reader);
|
||||
debugPrint(' ✅ [ChannelMessage V3] Parsed successfully');
|
||||
onMessageReceived?.call(message);
|
||||
} catch (e) {
|
||||
debugPrint(' ❌ [ChannelMessage V3] Parsing error: $e');
|
||||
onError?.call('Channel message V3 parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ContactDeleted push (0x8F) — contact overwritten due to contacts full
|
||||
void _handleContactDeleted(BufferReader reader) {
|
||||
try {
|
||||
if (reader.remainingBytesCount >= 32) {
|
||||
final publicKey = reader.readBytes(32);
|
||||
debugPrint(' ✅ [ContactDeleted] Contact removed by firmware');
|
||||
onContactDeleted?.call(Uint8List.fromList(publicKey));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(' ❌ [ContactDeleted] Parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle TelemetryResponse push
|
||||
void _handleTelemetryResponse(BufferReader reader) {
|
||||
try {
|
||||
|
||||
@@ -91,6 +91,8 @@ class MeshCoreBleService {
|
||||
OnErrorCallback? onError;
|
||||
OnContactNotFoundCallback? onContactNotFound;
|
||||
OnChannelInfoCallback? onChannelInfoReceived;
|
||||
void Function(Uint8List publicKey)? onContactDeleted;
|
||||
VoidCallback? onContactsFull;
|
||||
|
||||
// Activity callbacks (for blinking indicators)
|
||||
VoidCallback? onRxActivity;
|
||||
@@ -213,6 +215,12 @@ class MeshCoreBleService {
|
||||
_responseHandler.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
|
||||
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
|
||||
};
|
||||
_responseHandler.onContactDeleted = (publicKey) {
|
||||
onContactDeleted?.call(publicKey);
|
||||
};
|
||||
_responseHandler.onContactsFull = () {
|
||||
onContactsFull?.call();
|
||||
};
|
||||
_responseHandler.onRxActivity = () {
|
||||
onRxActivity?.call();
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// MeshCore BLE and Protocol Constants
|
||||
class MeshCoreConstants {
|
||||
// Supported protocol version
|
||||
static const int supportedCompanionProtocolVersion = 1;
|
||||
// Supported protocol version (firmware uses this to decide V1 vs V3 message frames)
|
||||
static const int supportedCompanionProtocolVersion = 3;
|
||||
|
||||
// BLE Service and Characteristic UUIDs
|
||||
static const String bleServiceUuid =
|
||||
@@ -39,6 +39,8 @@ class MeshCoreConstants {
|
||||
static const int cmdSendRawData = 25;
|
||||
static const int cmdSendLogin = 26;
|
||||
static const int cmdSendStatusReq = 27;
|
||||
static const int cmdHasConnection = 28;
|
||||
static const int cmdLogout = 29;
|
||||
static const int cmdGetContactByKey = 30;
|
||||
static const int cmdGetChannel = 31;
|
||||
static const int cmdSetChannel = 32;
|
||||
@@ -46,9 +48,23 @@ class MeshCoreConstants {
|
||||
static const int cmdSignData = 34;
|
||||
static const int cmdSignFinish = 35;
|
||||
static const int cmdSendTracePath = 36;
|
||||
static const int cmdSetDevicePin = 37;
|
||||
static const int cmdSetOtherParams = 38;
|
||||
static const int cmdSendTelemetryReq = 39;
|
||||
static const int cmdGetCustomVars = 40;
|
||||
static const int cmdSetCustomVar = 41;
|
||||
static const int cmdGetAdvertPath = 42;
|
||||
static const int cmdGetTuningParams = 43;
|
||||
static const int cmdSendBinaryReq = 50;
|
||||
static const int cmdFactoryReset = 51;
|
||||
static const int cmdSendPathDiscoveryReq = 52;
|
||||
static const int cmdSetFloodScope = 54; // v8+
|
||||
static const int cmdSendControlData = 55; // v8+
|
||||
static const int cmdGetStats = 56; // v8+
|
||||
static const int cmdSendAnonReq = 57;
|
||||
static const int cmdSetAutoaddConfig = 58;
|
||||
static const int cmdGetAutoaddConfig = 59;
|
||||
static const int cmdGetAllowedRepeatFreq = 60;
|
||||
|
||||
// Response Codes (Device -> App)
|
||||
static const int respOk = 0;
|
||||
@@ -58,8 +74,8 @@ class MeshCoreConstants {
|
||||
static const int respEndOfContacts = 4;
|
||||
static const int respSelfInfo = 5;
|
||||
static const int respSent = 6;
|
||||
static const int respContactMsgRecv = 7;
|
||||
static const int respChannelMsgRecv = 8;
|
||||
static const int respContactMsgRecv = 7; // firmware ver < 3
|
||||
static const int respChannelMsgRecv = 8; // firmware ver < 3
|
||||
static const int respCurrTime = 9;
|
||||
static const int respNoMoreMessages = 10;
|
||||
static const int respExportContact = 11;
|
||||
@@ -67,12 +83,17 @@ class MeshCoreConstants {
|
||||
static const int respDeviceInfo = 13;
|
||||
static const int respPrivateKey = 14;
|
||||
static const int respDisabled = 15;
|
||||
static const int respContactMsgRecvV3 = 16; // firmware ver >= 3 (adds SNR header)
|
||||
static const int respChannelMsgRecvV3 = 17; // firmware ver >= 3 (adds SNR header)
|
||||
static const int respChannelInfo = 18;
|
||||
static const int respSignStart = 19;
|
||||
static const int respSignature = 20;
|
||||
static const int respCustomVars = 21;
|
||||
static const int respAdvertPath = 22;
|
||||
static const int respTuningParams = 21; // Same as respCustomVars per protocol
|
||||
static const int respTuningParams = 23;
|
||||
static const int respStats = 24; // v8+
|
||||
static const int respAutoaddConfig = 25;
|
||||
static const int respAllowedRepeatFreq = 26;
|
||||
|
||||
// Push Codes (Device -> App, unsolicited)
|
||||
static const int pushAdvert = 0x80;
|
||||
@@ -88,6 +109,15 @@ class MeshCoreConstants {
|
||||
static const int pushNewAdvert = 0x8A;
|
||||
static const int pushTelemetryResponse = 0x8B;
|
||||
static const int pushBinaryResponse = 0x8C;
|
||||
static const int pushPathDiscoveryResponse = 0x8D;
|
||||
static const int pushControlData = 0x8E; // v8+
|
||||
static const int pushContactDeleted = 0x8F; // contact overwritten when contacts full
|
||||
static const int pushContactsFull = 0x90; // contacts storage is full
|
||||
|
||||
// Stats sub-types for cmdGetStats
|
||||
static const int statsTypeCore = 0;
|
||||
static const int statsTypeRadio = 1;
|
||||
static const int statsTypePackets = 2;
|
||||
|
||||
// Error Codes
|
||||
static const int errUnsupportedCmd = 1;
|
||||
|
||||
@@ -59,6 +59,10 @@ class MeshCoreOpcodeNames {
|
||||
return 'SEND_LOGIN';
|
||||
case MeshCoreConstants.cmdSendStatusReq:
|
||||
return 'SEND_STATUS_REQ';
|
||||
case MeshCoreConstants.cmdHasConnection:
|
||||
return 'HAS_CONNECTION';
|
||||
case MeshCoreConstants.cmdLogout:
|
||||
return 'LOGOUT';
|
||||
case MeshCoreConstants.cmdGetContactByKey:
|
||||
return 'GET_CONTACT_BY_KEY';
|
||||
case MeshCoreConstants.cmdGetChannel:
|
||||
@@ -73,12 +77,40 @@ class MeshCoreOpcodeNames {
|
||||
return 'SIGN_FINISH';
|
||||
case MeshCoreConstants.cmdSendTracePath:
|
||||
return 'SEND_TRACE_PATH';
|
||||
case MeshCoreConstants.cmdSetDevicePin:
|
||||
return 'SET_DEVICE_PIN';
|
||||
case MeshCoreConstants.cmdSetOtherParams:
|
||||
return 'SET_OTHER_PARAMS';
|
||||
case MeshCoreConstants.cmdSendTelemetryReq:
|
||||
return 'SEND_TELEMETRY_REQ';
|
||||
case MeshCoreConstants.cmdGetCustomVars:
|
||||
return 'GET_CUSTOM_VARS';
|
||||
case MeshCoreConstants.cmdSetCustomVar:
|
||||
return 'SET_CUSTOM_VAR';
|
||||
case MeshCoreConstants.cmdGetAdvertPath:
|
||||
return 'GET_ADVERT_PATH';
|
||||
case MeshCoreConstants.cmdGetTuningParams:
|
||||
return 'GET_TUNING_PARAMS';
|
||||
case MeshCoreConstants.cmdSendBinaryReq:
|
||||
return 'SEND_BINARY_REQ';
|
||||
case MeshCoreConstants.cmdFactoryReset:
|
||||
return 'FACTORY_RESET';
|
||||
case MeshCoreConstants.cmdSendPathDiscoveryReq:
|
||||
return 'SEND_PATH_DISCOVERY_REQ';
|
||||
case MeshCoreConstants.cmdSetFloodScope:
|
||||
return 'SET_FLOOD_SCOPE';
|
||||
case MeshCoreConstants.cmdSendControlData:
|
||||
return 'SEND_CONTROL_DATA';
|
||||
case MeshCoreConstants.cmdGetStats:
|
||||
return 'GET_STATS';
|
||||
case MeshCoreConstants.cmdSendAnonReq:
|
||||
return 'SEND_ANON_REQ';
|
||||
case MeshCoreConstants.cmdSetAutoaddConfig:
|
||||
return 'SET_AUTOADD_CONFIG';
|
||||
case MeshCoreConstants.cmdGetAutoaddConfig:
|
||||
return 'GET_AUTOADD_CONFIG';
|
||||
case MeshCoreConstants.cmdGetAllowedRepeatFreq:
|
||||
return 'GET_ALLOWED_REPEAT_FREQ';
|
||||
default:
|
||||
return 'CMD_UNKNOWN';
|
||||
}
|
||||
@@ -119,12 +151,28 @@ class MeshCoreOpcodeNames {
|
||||
return 'PRIVATE_KEY';
|
||||
case MeshCoreConstants.respDisabled:
|
||||
return 'DISABLED';
|
||||
case MeshCoreConstants.respContactMsgRecvV3:
|
||||
return 'CONTACT_MSG_RECV_V3';
|
||||
case MeshCoreConstants.respChannelMsgRecvV3:
|
||||
return 'CHANNEL_MSG_RECV_V3';
|
||||
case MeshCoreConstants.respChannelInfo:
|
||||
return 'CHANNEL_INFO';
|
||||
case MeshCoreConstants.respSignStart:
|
||||
return 'SIGN_START';
|
||||
case MeshCoreConstants.respSignature:
|
||||
return 'SIGNATURE';
|
||||
case MeshCoreConstants.respCustomVars:
|
||||
return 'CUSTOM_VARS';
|
||||
case MeshCoreConstants.respAdvertPath:
|
||||
return 'ADVERT_PATH';
|
||||
case MeshCoreConstants.respTuningParams:
|
||||
return 'TUNING_PARAMS';
|
||||
case MeshCoreConstants.respStats:
|
||||
return 'STATS';
|
||||
case MeshCoreConstants.respAutoaddConfig:
|
||||
return 'AUTOADD_CONFIG';
|
||||
case MeshCoreConstants.respAllowedRepeatFreq:
|
||||
return 'ALLOWED_REPEAT_FREQ';
|
||||
default:
|
||||
return 'RESP_UNKNOWN';
|
||||
}
|
||||
@@ -159,6 +207,14 @@ class MeshCoreOpcodeNames {
|
||||
return 'TELEMETRY_RESPONSE';
|
||||
case MeshCoreConstants.pushBinaryResponse:
|
||||
return 'BINARY_RESPONSE';
|
||||
case MeshCoreConstants.pushPathDiscoveryResponse:
|
||||
return 'PATH_DISCOVERY_RESPONSE';
|
||||
case MeshCoreConstants.pushControlData:
|
||||
return 'CONTROL_DATA';
|
||||
case MeshCoreConstants.pushContactDeleted:
|
||||
return 'CONTACT_DELETED';
|
||||
case MeshCoreConstants.pushContactsFull:
|
||||
return 'CONTACTS_FULL';
|
||||
default:
|
||||
return 'PUSH_UNKNOWN';
|
||||
}
|
||||
|
||||
@@ -59,6 +59,23 @@ class FrameParser {
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Parse ContactMessage V3 response (firmware ver >= 3)
|
||||
/// V3 prepends 3 bytes: [snr_scaled(int8)][reserved][reserved]
|
||||
/// snr_dB = snr_scaled / 4.0
|
||||
static Message parseContactMessageV3(BufferReader reader) {
|
||||
reader.readInt8(); // snr scaled by 4 (ignored for now)
|
||||
reader.readBytes(2); // reserved
|
||||
return parseContactMessage(reader);
|
||||
}
|
||||
|
||||
/// Parse ChannelMessage V3 response (firmware ver >= 3)
|
||||
/// V3 prepends 3 bytes: [snr_scaled(int8)][reserved][reserved]
|
||||
static Message parseChannelMessageV3(BufferReader reader) {
|
||||
reader.readInt8(); // snr scaled by 4 (ignored for now)
|
||||
reader.readBytes(2); // reserved
|
||||
return parseChannelMessage(reader);
|
||||
}
|
||||
|
||||
/// Parse ContactMessage response
|
||||
static Message parseContactMessage(BufferReader reader) {
|
||||
final pubKeyPrefix = reader.readBytes(6);
|
||||
|
||||
@@ -868,11 +868,29 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
],
|
||||
// Time for regular messages (not shown for SAR/drawing as it's already above)
|
||||
if (!isSarMarker && !message.isDrawing)
|
||||
if (!isSarMarker && !message.isDrawing) ...[
|
||||
// Hop count indicator for received messages
|
||||
if (!isOwnMessage && message.pathLen < 255) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.alt_route,
|
||||
size: 11,
|
||||
color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Reference in New Issue
Block a user