diff --git a/.gitignore b/.gitignore index 3820a95..6654d52 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +*.zip +*.ipa \ No newline at end of file diff --git a/ADVERT_PATH_TRACKING.md b/ADVERT_PATH_TRACKING.md deleted file mode 100644 index 95c31ef..0000000 --- a/ADVERT_PATH_TRACKING.md +++ /dev/null @@ -1,257 +0,0 @@ -# Advertisement Path Tracking Implementation - -## Overview -Contacts now automatically track their advertisement location history, allowing visualization of movement paths on the map. - -## Implementation Details - -### 1. Data Model (lib/models/) - -#### AdvertLocation Model -**File**: `lib/models/advert_location.dart` - -- Stores a single GPS point with timestamp -- Provides `timeAgo` display formatting -- Immutable value object with proper equality implementation - -#### Contact Model Updates -**File**: `lib/models/contact.dart` - -**New fields:** -- `advertHistory: List` - Stores up to 100 most recent location points - -**New methods:** -- `addAdvertLocation(LatLng, DateTime)` - Intelligently adds location to history - - Deduplicates points (skips if <5m apart and <60s interval) - - Maintains max 100 points (oldest removed first) - - Uses Haversine formula for distance calculation - -### 2. State Management (lib/providers/) - -#### ContactsProvider Updates -**File**: `lib/providers/contacts_provider.dart` - -**Modified**: `addOrUpdateContact()` method -- Automatically adds advertisement location to history when contact is updated -- Preserves existing history when updating contacts -- Timestamp extracted from `lastAdvert` field (Unix seconds) - -#### MapProvider Updates -**File**: `lib/providers/map_provider.dart` - -**New state:** -- `_visibleContactPaths: Set` - Tracks which paths are currently visible - -**New methods:** -- `toggleContactPath(publicKeyHex)` - Show/hide path for specific contact -- `isContactPathVisible(publicKeyHex)` - Check path visibility -- `hideAllPaths()` - Clear all visible paths -- `showOnlyPath(publicKeyHex)` - Isolate single contact's path - -### 3. Map Rendering (lib/screens/) - -#### MapTab Updates -**File**: `lib/screens/map_tab.dart` - -**Added**: `PolylineLayer` before `MarkerLayer` -- Renders blue polylines with white borders (3px stroke + 1px border) -- Only renders paths for contacts marked as visible in MapProvider -- Only renders if contact has ≥2 location points -- Uses `Consumer` to reactively update when visibility changes - -**Visual styling:** -- Color: `Colors.blue` with 70% opacity -- Border: `Colors.white` with 50% opacity -- Stroke width: 3px (main), 1px (border) - -### 4. User Interface (lib/widgets/) - -#### DetailedCompassDialog Updates -**File**: `lib/widgets/map/detailed_compass_dialog.dart` - -**Added**: Path toggle button in contact detail view -- Only visible when contact has ≥2 location points -- Shows route icon (filled when active, outlined when inactive) -- Tooltip displays current state and point count -- Primary color when path is visible -- Uses `Consumer` for reactivity - -**User flow:** -1. Tap contact marker on map → opens detailed compass dialog -2. If contact has movement history (≥2 points), path toggle button appears -3. Tap button → path appears on map as blue polyline -4. Tap again → path disappears - -### 5. Data Flow - -``` -BLE Device → PUSH_CODE_NEW_ADVERT (0x8A) - ↓ -FrameParser.parseContact() - ↓ -ContactsProvider.addOrUpdateContact() - ↓ -Contact.addAdvertLocation() [auto-deduplication] - ↓ -advertHistory updated (max 100 points) - ↓ -MapTab.PolylineLayer renders if path visible -``` - -### 6. Storage Behavior - -**Persistence**: Advertisement history is persisted via `ContactStorageService` -- Uses `contact_storage.json` in app documents directory -- Automatically saved when contacts are updated -- Loaded on app startup - -**Capacity**: Each contact stores up to 100 location points -- Oldest points automatically removed when limit reached -- Prevents unbounded memory growth - -## Usage Example - -1. **Automatic tracking** - No user action required: - ``` - Contact broadcasts location → History automatically updated - ``` - -2. **View path on map**: - ``` - Tap contact marker → Path toggle button → Tap to show → Blue line appears - ``` - -3. **Multiple paths**: - ``` - Each contact has independent path visibility - Can show multiple paths simultaneously - ``` - -## Performance Considerations - -1. **Deduplication**: Prevents excessive data accumulation for stationary contacts - - Skip if <5 meters apart AND <60 seconds interval - -2. **Bounded history**: Max 100 points per contact - - Typical SAR operation: 1 point/minute × 8 hours = 480 points (trimmed to 100) - -3. **Conditional rendering**: Polylines only rendered when: - - Path visibility enabled via MapProvider - - Contact has ≥2 location points - -4. **Memory efficiency**: - - Each AdvertLocation: ~48 bytes (2 doubles + DateTime) - - Max per contact: 100 × 48 = ~4.8KB - - 50 contacts: ~240KB total - -## Future: GPX Export (Not Implemented) - -### Rationale for Deferring -GPX export was intentionally NOT implemented in this iteration to: -1. Validate path tracking UX first -2. Gather user feedback on data granularity needs -3. Determine preferred export formats (GPX vs KML vs GeoJSON) - -### Implementation Considerations - -When implementing GPX export, consider: - -1. **Track segmentation**: - ```xml - - Contact Name - YYYY-MM-DD - - - - - - - - ``` - -2. **Metadata**: - - Contact name - - Date range of track - - Device type (from contact telemetry) - - Total distance traveled - - Duration - -3. **Gap handling**: - - Break into segments if gap >15 minutes between points - - Prevents drawing straight lines across large time gaps - -4. **Multi-contact export**: - - Option to export all visible paths as separate tracks - - Single GPX file with multiple `` elements - -5. **UI integration**: - - Add "Export Path" button in detailed compass dialog - - Share sheet for exporting GPX file - - Option to select date range - -### Recommended Package -```yaml -dependencies: - gpx: ^2.2.0 # GPX file generation and parsing -``` - -### Sample Implementation (Future) -```dart -import 'package:gpx/gpx.dart'; - -String exportContactPathToGpx(Contact contact) { - final gpx = Gpx(); - gpx.creator = 'MeshCore SAR'; - - final track = Trk(); - track.name = '${contact.advName} - ${DateTime.now().toIso8601String()}'; - - final segment = Trkseg(); - for (final point in contact.advertHistory.reversed) { - segment.trkpts.add(Wpt( - lat: point.location.latitude, - lon: point.location.longitude, - time: point.timestamp, - )); - } - - track.trksegs.add(segment); - gpx.trks.add(track); - - return GpxWriter().asString(gpx, pretty: true); -} -``` - -## Testing Checklist - -- [x] Advertisement locations automatically tracked when contact updates -- [x] Deduplication prevents duplicate points for stationary contacts -- [x] History limited to 100 points per contact -- [x] Path toggle button appears only when ≥2 points exist -- [x] Polyline renders correctly on map -- [x] Path visibility persists across dialog open/close -- [x] Multiple contact paths can be visible simultaneously -- [ ] GPX export (deferred to future iteration) - -## Known Limitations - -1. **No manual path clearing**: Users cannot manually clear a contact's path history - - Workaround: Path auto-trims to 100 points - -2. **No date range filtering**: Cannot view path for specific time period - - All points always rendered (up to 100) - -3. **No distance/duration display**: Path metadata not calculated - - Future enhancement: Show "Total: 2.4km over 3h" - -## Migration Notes - -**Existing contacts**: No migration required -- Existing contacts start with empty `advertHistory` -- History begins accumulating from first update after app upgrade -- No data loss or corruption risk - -**Storage format**: JSON-compatible -- `advertHistory` serialized as array of objects -- Standard DateTime ISO-8601 strings -- LatLng as lat/lon decimal degrees diff --git a/ADVERT_SYSTEM.md b/ADVERT_SYSTEM.md deleted file mode 100644 index e48ac6f..0000000 --- a/ADVERT_SYSTEM.md +++ /dev/null @@ -1,303 +0,0 @@ -# MeshCore Advertisement System - -This document explains how the MeshCore mesh network advertisement system works and how your app receives and processes contact updates. - -## Overview - -The MeshCore mesh network uses a broadcast advertisement system where nodes periodically announce their presence, location, and metadata to the network. Your Flutter app receives these advertisements and automatically updates the contact list. - -## Advertisement Flow - -### 1. Node Broadcasts Advertisement - -When a node in the mesh network wants to announce its presence: -- The node broadcasts an advertisement packet over LoRa -- Advertisement contains: public key, name, location, type, etc. -- Advertisements are typically sent every few minutes or when data changes - -### 2. Companion Radio Receives Advertisement - -Your BLE-connected companion radio listens to the mesh network and receives these advertisements: - -``` -[Mesh Network] ───(LoRa)──→ [Companion Radio] -``` - -### 3. Companion Radio Notifies App - -The companion radio forwards advertisement notifications to your app via BLE: - -#### Step 3a: PUSH_CODE_ADVERT (0x80) -First, you receive a simple notification that an advert was received: - -```dart -flutter: 📥 [RX] Received: ADVERT (0x80) -flutter: Data size: 33 bytes -flutter: Payload: 32 bytes -flutter: → Handling Advert push -flutter: [Advert] Parsing advert push notification... -flutter: 📡 ADVERT RECEIVED FROM NODE: -flutter: Public key prefix (6 bytes): a5:9c:36:02:c0:d7 -flutter: Public key (full 32 bytes): a5:9c:36:02:c0:d7:e4:c3:... -flutter: ℹ️ This indicates the node is broadcasting its presence -flutter: ℹ️ The companion radio will automatically update contact info -flutter: ℹ️ Expected follow-up: -flutter: - If manual_add_contacts=0: PUSH_CODE_NEW_ADVERT with full details -flutter: - If manual_add_contacts=1: Call CMD_GET_CONTACTS to sync -``` - -**Protocol Format:** -``` -[0x80] - PUSH_CODE_ADVERT -[32 bytes] - Public key of advertising node -``` - -#### Step 3b: PUSH_CODE_NEW_ADVERT (0x8A) - Automatic Contact Update - -If your device has `manual_add_contacts=0` (automatic mode), the companion radio automatically sends the full contact details: - -```dart -flutter: 📥 [RX] Received: NEW_ADVERT (0x8A) -flutter: Data size: 145 bytes -flutter: Payload: 144 bytes -flutter: → Handling NewAdvert push -flutter: [NewAdvert] Parsing new advertisement... -flutter: Public key prefix: a5:9c:36:02:c0:d7 -flutter: Type byte: 1 → Type: ContactType.chat -flutter: Advertised name: "SAR Team Alpha" -flutter: Latitude: 46.056900° -flutter: Longitude: 14.505800° -flutter: ✅ [NewAdvert] Parsed successfully - new contact advertised on network -``` - -**Protocol Format:** -``` -[0x8A] - PUSH_CODE_NEW_ADVERT -[32 bytes] - Public key -[1 byte] - Type (ADV_TYPE_*) -[1 byte] - Flags -[1 byte] - Out path length -[64 bytes] - Out path -[32 bytes] - Advertised name (null-terminated) -[4 bytes] - Last advert timestamp (uint32) -[4 bytes] - Latitude * 1E6 (int32) -[4 bytes] - Longitude * 1E6 (int32) -[4 bytes] - Last modified timestamp (uint32) -``` - -The app automatically adds/updates this contact via the `onContactReceived` callback! - -## Manual vs Automatic Contact Management - -Your companion radio has a setting called `manual_add_contacts`: - -### Automatic Mode (manual_add_contacts=0) - RECOMMENDED - -**Behavior:** -1. ✅ PUSH_CODE_ADVERT (0x80) received → just informational -2. ✅ PUSH_CODE_NEW_ADVERT (0x8A) received → **contact automatically added to app** -3. ✅ No action needed from app - -**Advantages:** -- Contacts appear instantly when they advertise -- No need to manually sync -- Perfect for SAR operations where team members join dynamically - -### Manual Mode (manual_add_contacts=1) - -**Behavior:** -1. ✅ PUSH_CODE_ADVERT (0x80) received → informational -2. ❌ PUSH_CODE_NEW_ADVERT (0x8A) NOT sent -3. 📞 App must call `CMD_GET_CONTACTS` to sync - -**When to use:** -- When you want control over which contacts are added -- When bandwidth is very limited -- When you have a static team roster - -## Implementation in Your App - -### Current Implementation - -The app is already fully configured to handle advertisements automatically: - -```dart -// In MeshCoreBleService (_handleNewAdvert) -final contact = Contact( - publicKey: publicKey, - type: type, - flags: flags, - outPathLen: outPathLen, - outPath: outPath, - advName: advName, - lastAdvert: lastAdvert, - advLat: advLat, - advLon: advLon, - lastMod: lastMod, -); - -// This callback automatically updates the contact list -onContactReceived?.call(contact); -``` - -```dart -// In AppProvider (_setupCallbacks) -connectionProvider.onContactReceived = (contact) { - // Automatically add or update contact in the list - contactsProvider.addOrUpdateContact(contact); -}; -``` - -### Event Flow - -``` -[Mesh Node Advertises] - ↓ -[Companion Radio Receives via LoRa] - ↓ -[PUSH_CODE_ADVERT (0x80) sent to app] - ↓ (if manual_add_contacts=0) -[PUSH_CODE_NEW_ADVERT (0x8A) sent to app] - ↓ -[onContactReceived callback fired] - ↓ -[contactsProvider.addOrUpdateContact(contact)] - ↓ -[UI automatically updates via notifyListeners()] -``` - -## Checking Your Device Settings - -To see if your device is in automatic or manual mode: - -```dart -// Check the manualAddContacts field from SelfInfo -final manualMode = connectionProvider.deviceInfo.manualAddContacts; - -if (manualMode == false) { - print('✅ Automatic mode: Contacts will appear automatically'); -} else { - print('⚠️ Manual mode: You need to call getContacts() after adverts'); -} -``` - -You can change this setting: - -```dart -await connectionProvider.setOtherParams( - manualAddContacts: 0, // 0 = automatic, 1 = manual - telemetryModes: currentTelemetryModes, - advertLocationPolicy: currentLocationPolicy, -); -``` - -## Troubleshooting - -### "I receive ADVERT (0x80) but no NEW_ADVERT (0x8A)" - -**Cause:** Your device is in manual mode (`manual_add_contacts=1`) - -**Solution:** -1. Check device settings via SelfInfo -2. Change to automatic mode, OR -3. Call `CMD_GET_CONTACTS` after receiving adverts - -### "Contacts don't appear on the map" - -**Possible causes:** -1. Contact type is not `ContactType.chat` (only chat contacts show on map) -2. Contact has no GPS coordinates (lat/lon = 0) -3. Contact hasn't advertised recently - -**Debug:** -```dart -// Check contact properties -print('Contact: ${contact.advName}'); -print('Type: ${contact.type}'); // Should be ContactType.chat -print('Lat: ${contact.latitude}'); // Should not be null -print('Lon: ${contact.longitude}'); // Should not be null -``` - -### "Room contact not found for login" - -**Cause:** Room hasn't advertised yet or wasn't synced - -**Solution:** -```dart -// Force sync contacts first -await connectionProvider.getContacts(); - -// Small delay to ensure contacts are loaded -await Future.delayed(const Duration(milliseconds: 500)); - -// Then try login -await connectionProvider.loginToRoom( - roomPublicKey: roomContact.publicKey, - password: 'your_password', -); -``` - -## Best Practices - -1. **Use automatic mode for SAR operations** - Team members will appear as they join -2. **Sync contacts on first connect** - Always call `getContacts()` after connecting -3. **Handle both modes gracefully** - Check `manual_add_contacts` setting -4. **Monitor advert activity** - Use `onAdvertReceived` callback to show network activity -5. **Cache contacts locally** - Don't rely solely on live advertisements - -## Protocol Summary - -| Push Code | Name | When Sent | Contains | Action Required | -|-----------|------|-----------|----------|-----------------| -| 0x80 | PUSH_CODE_ADVERT | When any node advertises | Just public key | None (informational) | -| 0x8A | PUSH_CODE_NEW_ADVERT | After ADVERT, if manual_add_contacts=0 | Full contact details | None (auto-added) | - -## Example Logs - -### Successful Automatic Flow - -``` -📥 [RX] Received: ADVERT (0x80) - 📡 ADVERT RECEIVED FROM NODE: - Public key prefix (6 bytes): a5:9c:36:02:c0:d7 -📥 [Provider] Advert received from node - Note: Waiting for PUSH_CODE_NEW_ADVERT (0x8A) with full contact details - -📥 [RX] Received: NEW_ADVERT (0x8A) - [NewAdvert] Parsing new advertisement... - Advertised name: "SAR Team Alpha" - Latitude: 46.056900° - Longitude: 14.505800° - ✅ [NewAdvert] Parsed successfully -✅ [Provider] Contact added: SAR Team Alpha -``` - -### Manual Mode Flow - -``` -📥 [RX] Received: ADVERT (0x80) - 📡 ADVERT RECEIVED FROM NODE: - Public key prefix (6 bytes): a5:9c:36:02:c0:d7 -📥 [Provider] Advert received from node - Note: manual_add_contacts=1, you need to call CMD_GET_CONTACTS - -📤 [TX] Sending command: GET_CONTACTS (0x04) -📥 [RX] Received: CONTACTS_START (0x02) -📥 [RX] Received: CONTACT (0x03) - [Contact] Parsing contact... - Advertised name: "SAR Team Alpha" -✅ [Provider] Contact added: SAR Team Alpha -``` - -## Related Files - -- `lib/services/meshcore_ble_service.dart:876` - `_handleAdvert()` implementation -- `lib/services/meshcore_ble_service.dart:924` - `_handleNewAdvert()` implementation -- `lib/providers/connection_provider.dart:164` - `onAdvertReceived` callback setup -- `lib/providers/app_provider.dart:44` - Contact sync setup -- `lib/models/contact.dart` - Contact data model - -## Further Reading - -- [MeshCore Protocol Documentation](CLAUDE.md) - Full protocol specification -- [Contact Management](lib/providers/contacts_provider.dart) - Contact provider implementation diff --git a/BLE_PACKET_LOG_ANALYSIS.md b/BLE_PACKET_LOG_ANALYSIS.md deleted file mode 100644 index 24d7fb1..0000000 --- a/BLE_PACKET_LOG_ANALYSIS.md +++ /dev/null @@ -1,638 +0,0 @@ -# BLE Packet Log Analysis - Message Send/Receive Flow - -**Date**: 2025-01-15 -**Purpose**: Analyze BLE packet logs to understand message transmission and delivery - -## Overview - -This document explains how to use the **BLE Packet Log** feature (already implemented in the app) to diagnose message send/receive issues. The app automatically logs ALL BLE communication between the Flutter app and the MeshCore companion device. - -## Quick Start: Viewing Packet Logs - -### Access the Packet Log Screen - -**Currently**: The packet log screen exists but is not accessible from the main UI. - -**Location**: `lib/screens/packet_log_screen.dart` - -### How to Add Navigation (Quick Fix) - -**Option 1: Add to Home Screen AppBar** (`lib/screens/home_screen.dart`): - -```dart -// In HomeScreen's AppBar actions: -actions: [ - // ... existing RX/TX indicators ... - - // NEW: Packet log button - IconButton( - icon: const Icon(Icons.list_alt), - tooltip: 'BLE Packet Logs', - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PacketLogScreen( - bleService: widget.connectionProvider.bleService, - ), - ), - ); - }, - ), - - // ... existing long press indicator ... -], -``` - -**Option 2: Add to Debug Menu** (if you have one): - -```dart -ListTile( - leading: const Icon(Icons.bug_report), - title: const Text('BLE Packet Logs'), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PacketLogScreen( - bleService: connectionProvider.bleService, - ), - ), - ), -), -``` - -### Packet Log Features (Already Implemented) - -1. **Auto-logging**: Every BLE packet automatically logged -2. **Direction indicators**: RX (received) vs TX (sent) with color coding -3. **Opcode names**: Human-readable names (e.g., "CONTACT_MSG_RECV" instead of "0x07") -4. **Hex dump**: Full packet data in hexadecimal -5. **Search/filter**: Search by hex data, description, or opcode name -6. **Export**: Export logs as CSV or TXT for analysis -7. **Auto-scroll**: Option to automatically scroll to newest packets - -## Message Send/Receive Protocol Flow - -### Complete Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ USER SENDS MESSAGE │ -└────────────────┬────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ 1. TX: CMD_SEND_TXT_MSG (0x02) or CMD_SEND_CHANNEL_TXT_MSG (0x03) │ -│ - Contains: message text, recipient pub key, timestamp │ -│ - Logged as: PacketDirection.tx │ -└────────────────┬────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ 2. RX: RESP_CODE_SENT (0x06) │ -│ - Contains: expected ACK tag, suggested timeout (e.g., 30000ms) │ -│ - Message status: sending → sent │ -│ - Logged as: PacketDirection.rx │ -└────────────────┬────────────────────────────────────────────────────────┘ - │ - ├──────────────────────────┬─────────────────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ - │ 3a. SUCCESS PATH │ │ 3b. TIMEOUT PATH │ │ 3c. DIAGNOSTIC PATH │ - └──────────────────────┘ └──────────────────────┘ └─────────────────────┘ - │ │ │ - ▼ ▼ ▼ - ┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ - │ RX: PUSH_CODE_ │ │ Timer expires │ │ RX: PUSH_CODE_ │ - │ SEND_CONFIRMED │ │ (30000ms) │ │ LOG_RX_DATA │ - │ (0x82) │ │ │ │ (0x88) │ - │ │ │ Message status: │ │ │ - │ Contains: │ │ sent → failed │ │ Contains: │ - │ - ACK code │ │ │ │ - SNR, RSSI │ - │ - RTT (ms) │ │ No retry triggered │ │ - Raw packet data │ - │ │ │ (manual retry only) │ │ │ - │ Message status: │ └──────────────────────┘ │ Diagnostic only │ - │ sent → delivered │ │ (doesn't affect │ - └──────────────────────┘ │ message status) │ - └─────────────────────┘ - -┌─────────────────────────────────────────────────────────────────────────┐ -│ REMOTE USER SENDS MESSAGE │ -└────────────────┬────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ 1. Message arrives at companion device over LoRa │ -│ - Device stores in internal queue │ -│ - May trigger LOG_RX_DATA (0x88) diagnostic push │ -└────────────────┬────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ 2. RX: PUSH_CODE_MSG_WAITING (0x83) │ -│ - Asynchronous notification: "New message ready" │ -│ - Contains: no data (just notification) │ -│ - Logged as: PacketDirection.rx │ -└────────────────┬────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ 3. TX: CMD_SYNC_NEXT_MESSAGE (0x0A) │ -│ - Request to fetch next message from queue │ -│ - Contains: no data (just command code) │ -│ - Logged as: PacketDirection.tx │ -└────────────────┬────────────────────────────────────────────────────────┘ - │ - ├──────────────────────────┬─────────────────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ - │ 4a. DIRECT MESSAGE │ │ 4b. CHANNEL MESSAGE │ │ 4c. QUEUE EMPTY │ - └──────────────────────┘ └──────────────────────┘ └─────────────────────┘ - │ │ │ - ▼ ▼ ▼ - ┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ - │ RX: RESP_CODE_ │ │ RX: RESP_CODE_ │ │ RX: RESP_CODE_ │ - │ CONTACT_MSG_RECV │ │ CHANNEL_MSG_RECV │ │ NO_MORE_MESSAGES │ - │ (0x07) │ │ (0x08) │ │ (0x0A) │ - │ │ │ │ │ │ - │ Contains: │ │ Contains: │ │ Stop syncing loop │ - │ - Sender pub key │ │ - Channel index │ └─────────────────────┘ - │ (6 bytes) │ │ - Path length │ - │ - Path length │ │ - Text type │ - │ - Text type │ │ - Timestamp │ - │ - Timestamp │ │ - Text (format: │ - │ - Text (plain) │ │ "Name: Message") │ - │ │ │ │ - │ App displays message │ │ App displays message │ - └──────────────────────┘ └──────────────────────┘ - │ │ - └──────────────┬───────────┘ - │ - ▼ - ┌──────────────────────────────────┐ - │ Loop back to CMD_SYNC_NEXT_MSG │ - │ until RESP_CODE_NO_MORE_MESSAGES │ - └──────────────────────────────────┘ -``` - -## BLE Packet Log Interpretation Guide - -### Sending a Direct Message - -#### Expected Log Sequence - -``` -1. [TX] SEND_TXT_MSG (0x02) - 18 bytes - Hex: 02 00 00 e8 76 67 67 8b 33 f2 a1 4c d9 48 65 6c 6c 6f - Breakdown: - 02 = CMD_SEND_TXT_MSG - 00 = TXT_TYPE_PLAIN - 00 = Attempt 0 (first send) - e8 76 67 67 = Timestamp (Little Endian): 1734567912 - 8b 33 f2 a1 4c d9 = Recipient public key prefix (6 bytes) - 48 65 6c 6c 6f = "Hello" (UTF-8) - -2. [RX] SENT (0x06) - 9 bytes - Hex: 06 00 d2 04 00 00 30 75 00 00 - Breakdown: - 06 = RESP_CODE_SENT - 00 = Send type: 0=direct route - d2 04 00 00 = Expected ACK tag (Little Endian): 1234 - 30 75 00 00 = Suggested timeout (Little Endian): 30000ms (30 seconds) - - Result: Message now in "Sent" state, waiting for confirmation - -3a. [RX] SEND_CONFIRMED (0x82) - 9 bytes (SUCCESS PATH) - Hex: 82 d2 04 00 00 10 27 00 00 - Breakdown: - 82 = PUSH_CODE_SEND_CONFIRMED - d2 04 00 00 = ACK code (Little Endian): 1234 (matches expected) - 10 27 00 00 = Round trip time (Little Endian): 10000ms - - Result: Message marked "Delivered", timeout timer cancelled - -3b. (No packet received, timeout after 30000ms) (TIMEOUT PATH) - Result: Timeout timer expires, message marked "Failed" -``` - -### Sending a Channel Message - -#### Expected Log Sequence - -``` -1. [TX] SEND_CHANNEL_TXT_MSG (0x03) - 13 bytes - Hex: 03 00 00 e8 76 67 67 48 69 20 61 6c 6c - Breakdown: - 03 = CMD_SEND_CHANNEL_TXT_MSG - 00 = TXT_TYPE_PLAIN - 00 = Channel index 0 (public) - e8 76 67 67 = Timestamp (Little Endian): 1734567912 - 48 69 20 61 6c 6c = "Hi all" (UTF-8) - -2. [RX] SENT (0x06) - 9 bytes - Hex: 06 01 e3 05 00 00 50 c3 00 00 - Breakdown: - 06 = RESP_CODE_SENT - 01 = Send type: 1=flood mode (broadcast) - e3 05 00 00 = Expected ACK/TAG (Little Endian): 1507 - 50 c3 00 00 = Suggested timeout (Little Endian): 50000ms - - Result: Channel message broadcast, waiting for confirmation - -3. [RX] SEND_CONFIRMED (0x82) - 9 bytes - Hex: 82 e3 05 00 00 88 13 00 00 - Breakdown: - 82 = PUSH_CODE_SEND_CONFIRMED - e3 05 00 00 = ACK code (Little Endian): 1507 (matches) - 88 13 00 00 = RTT (Little Endian): 5000ms - - Result: Broadcast confirmed delivered -``` - -### Receiving a Direct Message - -#### Expected Log Sequence - -``` -1. [RX] MSG_WAITING (0x83) - 1 byte - Hex: 83 - Breakdown: - 83 = PUSH_CODE_MSG_WAITING - - Result: App calls CMD_SYNC_NEXT_MESSAGE - -2. [TX] SYNC_NEXT_MESSAGE (0x0A) - 1 byte - Hex: 0a - Breakdown: - 0a = CMD_SYNC_NEXT_MESSAGE - - Result: Request next message from device queue - -3. [RX] CONTACT_MSG_RECV (0x07) - 19 bytes - Hex: 07 8b 33 f2 a1 4c d9 ff 00 e8 76 67 67 48 69 - Breakdown: - 07 = RESP_CODE_CONTACT_MSG_RECV - 8b 33 f2 a1 4c d9 = Sender public key prefix (6 bytes) - ff = Path length: 0xFF = direct path (not flood) - 00 = TXT_TYPE_PLAIN - e8 76 67 67 = Sender timestamp (Little Endian): 1734567912 - 48 69 = "Hi" (UTF-8) - - Result: Message displayed in app, matched to contact by pub key prefix - -4. [TX] SYNC_NEXT_MESSAGE (0x0A) - 1 byte - Hex: 0a - - Result: Check for more messages - -5. [RX] NO_MORE_MESSAGES (0x0A) - 1 byte - Hex: 0a - Breakdown: - 0a = RESP_CODE_NO_MORE_MESSAGES - - Result: Stop syncing loop, all messages fetched -``` - -### Receiving a Channel Message - -#### Expected Log Sequence - -``` -1. [RX] MSG_WAITING (0x83) - 1 byte - Hex: 83 - -2. [TX] SYNC_NEXT_MESSAGE (0x0A) - 1 byte - Hex: 0a - -3. [RX] CHANNEL_MSG_RECV (0x08) - 22 bytes - Hex: 08 00 03 00 e8 76 67 67 4a 6f 68 6e 3a 20 48 65 6c 6c 6f - Breakdown: - 08 = RESP_CODE_CHANNEL_MSG_RECV - 00 = Channel index 0 (public) - 03 = Path length: 3 hops - 00 = TXT_TYPE_PLAIN - e8 76 67 67 = Sender timestamp (Little Endian): 1734567912 - 4a 6f 68 6e 3a 20 48 65 6c 6c 6f = "John: Hello" (UTF-8) - - Result: Parse sender name from text ("John"), display message - -4. [TX] SYNC_NEXT_MESSAGE (0x0A) - 1 byte - Hex: 0a - -5. [RX] NO_MORE_MESSAGES (0x0A) - 1 byte - Hex: 0a -``` - -## Diagnostic: LOG_RX_DATA Push (0x88) - -### What is LOG_RX_DATA? - -**Purpose**: Diagnostic push notification containing raw over-the-air LoRa packets - -**When it triggers**: Every time the companion device receives a packet from another mesh node - -**Frame format**: -``` -[0x88] = PUSH_CODE_LOG_RX_DATA -[1 byte] = SNR × 4 (signed int8, divide by 4 for dB) -[1 byte] = RSSI (signed int8, in dBm) -[N bytes] = Raw encrypted LoRa packet data -``` - -### Example LOG_RX_DATA Packet - -``` -Hex dump: -88 = PUSH_CODE_LOG_RX_DATA -14 = SNR: 20 (÷4 = 5.0 dB) -d6 = RSSI: -42 dBm (signed) -f3 e2 a1 9c 7f 3d 42 ... = Raw encrypted mesh packet (high entropy) -``` - -### App's LOG_RX_DATA Handler - -**Location**: `lib/services/meshcore_ble_service.dart:1017-1333` - -The app already has EXTENSIVE decoding analysis for LOG_RX_DATA packets: - -1. **Signal Quality Metrics**: - - SNR (Signal-to-Noise Ratio) in dB - - RSSI (Received Signal Strength) in dBm - -2. **Hex Dump**: Formatted 16 bytes per line with ASCII view - -3. **Forced Decoding** (9 different interpretations): - - All uint32 values at each offset - - All int32 values (GPS coordinates) - - All uint16 values - - Byte pair correlation (pattern detection) - - Nibble distribution analysis - - XOR pattern detection (simple encryption) - - Checksum/CRC candidates - - Bit-level analysis (entropy check) - - LoRa modulation parameter detection - -4. **Entropy Calculation**: Detect if packet is encrypted (>70% entropy) - -5. **String Extraction**: Find embedded ASCII strings (4+ printable chars) - -### Why LOG_RX_DATA Packets Don't Affect Messages - -**Critical**: LOG_RX_DATA is **diagnostic only** - it does NOT affect message delivery status! - -``` -┌────────────────────────────────────────────────────────────┐ -│ Message Send Flow (affects delivery status) │ -├────────────────────────────────────────────────────────────┤ -│ TX: CMD_SEND_TXT_MSG (0x02) │ -│ ↓ │ -│ RX: RESP_CODE_SENT (0x06) ← Message now "Sent" │ -│ ↓ │ -│ RX: PUSH_CODE_SEND_CONFIRMED (0x82) ← Message "Delivered"│ -└────────────────────────────────────────────────────────────┘ - -┌────────────────────────────────────────────────────────────┐ -│ Diagnostic Flow (does NOT affect delivery status) │ -├────────────────────────────────────────────────────────────┤ -│ RX: PUSH_CODE_LOG_RX_DATA (0x88) │ -│ ↓ │ -│ Logged to packet log, analyzed for debugging │ -│ ↓ │ -│ No state change in MessagesProvider │ -└────────────────────────────────────────────────────────────┘ -``` - -**Use Cases for LOG_RX_DATA**: -1. Monitor mesh network activity in real-time -2. Analyze signal quality (SNR/RSSI) for received packets -3. Debug packet reception issues -4. Understand network topology -5. Detect interference or poor RF conditions - -**Note**: The raw packet data is typically encrypted (high entropy ~95%+), so direct decoding is not possible. The app's exhaustive analysis tries to extract any structured information. - -## Troubleshooting Message Issues - -### Symptom: Messages Stuck in "Sending" Status - -**Check packet log for**: -1. ✅ `[TX] SEND_TXT_MSG (0x02)` present → Message sent to device -2. ❌ `[RX] SENT (0x06)` missing → Device not responding - -**Possible causes**: -- BLE connection dropped -- Companion device frozen -- BLE service not properly initialized - -**Fix**: -- Reconnect to device -- Check device battery -- Restart companion device - -### Symptom: Messages Stuck in "Sent" Status (Never Delivered) - -**Check packet log for**: -1. ✅ `[TX] SEND_TXT_MSG (0x02)` present -2. ✅ `[RX] SENT (0x06)` present → Message acknowledged by device -3. ❌ `[RX] SEND_CONFIRMED (0x82)` missing → No delivery confirmation -4. ⏱️ Timeout timer should fire after suggested timeout - -**Check LOG_RX_DATA packets**: -- If NO `[RX] LOG_RX_DATA (0x88)` packets: Network is silent, no mesh activity -- If many `[RX] LOG_RX_DATA (0x88)` packets: Network is active - - Check SNR/RSSI values (should be > -120 dBm) - - Low SNR/RSSI indicates poor signal quality - -**Possible causes**: -- Recipient device out of range -- No mesh route to recipient -- Recipient device off/offline -- Network congestion (many nodes transmitting) -- Poor RF conditions (interference, obstacles) - -**Fix**: -- Check recipient device status -- Move closer to establish direct line-of-sight -- Wait for timeout, then retry -- Check if other nodes are receiving messages - -### Symptom: Messages Never Received (No MSG_WAITING) - -**Check packet log for**: -1. ❌ `[RX] MSG_WAITING (0x83)` missing → No messages in device queue - -**Possible causes**: -- No one sent you a message -- Messages filtered by contact flags -- Device message queue full (old messages overwritten) -- Room not logged in (room messages require login) - -**Fix**: -- Verify sender actually sent message -- Check contact flags (telemetry_modes, advert_location_policy) -- Login to room if expecting room messages -- Check device storage (CMD_GET_BATT_AND_STORAGE) - -### Symptom: Channel Messages Not Received - -**Check packet log for**: -1. ❌ `[RX] CHANNEL_MSG_RECV (0x08)` never appears after MSG_WAITING - -**Possible causes**: -- Message queue only had direct messages, no channel messages -- Channel message from unknown sender (name not in contacts) - -**Fix**: -- Call `CMD_SYNC_NEXT_MESSAGE` repeatedly until `NO_MORE_MESSAGES` -- Check if message appears as `CONTACT_MSG_RECV (0x07)` instead - -### Symptom: Room Messages Not Syncing After Login - -**Check packet log for**: -1. ✅ `[TX] SEND_LOGIN (0x1A)` present -2. ✅ `[RX] LOGIN_SUCCESS (0x85)` present → Login succeeded -3. ⚠️ Immediately called `CMD_SYNC_NEXT_MESSAGE`? → **WRONG!** - -**Protocol compliance check**: -``` -WRONG ❌: - LOGIN_SUCCESS → CMD_SYNC_NEXT_MESSAGE → NO_MORE_MESSAGES - (Room hasn't pushed messages yet, they arrive 2000ms later!) - -CORRECT ✅: - LOGIN_SUCCESS → wait for MSG_WAITING → CMD_SYNC_NEXT_MESSAGE - (Room server pushes messages automatically every 1200ms) -``` - -**Fix**: -- Don't call `syncAllMessages()` immediately after login -- Wait for `PUSH_CODE_MSG_WAITING (0x83)` notifications -- Room server pushes messages automatically (see MESSAGES.md lines 679-728) - -## Export and Analysis - -### Export Packet Logs - -**CSV Export** (for spreadsheet analysis): -```csv -Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description -2025-01-15T10:30:15.123,TX,18,SEND_TXT_MSG,2,"02 00 00 e8 76 67 67 8b 33 f2 a1 4c d9 48 65 6c 6c 6f","Send Text Message" -2025-01-15T10:30:15.456,RX,9,SENT,6,"06 00 d2 04 00 00 30 75 00 00","" -2025-01-15T10:30:25.789,RX,9,SEND_CONFIRMED,130,"82 d2 04 00 00 10 27 00 00","" -``` - -**Text Export** (for log analysis): -``` -MeshCore BLE Packet Logs -================================================================================ -Exported: 2025-01-15T10:35:00.000Z -Total packets: 127 -================================================================================ - -2025-01-15T10:30:15.123Z [TX] SEND_TXT_MSG (0x02) 18 bytes: 02 00 00 e8 76 67 67 8b 33 f2 a1 4c d9 48 65 6c 6c 6f - Send Text Message -2025-01-15T10:30:15.456Z [RX] SENT (0x06) 9 bytes: 06 00 d2 04 00 00 30 75 00 00 -2025-01-15T10:30:25.789Z [RX] SEND_CONFIRMED (0x82) 9 bytes: 82 d2 04 00 00 10 27 00 00 -``` - -### Analyzing Exports - -**Python script to analyze CSV**: - -```python -import csv -from datetime import datetime - -with open('ble_packets.csv') as f: - reader = csv.DictReader(f) - packets = list(reader) - -# Find all sent messages with their ACK tags -sent_messages = {} -for packet in packets: - if packet['Opcode Name'] == 'SENT': - # Parse ACK tag from hex data - hex_bytes = packet['Hex Data'].split() - ack_tag = int.join(hex_bytes[2:6], '', 16) # Little Endian - sent_messages[ack_tag] = { - 'sent_at': datetime.fromisoformat(packet['Timestamp']), - 'confirmed': False, - } - -# Match with confirmations -for packet in packets: - if packet['Opcode Name'] == 'SEND_CONFIRMED': - hex_bytes = packet['Hex Data'].split() - ack_tag = int.join(hex_bytes[1:5], '', 16) - if ack_tag in sent_messages: - sent_messages[ack_tag]['confirmed'] = True - sent_messages[ack_tag]['confirmed_at'] = datetime.fromisoformat(packet['Timestamp']) - rtt_ms = int.join(hex_bytes[5:9], '', 16) - sent_messages[ack_tag]['rtt_ms'] = rtt_ms - -# Report -for ack_tag, info in sent_messages.items(): - if info['confirmed']: - rtt = info['confirmed_at'] - info['sent_at'] - print(f"ACK {ack_tag}: Delivered in {rtt.total_seconds():.3f}s (RTT: {info['rtt_ms']}ms)") - else: - print(f"ACK {ack_tag}: NOT DELIVERED (timed out)") -``` - -## Summary - -### Key Takeaways - -1. **Packet Log is Already Implemented**: Full BLE packet logging exists in `lib/screens/packet_log_screen.dart` -2. **Just Needs Navigation**: Add a button to navigate to PacketLogScreen from HomeScreen -3. **Comprehensive Diagnostics**: App already logs and analyzes everything -4. **LOG_RX_DATA is Diagnostic Only**: Does NOT affect message delivery status -5. **Timeout Handling Implemented**: Messages automatically fail after timeout (see MESSAGING_IMPROVEMENTS_IMPLEMENTED.md) -6. **Retry Logic Implemented**: Manual retry for failed messages (see MESSAGING_IMPROVEMENTS_IMPLEMENTED.md) - -### Quick Reference: Packet Codes - -| Code | Name | Direction | Meaning | -|------|------|-----------|---------| -| 0x02 | SEND_TXT_MSG | TX | Sending direct message | -| 0x03 | SEND_CHANNEL_TXT_MSG | TX | Sending channel message | -| 0x06 | SENT | RX | Message accepted, ACK tag provided | -| 0x07 | CONTACT_MSG_RECV | RX | Direct message received | -| 0x08 | CHANNEL_MSG_RECV | RX | Channel message received | -| 0x0A (CMD) | SYNC_NEXT_MESSAGE | TX | Fetch next message | -| 0x0A (RESP) | NO_MORE_MESSAGES | RX | Message queue empty | -| 0x1A | SEND_LOGIN | TX | Login to room | -| 0x82 | SEND_CONFIRMED | RX | Delivery confirmed (with RTT) | -| 0x83 | MSG_WAITING | RX | New message available | -| 0x85 | LOGIN_SUCCESS | RX | Room login succeeded | -| 0x86 | LOGIN_FAIL | RX | Room login failed | -| 0x88 | LOG_RX_DATA | RX | Diagnostic: raw over-the-air packet | - -### Next Steps - -1. **Add Navigation to Packet Log Screen**: - - Update `lib/screens/home_screen.dart` - - Add IconButton in AppBar actions - - Wire to PacketLogScreen - -2. **Test Message Flow**: - - Send messages and watch packet log in real-time - - Enable auto-scroll to see newest packets - - Export logs for offline analysis - -3. **Debug Failed Messages**: - - Check for missing SEND_CONFIRMED packets - - Analyze LOG_RX_DATA for signal quality issues - - Verify timeout values from SENT responses - -## References - -- **BLE Packet Log Implementation**: `lib/screens/packet_log_screen.dart` -- **BLE Packet Model**: `lib/models/ble_packet_log.dart` -- **BLE Service (Logging)**: `lib/services/meshcore_ble_service.dart:275-319` -- **Opcode Names**: `lib/services/meshcore_opcode_names.dart` -- **Message Protocol**: `MESSAGES.md` -- **Gap Analysis**: `MESSAGE_SEND_RECEIVE_GAP_ANALYSIS.md` -- **Timeout/Retry**: `MESSAGING_IMPROVEMENTS_IMPLEMENTED.md` -- **Protocol Spec**: `/Users/dz0ny/meshcore-sar/MeshCore/docs/companion.md` diff --git a/CLAUDE.md b/CLAUDE.md index ecce248..90d2322 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,10 @@ AI assistant guide for the MeshCore SAR Flutter application. ``` lib/ -├── models/ # Data models (contact, message, sar_marker, device_info, room_login_state, map_layer) +├── models/ # Data models +│ ├── contact.dart, message.dart, sar_marker.dart +│ ├── map_drawing.dart # MapDrawing, LineDrawing, RectangleDrawing +│ ├── device_info.dart, room_login_state.dart, map_layer.dart ├── services/ # Business logic │ ├── meshcore_ble_service.dart # BLE coordinator (399 lines) │ ├── protocol/ # Frame parsing & building (628 lines) @@ -40,10 +43,23 @@ lib/ │ ├── location_tracking_service.dart # GPS + mesh broadcast (501 lines) │ ├── map_marker_service.dart # Marker generation + geodesic (518 lines) │ └── validation_service.dart # Form validation (511 lines) -├── providers/ # State management (ConnectionProvider, ContactsProvider, MessagesProvider, MapProvider, AppProvider) +├── providers/ # State management +│ ├── connection_provider.dart # BLE connection state +│ ├── contacts_provider.dart # Contact list +│ ├── messages_provider.dart # Messages + SAR markers +│ ├── map_provider.dart # Map navigation +│ ├── drawing_provider.dart # Map drawing state +│ └── app_provider.dart # Coordinator (uses all above) ├── screens/ # UI screens (home, messages, contacts, map, settings, device_config, map_management, packet_log) -├── widgets/ # Reusable components (map_markers, messages/, contacts/, map/) -└── utils/ # Utilities (sar_message_parser) +├── widgets/ # Reusable components +│ ├── map_markers.dart # Map marker rendering +│ ├── map/ # Map-specific widgets +│ │ ├── drawing_layer.dart # Drawing rendering on map +│ │ └── drawing_toolbar.dart # Drawing UI controls +│ ├── messages/, contacts/ # Feature-specific widgets +└── utils/ # Utilities + ├── sar_message_parser.dart # SAR marker parsing + └── drawing_message_parser.dart # Drawing message parsing ``` ## MeshCore Protocol @@ -195,14 +211,64 @@ Format: `[Channel] [Type] [Data...]` ### SAR Message Format -Format: `S::,` +Format: `S::,:` **Recognized Emojis:** - 🧑 or 👤: Found Person - 🔥: Fire Location - 🏕️ or ⛺: Staging Area -**Rules:** Must start with `S:`, single emoji after first colon, comma-separated lat/lon, no spaces +**Rules:** +- Must start with `S:` +- Single emoji after first colon +- Comma-separated lat/lon coordinates +- Optional message after third colon (displayed in message bubble) +- No spaces in coordinates section + +**Examples:** +- `S:🧑:37.7749,-122.4194` - Basic SAR marker +- `S:🔥:40.7128,-74.0060:Large wildfire spreading rapidly` - With message +- `S:🏕️:34.0522,-118.2437:Base camp established, supplies available` - With detailed note + +**Message Display:** +- SAR markers shown with highlighted colored bubble +- Emoji, type name, and coordinates always displayed +- Optional message shown in secondary container below coordinates +- Tap to navigate to location on map + +### Map Drawing Message Format + +Format: `D:` + +**Ultra-Compact JSON Format:** +- **Prefix**: `D:` identifies drawing messages +- **Sender**: Extracted from packet metadata (not in JSON) +- **Type field (`t`)**: Shape type as integer + - `0`: Line drawing + - `1`: Rectangle drawing +- **Color field (`c`)**: Color index (0-7) + - `0`: Red, `1`: Blue, `2`: Green, `3`: Yellow + - `4`: Orange, `5`: Purple, `6`: Pink, `7`: Cyan +- **Points field (`p`)**: Flat array of coordinates `[lat1,lon1,lat2,lon2,...]` +- **Bounds field (`b`)**: Rectangle bounds `[topLat,topLon,botLat,botLon]` + +**Example Line Drawing (red, 2 points):** +```json +D:{"t":0,"c":0,"p":[45.123,-122.456,45.234,-122.567]} +``` + +**Example Rectangle Drawing (blue):** +```json +D:{"t":1,"c":1,"b":[45.1,-122.5,45.2,-122.4]} +``` + +**Implementation Details:** +- Models: `lib/models/map_drawing.dart` (MapDrawing, LineDrawing, RectangleDrawing) +- Parser: `lib/utils/drawing_message_parser.dart` (DrawingMessageParser) +- Provider: `lib/providers/drawing_provider.dart` (DrawingProvider) +- Colors: 8 predefined colors mapped to indices for bandwidth efficiency +- Local persistence uses full JSON format with timestamps and IDs +- Network transmission uses ultra-compact format (~37% size reduction) ## State Management Architecture @@ -213,6 +279,7 @@ MultiProvider ├── ContactsProvider # Contact list ├── MessagesProvider # Messages + SAR markers ├── MapProvider # Map navigation +├── DrawingProvider # Map drawing state └── AppProvider # Coordinator (uses all above) ``` @@ -222,10 +289,18 @@ BLE Device → MeshCoreBleService → ConnectionProvider → AppProvider ↓ ContactsProvider MessagesProvider + DrawingProvider ↓ UI ``` +**Drawing Message Flow:** +``` +User draws → DrawingProvider → DrawingToolbar (share) → ConnectionProvider (BLE) + ↓ +Remote User ← UI ← DrawingProvider ← AppProvider ← ConnectionProvider ← BLE Device +``` + **Contact Types:** - none(0): Unknown/invalid - chat(1): Team member (shown on map) @@ -321,6 +396,28 @@ Ultra-compact location display, tap to toggle DD/DMS formats, no close button (t 2. Add to `allLayers` list 3. Layer appears automatically in layer selector UI +### Working with Map Drawings +**Drawing Flow:** +1. User selects drawing mode (line/rectangle) → `DrawingProvider.setDrawingMode()` +2. User taps map → touch events captured by `DrawingLayer` +3. Preview rendered during drawing → `DrawingProvider.getPreviewDrawing()` +4. User completes drawing → saved to `DrawingProvider._drawings` list +5. User shares drawing → `DrawingToolbar._shareDrawingsToChannel()` or `_shareDrawingsToRoom()` +6. Message sent via BLE → `ConnectionProvider.sendChannelMessage()` or `sendTextMessage()` +7. Receiver parses message → `DrawingMessageParser.parseDrawingMessage()` with sender from packet +8. Drawing added to map → `DrawingProvider.addReceivedDrawing()` + +**Color Management:** +- UI uses `DrawingColors.palette` (8 Flutter Color objects) +- Network uses color indices (0-7) via `DrawingColors.colorToIndex()`/`indexToColor()` +- Persistence uses full ARGB32 color values + +**Key Files:** +- Models: `lib/models/map_drawing.dart` (278 lines) +- Parser: `lib/utils/drawing_message_parser.dart` (45 lines) +- Provider: `lib/providers/drawing_provider.dart` (280 lines) +- UI: `lib/widgets/map/drawing_toolbar.dart`, `lib/widgets/map/drawing_layer.dart` + ## Build Commands ```bash diff --git a/CLOCK_DRIFT_DETECTION.md b/CLOCK_DRIFT_DETECTION.md deleted file mode 100644 index af1d3de..0000000 --- a/CLOCK_DRIFT_DETECTION.md +++ /dev/null @@ -1,276 +0,0 @@ -# Clock Drift Detection - Implementation Summary - -## Problem - -The user suspected that timestamp-related issues might be causing room login failures. Specifically, clock drift between the Flutter app and the companion radio could affect: -- The `sender_timestamp` parameter in `CMD_SEND_LOGIN` -- The `sync_since` parameter for message synchronization - -## Solution Implemented - -Implemented `CMD_GET_DEVICE_TIME` (0x05) functionality to query the companion radio's current time and compare it with the app's time to detect clock synchronization issues. - -## Files Modified - -### 1. **lib/services/meshcore_ble_service.dart** - -#### Added Command Method (lines 1446-1457) -```dart -/// Get device time from companion radio -/// -/// Queries the companion radio's current time to detect clock drift. -/// Response will be RESP_CODE_CURR_TIME (9). -/// -/// Protocol format (CMD_GET_DEVICE_TIME): -/// - 1 byte: command code (5) -Future getDeviceTime() async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdGetDeviceTime); - await _writeData(writer.toBytes()); -} -``` - -#### Added Response Handler (lines 1237-1274) -```dart -/// Handle CurrentTime response (RESP_CODE_CURR_TIME) -/// -/// Protocol format: -/// - 4 bytes: current device time (uint32, epoch seconds, UTC) -void _handleCurrentTime(BufferReader reader) { - try { - print(' [CurrentTime] Parsing device time...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.remainingBytesCount >= 4) { - final deviceTime = reader.readUInt32LE(); - final appTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final drift = appTime - deviceTime; - - print(' 📍 CLOCK COMPARISON:'); - print(' Radio time: $deviceTime (${DateTime.fromMillisecondsSinceEpoch(deviceTime * 1000)})'); - print(' App time: $appTime (${DateTime.fromMillisecondsSinceEpoch(appTime * 1000)})'); - print(' Clock drift: $drift seconds'); - - if (drift.abs() > 60) { - print(' ⚠️ WARNING: Clock drift exceeds 60 seconds!'); - print(' This may cause login or message sync issues'); - print(' Consider calling setDeviceTime() to sync the radio\'s clock'); - } else if (drift.abs() > 5) { - print(' ℹ️ Minor clock drift detected (${drift}s)'); - } else { - print(' ✅ Clocks are well synchronized (drift: ${drift}s)'); - } - - print(' ✅ [CurrentTime] Parsed successfully'); - } else { - print(' ⚠️ [CurrentTime] Insufficient data for full parsing'); - } - } catch (e) { - print(' ❌ [CurrentTime] Parsing error: $e'); - onError?.call('CurrentTime parsing error: $e'); - } -} -``` - -#### Added Switch Case (lines 376-379) -```dart -case MeshCoreConstants.respCurrTime: - print(' → Handling CurrentTime'); - _handleCurrentTime(reader); - break; -``` - -### 2. **lib/providers/connection_provider.dart** - -#### Exposed Method (lines 424-438) -```dart -/// Get device time from companion radio to detect clock drift -Future getDeviceTime() async { - if (!_bleService.isConnected) { - _error = 'Not connected to device'; - notifyListeners(); - return; - } - - try { - await _bleService.getDeviceTime(); - } catch (e) { - _error = 'Failed to get device time: $e'; - notifyListeners(); - } -} -``` - -### 3. **lib/screens/contacts_tab.dart** - -#### Updated Login Flow (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(); - // Give time for response to be logged - 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 -} -``` - -## How It Works - -### Login Flow (Updated) - -1. **User clicks "Login to Room"** - -2. **Clock Drift Check (NEW):** - - Send `CMD_GET_DEVICE_TIME` to companion radio - - Radio responds with `RESP_CODE_CURR_TIME` containing its current epoch timestamp - - `_handleCurrentTime()` parses the response and compares with app time - - Logs detailed drift information - -3. **Radio Contact Verification:** - - Call `CMD_GET_CONTACTS` to sync from radio - - Wait 800ms for contacts to be processed - - Check if room exists in synced contacts - -4. **Automatic Contact Addition (if needed):** - - If room NOT found on radio: - - Call `CMD_ADD_UPDATE_CONTACT` with room details - - Wait 500ms for radio to save to flash - - Proceed with login - -5. **Login Request:** - - Send `CMD_SEND_LOGIN` with room public key and password - - Radio can now find the room in its contact table - - Login succeeds! - -## Expected Log Output - -### When Clocks Are Synchronized -``` -🕐 [RoomLogin] Checking for clock drift between app and radio... -📤 [TX] Sending command: GET_DEVICE_TIME (0x05) - Data size: 1 bytes - Hex: 05 -✅ [TX] Command sent successfully -📥 [RX] Received: CURRENT_TIME (0x09) - Data size: 5 bytes - Hex: 09 d4 e3 5a 67 - → Handling CurrentTime - [CurrentTime] Parsing device time... - Remaining bytes: 4 - 📍 CLOCK COMPARISON: - Radio time: 1734568916 (2024-12-18 21:15:16.000) - App time: 1734568918 (2024-12-18 21:15:18.000) - Clock drift: 2 seconds - ✅ Clocks are well synchronized (drift: 2s) - ✅ [CurrentTime] Parsed successfully -``` - -### When Clock Drift Is Detected -``` -🕐 [RoomLogin] Checking for clock drift between app and radio... -📤 [TX] Sending command: GET_DEVICE_TIME (0x05) -📥 [RX] Received: CURRENT_TIME (0x09) - [CurrentTime] Parsing device time... - 📍 CLOCK COMPARISON: - Radio time: 1734567916 (2024-12-18 21:05:16.000) - App time: 1734568918 (2024-12-18 21:15:18.000) - Clock drift: 1002 seconds - ⚠️ WARNING: Clock drift exceeds 60 seconds! - This may cause login or message sync issues - Consider calling setDeviceTime() to sync the radio's clock - ✅ [CurrentTime] Parsed successfully -``` - -## Benefits - -1. **Diagnostic Information:** - - Immediately reveals clock synchronization issues - - Shows exact drift amount in seconds - - Displays both timestamps in human-readable format - -2. **Non-Intrusive:** - - Runs as a diagnostic check before login - - Doesn't block login on failure - - Only logs information for debugging - -3. **Actionable Warnings:** - - Warns if drift exceeds 60 seconds - - Suggests calling `setDeviceTime()` to fix the issue - - Helps identify root cause of timestamp-related failures - -## Future Enhancements - -1. **Automatic Clock Sync:** - - If drift > 60s, automatically call `setDeviceTime()` before login - - Add user setting to enable/disable auto-sync - -2. **UI Display:** - - Show clock drift indicator in settings screen - - Add manual "Sync Clock" button - -3. **Persistent Monitoring:** - - Track clock drift over time - - Alert user if drift increases rapidly (possible hardware issue) - -## Testing - -### Test Case 1: Well-Synchronized Clocks -``` -Input: Radio and app clocks within 5 seconds -Expected: "✅ Clocks are well synchronized (drift: Xs)" -Result: ✅ PASS -``` - -### Test Case 2: Minor Clock Drift -``` -Input: Radio and app clocks differ by 10-60 seconds -Expected: "ℹ️ Minor clock drift detected (Xs)" -Result: ✅ PASS -``` - -### Test Case 3: Major Clock Drift -``` -Input: Radio and app clocks differ by >60 seconds -Expected: "⚠️ WARNING: Clock drift exceeds 60 seconds!" -Result: ✅ PASS -``` - -### Test Case 4: Clock Check Failure -``` -Input: CMD_GET_DEVICE_TIME fails or times out -Expected: Login proceeds anyway with warning -Result: ✅ PASS -``` - -## Protocol Reference - -**CMD_GET_DEVICE_TIME (5)**: -``` -[0x05] - Command code (5) -``` - -**RESP_CODE_CURR_TIME (9)**: -``` -[0x09] - Response code (9) -[4 bytes] - Current device time (uint32, epoch seconds, UTC) -``` - -## Related Documentation - -- `IMPLEMENTATION_SUMMARY.md` - Room login fix with automatic contact addition -- `ROOM_LOGIN_FIX.md` - Detailed explanation of dual contact list issue -- `CLAUDE.md` - Full MeshCore protocol specification - -## Success! - -The clock drift detection feature is now fully implemented. The app will automatically: -1. ✅ Check clock drift before login -2. ✅ Log detailed drift information -3. ✅ Warn about significant drift -4. ✅ Suggest remediation (setDeviceTime) -5. ✅ Continue with login regardless of drift - -This helps diagnose timestamp-related login failures! 🎉 diff --git a/DEBUG_CONTACTS.md b/DEBUG_CONTACTS.md deleted file mode 100644 index ea35090..0000000 --- a/DEBUG_CONTACTS.md +++ /dev/null @@ -1,238 +0,0 @@ -# Debugging "Not Found" Error When Logging Into Room - -## The Problem - -You're seeing this error: -``` -📤 [TX] Sending command: SEND_LOGIN (0x1A) -📥 [RX] Received: ERROR (0x01) -❌ [Error] Not found -``` - -This means the companion radio doesn't have a room contact with that public key in its contact table. - -## What's Happening - -When you send `CMD_SEND_LOGIN` with a public key, the companion radio needs to: -1. Look up that public key in its internal contact table -2. Find the matching room contact -3. Send the login request to that room via the mesh network - -**If the contact doesn't exist → ERR_CODE_NOT_FOUND (2)** - -## Your Login Command Breakdown - -From your hex dump: -``` -1a d2 b3 ee 68 00 00 00 00 15 59 89 54 b4 d4 e1 d5 d3 12 a7 4e 44 ed d3 68 95 7c ee f3 3e 86 ec 88 b9 8f ab 62 24 6b ae c5 77 65 74 77 65 74 -``` - -Decoded: -- `1a` = CMD_SEND_LOGIN -- `d2 b3 ee 68` = timestamp (1754059730) -- `00 00 00 00` = sync_since (0 = all messages) -- `15 59 89 54 ... ae c5` = Room public key (32 bytes) -- `77 65 74 77 65 74` = "wetwet" (password) - -**You're trying to login to room with public key starting with: `15:59:89:54:b4:d4`** - -## How to Fix - -### Option 1: Sync Contacts First (RECOMMENDED) - -Add this before trying to login: - -```dart -// In your UI code, before showing the login dialog: -await connectionProvider.getContacts(); -await Future.delayed(Duration(milliseconds: 500)); - -// Now show the login dialog - the room should exist -``` - -### Option 2: Check What Rooms You Have - -Add debug logging to see what rooms are actually synced: - -```dart -// In contacts_tab.dart, add a debug button: -FloatingActionButton( - onPressed: () { - final rooms = contactsProvider.rooms; - print('📋 Available Rooms (${rooms.length}):'); - for (final room in rooms) { - final pkHex = room.publicKey.sublist(0, 6) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(':'); - print(' - ${room.advName}'); - print(' Public key prefix: $pkHex...'); - print(' Full public key: ${room.publicKeyHex}'); - } - }, - child: Icon(Icons.bug_report), -) -``` - -### Option 3: Wait for Room to Advertise - -If the room is actively broadcasting: -1. Wait for `PUSH_CODE_ADVERT` (0x80) from the room -2. If `manual_add_contacts=0`, you'll automatically receive `PUSH_CODE_NEW_ADVERT` (0x8A) -3. The room will be added to your contacts -4. Then you can login - -### Option 4: Import Room Contact Manually - -If you have the room's "business card" (from CMD_EXPORT_CONTACT): - -```dart -await connectionProvider.importContact(cardData); -``` - -## Add Pre-Login Check - -Modify your login dialog to check if the room exists first: - -```dart -// In _RoomLoginSheetState._loginToRoom() -Future _loginToRoom() async { - final password = _passwordController.text.trim().isEmpty - ? 'hello' - : _passwordController.text.trim(); - - final connectionProvider = context.read(); - final contactsProvider = context.read(); - - // ✅ CHECK: Does the room exist in our contacts? - final roomExists = contactsProvider.rooms.any( - (room) => room.publicKeyHex == widget.contact.publicKeyHex - ); - - if (!roomExists) { - print('⚠️ [RoomLogin] Room not found in contacts, syncing...'); - - // Try to sync contacts first - await connectionProvider.getContacts(); - await Future.delayed(Duration(milliseconds: 500)); - - // Check again - final stillNotFound = !contactsProvider.rooms.any( - (room) => room.publicKeyHex == widget.contact.publicKeyHex - ); - - if (stillNotFound) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Room "${widget.contact.advName}" not found on device.\n' - 'Make sure the room is advertising or sync contacts.'), - backgroundColor: Colors.red, - duration: Duration(seconds: 5), - ), - ); - return; - } - } - - // Now proceed with login... - setState(() { - _isLoggingIn = true; - }); - - // ... rest of your login code -} -``` - -## Verify Your Device Settings - -Check if your device is in manual or automatic mode: - -```dart -// In HomeScreen or somewhere visible: -Consumer( - builder: (context, connectionProvider, child) { - final manualMode = connectionProvider.deviceInfo.manualAddContacts; - return Text( - 'Contact Mode: ${manualMode == true ? "Manual" : "Automatic"}', - style: TextStyle(fontSize: 10), - ); - }, -) -``` - -- **Automatic mode (0)**: Rooms will appear automatically when they advertise -- **Manual mode (1)**: You must call `getContacts()` after receiving adverts - -## Expected Flow (Automatic Mode) - -``` -1. Room broadcasts advertisement on mesh - ↓ -2. Companion radio receives advert - ↓ -3. PUSH_CODE_ADVERT (0x80) sent to app - 📥 Advert received from: 15:59:89:54:b4:d4 - ↓ -4. PUSH_CODE_NEW_ADVERT (0x8A) sent to app - 📥 New contact: "MyRoom" (type: room) - ↓ -5. contactsProvider.addOrUpdateContact() called - ✅ Room added to contacts list - ↓ -6. NOW you can login successfully - 📤 SEND_LOGIN to 15:59:89:54:b4:d4 - 📥 LOGIN_SUCCESS (0x85) -``` - -## Quick Test - -Run this in your app to see what's in your contacts: - -```dart -// Add a button somewhere: -ElevatedButton( - onPressed: () async { - final contactsProvider = context.read(); - final connectionProvider = context.read(); - - print('🔍 CONTACT SYNC TEST'); - print('══════════════════════════════════════'); - - // Force sync - await connectionProvider.getContacts(); - await Future.delayed(Duration(milliseconds: 1000)); - - final allContacts = contactsProvider.allContacts; - print('Total contacts: ${allContacts.length}'); - print(''); - - final rooms = contactsProvider.rooms; - print('Rooms (${rooms.length}):'); - for (final room in rooms) { - final pk = room.publicKey.sublist(0, 6) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(':'); - print(' 📍 ${room.advName}'); - print(' PK: $pk...'); - print(' Type: ${room.type}'); - print(' Last seen: ${room.timeSinceLastSeen}'); - print(''); - } - print('══════════════════════════════════════'); - }, - child: Text('Debug: List All Rooms'), -) -``` - -## Common Causes - -1. **Room hasn't advertised yet** - Wait for advertisement or import contact -2. **Device in manual mode** - Need to call `getContacts()` manually -3. **Wrong public key** - Verify you're using the correct public key -4. **Room was deleted** - Re-add or re-import the room contact - -## Next Steps - -1. Add the pre-login check to your login dialog -2. Always call `getContacts()` before attempting login -3. Add debug logging to see what rooms are available -4. Check your device's `manual_add_contacts` setting diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index f68eb04..0000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,215 +0,0 @@ -# Implementation Summary - Room Login Fix - -## Problem Solved - -**Issue:** "Not Found" error when logging into rooms -**Root Cause:** Room contact exists in app but not in companion radio's flash storage - -## Solution Implemented - -### 1. **CMD_ADD_UPDATE_CONTACT** Command (meshcore_ble_service.dart) - -Added method to manually add/update contacts on the companion radio: - -```dart -Future addOrUpdateContact(Contact contact) async -``` - -**What it does:** -- Sends CMD_ADD_UPDATE_CONTACT (0x09) to companion radio -- Adds the contact to the radio's internal flash storage -- Persists across reboots -- Makes the contact available for login - -**Protocol:** -``` -[0x09] - CMD_ADD_UPDATE_CONTACT -[32 bytes] - Public key -[1 byte] - Type (room/chat/repeater) -[1 byte] - Flags -[1 byte] - Out path length -[64 bytes] - Out path -[32 bytes] - Name (null-terminated) -[4 bytes] - Last advert timestamp -[4 bytes] - Latitude * 1E6 -[4 bytes] - Longitude * 1E6 -``` - -### 2. **Automatic Room Contact Addition** (contacts_tab.dart) - -Enhanced the login flow to automatically fix missing contacts: - -**Before:** -``` -Check app contacts → Found → Try login → ERROR: Not found ❌ -``` - -**After:** -``` -Check app contacts → Found - ↓ -Check radio contacts → Not found - ↓ -Sync contacts from radio → Still not found - ↓ -Add contact to radio via CMD_ADD_UPDATE_CONTACT → Success - ↓ -Try login → SUCCESS ✅ -``` - -### 3. **Enhanced Logging** - -Added detailed logs at every step: - -``` -🔍 Checking room "Repetitor"... - Local contact list: ✅ Found -⚠️ Room not in local contacts - syncing with device... -📤 Sending CMD_GET_CONTACTS - After sync: ❌ Still not found -🔧 Attempting to add room contact to companion radio... -📝 Adding/updating contact on companion radio: - Name: Repetitor - Public key prefix: 15:59:89:54:b4:d4 - Type: ContactType.room (3) -📤 Sending command: ADD_UPDATE_CONTACT (0x09) -✅ CMD_ADD_UPDATE_CONTACT sent -✅ Room contact should now be available - proceeding with login -🔐 Preparing login request... -📤 Sending command: SEND_LOGIN (0x1A) -✅ LOGIN_SUCCESS -``` - -## Files Modified - -1. **lib/services/meshcore_ble_service.dart** - - Added `dart:convert` import for UTF-8 encoding - - Implemented `addOrUpdateContact()` method - - Enhanced login request logging - -2. **lib/providers/connection_provider.dart** - - Exposed `addOrUpdateContact()` method - - Added error handling - -3. **lib/screens/contacts_tab.dart** - - Enhanced `_loginToRoom()` with automatic contact addition - - Added comprehensive pre-login checks - - Improved error messages - -## How It Works Now - -### Login Flow - -1. **User clicks "Login to Room"** - -2. **Pre-Login Check:** - - Check if room exists in app's contact list - - If found, continue to step 3 - - If not found, show error (shouldn't happen) - -3. **Radio Contact Verification:** - - Call `CMD_GET_CONTACTS` to sync from radio - - Wait 800ms for contacts to be processed - - Check if room exists in synced contacts - -4. **Automatic Contact Addition (if needed):** - - If room NOT found on radio: - - Call `CMD_ADD_UPDATE_CONTACT` with room details - - Wait 500ms for radio to save to flash - - Proceed with login - -5. **Login Request:** - - Send `CMD_SEND_LOGIN` with room public key and password - - Radio can now find the room in its contact table - - Login succeeds! ✅ - -## Testing - -### Test Case 1: Room Already on Radio -``` -Input: Login to room that exists on radio -Expected: Login succeeds immediately -Result: ✅ PASS -``` - -### Test Case 2: Room Missing from Radio -``` -Input: Login to room that doesn't exist on radio -Expected: Room is automatically added, then login succeeds -Result: ✅ PASS (with new implementation) -``` - -### Test Case 3: Room Doesn't Exist Anywhere -``` -Input: Login to non-existent room -Expected: Clear error message -Result: ✅ PASS -``` - -## Benefits - -1. **User Experience:** - - No more confusing "Not found" errors - - Automatic recovery from missing contacts - - Clear error messages - -2. **Reliability:** - - Handles radio factory resets gracefully - - Handles manual contact deletions - - Persists contacts to flash storage - -3. **Debugging:** - - Comprehensive logging at every step - - Clear indication of what's happening - - Helps diagnose issues quickly - -## Usage Example - -```dart -// Manual usage (if needed): -final room = contactsProvider.rooms.firstWhere( - (r) => r.advName == 'MyRoom' -); - -// Add room to companion radio -await connectionProvider.addOrUpdateContact(room); - -// Now login will work -await connectionProvider.loginToRoom( - roomPublicKey: room.publicKey, - password: 'mypassword', -); -``` - -## Future Enhancements - -1. **Contact Import/Export** - - Implement `CMD_IMPORT_CONTACT` for QR code sharing - - Implement `CMD_EXPORT_CONTACT` for backup - -2. **Contact Management UI** - - Add button to manually sync contacts - - Show radio vs app contact differences - - Allow manual contact deletion - -3. **Persistent Contact Cache** - - Save contacts to SharedPreferences - - Auto-restore on app launch - - Detect and fix mismatches - -## Related Documentation - -- `ROOM_LOGIN_FIX.md` - Detailed explanation of the issue -- `DEBUG_CONTACTS.md` - Debugging guide -- `ADVERT_SYSTEM.md` - Advertisement system overview -- `CLAUDE.md` - Full protocol specification - -## Success! - -The room login issue is now completely resolved. The app will automatically: -1. ✅ Check if room exists -2. ✅ Sync from radio if needed -3. ✅ Add room to radio if missing -4. ✅ Login successfully - -No more "Not found" errors! 🎉 diff --git a/MESSAGE_SEND_RECEIVE_GAP_ANALYSIS.md b/MESSAGE_SEND_RECEIVE_GAP_ANALYSIS.md deleted file mode 100644 index bb32c0b..0000000 --- a/MESSAGE_SEND_RECEIVE_GAP_ANALYSIS.md +++ /dev/null @@ -1,686 +0,0 @@ -# Message Send & Receive - Gap Analysis - -**Date**: 2025-01-14 -**Purpose**: Identify what's missing in the messaging implementation - -## Executive Summary - -Your messaging implementation is **90% complete**! The core functionality works correctly. Here's what's implemented vs what's missing: - -### ✅ What Works (Already Implemented) - -1. ✅ **Sending channel messages** (public broadcast) -2. ✅ **Sending direct messages to rooms** (persistent SAR markers) -3. ✅ **Receiving messages** via `PUSH_CODE_MSG_WAITING` -4. ✅ **Message delivery tracking** (sending → sent → delivered) -5. ✅ **SAR marker parsing and display** -6. ✅ **Message persistence** (MessageStorageService) -7. ✅ **Protocol compliance** (all frame formats correct) - -### ❌ What's Missing (Gaps) - -1. ❌ **Sending direct messages to individual contacts** (only room DMs work) -2. ❌ **Timeout handling** for failed messages -3. ❌ **Message retry logic** (automatic retries on failure) -4. ❌ **User can't send regular messages to contacts** (only SAR markers to rooms) - ---- - -## 1. Current Implementation Analysis - -### 1.1 Sending Messages - What Works - -#### ✅ Channel Messages (Public Broadcast) - -**File**: `messages_tab.dart:49-93` - -```dart -Future _sendMessage() async { - final text = _textController.text.trim(); - - // Always send to public channel (channel 0) - await connectionProvider.sendChannelMessage( - channelIdx: 0, - text: text, - ); -} -``` - -**Status**: ✅ **WORKING** -- Sends to public channel -- Text limit enforced (160 chars) -- User feedback via snackbar - -#### ✅ SAR Markers to Rooms - -**File**: `messages_tab.dart:109-221` - -```dart -Future _sendSarMessage(...) async { - // Format: S::, - final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}'; - - if (sendToChannel) { - // Send to public channel (ephemeral) - await connectionProvider.sendChannelMessage( - channelIdx: 0, - text: fullMessage, - ); - } else { - // Send to room (persistent) - final sentSuccessfully = await connectionProvider.sendTextMessage( - contactPublicKey: roomPublicKey!, - text: fullMessage, - messageId: messageId, - ); - } -} -``` - -**Status**: ✅ **WORKING** -- Sends SAR markers to rooms -- Tracks delivery with message ID -- Updates status (sending → sent → delivered) - -### 1.2 Sending Messages - What's Missing - -#### ❌ Direct Messages to Individual Contacts - -**Current State**: No UI to send regular messages to individual contacts! - -**Gap**: User can only: -- Send to public channel -- Send SAR markers to rooms - -**Missing**: Send regular text messages to individual team members - -**Example Use Case**: -``` -User wants to send "Meet at checkpoint B" to John (a chat contact) -Current: ❌ No way to do this -Should: ✅ Send direct message via CMD_SEND_TXT_MSG -``` - -#### ❌ Timeout Handling - -**Current State**: Messages marked "Sent" wait forever for delivery confirmation - -**File**: `messages_provider.dart:262-279` - -```dart -void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) { - final updatedMessage = message.copyWith( - deliveryStatus: MessageDeliveryStatus.sent, - expectedAckTag: expectedAckTag, - suggestedTimeoutMs: suggestedTimeoutMs, // ⚠️ Stored but not used! - ); - - _pendingSentMessages[expectedAckTag] = updatedMessage; - // ❌ No timeout timer started! -} -``` - -**Gap**: No timer to mark message as "Failed" if timeout expires - -**Should Do**: -```dart -void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) { - // ... existing code ... - - // Start timeout timer - Future.delayed(Duration(milliseconds: suggestedTimeoutMs), () { - if (_pendingSentMessages.containsKey(expectedAckTag)) { - // Message not delivered within timeout - markMessageFailed(messageId); - } - }); -} -``` - -#### ❌ Message Retry Logic - -**Current State**: Failed messages stay failed, no retry - -**Gap**: MeshCore supports retries with `attempt` parameter (0-3) - -**Protocol Spec** (MESSAGES.md): -``` -CMD_SEND_TXT_MSG: -- attempt (1 byte): 0-3 (retry attempt number) -``` - -**Current Implementation** (meshcore_ble_service.dart:1183-1201): -```dart -Future sendTextMessage({ - required Uint8List contactPublicKey, - required String text, - int textType = 0, - int attempt = 0, // ✅ Parameter exists but never used! -}) async { - writer.writeByte(attempt); // Always 0 -} -``` - -**Missing**: Retry logic that increments `attempt` on timeout - ---- - -## 2. Detailed Gap Analysis - -### Gap #1: No UI for Direct Messages to Contacts - -#### Problem - -**Current UI** (`messages_tab.dart`): -``` -┌─────────────────────────────┐ -│ Messages Tab │ -├─────────────────────────────┤ -│ │ -│ [Message List] │ -│ │ -│ │ -├─────────────────────────────┤ -│ [SAR] [Text Input] [Send] │ ← Always sends to public channel -└─────────────────────────────┘ -``` - -**Missing**: -- No recipient selector -- No way to send DM to individual contact -- Can only send to public channel OR rooms (via SAR dialog) - -#### Solution - -**Add Recipient Selector**: - -```dart -Contact? _selectedRecipient; // null = public channel - -// In build(): -Row( - children: [ - // Recipient dropdown - DropdownButton( - value: _selectedRecipient, - hint: Text('Public Channel'), - items: [ - DropdownMenuItem(value: null, child: Text('📢 Public')), - ...contactsProvider.chatContacts.map((contact) => - DropdownMenuItem( - value: contact, - child: Text('👤 ${contact.displayName}'), - ), - ), - ], - onChanged: (value) => setState(() => _selectedRecipient = value), - ), - - // Message input - Expanded(child: TextField(...)), - - // Send button - IconButton( - onPressed: () => _selectedRecipient == null - ? _sendChannelMessage() - : _sendDirectMessage(_selectedRecipient!), - ), - ], -) -``` - -**New Method**: -```dart -Future _sendDirectMessage(Contact recipient) async { - final text = _textController.text.trim(); - - final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent'; - - // Create sent message - final sentMessage = Message( - id: messageId, - messageType: MessageType.contact, - senderPublicKeyPrefix: devicePublicKey?.sublist(0, 6), - pathLen: 0, - textType: MessageTextType.plain, - senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, - text: text, - receivedAt: DateTime.now(), - deliveryStatus: MessageDeliveryStatus.sending, - ); - - // Add to messages list - messagesProvider.addSentMessage(sentMessage); - - // Send via BLE - final success = await connectionProvider.sendTextMessage( - contactPublicKey: recipient.publicKey, - text: text, - messageId: messageId, - ); - - if (!success) { - messagesProvider.markMessageFailed(messageId); - } -} -``` - ---- - -### Gap #2: No Timeout Handling - -#### Problem - -**Current Flow**: -``` -Send Message - ↓ -RESP_CODE_SENT (ACK tag: 12345, timeout: 30000ms) - ↓ -Mark as "Sent" - ↓ -⏳ Wait forever for PUSH_CODE_SEND_CONFIRMED... - ↓ -❌ If never arrives, message stays "Sent" indefinitely -``` - -**Should Be**: -``` -Send Message - ↓ -RESP_CODE_SENT (ACK tag: 12345, timeout: 30000ms) - ↓ -Mark as "Sent" + Start 30s timeout timer - ↓ -├─ PUSH_CODE_SEND_CONFIRMED arrives → ✅ Mark "Delivered" -└─ Timeout expires → ❌ Mark "Failed" -``` - -#### Solution - -**Update MessagesProvider** (`messages_provider.dart`): - -```dart -// Track timeout timers by ACK tag -final Map _timeoutTimers = {}; - -void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) { - final index = _messages.indexWhere((m) => m.id == messageId); - if (index != -1) { - final message = _messages[index]; - final updatedMessage = message.copyWith( - deliveryStatus: MessageDeliveryStatus.sent, - expectedAckTag: expectedAckTag, - suggestedTimeoutMs: suggestedTimeoutMs, - ); - _messages[index] = updatedMessage; - - // Track by ACK tag - _pendingSentMessages[expectedAckTag] = updatedMessage; - - // ✅ NEW: Start timeout timer - _timeoutTimers[expectedAckTag] = Timer( - Duration(milliseconds: suggestedTimeoutMs), - () { - // Timeout expired - mark as failed - if (_pendingSentMessages.containsKey(expectedAckTag)) { - print('⏱️ Message timeout: ACK $expectedAckTag not received within ${suggestedTimeoutMs}ms'); - markMessageFailed(messageId); - } - }, - ); - - _persistMessages(); - notifyListeners(); - } -} - -void markMessageDelivered(int ackCode, int roundTripTimeMs) { - // Find message by ACK code - final message = _pendingSentMessages[ackCode]; - if (message != null) { - final index = _messages.indexWhere((m) => m.id == message.id); - if (index != -1) { - final updatedMessage = message.copyWith( - deliveryStatus: MessageDeliveryStatus.delivered, - roundTripTimeMs: roundTripTimeMs, - deliveredAt: DateTime.now(), - ); - _messages[index] = updatedMessage; - - // ✅ NEW: Cancel timeout timer - _timeoutTimers[ackCode]?.cancel(); - _timeoutTimers.remove(ackCode); - - // Remove from pending - _pendingSentMessages.remove(ackCode); - - _persistMessages(); - notifyListeners(); - } - } -} - -void markMessageFailed(String messageId) { - final index = _messages.indexWhere((m) => m.id == messageId); - if (index != -1) { - final message = _messages[index]; - final updatedMessage = message.copyWith( - deliveryStatus: MessageDeliveryStatus.failed, - ); - _messages[index] = updatedMessage; - - // ✅ NEW: Cancel timeout timer if exists - if (message.expectedAckTag != null) { - _timeoutTimers[message.expectedAckTag]?.cancel(); - _timeoutTimers.remove(message.expectedAckTag); - _pendingSentMessages.remove(message.expectedAckTag); - } - - _persistMessages(); - notifyListeners(); - } -} - -// ✅ NEW: Cleanup on dispose -@override -void dispose() { - // Cancel all pending timers - for (final timer in _timeoutTimers.values) { - timer.cancel(); - } - _timeoutTimers.clear(); - super.dispose(); -} -``` - ---- - -### Gap #3: No Message Retry Logic - -#### Problem - -**Current**: Failed messages stay failed forever - -**Protocol Supports**: -``` -Attempt 0 → Timeout → ❌ Failed (no retry) -``` - -**Should Support**: -``` -Attempt 0 → Timeout → Retry -Attempt 1 → Timeout → Retry -Attempt 2 → Timeout → Retry -Attempt 3 → Timeout → ❌ Failed (last attempt uses flood mode) -``` - -#### Solution - -**Option 1: Manual Retry (Simple)** - -Add "Retry" button to failed messages: - -```dart -// In _MessageBubble: -if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[ - ElevatedButton.icon( - onPressed: () => _retryMessage(message), - icon: Icon(Icons.refresh), - label: Text('Retry'), - ), -], -``` - -**Option 2: Automatic Retry (Advanced)** - -Update timeout handler: - -```dart -void _handleMessageTimeout(String messageId, int attemptNumber) { - if (attemptNumber < 3) { - // Retry with next attempt number - print('⏱️ Attempt $attemptNumber timeout - retrying...'); - _retryMessage(messageId, attemptNumber + 1); - } else { - // All attempts exhausted - print('❌ All 4 attempts failed - marking as failed'); - markMessageFailed(messageId); - } -} - -Future _retryMessage(String messageId, int attempt) async { - final message = _messages.firstWhere((m) => m.id == messageId); - - // Update attempt count - final updatedMessage = message.copyWith( - deliveryStatus: MessageDeliveryStatus.sending, - ); - // ... update in list ... - - // Resend with incremented attempt number - final success = await connectionProvider.sendTextMessage( - contactPublicKey: message.recipientPublicKey, - text: message.text, - messageId: messageId, - attempt: attempt, - ); -} -``` - ---- - -## 3. Priority Ranking - -### Priority 1: CRITICAL (Blocks Core Functionality) - -1. **❌ Gap #1: Direct Messages to Contacts** - - **Impact**: Users can't send messages to individual team members - - **Complexity**: Medium (UI + wire to existing BLE code) - - **Effort**: 2-3 hours - -### Priority 2: HIGH (Improves Reliability) - -2. **❌ Gap #2: Timeout Handling** - - **Impact**: Failed messages never show as failed - - **Complexity**: Low (timer logic) - - **Effort**: 1 hour - -### Priority 3: MEDIUM (Nice to Have) - -3. **❌ Gap #3: Automatic Retry** - - **Impact**: Failed messages need manual intervention - - **Complexity**: Medium (retry orchestration) - - **Effort**: 2-3 hours - ---- - -## 4. Implementation Roadmap - -### Phase 1: Basic DM Support (Priority 1) - -**Goal**: Enable sending direct messages to contacts - -**Tasks**: -1. ✅ Add recipient selector dropdown to messages tab -2. ✅ Add `_sendDirectMessage()` method -3. ✅ Wire to existing `sendTextMessage()` BLE method -4. ✅ Test with team members - -**Files to Modify**: -- `lib/screens/messages_tab.dart` - - Add `Contact? _selectedRecipient` state - - Add recipient dropdown above message input - - Add `_sendDirectMessage()` method - - Update `_sendMessage()` to route to channel vs contact - -**Estimated Time**: 2-3 hours - -### Phase 2: Timeout Handling (Priority 2) - -**Goal**: Mark messages as failed when timeout expires - -**Tasks**: -1. ✅ Add `Map _timeoutTimers` to MessagesProvider -2. ✅ Start timer in `markMessageSent()` -3. ✅ Cancel timer in `markMessageDelivered()` -4. ✅ Call `markMessageFailed()` on timeout -5. ✅ Add `dispose()` to cancel timers - -**Files to Modify**: -- `lib/providers/messages_provider.dart` - - Add timeout timer tracking - - Update `markMessageSent()` - - Update `markMessageDelivered()` - - Update `markMessageFailed()` - - Add `dispose()` - -**Estimated Time**: 1 hour - -### Phase 3: Manual Retry (Priority 3a) - -**Goal**: Let user manually retry failed messages - -**Tasks**: -1. ✅ Add "Retry" button to failed message bubbles -2. ✅ Add `_retryMessage()` method -3. ✅ Test retry flow - -**Files to Modify**: -- `lib/screens/messages_tab.dart` - - Add retry button to `_MessageBubble` for failed messages - - Add `_retryMessage()` callback - -**Estimated Time**: 1 hour - -### Phase 4: Automatic Retry (Priority 3b) - OPTIONAL - -**Goal**: Automatically retry failed messages - -**Tasks**: -1. ✅ Update `_handleMessageTimeout()` to retry -2. ✅ Pass `attempt` parameter through send chain -3. ✅ Test 4-attempt retry cycle -4. ✅ Verify attempt 3 uses flood mode (per protocol) - -**Files to Modify**: -- `lib/providers/messages_provider.dart` -- `lib/providers/connection_provider.dart` -- `lib/services/meshcore_ble_service.dart` - -**Estimated Time**: 2-3 hours - ---- - -## 5. Quick Fixes (Can Do Right Now) - -### Quick Fix #1: Add "Reply" to Contact Messages - -**File**: `lib/screens/messages_tab.dart` - -Add long-press handler to contact messages: - -```dart -// In _MessageBubble: -GestureDetector( - onLongPress: message.isContactMessage - ? () => _showReplyOptions(context, message) - : null, - child: Container(...), -) -``` - -```dart -void _showReplyOptions(BuildContext context, Message message) { - showModalBottomSheet( - context: context, - builder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon(Icons.reply), - title: Text('Reply to ${message.displaySender}'), - onTap: () { - // Set recipient and open keyboard - Navigator.pop(context); - // ... set _selectedRecipient ... - }, - ), - ], - ), - ); -} -``` - ---- - -## 6. Testing Checklist - -### After Implementing Gap #1 (Direct Messages) - -- [ ] Can send DM to chat contact -- [ ] Message appears in recipient's messages list -- [ ] Delivery status shows: Sending → Sent → Delivered -- [ ] Failed messages show "Failed" status -- [ ] Can send to public channel (existing feature still works) - -### After Implementing Gap #2 (Timeout Handling) - -- [ ] Turn off recipient device -- [ ] Send message -- [ ] Verify "Sent" status appears -- [ ] Wait for timeout (30s) -- [ ] Verify status changes to "Failed" -- [ ] Turn on recipient device -- [ ] Send message -- [ ] Verify status changes to "Delivered" before timeout - -### After Implementing Gap #3 (Retry) - -- [ ] Manual retry: Click "Retry" on failed message -- [ ] Verify message sends again -- [ ] Auto retry: Turn off recipient device -- [ ] Send message -- [ ] Verify 4 retry attempts occur -- [ ] Verify final status is "Failed" after all attempts - ---- - -## 7. Summary - -### What's Already Great ✅ - -1. ✅ Protocol implementation is 100% correct -2. ✅ Delivery tracking infrastructure exists -3. ✅ SAR markers work perfectly -4. ✅ Room messages work -5. ✅ Channel messages work - -### What Needs Adding ❌ - -1. ❌ **UI for direct messages to contacts** (2-3 hours) -2. ❌ **Timeout timers** (1 hour) -3. ❌ **Retry logic** (2-3 hours) - -### Total Estimated Effort - -**Minimum Viable** (Phase 1 + 2): **3-4 hours** -**Full Featured** (All phases): **6-9 hours** - ---- - -## 8. Recommended Next Steps - -1. **Immediate** (Today): Implement Gap #1 (Direct Messages UI) - - This unlocks the core messaging functionality - - Users can finally message each other - -2. **Short Term** (This Week): Implement Gap #2 (Timeout Handling) - - Improves reliability - - Users see when messages fail - -3. **Optional** (Next Week): Implement Gap #3 (Retry Logic) - - Automatic retries improve success rate - - Manual retry button is simple fallback - -Would you like me to implement Gap #1 (Direct Messages UI) first? diff --git a/MESSAGING_IMPLEMENTATION_GUIDE.md b/MESSAGING_IMPLEMENTATION_GUIDE.md deleted file mode 100644 index af3343a..0000000 --- a/MESSAGING_IMPLEMENTATION_GUIDE.md +++ /dev/null @@ -1,1276 +0,0 @@ -# MeshCore Messaging System Implementation Guide - -**Complete reference for implementing message sending and receiving in the Flutter app** - -Date: 2025-01-14 -Source: MeshCore C++ firmware (v1.9.1, firmware code 7) -Files analyzed: -- `/Users/dz0ny/meshcore-sar/MeshCore/examples/companion_radio/MyMesh.cpp` -- `/Users/dz0ny/meshcore-sar/MeshCore/examples/simple_room_server/MyMesh.cpp` -- `/Users/dz0ny/meshcore-sar/MeshCore/src/helpers/BaseChatMesh.cpp` -- `/Users/dz0ny/meshcore-sar/MeshCore/src/Packet.h` - -## Table of Contents - -1. [Message Types Overview](#message-types-overview) -2. [Sending Messages](#sending-messages) -3. [Receiving Messages](#receiving-messages) -4. [Room Login Protocol](#room-login-protocol) -5. [Message Confirmation Flow](#message-confirmation-flow) -6. [Binary Protocol Specifications](#binary-protocol-specifications) -7. [Implementation Requirements](#implementation-requirements) -8. [Common Pitfalls](#common-pitfalls) -9. [Complete Example Flows](#complete-example-flows) - ---- - -## Message Types Overview - -### Three Message Types - -1. **Direct Messages (DM)** - - **Protocol**: `CMD_SEND_TXT_MSG` (code 2) → `PAYLOAD_TYPE_TXT_MSG` (0x02) - - **Routing**: Uses contact's `out_path` if available, otherwise flood mode - - **Confirmation**: Receives ACK from recipient - - **Use case**: Person-to-person messages, messages to rooms - - **Persistence**: Only persisted if sent to a room (ADV_TYPE_ROOM) - -2. **Channel Messages** (Public Broadcast) - - **Protocol**: `CMD_SEND_CHANNEL_TXT_MSG` (code 3) → `PAYLOAD_TYPE_GRP_TXT` (0x05) - - **Routing**: Always flood mode (broadcast to all nodes) - - **Confirmation**: No ACK (fire-and-forget) - - **Use case**: Public announcements, SAR markers to all nodes - - **Persistence**: **EPHEMERAL - not stored anywhere!** - -3. **Room Messages** (Persistent Storage) - - **Protocol**: Same as Direct Messages but recipient is ADV_TYPE_ROOM contact - - **Routing**: Uses room's `out_path` (after login establishes it) - - **Confirmation**: Receives ACK from room server - - **Use case**: Persistent SAR markers, logged communications - - **Persistence**: **IMMUTABLE storage in room's flash memory** - ---- - -## CRITICAL: Channels vs Rooms - -### Channels (Ephemeral Broadcast) - -**Definition**: Numeric identifiers (0 = "Public Channel") for over-the-air broadcasts - -**Characteristics**: -- Messages broadcast via radio, **NOT stored anywhere** -- No login required -- No persistence - if a node is offline, it misses the message -- Flood routing only (no direct paths) -- No ACK/confirmation -- Channel 0 = "Public Channel" (default) -- Channel 1+ = Reserved for future use - -**Protocol**: -``` -CMD_SEND_CHANNEL_TXT_MSG (code 3) -[0x03] - Command code -[1 byte] - Text type (TXT_TYPE_PLAIN = 0) -[1 byte] - Channel index (0 for public) -[4 bytes] - Sender timestamp (uint32 LE, epoch seconds) -[N bytes] - Text (remainder, max 160 - name_len - 2) -``` - -### Rooms (Persistent Storage) - -**Definition**: Actual **contacts** with public keys that provide database-backed storage - -**Characteristics**: -- Messages sent as **direct messages** to the room contact -- Requires login with password -- **Persistent and immutable** - stored in room's flash even when offline -- Room pushes messages to clients automatically after login -- Provides ACK confirmation -- Supports admin/guest permissions - -**How to identify rooms**: -- `contact.type == ADV_TYPE_ROOM` (value 3) -- Appears in Contacts tab -- Has 32-byte public key -- Requires password to access - -**Protocol to send to room**: -``` -Use CMD_SEND_TXT_MSG with room's public key (same as person-to-person message) -``` - ---- - -## Sending Messages - -### 1. Send Direct Message to Contact - -**File**: `lib/services/meshcore_ble_service.dart:1409-1427` - -**Protocol** (`CMD_SEND_TXT_MSG`, code 2): -``` -[0x02] - Command code -[1 byte] - Text type (TXT_TYPE_PLAIN=0, TXT_TYPE_CLI_DATA=1, TXT_TYPE_SIGNED_PLAIN=2) -[1 byte] - Attempt number (0-3 for retries) -[4 bytes] - Sender timestamp (uint32 LE, epoch seconds UTC) -[6 bytes] - Recipient public key PREFIX (first 6 bytes only!) -[N bytes] - Message text (remainder, UTF-8, max 160 bytes) -``` - -**Example**: -```dart -Future sendTextMessage({ - required Uint8List contactPublicKey, - required String text, - int textType = 0, // TXT_TYPE_PLAIN - int attempt = 0, -}) async { - if (text.length > 160) { - throw ArgumentError('Text message exceeds 160 character limit'); - } - - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02 - writer.writeByte(textType); // TXT_TYPE_* - writer.writeByte(attempt); // 0-3 - writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds - writer.writeBytes(contactPublicKey.sublist(0, 6)); // ONLY first 6 bytes! - writer.writeString(text); - await _writeData(writer.toBytes()); -} -``` - -**Response** (`RESP_CODE_SENT`, code 6): -``` -[0x06] - Response code -[1 byte] - Send type (1=flood, 0=direct) -[4 bytes] - Expected ACK code or TAG (for matching confirmation later) -[4 bytes] - Suggested timeout (uint32 LE, milliseconds) -``` - -**Source**: -- Command composition: `MyMesh.cpp:818-862` (Companion Radio) -- Protocol implementation: `BaseChatMesh.cpp:334-351` (Core library) -- Message packet creation: `BaseChatMesh.cpp:312-332` (`composeMsgPacket`) - -### 2. Send Channel Message (Public Broadcast) - -**File**: `lib/services/meshcore_ble_service.dart:1439-1456` - -**Protocol** (`CMD_SEND_CHANNEL_TXT_MSG`, code 3): -``` -[0x03] - Command code -[1 byte] - Text type (TXT_TYPE_PLAIN=0) -[1 byte] - Channel index (0 for 'public') -[4 bytes] - Sender timestamp (uint32 LE, epoch seconds) -[N bytes] - Message text (remainder, max ~140 chars depending on sender name) -``` - -**Example**: -```dart -Future sendChannelMessage({ - required int channelIdx, - required String text, - int textType = 0, // TXT_TYPE_PLAIN -}) async { - if (text.length > 160) { - throw ArgumentError('Channel message too long'); - } - - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03 - writer.writeByte(textType); // TXT_TYPE_* - writer.writeByte(channelIdx); // 0 for 'public' channel - writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); - writer.writeString(text); - await _writeData(writer.toBytes()); -} -``` - -**Note**: Channel messages do NOT receive ACK confirmations. They are fire-and-forget broadcasts. - -**Source**: -- Command handling: `MyMesh.cpp:863-882` (Companion Radio) -- Message composition: `BaseChatMesh.cpp:379-398` (`sendGroupMessage`) - -### 3. Send Message to Room (Persistent Storage) - -**CRITICAL**: To send persistent messages to a room, use `CMD_SEND_TXT_MSG` (direct message) with the room's public key, NOT channel messages. - -```dart -// ✅ CORRECT - Sends to room for persistent storage -await sendTextMessage( - contactPublicKey: roomContact.publicKey, // Room's 32-byte public key - text: 'S:🧑:46.0569,14.5058', // SAR marker -); - -// ❌ WRONG - Ephemeral broadcast, NOT stored in room -await sendChannelMessage( - channelIdx: 0, - text: 'S:🧑:46.0569,14.5058', -); -``` - -**Why?** -- Rooms are contacts with `type == ADV_TYPE_ROOM` (value 3) -- Rooms receive direct messages and store them in flash memory -- Channel messages are over-the-air only and never stored -- SAR markers MUST be persistent for search coordination - ---- - -## Receiving Messages - -### Message Queue Architecture - -The companion radio maintains an **internal message queue** in memory: -- Messages received over the air are added to queue -- Queue holds messages until app fetches them -- Max queue size: `OFFLINE_QUEUE_SIZE` (typically 16 messages) -- When full, oldest channel messages are deleted first - -### Message Receive Flow - -``` -1. Message received over radio - ↓ -2. Companion radio decrypts and validates - ↓ -3. Companion radio adds to internal queue - ↓ -4. Companion radio sends PUSH_CODE_MSG_WAITING (0x83) to app - ↓ -5. App's onMessageWaiting callback fires - ↓ -6. App calls CMD_SYNC_NEXT_MESSAGE (10) - ↓ -7. Companion radio sends RESP_CODE_CONTACT_MSG_RECV or RESP_CODE_CHANNEL_MSG_RECV - ↓ -8. App processes message and adds to UI - ↓ -9. Repeat steps 6-8 until RESP_CODE_NO_MORE_MESSAGES (10) -``` - -### 1. Handle Message Waiting Notification - -**Protocol** (`PUSH_CODE_MSG_WAITING`, code 0x83): -``` -[0x83] - Push code -(no additional data) -``` - -**Implementation**: -```dart -// Set up callback in connection initialization -_bleService.onMessageWaiting = () { - print('📨 New message(s) waiting in companion radio queue'); - // Start fetching messages from queue - _fetchAllPendingMessages(); -}; - -Future _fetchAllPendingMessages() async { - bool hasMore = true; - while (hasMore) { - try { - await _bleService.syncNextMessage(); - // Wait for response (RESP_CODE_CONTACT_MSG_RECV or RESP_CODE_NO_MORE_MESSAGES) - // Response is handled by _onDataReceived() - await Future.delayed(Duration(milliseconds: 100)); // Small delay between requests - } catch (e) { - print('Error fetching message: $e'); - hasMore = false; - } - } -} -``` - -**Source**: `MyMesh.cpp:363-367, 428-436` (companion radio queue push notification) - -### 2. Sync Next Message - -**Protocol** (`CMD_SYNC_NEXT_MESSAGE`, code 10): -``` -[0x0A] - Command code -(no additional data) -``` - -**Implementation**: -```dart -Future syncNextMessage() async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSyncNextMessage); // 0x0A - await _writeData(writer.toBytes()); -} -``` - -**Source**: `MyMesh.cpp:1056-1066` (companion radio command handler) - -### 3. Parse Contact Message Response - -**Protocol** (`RESP_CODE_CONTACT_MSG_RECV`, code 7): -``` -[0x07] - Response code -[6 bytes] - Sender public key PREFIX (first 6 bytes) -[1 byte] - Path length (0xFF if direct, else hop count) -[1 byte] - Text type (TXT_TYPE_*) -[4 bytes] - Sender timestamp (uint32 LE, epoch seconds) -[N bytes] - Message text (remainder, null-terminated) -``` - -**For signed messages** (`TXT_TYPE_SIGNED_PLAIN`): -``` -[0x07] - Response code -[6 bytes] - Sender public key PREFIX -[1 byte] - Path length -[1 byte] - Text type (TXT_TYPE_SIGNED_PLAIN = 2) -[4 bytes] - Sender timestamp -[4 bytes] - Author public key prefix (first 4 bytes of original author) -[N bytes] - Message text (remainder) -``` - -**Implementation** (file: `lib/services/meshcore_ble_service.dart:512-578`): -```dart -void _handleContactMessage(BufferReader reader) { - try { - final pubKeyPrefix = reader.readBytes(6); - final pathLen = reader.readByte(); - final txtTypeByte = reader.readByte(); - final txtType = MessageTextType.fromValue(txtTypeByte); - final senderTimestamp = reader.readUInt32LE(); - - String text; - Uint8List? authorPrefix; - - if (txtType == MessageTextType.signedPlain) { - // Signed message: next 4 bytes are author prefix - authorPrefix = reader.readBytes(4); - text = reader.readString(); - } else { - // Plain message - text = reader.readString(); - } - - final message = Message( - id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}', - messageType: MessageType.contact, - senderPublicKeyPrefix: pubKeyPrefix, - pathLen: pathLen, - textType: txtType, - senderTimestamp: senderTimestamp, - text: text, - receivedAt: DateTime.now(), - authorPublicKeyPrefix: authorPrefix, // For signed messages from rooms - ); - - onMessageReceived?.call(message); - } catch (e) { - print('Error parsing contact message: $e'); - onError?.call('Contact message parsing error: $e'); - } -} -``` - -**Source**: `MyMesh.cpp:334-379` (companion radio message queueing) - -### 4. Parse Channel Message Response - -**Protocol** (`RESP_CODE_CHANNEL_MSG_RECV`, code 8): -``` -[0x08] - Response code -[1 byte] - Channel index (0 for 'public') -[1 byte] - Path length (0xFF if direct, else hop count) -[1 byte] - Text type (TXT_TYPE_*) -[4 bytes] - Sender timestamp (uint32 LE, epoch seconds) -[N bytes] - Message text (remainder, null-terminated) -``` - -**Implementation** (file: `lib/services/meshcore_ble_service.dart:581-647`): -```dart -void _handleChannelMessage(BufferReader reader) { - try { - final channelIdx = reader.readInt8(); - final pathLen = reader.readByte(); - final txtTypeByte = reader.readByte(); - final txtType = MessageTextType.fromValue(txtTypeByte); - final senderTimestamp = reader.readUInt32LE(); - final text = reader.readString(); - - final message = Message( - id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx', - messageType: MessageType.channel, - channelIdx: channelIdx, - pathLen: pathLen, - textType: txtType, - senderTimestamp: senderTimestamp, - text: text, - receivedAt: DateTime.now(), - ); - - onMessageReceived?.call(message); - } catch (e) { - print('Error parsing channel message: $e'); - onError?.call('Channel message parsing error: $e'); - } -} -``` - -**Source**: `MyMesh.cpp:401-446` (companion radio channel message queueing) - -### 5. Handle No More Messages - -**Protocol** (`RESP_CODE_NO_MORE_MESSAGES`, code 10): -``` -[0x0A] - Response code -(no additional data) -``` - -**Implementation**: -```dart -case MeshCoreConstants.respNoMoreMessages: - print('No more messages in queue'); - onNoMoreMessages?.call(); - break; -``` - -**Source**: `MyMesh.cpp:1063-1065` (companion radio queue empty response) - ---- - -## Room Login Protocol - -### CRITICAL: Understanding Room Message Push - -**How room message sync works**: - -1. **Client sends login request** with `sync_since` timestamp -2. **Room server stores** `client->extra.room.sync_since` value -3. **Room server AUTOMATICALLY PUSHES** messages where `post_timestamp > sync_since` -4. **Room server uses round-robin** polling every 1200ms (SYNC_PUSH_INTERVAL) -5. **Room server waits for ACK** before advancing to next message -6. **Client receives pushed messages** via normal `PUSH_CODE_MSG_WAITING` flow - -**What the app MUST do**: -- ✅ Wait for `PUSH_CODE_MSG_WAITING` notifications -- ✅ Call `syncNextMessage()` when notified -- ✅ Continue until `RESP_CODE_NO_MORE_MESSAGES` - -**What the app MUST NOT do**: -- ❌ DON'T call `syncNextMessage()` immediately after `PUSH_CODE_LOGIN_SUCCESS` -- ❌ DON'T try to "pull" messages manually -- ❌ DON'T implement a timer to check for messages - -### Room Login Flow - -``` -1. App: CMD_SEND_LOGIN (26) with password and sync_since - ↓ -2. Radio: Sends PAYLOAD_TYPE_ANON_REQ to room - ↓ -3. Room: Validates password, stores sync_since - ↓ -4. Room: Sends login response back - ↓ -5. App: Receives PUSH_CODE_LOGIN_SUCCESS (0x85) - ↓ -6. Room: Delays 2000ms (PUSH_NOTIFY_DELAY_MILLIS) - ↓ -7. Room: Starts round-robin message push loop (every 1200ms) - ↓ -8. For each logged-in client: - If post_timestamp > client.sync_since: - - Room calls pushPostToClient() - - Sends PAYLOAD_TYPE_TXT_MSG to client - - Waits for ACK - - Advances client.sync_since - - Continues to next message - ↓ -9. Radio: Receives pushed message from room - ↓ -10. Radio: Adds to internal queue - ↓ -11. Radio: Sends PUSH_CODE_MSG_WAITING (0x83) to app - ↓ -12. App: onMessageWaiting callback fires - ↓ -13. App: Calls syncNextMessage() to fetch from queue - ↓ -14. App: Receives RESP_CODE_CONTACT_MSG_RECV with message - ↓ -15. Repeat steps 7-14 until all messages pushed -``` - -### Login Request Protocol - -**Protocol** (`CMD_SEND_LOGIN`, code 26): -``` -[0x1A] - Command code (26) -[4 bytes] - Sender timestamp (uint32 LE, current epoch seconds) -[4 bytes] - sync_since timestamp (uint32 LE, epoch seconds - 0 for all messages) -[32 bytes] - Room public key -[N bytes] - Password (max 15 bytes, null-terminated) -``` - -**Implementation** (file: `lib/services/meshcore_ble_service.dart:1616-1642`): -```dart -Future 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 - writer.writeUInt32LE(now); // sender timestamp - writer.writeUInt32LE(syncSince); // sync messages since this timestamp (0 = all) - writer.writeBytes(roomPublicKey); // 32 bytes - writer.writeString(password); // Max 15 bytes, null-terminated - await _writeData(writer.toBytes()); -} -``` - -**Source**: -- Login request composition: `BaseChatMesh.cpp:431-464` -- Companion radio command handler: `MyMesh.cpp:1196-1217` -- Room server login processing: `simple_room_server/MyMesh.cpp:282-363` (lines 286-324 critical) - -### Login Success Response - -**Protocol** (`PUSH_CODE_LOGIN_SUCCESS`, code 0x85): -``` -[0x85] - Push code -[1 byte] - Permissions (lowest bit = is_admin) -[6 bytes] - Room public key prefix (first 6 bytes) -[4 bytes] - Tag (int32 LE) -[1 byte] - (V7+) New permissions -``` - -**Implementation** (file: `lib/services/meshcore_ble_service.dart:1175-1206`): -```dart -void _handleLoginSuccess(BufferReader reader) { - try { - 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 - int? newPermissions; - if (reader.hasRemaining) { - newPermissions = reader.readByte(); - } - - print('✅ Successfully logged into room'); - onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag); - - // DO NOT call syncAllMessages() here! - // Wait for PUSH_CODE_MSG_WAITING instead - } - } catch (e) { - print('Login success parsing error: $e'); - onError?.call('Login success parsing error: $e'); - } -} -``` - -**Source**: -- Companion radio response parsing: `MyMesh.cpp:496-525` -- Room server login success response: `simple_room_server/MyMesh.cpp:335-346` - -### Login Fail Response - -**Protocol** (`PUSH_CODE_LOGIN_FAIL`, code 0x86): -``` -[0x86] - Push code -[1 byte] - Reserved (zero) -[6 bytes] - Room public key prefix -``` - -**Source**: Room server validation: `simple_room_server/MyMesh.cpp:303-314` - -### Room Message Push Implementation - -**Room server C++ code** (`simple_room_server/MyMesh.cpp:777-820`): -```cpp -// Round-robin polling every SYNC_PUSH_INTERVAL (1200ms) -void MyMesh::loop() { - mesh::Mesh::loop(); - - if (millisHasNowPassed(next_push) && acl.getNumClients() > 0) { - // Check for ACK timeouts - for (int i = 0; i < acl.getNumClients(); i++) { - auto c = acl.getClientByIdx(i); - if (c->extra.room.pending_ack && millisHasNowPassed(c->extra.room.ack_timeout)) { - c->extra.room.push_failures++; - c->extra.room.pending_ack = 0; // reset - } - } - - // Check next Round-Robin client, and sync next new post - auto client = acl.getClientByIdx(next_client_idx); - bool did_push = false; - - if (client->extra.room.pending_ack == 0 && // not waiting for ACK - client->last_activity != 0 && // not evicted - client->extra.room.push_failures < 3) { // retries not maxed - - uint32_t now = getRTCClock()->getCurrentTime(); - for (int k = 0, idx = next_post_idx; k < MAX_UNSYNCED_POSTS; k++) { - auto p = &posts[idx]; - if (now >= p->post_timestamp + POST_SYNC_DELAY_SECS && - p->post_timestamp > client->extra.room.sync_since && // is new post? - !p->author.matches(client->id)) { // don't push to author - - // Push this post to Client, then wait for ACK - pushPostToClient(client, *p); - did_push = true; - break; - } - idx = (idx + 1) % MAX_UNSYNCED_POSTS; // wrap cyclic queue - } - } - - next_client_idx = (next_client_idx + 1) % acl.getNumClients(); // round robin - - if (did_push) { - next_push = futureMillis(SYNC_PUSH_INTERVAL); // 1200ms - } else { - next_push = futureMillis(SYNC_PUSH_INTERVAL / 8); // faster when no pushes - } - } - // ... rest of loop -} -``` - -**Key constants**: -- `PUSH_NOTIFY_DELAY_MILLIS` = 2000ms (initial delay after login) -- `SYNC_PUSH_INTERVAL` = 1200ms (time between push attempts) -- `POST_SYNC_DELAY_SECS` = 6 (wait 6 seconds after post before pushing) - ---- - -## Message Confirmation Flow - -### ACK Protocol for Direct Messages - -When you send a direct message, you receive an expected ACK code that you should match later. - -**1. Send Message** -```dart -await sendTextMessage( - contactPublicKey: contact.publicKey, - text: 'Hello!', -); -``` - -**2. Receive RESP_CODE_SENT** -``` -[0x06] - Response code -[1 byte] - Send type (1=flood, 0=direct) -[4 bytes] - Expected ACK code (store this!) -[4 bytes] - Suggested timeout (milliseconds) -``` - -**3. Wait for PUSH_CODE_SEND_CONFIRMED** -``` -[0x82] - Push code -[4 bytes] - ACK code (match with expected ACK from step 2) -[4 bytes] - Round trip time (uint32 LE, milliseconds) -``` - -**Implementation**: -```dart -// Store expected ACKs -final Map _expectedAcks = {}; - -// When sending -void _handleSentConfirmation(BufferReader reader) { - final sendType = reader.readByte(); - final expectedAckOrTag = reader.readBytes(4); - final suggestedTimeout = reader.readUInt32LE(); - - // Store for matching later - final ackKey = expectedAckOrTag.map((b) => b.toRadixString(16)).join(); - _expectedAcks[ackKey] = ExpectedAck( - timestamp: DateTime.now(), - timeout: Duration(milliseconds: suggestedTimeout), - ); - - // Set timeout - Future.delayed(Duration(milliseconds: suggestedTimeout), () { - if (_expectedAcks.containsKey(ackKey)) { - _expectedAcks.remove(ackKey); - print('⏱️ Message timeout - no ACK received'); - // Notify UI of timeout - } - }); -} - -// When confirmation arrives -void _handleSendConfirmed(BufferReader reader) { - final ackCode = reader.readBytes(4); - final roundTripTime = reader.readUInt32LE(); - - final ackKey = ackCode.map((b) => b.toRadixString(16)).join(); - if (_expectedAcks.containsKey(ackKey)) { - _expectedAcks.remove(ackKey); - print('✅ Message confirmed! RTT: ${roundTripTime}ms'); - // Notify UI of successful delivery - } -} -``` - -**Source**: -- Expected ACK calculation: `BaseChatMesh.cpp:323` (SHA256 hash of message) -- ACK table management: `MyMesh.cpp:316-332` (companion radio) -- Confirmation push: `MyMesh.cpp:320-324` - -### No ACK for Channel Messages - -Channel messages (public broadcasts) do NOT receive ACK confirmations. They are fire-and-forget. - ---- - -## Binary Protocol Specifications - -### All Integer Types are Little Endian! - -**CRITICAL**: All multi-byte integers in MeshCore protocol use **Little Endian** byte order! - -```dart -// ✅ CORRECT - Little Endian -writer.writeUInt32LE(timestamp); -writer.writeInt32LE(latitude); - -// ❌ WRONG - Big Endian (will cause protocol errors!) -writer.writeUInt32BE(timestamp); -``` - -### Text Type Enum - -``` -TXT_TYPE_PLAIN = 0 // Plain text message -TXT_TYPE_CLI_DATA = 1 // CLI command (admin only) -TXT_TYPE_SIGNED_PLAIN = 2 // Plain text, signed by original author -``` - -Source: `TxtDataHelpers.h:6-8` - -### Contact Types (ADV_TYPE) - -``` -ADV_TYPE_NONE = 0 // Unknown/invalid -ADV_TYPE_CHAT = 1 // Team member (person-to-person) -ADV_TYPE_REPEATER = 2 // Network repeater node -ADV_TYPE_ROOM = 3 // Room/server with persistent storage -``` - -Source: `AdvertDataHelpers.h` (inferred from protocol) - -### Message Length Limits - -``` -MAX_TEXT_LEN = 160 bytes // For direct messages - -Channel messages: 160 - len(advert_name) - 2 bytes -Typical: ~140 bytes if name is 18 chars -``` - -Source: -- `BaseChatMesh.h:8` (MAX_TEXT_LEN definition) -- `MyMesh.cpp:870` (channel message validation) - -### Public Key Handling - -**CRITICAL**: Different commands use different public key lengths! - -``` -CMD_SEND_TXT_MSG: 6 bytes (prefix only!) -CMD_SEND_LOGIN: 32 bytes (full key) -CMD_ADD_UPDATE_CONTACT: 32 bytes (full key) -RESP_CODE_CONTACT: 32 bytes (full key) -RESP_CODE_CONTACT_MSG_RECV: 6 bytes (prefix) -``` - -**Why?** To save bandwidth, message sends use only 6-byte prefix for recipient identification. The companion radio looks up the full 32-byte key from its internal contact table. - ---- - -## Implementation Requirements - -### What the App MUST Implement - -1. **Message Queue Handling** - - ✅ Respond to `PUSH_CODE_MSG_WAITING` by calling `syncNextMessage()` - - ✅ Continue calling `syncNextMessage()` until `RESP_CODE_NO_MORE_MESSAGES` - - ✅ Handle both `RESP_CODE_CONTACT_MSG_RECV` and `RESP_CODE_CHANNEL_MSG_RECV` - -2. **Room Login Flow** - - ✅ Send `CMD_SEND_LOGIN` with password and `sync_since` - - ✅ Wait for `PUSH_CODE_LOGIN_SUCCESS` or `PUSH_CODE_LOGIN_FAIL` - - ✅ After login success, **DO NOTHING** - room will push messages automatically - - ✅ Handle pushed messages via normal `PUSH_CODE_MSG_WAITING` flow - -3. **Contact Management** - - ✅ Call `CMD_GET_CONTACTS` after connection to sync contacts - - ✅ Use `CMD_ADD_UPDATE_CONTACT` to add rooms that haven't advertised - - ✅ Store contacts locally for offline access - -4. **Message Sending** - - ✅ Use `CMD_SEND_TXT_MSG` for direct messages and room messages - - ✅ Use `CMD_SEND_CHANNEL_TXT_MSG` only for ephemeral public broadcasts - - ✅ Store expected ACK codes from `RESP_CODE_SENT` - - ✅ Match ACKs in `PUSH_CODE_SEND_CONFIRMED` to mark messages as delivered - -5. **Signed Messages (from Rooms)** - - ✅ Parse `TXT_TYPE_SIGNED_PLAIN` messages correctly - - ✅ Extract 4-byte author prefix after sender timestamp - - ✅ Show original author in UI, not room's public key - -### What the App MUST NOT Do - -1. **❌ DON'T call `syncNextMessage()` immediately after `PUSH_CODE_LOGIN_SUCCESS`** - - The room server delays pushes by 2000ms - - Calling sync immediately will get `RESP_CODE_NO_MORE_MESSAGES` - - Wait for `PUSH_CODE_MSG_WAITING` instead! - -2. **❌ DON'T send SAR markers as channel messages** - - Channel messages are ephemeral (not stored) - - SAR markers MUST be persistent - - Use `CMD_SEND_TXT_MSG` to room contacts instead - -3. **❌ DON'T use Big Endian for integers** - - All multi-byte integers MUST be Little Endian - - Check your BufferWriter implementation! - -4. **❌ DON'T send full 32-byte public key in `CMD_SEND_TXT_MSG`** - - Only send first 6 bytes (prefix) - - Sending 32 bytes will cause protocol error - -5. **❌ DON'T retry login immediately on failure** - - Wait at least 5 seconds between retries - - Excessive retries may get you blocked by room - ---- - -## Common Pitfalls - -### 1. Incorrect Room Message Routing - -**Problem**: Sending SAR markers to public channel instead of room - -```dart -// ❌ WRONG - Ephemeral, not stored -await sendChannelMessage( - channelIdx: 0, - text: 'S:🧑:46.0569,14.5058', -); - -// ✅ CORRECT - Persistent in room -await sendTextMessage( - contactPublicKey: roomContact.publicKey, - text: 'S:🧑:46.0569,14.5058', -); -``` - -### 2. Calling syncNextMessage() Too Early - -**Problem**: Calling `syncNextMessage()` right after login success - -```dart -// ❌ WRONG -onLoginSuccess = (prefix, perms, isAdmin, tag) async { - await syncAllMessages(); // Too early! Room hasn't pushed yet -}; - -// ✅ CORRECT -onLoginSuccess = (prefix, perms, isAdmin, tag) { - print('Login successful, waiting for message pushes...'); - // Don't call syncNextMessage() - wait for PUSH_CODE_MSG_WAITING -}; - -onMessageWaiting = () async { - // Now fetch messages - await fetchAllPendingMessages(); -}; -``` - -**Why?** Room server delays first push by 2000ms (`PUSH_NOTIFY_DELAY_MILLIS`). Calling sync immediately gets `RESP_CODE_NO_MORE_MESSAGES`. - -### 3. Using Wrong Public Key Length - -**Problem**: Sending 32-byte public key in `CMD_SEND_TXT_MSG` - -```dart -// ❌ WRONG -writer.writeBytes(contactPublicKey); // 32 bytes - -// ✅ CORRECT -writer.writeBytes(contactPublicKey.sublist(0, 6)); // Only first 6 bytes -``` - -### 4. Big Endian vs Little Endian - -**Problem**: Using wrong byte order for integers - -```dart -// ❌ WRONG - Big Endian -writer.writeUInt32BE(timestamp); - -// ✅ CORRECT - Little Endian -writer.writeUInt32LE(timestamp); -``` - -All integers in MeshCore protocol are Little Endian! - -### 5. Not Handling Signed Messages - -**Problem**: Displaying room public key as sender instead of original author - -```dart -// In room messages, sender is the room, but author is in message data - -if (txtType == MessageTextType.signedPlain) { - // Next 4 bytes are original author's public key prefix - final authorPrefix = reader.readBytes(4); - // Show authorPrefix as sender, not room's pubKeyPrefix -} -``` - -### 6. Room Contact Not in Companion Radio Table - -**Problem**: Getting `ERR_CODE_NOT_FOUND` when trying to login - -**Solution**: Add room contact to companion radio first: - -```dart -// Before login, ensure room contact exists -await _bleService.addOrUpdateContact(roomContact); - -// Wait a moment for contact to be added -await Future.delayed(Duration(milliseconds: 500)); - -// Now login -await _bleService.loginToRoom( - roomPublicKey: roomContact.publicKey, - password: 'mypassword', -); -``` - -### 7. Not Handling Message Queue Overflow - -**Problem**: Offline queue fills up (16 messages max), oldest messages lost - -**Solution**: Fetch messages promptly when `PUSH_CODE_MSG_WAITING` arrives. Don't delay message fetching. - ---- - -## Complete Example Flows - -### Example 1: Send SAR Marker to Room (Persistent) - -```dart -// 1. Ensure we have room contact -final room = contacts.firstWhere( - (c) => c.type == ContactType.room && c.advName == 'SAR Room', - orElse: () => throw Exception('Room not found'), -); - -// 2. Add room to companion radio if not already there -await _bleService.addOrUpdateContact(room); -await Future.delayed(Duration(milliseconds: 500)); - -// 3. Login to room -await _bleService.loginToRoom( - roomPublicKey: room.publicKey, - password: 'sarpassword', - syncSince: 0, // Get all historical messages -); - -// 4. Wait for login success -// (onLoginSuccess callback will fire) - -// 5. Send SAR marker as direct message to room -await _bleService.sendTextMessage( - contactPublicKey: room.publicKey, - text: 'S:🧑:46.0569,14.5058', -); - -// 6. Wait for RESP_CODE_SENT and store expected ACK -// (sendTextMessage will return immediately) - -// 7. Wait for PUSH_CODE_SEND_CONFIRMED to confirm delivery -// (onSendConfirmed callback will fire when room ACKs) -``` - -### Example 2: Receive Messages from Room - -```dart -// Set up callbacks -_bleService.onLoginSuccess = (prefix, perms, isAdmin, tag) { - print('✅ Logged into room successfully'); - print(' Waiting for automatic message push from room...'); - // DO NOT call syncNextMessage() here! -}; - -_bleService.onMessageWaiting = () async { - print('📨 Message(s) waiting, fetching from queue...'); - await _fetchAllPendingMessages(); -}; - -_bleService.onMessageReceived = (message) { - if (message.textType == MessageTextType.signedPlain) { - // This is a room message - show original author - print('Room message from author: ${message.authorPublicKeyPrefix}'); - } else { - // Normal direct message - print('Direct message from: ${message.senderPublicKeyPrefix}'); - } - - // Add to UI - setState(() { - _messages.add(message); - }); -}; - -_bleService.onNoMoreMessages = () { - print('✅ All messages fetched from queue'); -}; - -// Fetch all pending messages from queue -Future _fetchAllPendingMessages() async { - while (true) { - try { - await _bleService.syncNextMessage(); - - // Wait for response - await Future.delayed(Duration(milliseconds: 100)); - - // If RESP_CODE_NO_MORE_MESSAGES, onNoMoreMessages callback fires - // and we can break (but it's safer to let it timeout naturally) - } catch (e) { - print('Error fetching message: $e'); - break; - } - } -} -``` - -### Example 3: Send to Public Channel (Ephemeral Broadcast) - -```dart -// This is for emergency broadcasts that ALL nodes should see immediately -// But it's NOT stored anywhere! - -await _bleService.sendChannelMessage( - channelIdx: 0, // 0 = "Public Channel" - text: 'Emergency: Flash flood warning!', -); - -// No ACK confirmation - fire and forget -// Offline nodes will never see this message -``` - -### Example 4: Handle Login Failure and Retry - -```dart -int _loginAttempts = 0; -const maxLoginAttempts = 3; - -_bleService.onLoginFail = (prefix) async { - print('❌ Login failed to room: $prefix'); - - _loginAttempts++; - if (_loginAttempts < maxLoginAttempts) { - print(' Retrying in 5 seconds... (attempt ${_loginAttempts + 1}/$maxLoginAttempts)'); - - await Future.delayed(Duration(seconds: 5)); - - // Retry login - await _bleService.loginToRoom( - roomPublicKey: roomContact.publicKey, - password: _password, - ); - } else { - print(' Max login attempts reached. Check password.'); - // Show error to user - } -}; -``` - ---- - -## File References - -All source code locations in MeshCore C++ firmware: - -### Companion Radio Implementation -- **Command handler**: `/Users/dz0ny/meshcore-sar/MeshCore/examples/companion_radio/MyMesh.cpp` - - Lines 818-862: `CMD_SEND_TXT_MSG` handler - - Lines 863-882: `CMD_SEND_CHANNEL_TXT_MSG` handler - - Lines 1056-1066: `CMD_SYNC_NEXT_MESSAGE` handler - - Lines 1196-1217: `CMD_SEND_LOGIN` handler - - Lines 334-379: Message queueing (`queueMessage`) - - Lines 316-332: ACK processing (`processAck`) - -### Room Server Implementation -- **Room server**: `/Users/dz0ny/meshcore-sar/MeshCore/examples/simple_room_server/MyMesh.cpp` - - Lines 282-363: Login processing (`onAnonDataRecv`) - - Lines 286-324: Password validation and client setup - - Lines 335-346: Login success response - - Lines 777-820: Message push loop (`loop()`) - - Lines 53-89: Push message to client (`pushPostToClient`) - - Lines 91-100: Count unsynced messages (`getUnsyncedCount`) - - Lines 102-113: ACK processing for pushed messages (`processAck`) - -### Core Library -- **Message sending**: `/Users/dz0ny/meshcore-sar/MeshCore/src/helpers/BaseChatMesh.cpp` - - Lines 334-351: Send direct message (`sendMessage`) - - Lines 379-398: Send channel message (`sendGroupMessage`) - - Lines 431-464: Send login request (`sendLogin`) - - Lines 312-332: Compose message packet (`composeMsgPacket`) - - Lines 143-233: Receive and process messages (`onPeerDataRecv`) - -### Protocol Definitions -- **Packet types**: `/Users/dz0ny/meshcore-sar/MeshCore/src/Packet.h` - - Lines 19-31: Payload type definitions - - Lines 14-17: Route type definitions - ---- - -## Flutter App Implementation Status - -### Current Implementation (Correct) - -✅ **BLE Service** (`lib/services/meshcore_ble_service.dart`): -- Lines 1409-1427: `sendTextMessage()` - correctly sends direct messages -- Lines 1439-1456: `sendChannelMessage()` - correctly sends channel broadcasts -- Lines 1616-1642: `loginToRoom()` - correctly sends login with sync_since -- Lines 1479-1483: `syncNextMessage()` - correctly fetches from queue -- Lines 1368-1398: `addOrUpdateContact()` - correctly adds room contacts -- Lines 512-578: `_handleContactMessage()` - correctly parses contact messages -- Lines 581-647: `_handleChannelMessage()` - correctly parses channel messages -- Lines 1157-1166: `_handleMsgWaiting()` - correctly triggers onMessageWaiting callback -- Lines 1175-1206: `_handleLoginSuccess()` - correctly parses login success - -### Issues to Fix - -❌ **Room login state management** (`lib/models/room_login_state.dart`): -- This file exists but implementation details not reviewed yet -- Ensure state machine doesn't call `syncNextMessage()` immediately after login success - -❌ **Message routing decision** (`lib/screens/messages_tab.dart`): -- Need to check if SAR markers are being sent to rooms vs channels -- Line 49 (`_sendMessage`): Verify routing logic - -❌ **Connection provider** (`lib/providers/connection_provider.dart`): -- Lines 123, 600, 608, 643: Message waiting and sync handling -- Verify `syncNextMessage()` is only called when `onMessageWaiting` fires -- Check if there's any premature syncing after login - -### Recommended Next Steps - -1. **Review `connection_provider.dart`** message sync logic -2. **Review `messages_tab.dart`** for SAR marker routing -3. **Add ACK tracking** for message delivery confirmation -4. **Implement retry logic** for failed logins -5. **Add UI indicators** for message delivery status (sending/sent/confirmed/failed) - ---- - -## Testing Checklist - -### Room Login Testing - -- [ ] Login succeeds with correct password -- [ ] Login fails with incorrect password -- [ ] `PUSH_CODE_MSG_WAITING` arrives after 2+ seconds -- [ ] Calling `syncNextMessage()` before push returns `NO_MORE_MESSAGES` -- [ ] Room pushes all messages where `post_timestamp > sync_since` -- [ ] Room pushes continue until all messages delivered -- [ ] Room doesn't push messages to original author -- [ ] Second login with higher `sync_since` only gets new messages - -### Message Sending Testing - -- [ ] Direct message to person succeeds -- [ ] Direct message to room succeeds -- [ ] Channel message broadcasts successfully -- [ ] SAR marker sent to room is persistent -- [ ] SAR marker sent to channel is NOT persistent (verify by rebooting) -- [ ] Message length limit (160 bytes) enforced -- [ ] Public key prefix (6 bytes) used in direct messages -- [ ] Expected ACK code received in `RESP_CODE_SENT` -- [ ] `PUSH_CODE_SEND_CONFIRMED` arrives after message delivery -- [ ] Round-trip time is reasonable (<10 seconds typically) - -### Message Receiving Testing - -- [ ] Contact message received and displayed -- [ ] Channel message received and displayed -- [ ] Signed message shows original author, not room -- [ ] `PUSH_CODE_MSG_WAITING` triggers message fetch -- [ ] Multiple messages fetched from queue in sequence -- [ ] `RESP_CODE_NO_MORE_MESSAGES` stops fetch loop -- [ ] Offline messages queued (up to 16) and delivered later -- [ ] Message timestamps are correct (UTC epoch seconds) - -### Error Handling Testing - -- [ ] `ERR_CODE_NOT_FOUND` when room contact missing -- [ ] `ERR_CODE_NOT_FOUND` resolved by adding contact -- [ ] Login timeout handled gracefully -- [ ] Message send timeout detected -- [ ] Queue overflow handled (oldest messages dropped) -- [ ] Clock drift detected and corrected -- [ ] Invalid message format doesn't crash app - ---- - -## Glossary - -**ACK (Acknowledgement)**: Confirmation packet sent by recipient to prove message delivery - -**Companion Radio**: The MeshCore hardware device that handles LoRa radio communication - -**Contact**: An entity in the mesh network (person, repeater, or room) with a 32-byte public key - -**Direct Message**: Message sent point-to-point to a specific contact using their public key prefix - -**Channel Message**: Broadcast message sent flood-mode to all nodes listening to a channel - -**Epoch Seconds**: Unix timestamp (seconds since 1970-01-01 00:00:00 UTC) - -**Flood Mode**: Routing where message is rebroadcast by all nodes to cover entire network - -**Little Endian**: Byte order where least significant byte comes first (used for all integers) - -**Public Key Prefix**: First 6 bytes of a contact's 32-byte Ed25519 public key - -**Room**: A server contact (ADV_TYPE_ROOM) that provides persistent message storage - -**sync_since**: Timestamp used by rooms to determine which messages to push to client - -**TAG**: Random unique identifier used to match requests with responses - ---- - -## Version History - -- **v1.0** (2025-01-14): Initial documentation based on MeshCore firmware v1.9.1 - ---- - -## Credits - -This guide is based on analysis of the MeshCore firmware source code: -- MeshCore firmware: https://github.com/meshcore-dev/meshcore -- Firmware version: v1.9.1 (firmware code 7, build date: 2 Oct 2025) -- Protocol specification: Derived from C++ source code analysis - -For questions or corrections, please refer to the source code comments. diff --git a/MESSAGING_IMPROVEMENTS_IMPLEMENTED.md b/MESSAGING_IMPROVEMENTS_IMPLEMENTED.md deleted file mode 100644 index 8f8c3d1..0000000 --- a/MESSAGING_IMPROVEMENTS_IMPLEMENTED.md +++ /dev/null @@ -1,341 +0,0 @@ -# Messaging Improvements Implementation Summary - -## Date: 2025-01-14 - -## Overview - -This document summarizes the messaging improvements implemented based on the gap analysis in `MESSAGE_SEND_RECEIVE_GAP_ANALYSIS.md`. - -## Gap Analysis Results - -### Gap #1: Direct Messages UI -**Status**: ✅ ALREADY IMPLEMENTED -- **Location**: `lib/screens/contacts_tab.dart:351-937` -- Direct message UI exists via Contacts tab -- Users can tap message icon on chat contacts to open direct message sheet -- `_DirectMessageSheet` widget provides full message composition UI -- Messages are sent with delivery tracking via `sendTextMessage()` - -### Gap #2: Timeout Handling -**Status**: ✅ NEWLY IMPLEMENTED -- **Files Modified**: - - `lib/providers/messages_provider.dart` - -#### Implementation Details - -**1. Added Timer Infrastructure** (lines 1, 19): -```dart -import 'dart:async'; - -// Track timeout timers for pending messages -final Map _timeoutTimers = {}; -``` - -**2. Start Timeout on Message Sent** (lines 280-291): -```dart -// Start timeout timer -_timeoutTimers[expectedAckTag] = Timer( - Duration(milliseconds: suggestedTimeoutMs), - () { - print('⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)'); - if (_pendingSentMessages.containsKey(expectedAckTag)) { - markMessageFailed(messageId); - } - }, -); -``` - -**3. Cancel Timeout on Delivery** (lines 312-314): -```dart -// Cancel timeout timer -_timeoutTimers[ackCode]?.cancel(); -_timeoutTimers.remove(ackCode); -``` - -**4. Cancel Timeout on Manual Failure** (lines 338-341): -```dart -// Cancel timeout timer if it exists -if (message.expectedAckTag != null) { - _timeoutTimers[message.expectedAckTag]?.cancel(); - _timeoutTimers.remove(message.expectedAckTag); - _pendingSentMessages.remove(message.expectedAckTag); -} -``` - -**5. Clean Up on Dispose** (lines 351-359): -```dart -@override -void dispose() { - // Cancel all pending timeout timers - for (final timer in _timeoutTimers.values) { - timer.cancel(); - } - _timeoutTimers.clear(); - super.dispose(); -} -``` - -### Gap #3: Retry Logic -**Status**: ✅ NEWLY IMPLEMENTED -- **Files Modified**: - - `lib/screens/messages_tab.dart` - - `lib/providers/connection_provider.dart` - -#### Implementation Details - -**1. Retry Button UI** (lines 570-597 in messages_tab.dart): -```dart -// Show retry button for failed messages -if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[ - const SizedBox(width: 8), - GestureDetector( - onTap: () => _retryFailedMessage(context, message), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.orange.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.orange, width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.refresh, size: 12, color: Colors.orange), - const SizedBox(width: 4), - Text( - 'Retry', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.orange, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), -], -``` - -**2. Retry Logic** (lines 400-479 in messages_tab.dart): -```dart -Future _retryFailedMessage(BuildContext context, Message failedMessage) async { - // Check connection - if (!connectionProvider.deviceInfo.isConnected) { - // Show error - return; - } - - // Check if max attempts reached (protocol supports 0-3, so 4 total attempts) - final currentAttempt = failedMessage.attemptNumber ?? 0; - if (currentAttempt >= 3) { - // Show max attempts reached error - return; - } - - final nextAttempt = currentAttempt + 1; - final retryMessageId = '${failedMessage.id}_retry_$nextAttempt'; - - // Create retry message with updated attempt number - final retryMessage = failedMessage.copyWith( - id: retryMessageId, - deliveryStatus: MessageDeliveryStatus.sending, - attemptNumber: nextAttempt, - sentAt: DateTime.now(), - ); - - messagesProvider.addSentMessage(retryMessage); - - // Resend the message - if (failedMessage.messageType == MessageType.channel) { - await connectionProvider.sendChannelMessage( - channelIdx: failedMessage.channelIdx ?? 0, - text: failedMessage.text, - messageId: retryMessageId, - attempt: nextAttempt, - ); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Retrying message (attempt ${nextAttempt + 1}/4)...'), - backgroundColor: Colors.orange, - ), - ); - } -} -``` - -**3. Added Attempt Parameter to Connection Provider** (lines 400-468 in connection_provider.dart): - -Updated `sendTextMessage()`: -```dart -Future sendTextMessage({ - required Uint8List contactPublicKey, - required String text, - String? messageId, - int attempt = 0, // NEW: retry attempt number (0-3) -}) async { - await _bleService.sendTextMessage( - contactPublicKey: contactPublicKey, - text: text, - attempt: attempt, // NEW: pass to BLE service - ); - - if (messageId != null) { - _pendingSentMessageIds.add(messageId); - print(' Added message ID to pending queue: $messageId (attempt $attempt)'); - } -} -``` - -Updated `sendChannelMessage()`: -```dart -Future sendChannelMessage({ - required int channelIdx, - required String text, - String? messageId, // NEW: track delivery - int attempt = 0, // NEW: retry attempt number (0-3) -}) async { - await _bleService.sendChannelMessage( - channelIdx: channelIdx, - text: text, - attempt: attempt, // NEW: pass to BLE service - ); - - // NEW: Track message ID for delivery confirmation - if (messageId != null) { - _pendingSentMessageIds.add(messageId); - print(' Added message ID to pending queue: $messageId (attempt $attempt)'); - } -} -``` - -## How It Works - -### Timeout Flow - -1. User sends message → `addSentMessage()` called with `sending` status -2. BLE service sends message → receives `RESP_CODE_SENT` (code 6) -3. `markMessageSent()` called with ACK tag and timeout value -4. Timer started for specified timeout (e.g., 30000ms) -5. Two possible outcomes: - - **Success**: `PUSH_CODE_SEND_CONFIRMED` (0x82) arrives → `markMessageDelivered()` cancels timer → message marked `delivered` - - **Timeout**: Timer expires → message automatically marked `failed` - -### Retry Flow - -1. Message times out or fails → UI shows red "Failed" status with orange "Retry" button -2. User taps "Retry" button -3. Check attempt number (must be < 3, since protocol supports 0-3 = 4 total attempts) -4. Create new message with: - - New message ID: `{original_id}_retry_{attempt}` - - Status: `sending` - - Attempt number: `currentAttempt + 1` -5. Send message with new attempt number via BLE -6. New timeout timer started automatically -7. Process repeats until delivered or max attempts reached - -## Protocol Compliance - -All implementations follow the MeshCore BLE Companion Radio protocol: - -- **Timeout values**: Use `suggestedTimeoutMs` from `RESP_CODE_SENT` (code 6) -- **Attempt numbers**: Range 0-3 (4 total attempts) as specified in protocol -- **Message tracking**: Use expected ACK tag from `RESP_CODE_SENT` to match with `PUSH_CODE_SEND_CONFIRMED` (0x82) -- **Delivery confirmation**: Round-trip time (RTT) stored from delivery confirmation - -## Testing Checklist - -### Timeout Handling -- [ ] Send message to unreachable contact -- [ ] Verify message shows "Sent" status initially -- [ ] Wait for timeout period (e.g., 30 seconds) -- [ ] Verify message automatically changes to "Failed" status -- [ ] Check logs for timeout message: `⏱️ [MessagesProvider] Timeout for message...` - -### Retry Logic -- [ ] Cause a message to fail (send to non-existent contact or wait for timeout) -- [ ] Verify "Failed" status shows with orange "Retry" button -- [ ] Tap "Retry" button -- [ ] Verify new message appears with "Sending" status -- [ ] Verify snackbar shows "Retrying message (attempt 2/4)..." -- [ ] Repeat retry up to 4 total attempts -- [ ] On 4th attempt, verify "Retry" button disappears -- [ ] Attempt to retry again, verify error: "Maximum retry attempts reached" - -### Delivery Success -- [ ] Send message to reachable contact -- [ ] Verify message shows "Sent" status -- [ ] Wait for delivery confirmation -- [ ] Verify message changes to "Delivered" status with green checkmarks -- [ ] Verify timeout timer was cancelled (no failure after timeout period) -- [ ] Check logs for delivery message: `✅ [MessagesProvider] Message {id} delivered in {ms}ms` - -## Known Limitations - -1. **Direct Message Retry**: Not yet implemented - - Retry button works only for channel messages - - Direct message retry would require looking up contact's full public key - - Shows "Direct message retry not yet implemented" message - -2. **Automatic Retry**: Not implemented - - User must manually tap "Retry" button - - Future enhancement could add automatic retry with exponential backoff - -3. **Retry Deduplication**: Messages show as separate entries - - Each retry creates a new message in the history - - Future enhancement could group retries under original message - -## Files Changed - -1. **lib/providers/messages_provider.dart** - - Added `dart:async` import - - Added `_timeoutTimers` map - - Modified `markMessageSent()` to start timers - - Modified `markMessageDelivered()` to cancel timers - - Modified `markMessageFailed()` to cancel timers - - Added `dispose()` method to clean up timers - -2. **lib/providers/connection_provider.dart** - - Modified `sendTextMessage()` to accept `attempt` parameter - - Modified `sendChannelMessage()` to accept `messageId` and `attempt` parameters - - Both methods now track message IDs for delivery confirmation - -3. **lib/screens/messages_tab.dart** - - Added retry button UI to `_MessageBubble` widget - - Added `_retryFailedMessage()` method - - Retry UI appears only for failed messages - - Shows attempt count (e.g., "attempt 2/4") - -## Performance Impact - -- **Memory**: Minimal - one Timer object per pending message -- **CPU**: Negligible - timers use OS-level scheduling -- **Network**: No change - only affects local message state management - -## Future Enhancements - -1. **Automatic Retry with Backoff** - - Implement exponential backoff (e.g., 5s, 10s, 20s, 40s) - - Configurable via settings - -2. **Retry Grouping** - - Group retry attempts under original message - - Show retry history in message details - -3. **Direct Message Retry** - - Add contact lookup by public key prefix - - Implement retry for direct messages - -4. **Smart Timeout Adjustment** - - Learn from network conditions - - Adjust timeout based on historical RTT - -5. **Batch Retry** - - "Retry All Failed" button - - Retry multiple failed messages at once - -## Conclusion - -The messaging system now has robust timeout handling and manual retry capabilities for channel messages. Messages automatically fail after the protocol-specified timeout period, and users can retry failed messages up to 4 times as allowed by the MeshCore protocol. - -Direct messages can already be sent via the Contacts tab, so Gap #1 was already addressed. Gaps #2 and #3 are now fully implemented and ready for testing. diff --git a/ios/Runner.app.dSYM.zip b/ios/Runner.app.dSYM.zip index 3458047..4f2e12b 100644 Binary files a/ios/Runner.app.dSYM.zip and b/ios/Runner.app.dSYM.zip differ diff --git a/ios/Runner.ipa b/ios/Runner.ipa index 3b37fda..31fcc7a 100644 Binary files a/ios/Runner.ipa and b/ios/Runner.ipa differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3270b26..cf3d4a8 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; @@ -529,7 +529,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; @@ -545,7 +545,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; @@ -676,7 +676,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -699,7 +699,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 433e3dc..0b84376 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -21,7 +21,7 @@ CFBundleSignature ???? CFBundleVersion - 5 + 7 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 4146271..76f6bce 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,24 +5,22 @@ - + - + - + - - - + diff --git a/lib/models/map_drawing.dart b/lib/models/map_drawing.dart index 1cf1eb8..a54ef61 100644 --- a/lib/models/map_drawing.dart +++ b/lib/models/map_drawing.dart @@ -7,17 +7,29 @@ enum DrawingShapeType { rectangle, } +/// Drawing color enum for compact network transmission +enum DrawingColor { + red, // 0 + blue, // 1 + green, // 2 + yellow, // 3 + orange, // 4 + purple, // 5 + pink, // 6 + cyan, // 7 +} + /// Drawing colors available for user selection class DrawingColors { static const List palette = [ - Colors.red, - Colors.blue, - Colors.green, - Colors.yellow, - Colors.orange, - Colors.purple, - Colors.pink, - Colors.cyan, + Colors.red, // index 0 + Colors.blue, // index 1 + Colors.green, // index 2 + Colors.yellow, // index 3 + Colors.orange, // index 4 + Colors.purple, // index 5 + Colors.pink, // index 6 + Colors.cyan, // index 7 ]; static String colorToName(Color color) { @@ -31,6 +43,24 @@ class DrawingColors { if (color == Colors.cyan) return 'Cyan'; return 'Unknown'; } + + /// Convert Color to enum index for network transmission + static int colorToIndex(Color color) { + for (int i = 0; i < palette.length; i++) { + if (palette[i].value == color.value) { + return i; + } + } + return 0; // Default to red if not found + } + + /// Convert enum index to Color for network reception + static Color indexToColor(int index) { + if (index >= 0 && index < palette.length) { + return palette[index]; + } + return palette[0]; // Default to red if invalid index + } } /// Base class for map drawings @@ -56,23 +86,25 @@ abstract class MapDrawing { /// Convert to JSON for network transmission (compact format) /// Uses short field names and excludes createdAt to minimize message size - Map toNetworkJson(String senderName); + /// Sender will be fetched from packet metadata + Map toNetworkJson(); /// Parse network JSON (compact format) - static MapDrawing? fromNetworkJson(Map json) { - final typeStr = json['t'] as String?; - if (typeStr == null) return null; + /// senderName will be populated from packet metadata + static MapDrawing? fromNetworkJson(Map json, {String? senderName}) { + final typeNum = json['t'] as int?; + if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) { + return null; + } try { - final type = DrawingShapeType.values.firstWhere( - (e) => e.name == typeStr, - ); + final type = DrawingShapeType.values[typeNum]; switch (type) { case DrawingShapeType.line: - return LineDrawing.fromNetworkJson(json); + return LineDrawing.fromNetworkJson(json, senderName: senderName); case DrawingShapeType.rectangle: - return RectangleDrawing.fromNetworkJson(json); + return RectangleDrawing.fromNetworkJson(json, senderName: senderName); } } catch (e) { return null; @@ -126,13 +158,13 @@ class LineDrawing extends MapDrawing { } @override - Map toNetworkJson(String senderName) { - // Compact format: t=type, c=color, s=sender, p=points + Map toNetworkJson() { + // Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points // Points are encoded as flat array [lat1,lon1,lat2,lon2,...] + // Sender is fetched from packet metadata, not included in JSON return { - 't': type.name, - 'c': color.value, - 's': senderName, + 't': type.index, + 'c': DrawingColors.colorToIndex(color), 'p': points.expand((p) => [p.latitude, p.longitude]).toList(), }; } @@ -152,18 +184,17 @@ class LineDrawing extends MapDrawing { ); } - static LineDrawing fromNetworkJson(Map json) { - // Parse compact format + static LineDrawing fromNetworkJson(Map json, {String? senderName}) { + // Parse ultra-compact format final pointsFlat = (json['p'] as List).cast(); final points = []; for (int i = 0; i < pointsFlat.length; i += 2) { points.add(LatLng(pointsFlat[i], pointsFlat[i + 1])); } - final senderName = json['s'] as String?; return LineDrawing( id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID - color: Color(json['c'] as int), + color: DrawingColors.indexToColor(json['c'] as int), createdAt: DateTime.now(), points: points, senderName: senderName, @@ -219,12 +250,12 @@ class RectangleDrawing extends MapDrawing { } @override - Map toNetworkJson(String senderName) { - // Compact format: t=type, c=color, s=sender, b=bounds [lat1,lon1,lat2,lon2] + Map toNetworkJson() { + // Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2] + // Sender is fetched from packet metadata, not included in JSON return { - 't': type.name, - 'c': color.value, - 's': senderName, + 't': type.index, + 'c': DrawingColors.colorToIndex(color), 'b': [topLeft.latitude, topLeft.longitude, bottomRight.latitude, bottomRight.longitude], }; } @@ -245,14 +276,13 @@ class RectangleDrawing extends MapDrawing { ); } - static RectangleDrawing fromNetworkJson(Map json) { - // Parse compact format + static RectangleDrawing fromNetworkJson(Map json, {String? senderName}) { + // Parse ultra-compact format final bounds = (json['b'] as List).cast(); - final senderName = json['s'] as String?; return RectangleDrawing( id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID - color: Color(json['c'] as int), + color: DrawingColors.indexToColor(json['c'] as int), createdAt: DateTime.now(), topLeft: LatLng(bounds[0], bounds[1]), bottomRight: LatLng(bounds[2], bounds[3]), diff --git a/lib/models/message.dart b/lib/models/message.dart index 14cda9c..a145804 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -50,6 +50,7 @@ class Message { final bool isSarMarker; final SarMarkerType? sarMarkerType; final LatLng? sarGpsCoordinates; + final String? sarNotes; // Optional message/notes for SAR marker // Display metadata final DateTime receivedAt; @@ -78,6 +79,7 @@ class Message { this.isSarMarker = false, this.sarMarkerType, this.sarGpsCoordinates, + this.sarNotes, required this.receivedAt, this.senderName, this.deliveryStatus = MessageDeliveryStatus.received, @@ -162,7 +164,7 @@ class Message { timestamp: sentAt, senderPublicKey: senderPublicKeyPrefix, senderName: senderName, - notes: text, + notes: sarNotes, // Use dedicated notes field instead of full text ); } @@ -218,6 +220,7 @@ class Message { bool? isSarMarker, SarMarkerType? sarMarkerType, LatLng? sarGpsCoordinates, + String? sarNotes, DateTime? receivedAt, String? senderName, MessageDeliveryStatus? deliveryStatus, @@ -240,6 +243,7 @@ class Message { isSarMarker: isSarMarker ?? this.isSarMarker, sarMarkerType: sarMarkerType ?? this.sarMarkerType, sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates, + sarNotes: sarNotes ?? this.sarNotes, receivedAt: receivedAt ?? this.receivedAt, senderName: senderName ?? this.senderName, deliveryStatus: deliveryStatus ?? this.deliveryStatus, diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index e92a039..8390c75 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -68,7 +68,12 @@ class AppProvider with ChangeNotifier { // Check if message is a drawing broadcast if (DrawingMessageParser.isDrawingMessage(message.text)) { debugPrint('🎨 [AppProvider] Drawing message received, parsing...'); - final drawing = DrawingMessageParser.parseDrawingMessage(message.text); + // Extract sender name from message packet metadata + final senderName = message.senderName ?? 'unknown'; + final drawing = DrawingMessageParser.parseDrawingMessage( + message.text, + senderName: senderName, + ); if (drawing != null) { debugPrint('🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}'); drawingProvider.addReceivedDrawing(drawing); @@ -177,6 +182,9 @@ class AppProvider with ChangeNotifier { // Sync device time await connectionProvider.syncDeviceTime(); + // Get battery and storage information + await connectionProvider.getBatteryAndStorage(); + // Load contacts await connectionProvider.getContacts(); diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index d0f6f7d..81daccd 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -26,6 +26,14 @@ class PingResult { }); } +/// Scanned device with RSSI information +class ScannedDevice { + final BluetoothDevice device; + final int rssi; + + ScannedDevice({required this.device, required this.rssi}); +} + /// Connection Provider - manages MeshCore BLE connection class ConnectionProvider with ChangeNotifier { final MeshCoreBleService _bleService = MeshCoreBleService(); @@ -36,8 +44,8 @@ class ConnectionProvider with ChangeNotifier { DeviceInfo _deviceInfo = DeviceInfo(); DeviceInfo get deviceInfo => _deviceInfo; - List _scannedDevices = []; - List get scannedDevices => _scannedDevices; + List _scannedDevices = []; + List get scannedDevices => _scannedDevices; bool _isScanning = false; bool get isScanning => _isScanning; @@ -352,6 +360,15 @@ class ConnectionProvider with ChangeNotifier { notifyListeners(); }); }; + + _bleService.onRssiUpdate = (rssi) { + print('📡 [Provider] RSSI updated: $rssi dBm'); + _deviceInfo = _deviceInfo.copyWith( + signalRssi: rssi, + lastUpdate: DateTime.now(), + ); + notifyListeners(); + }; } /// Start scanning for MeshCore devices @@ -364,15 +381,26 @@ class ConnectionProvider with ChangeNotifier { print('✅ [Provider] Scan state initialized, notifying listeners'); try { - await for (final device + await for (final scanResult in _bleService.scanForDevices(timeout: const Duration(seconds: 10))) { - print('📱 [Provider] Device received from scan stream'); - if (!_scannedDevices.any((d) => d.remoteId == device.remoteId)) { - _scannedDevices.add(device); - print('✅ [Provider] Added device to list: ${device.platformName}, total: ${_scannedDevices.length}'); + print('📱 [Provider] Scan result received from scan stream'); + final device = scanResult.device; + final rssi = scanResult.rssi; + + if (!_scannedDevices.any((d) => d.device.remoteId == device.remoteId)) { + _scannedDevices.add(ScannedDevice(device: device, rssi: rssi)); + print('✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}'); notifyListeners(); } else { - print(' ⏭️ [Provider] Device already in list, skipping'); + // Update RSSI if device already exists + final index = _scannedDevices.indexWhere((d) => d.device.remoteId == device.remoteId); + if (index != -1 && _scannedDevices[index].rssi != rssi) { + _scannedDevices[index] = ScannedDevice(device: device, rssi: rssi); + print(' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm'); + notifyListeners(); + } else { + print(' ⏭️ [Provider] Device already in list with same RSSI, skipping'); + } } } } catch (e) { diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index 18db4c1..d3ebd55 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -273,7 +273,8 @@ class DrawingProvider with ChangeNotifier { /// Broadcast a drawing to contacts /// Returns the formatted message string ready to send - String createDrawingBroadcastMessage(MapDrawing drawing, String senderName) { - return DrawingMessageParser.createDrawingMessage(drawing, senderName); + /// Sender will be determined from packet metadata on receiving end + String createDrawingBroadcastMessage(MapDrawing drawing) { + return DrawingMessageParser.createDrawingMessage(drawing); } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5a5ee13..f3ca93b 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -29,7 +29,8 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State with SingleTickerProviderStateMixin { +class _HomeScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; int _currentIndex = 0; bool _isMapFullscreen = false; @@ -70,7 +71,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { if (context.mounted) { - ToastLogger.error(context, 'Location services are disabled. Please enable them in Settings.'); + ToastLogger.error( + context, + 'Location services are disabled. Please enable them in Settings.', + ); } return; } @@ -89,7 +93,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM if (permission == LocationPermission.deniedForever) { if (context.mounted) { - ToastLogger.error(context, 'Location permission permanently denied. Please enable in Settings.'); + ToastLogger.error( + context, + 'Location permission permanently denied. Please enable in Settings.', + ); } return; } @@ -124,7 +131,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM await connectionProvider.sendSelfAdvert(floodMode: true); if (context.mounted) { - ToastLogger.success(context, 'Advertised at ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}'); + ToastLogger.success( + context, + 'Advertised at ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}', + ); } } catch (e) { print('❌ Failed to advertise device: $e'); @@ -146,31 +156,40 @@ class _HomeScreenState extends State with SingleTickerProviderStateM backgroundColor: Colors.transparent, builder: (context) => Container( height: MediaQuery.of(context).size.height * 0.9, - decoration: const BoxDecoration( - color: Color(0xFF1E1E1E), - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( children: [ // Header Container( padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), child: Row( children: [ IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), + icon: Icon( + Icons.arrow_back, + color: Theme.of(context).colorScheme.onSurface, + ), onPressed: () { connectionProvider.stopScan(); Navigator.pop(context); }, ), - const Expanded( + Expanded( child: Column( children: [ Text( 'MeshCore', style: TextStyle( - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, fontSize: 18, fontWeight: FontWeight.bold, ), @@ -178,7 +197,9 @@ class _HomeScreenState extends State with SingleTickerProviderStateM Text( 'Scanning for devices...', style: TextStyle( - color: Colors.grey, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, fontSize: 14, ), ), @@ -186,8 +207,14 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), ), IconButton( - icon: const Icon(Icons.more_vert, color: Colors.white), - onPressed: () {}, + icon: Icon( + Icons.refresh, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + connectionProvider.stopScan(); + connectionProvider.startScan(); + }, ), ], ), @@ -203,12 +230,18 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), child: Row( children: [ - Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer), + Icon( + Icons.info_outline, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), const SizedBox(width: 12), Expanded( child: Text( 'The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings.', - style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer, fontSize: 13), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 13, + ), ), ), ], @@ -222,16 +255,41 @@ class _HomeScreenState extends State with SingleTickerProviderStateM child: Consumer( builder: (context, provider, child) { if (provider.isScanning && provider.scannedDevices.isEmpty) { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } if (provider.scannedDevices.isEmpty) { - return const Center( - child: Text( - 'No devices found', - style: TextStyle(color: Colors.grey, fontSize: 16), + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.bluetooth_searching, + size: 64, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'No devices found', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () { + connectionProvider.stopScan(); + connectionProvider.startScan(); + }, + icon: const Icon(Icons.refresh), + label: const Text('Scan Again'), + ), + ], ), ); } @@ -239,42 +297,80 @@ class _HomeScreenState extends State with SingleTickerProviderStateM return ListView.builder( itemCount: provider.scannedDevices.length, itemBuilder: (context, index) { - final device = provider.scannedDevices[index]; + final scannedDevice = provider.scannedDevices[index]; + final device = scannedDevice.device; + final rssi = scannedDevice.rssi; + final signalColor = _getSignalColor(rssi); + return Container( margin: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, ), decoration: BoxDecoration( - color: const Color(0xFF2D2D2D), - borderRadius: BorderRadius.circular(8), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withOpacity(0.2), + width: 1, + ), ), child: ListTile( - leading: const Icon( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: Icon( Icons.bluetooth, - color: Colors.white, + color: signalColor, size: 32, ), title: Text( device.platformName.isNotEmpty ? device.platformName : 'Unknown Device', - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 16, fontWeight: FontWeight.w500, ), ), - subtitle: const Text( - 'Tap to connect', - style: TextStyle(color: Colors.grey, fontSize: 14), + subtitle: Row( + children: [ + Text( + 'Tap to connect', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + const SizedBox(width: 8), + Text( + '${rssi} dBm', + style: TextStyle( + color: signalColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], ), - trailing: const Icon( + trailing: Icon( Icons.chevron_right, - color: Colors.white, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), onTap: () async { - print('🔵 [UI] User tapped device: ${device.platformName}'); + print( + '🔵 [UI] User tapped device: ${device.platformName}', + ); // Get app provider reference before popping dialog final appProvider = context.read(); @@ -284,17 +380,25 @@ class _HomeScreenState extends State with SingleTickerProviderStateM print('🔵 [UI] Calling provider.connect()...'); final success = await provider.connect(device); - print(success - ? '✅ [UI] provider.connect() returned success' - : '❌ [UI] provider.connect() returned failure'); + print( + success + ? '✅ [UI] provider.connect() returned success' + : '❌ [UI] provider.connect() returned failure', + ); if (success && provider.deviceInfo.isConnected) { - print('✅ [UI] Device is connected, initializing app provider...'); + print( + '✅ [UI] Device is connected, initializing app provider...', + ); await appProvider.initialize(); print('✅ [UI] App provider initialized'); } else { - print('❌ [UI] Device not connected after connect() call'); - print(' Connection state: ${provider.deviceInfo.connectionState}'); + print( + '❌ [UI] Device not connected after connect() call', + ); + print( + ' Connection state: ${provider.deviceInfo.connectionState}', + ); print(' Error: ${provider.error}'); } }, @@ -317,60 +421,77 @@ class _HomeScreenState extends State with SingleTickerProviderStateM final shouldHideUI = _isMapFullscreen && _currentIndex == 2; return Scaffold( - appBar: shouldHideUI ? null : AppBar( - title: _buildCompactStatusBar(), - actions: [ - PopupMenuButton( - icon: const Icon(Icons.more_vert), - itemBuilder: (context) => [ - PopupMenuItem( - child: const Row( - children: [ - Icon(Icons.map), - SizedBox(width: 8), - Text('Map Management'), + appBar: shouldHideUI + ? null + : AppBar( + title: _buildCompactStatusBar(), + actions: [ + Consumer( + builder: (context, provider, child) { + if (provider.deviceInfo.isConnected) { + return IconButton( + onPressed: () async { + await provider.disconnect(); + }, + icon: const Icon(Icons.power_settings_new), + tooltip: 'Disconnect', + color: Colors.red.shade700, + ); + } + return const SizedBox.shrink(); + }, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + itemBuilder: (context) => [ + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.map), + SizedBox(width: 8), + Text('Map Management'), + ], + ), + onTap: () { + Future.delayed(Duration.zero, () { + final appProvider = context.read(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MapManagementScreen( + tileCacheService: appProvider.tileCacheService, + ), + ), + ); + }); + }, + ), + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.settings), + SizedBox(width: 8), + Text('Settings'), + ], + ), + onTap: () { + Future.delayed(Duration.zero, () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SettingsScreen( + onThemeChanged: widget.onThemeChanged, + currentTheme: widget.currentTheme, + ), + ), + ); + }); + }, + ), ], ), - onTap: () { - Future.delayed(Duration.zero, () { - final appProvider = context.read(); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MapManagementScreen( - tileCacheService: appProvider.tileCacheService, - ), - ), - ); - }); - }, - ), - PopupMenuItem( - child: const Row( - children: [ - Icon(Icons.settings), - SizedBox(width: 8), - Text('Settings'), - ], - ), - onTap: () { - Future.delayed(Duration.zero, () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SettingsScreen( - onThemeChanged: widget.onThemeChanged, - currentTheme: widget.currentTheme, - ), - ), - ); - }); - }, - ), - ], - ), - ], - ), + ], + ), body: TabBarView( controller: _tabController, children: [ @@ -385,44 +506,46 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), ], ), - bottomNavigationBar: shouldHideUI ? null : Consumer2( - builder: (context, messagesProvider, contactsProvider, child) { - final unreadCount = messagesProvider.unreadCount; - final newContactsCount = contactsProvider.newContactsCount; + bottomNavigationBar: shouldHideUI + ? null + : Consumer2( + builder: (context, messagesProvider, contactsProvider, child) { + final unreadCount = messagesProvider.unreadCount; + final newContactsCount = contactsProvider.newContactsCount; - return Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 4, - offset: const Offset(0, -2), - ), - ], - ), - child: TabBar( - controller: _tabController, - tabs: [ - Tab( - icon: _buildTabIconWithBadge( - Icons.message, - unreadCount, + return Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], ), - text: 'Messages', - ), - Tab( - icon: _buildTabIconWithBadge( - Icons.contacts, - newContactsCount, + child: TabBar( + controller: _tabController, + tabs: [ + Tab( + icon: _buildTabIconWithBadge( + Icons.message, + unreadCount, + ), + text: 'Messages', + ), + Tab( + icon: _buildTabIconWithBadge( + Icons.contacts, + newContactsCount, + ), + text: 'Contacts', + ), + const Tab(icon: Icon(Icons.map), text: 'Map'), + ], ), - text: 'Contacts', - ), - const Tab(icon: Icon(Icons.map), text: 'Map'), - ], + ); + }, ), - ); - }, - ), ); } @@ -432,7 +555,9 @@ class _HomeScreenState extends State with SingleTickerProviderStateM final deviceInfo = provider.deviceInfo; final isConnected = deviceInfo.isConnected; - print('🎨 [UI] Building status bar - isConnected: $isConnected, state: ${deviceInfo.connectionState}'); + print( + '🎨 [UI] Building status bar - isConnected: $isConnected, state: ${deviceInfo.connectionState}', + ); return Row( children: [ @@ -443,24 +568,46 @@ class _HomeScreenState extends State with SingleTickerProviderStateM children: [ const Text( 'MeshCore', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), - Text( - isConnected - ? deviceInfo.displayName ?? 'Connected' - : (provider.isReconnecting - ? 'Reconnecting... (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})' - : 'Disconnected'), - style: TextStyle( - fontSize: 14, - color: provider.isReconnecting - ? Colors.orange[600] - : Colors.grey[600], + if (isConnected) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // BLE connection strength indicator + Icon( + Icons.bluetooth_connected, + color: deviceInfo.signalRssi != null + ? _getSignalColor(deviceInfo.signalRssi!) + : Colors.grey, + size: 16, + ), + const SizedBox(width: 8), + // Battery indicator + if (deviceInfo.batteryPercent != null) ...[ + Icon( + _getBatteryIcon(deviceInfo.batteryPercent!), + color: _getBatteryColor(deviceInfo.batteryPercent!), + size: 16, + ), + const SizedBox(width: 4), + Text( + '${deviceInfo.batteryPercent!.round()}%', + style: TextStyle( + fontSize: 14, + color: _getBatteryColor( + deviceInfo.batteryPercent!, + ), + ), + ), + ], + ], + ) + else if (provider.isReconnecting) + Text( + 'Reconnecting... (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})', + style: TextStyle(fontSize: 14, color: Colors.orange[600]), ), - ), ], ), ), @@ -478,13 +625,17 @@ class _HomeScreenState extends State with SingleTickerProviderStateM height: 14, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.black54), + valueColor: AlwaysStoppedAnimation( + Colors.black54, + ), ), ) : const Icon(Icons.bluetooth, size: 18), - label: Text(provider.isReconnecting - ? 'Reconnecting (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})' - : 'Connect'), + label: Text( + provider.isReconnecting + ? 'Reconnecting (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})' + : 'Connect', + ), style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black87, @@ -514,15 +665,27 @@ class _HomeScreenState extends State with SingleTickerProviderStateM Row( mainAxisSize: MainAxisSize.min, children: [ + // Advertise button (broadcast location) + FilledButton( + onPressed: () => _advertiseDevice(context), + style: FilledButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.all(10), + minimumSize: const Size(40, 40), + shape: const CircleBorder(), + ), + child: const Icon(Icons.campaign, size: 20), + ), + const SizedBox(width: 8), // RX/TX indicators with long press to open packet log GestureDetector( onLongPress: () { Navigator.push( context, MaterialPageRoute( - builder: (context) => PacketLogScreen( - bleService: provider.bleService, - ), + builder: (context) => + PacketLogScreen(bleService: provider.bleService), ), ); }, @@ -597,9 +760,8 @@ class _HomeScreenState extends State with SingleTickerProviderStateM Navigator.push( context, MaterialPageRoute( - builder: (context) => PacketLogScreen( - bleService: provider.bleService, - ), + builder: (context) => + PacketLogScreen(bleService: provider.bleService), ), ); }, @@ -610,34 +772,9 @@ class _HomeScreenState extends State with SingleTickerProviderStateM child: const Icon(Icons.settings, size: 20), ), ), - const SizedBox(width: 8), - // Advertise button (broadcast location) - FilledButton( - onPressed: () => _advertiseDevice(context), - style: FilledButton.styleFrom( - backgroundColor: Colors.blue.shade700, - foregroundColor: Colors.white, - padding: const EdgeInsets.all(10), - minimumSize: const Size(40, 40), - shape: const CircleBorder(), - ), - child: const Icon(Icons.campaign, size: 20), - ), - const SizedBox(width: 8), - // Disconnect button (prominent, icon only) - FilledButton( - onPressed: () async { - await provider.disconnect(); - }, - style: FilledButton.styleFrom( - backgroundColor: Colors.red.shade700, - foregroundColor: Colors.white, - padding: const EdgeInsets.all(10), - minimumSize: const Size(40, 40), - shape: const CircleBorder(), - ), - child: const Icon(Icons.power_settings_new, size: 20), - ), + const SizedBox(width: 16), + + // Disconnect button (prominent, icon only) - pushed to far right edge ], ), ], @@ -661,7 +798,9 @@ class _HomeScreenState extends State with SingleTickerProviderStateM children: [ // Connection status Icon( - isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled, + isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth_disabled, color: isConnected ? Colors.green : Colors.grey, ), const SizedBox(width: 8), @@ -764,7 +903,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM Expanded( child: Text( provider.error!, - style: const TextStyle(color: Colors.red, fontSize: 12), + style: const TextStyle( + color: Colors.red, + fontSize: 12, + ), ), ), IconButton( @@ -822,10 +964,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM color: Colors.red, shape: BoxShape.circle, ), - constraints: const BoxConstraints( - minWidth: 18, - minHeight: 18, - ), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), child: Text( count > 99 ? '99+' : count.toString(), style: const TextStyle( diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 1e4bc1f..3bc78be 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -117,7 +117,6 @@ class _MessagesTabState extends State { } } - void _showSarDialog() { showModalBottomSheet( context: context, @@ -125,7 +124,13 @@ class _MessagesTabState extends State { backgroundColor: Colors.transparent, builder: (context) => SarUpdateSheet( onSend: (sarType, position, notes, roomPublicKey, sendToChannel) async { - await _sendSarMessage(sarType, position, notes, roomPublicKey, sendToChannel); + await _sendSarMessage( + sarType, + position, + notes, + roomPublicKey, + sendToChannel, + ); }, ), ); @@ -155,7 +160,8 @@ class _MessagesTabState extends State { try { // Format: S::, - final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}'; + final sarMessage = + 'S:${sarType.emoji}:${position.latitude},${position.longitude}'; // Add notes if provided final fullMessage = notes != null && notes.isNotEmpty @@ -170,7 +176,10 @@ class _MessagesTabState extends State { ); if (!mounted) return; - ToastLogger.warning(context, '${sarType.displayName} marker broadcast to public channel'); + ToastLogger.warning( + context, + '${sarType.displayName} marker broadcast to public channel', + ); } else { // Create message ID final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent'; @@ -202,7 +211,7 @@ class _MessagesTabState extends State { final contactsProvider = context.read(); final roomContact = contactsProvider.contacts.where((c) { return c.publicKey.length >= roomPublicKey!.length && - _publicKeysMatch(c.publicKey, roomPublicKey!); + _publicKeysMatch(c.publicKey, roomPublicKey!); }).firstOrNull; // Send SAR message to selected room (persisted and immutable) @@ -219,7 +228,10 @@ class _MessagesTabState extends State { } if (!mounted) return; - ToastLogger.success(context, '${sarType.displayName} marker sent to room'); + ToastLogger.success( + context, + '${sarType.displayName} marker sent to room', + ); } } catch (e) { if (!mounted) return; @@ -227,7 +239,6 @@ class _MessagesTabState extends State { } } - /// Handle pull-to-refresh for manual message sync /// This is a FALLBACK mechanism - messages are normally synced automatically via PUSH_CODE_MSG_WAITING Future _handleRefresh() async { @@ -242,8 +253,6 @@ class _MessagesTabState extends State { try { print('🔄 [MessagesTab] Manual refresh triggered - syncing messages'); final messageCount = await connectionProvider.syncAllMessages(); - print('✅ [MessagesTab] Synced $messageCount message(s)'); - if (!mounted) return; if (messageCount > 0) { ToastLogger.success(context, 'Synced $messageCount message(s)'); @@ -277,65 +286,73 @@ class _MessagesTabState extends State { onRefresh: _handleRefresh, child: messages.isEmpty ? LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.message_outlined, - size: 64, - color: Theme.of(context).disabledColor, + builder: (context, constraints) => + SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.message_outlined, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + 'No messages yet', + style: Theme.of( + context, + ).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Pull down to sync messages', + style: Theme.of( + context, + ).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], ), - const SizedBox(height: 16), - Text( - 'No messages yet', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Pull down to sync messages', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ], + ), ), ), - ), - ), ) : ListView.builder( - reverse: true, - padding: const EdgeInsets.all(8), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; + reverse: true, + padding: const EdgeInsets.all(8), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; - // Display system messages with minimal styling - if (message.isSystemMessage) { - return _SystemMessageBubble(message: message); - } + // Display system messages with minimal styling + if (message.isSystemMessage) { + return _SystemMessageBubble(message: message); + } - return _MessageBubble( - message: message, - onTap: message.isSarMarker && - message.sarGpsCoordinates != null - ? () { - final mapProvider = - context.read(); - mapProvider.navigateToLocation( - location: message.sarGpsCoordinates!, - zoom: 15.0, - ); - widget.onNavigateToMap(); - } - : null, - ); - }, - ), + return _MessageBubble( + message: message, + onTap: + message.isSarMarker && + message.sarGpsCoordinates != null + ? () { + final mapProvider = context + .read(); + mapProvider.navigateToLocation( + location: message.sarGpsCoordinates!, + zoom: 15.0, + ); + widget.onNavigateToMap(); + } + : null, + ); + }, + ), ), ), @@ -352,68 +369,72 @@ class _MessagesTabState extends State { ), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // SAR quick action button - IconButton( - icon: const Icon(Icons.add_location_alt), - tooltip: 'Send SAR marker', - onPressed: _showSarDialog, - style: IconButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 8), - // Text field with embedded send button - Expanded( - child: TextField( - controller: _textController, - focusNode: _focusNode, - maxLength: _maxCharacters, - maxLines: null, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - hintText: 'Type a message...', - hintStyle: const TextStyle(fontSize: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - isDense: true, - counterText: _characterCount >= 150 - ? '$_characterCount/$_maxCharacters' - : '', - counterStyle: TextStyle( - fontSize: 10, - color: _characterCount > _maxCharacters * 0.9 - ? Colors.orange - : Theme.of(context).textTheme.bodySmall?.color, - ), - suffixIcon: IconButton( - icon: Icon( - Icons.send_rounded, - size: 22, - color: _textController.text.trim().isEmpty - ? Theme.of(context).disabledColor - : Theme.of(context).colorScheme.primary, - ), - onPressed: _textController.text.trim().isEmpty - ? null - : _sendMessage, - tooltip: 'Send', - ), - ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), - ), - ), - ], + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // SAR quick action button + IconButton( + icon: const Icon(Icons.add_location_alt), + tooltip: 'Send SAR marker', + onPressed: _showSarDialog, + style: IconButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + ), ), + const SizedBox(width: 8), + // Text field with embedded send button + Expanded( + child: TextField( + controller: _textController, + focusNode: _focusNode, + maxLength: _maxCharacters, + maxLines: null, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: 'Type a message...', + hintStyle: const TextStyle(fontSize: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + isDense: true, + counterText: _characterCount >= 150 + ? '$_characterCount/$_maxCharacters' + : '', + counterStyle: TextStyle( + fontSize: 10, + color: _characterCount > _maxCharacters * 0.9 + ? Colors.orange + : Theme.of(context).textTheme.bodySmall?.color, + ), + suffixIcon: IconButton( + icon: Icon( + Icons.send_rounded, + size: 22, + color: _textController.text.trim().isEmpty + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.primary, + ), + onPressed: _textController.text.trim().isEmpty + ? null + : _sendMessage, + tooltip: 'Send', + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + ), + ), + ], + ), ), ], ); @@ -426,10 +447,7 @@ class _MessageBubble extends StatelessWidget { final Message message; final VoidCallback? onTap; - const _MessageBubble({ - required this.message, - this.onTap, - }); + const _MessageBubble({required this.message, this.onTap}); /// Helper method to compare two public keys for equality bool _publicKeysMatch(Uint8List key1, Uint8List key2) { @@ -440,7 +458,10 @@ class _MessageBubble extends StatelessWidget { return true; } - Future _retryFailedMessage(BuildContext context, Message failedMessage) async { + Future _retryFailedMessage( + BuildContext context, + Message failedMessage, + ) async { final connectionProvider = context.read(); final messagesProvider = context.read(); @@ -467,15 +488,19 @@ class _MessageBubble extends StatelessWidget { // Direct message retry (for SAR markers sent to rooms) if (failedMessage.recipientPublicKey == null) { messagesProvider.markMessageFailed(retryMessageId); - ToastLogger.error(context, 'Cannot retry: recipient information missing'); + ToastLogger.error( + context, + 'Cannot retry: recipient information missing', + ); return; } // Look up the room contact for path logging final contactsProvider = context.read(); final roomContact = contactsProvider.contacts.where((c) { - return c.publicKey.length >= failedMessage.recipientPublicKey!.length && - _publicKeysMatch(c.publicKey, failedMessage.recipientPublicKey!); + return c.publicKey.length >= + failedMessage.recipientPublicKey!.length && + _publicKeysMatch(c.publicKey, failedMessage.recipientPublicKey!); }).firstOrNull; // Resend to the same room @@ -511,12 +536,14 @@ class _MessageBubble extends StatelessWidget { // Determine if this is own message final connectionProvider = context.read(); final selfPublicKey = connectionProvider.deviceInfo.publicKey; - final isOwnMessage = message.isSentMessage || message.isFromSelf(selfPublicKey); + final isOwnMessage = + message.isSentMessage || message.isFromSelf(selfPublicKey); // Check if we can reply to this message (must be contact message from someone else) - final canReply = message.isContactMessage && - !isOwnMessage && - message.senderPublicKeyPrefix != null; + final canReply = + message.isContactMessage && + !isOwnMessage && + message.senderPublicKeyPrefix != null; showModalBottomSheet( context: context, @@ -552,7 +579,10 @@ class _MessageBubble extends StatelessWidget { // Delete message option ListTile( leading: const Icon(Icons.delete, color: Colors.red), - title: const Text('Delete message', style: TextStyle(color: Colors.red)), + title: const Text( + 'Delete message', + style: TextStyle(color: Colors.red), + ), onTap: () { Navigator.pop(context); _showDeleteConfirmation(context); @@ -575,7 +605,12 @@ class _MessageBubble extends StatelessWidget { // Find contact by public key prefix (first 6 bytes) final senderKeyHex = message.senderPublicKeyPrefix! - .sublist(0, message.senderPublicKeyPrefix!.length < 6 ? message.senderPublicKeyPrefix!.length : 6) + .sublist( + 0, + message.senderPublicKeyPrefix!.length < 6 + ? message.senderPublicKeyPrefix!.length + : 6, + ) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); @@ -633,7 +668,8 @@ class _MessageBubble extends StatelessWidget { // after loading from storage final connectionProvider = context.read(); final selfPublicKey = connectionProvider.deviceInfo.publicKey; - final isOwnMessage = message.isSentMessage || message.isFromSelf(selfPublicKey); + final isOwnMessage = + message.isSentMessage || message.isFromSelf(selfPublicKey); // Debug logging for sent messages if (message.isSentMessage) { @@ -642,9 +678,13 @@ class _MessageBubble extends StatelessWidget { debugPrint(' Delivery Status: ${message.deliveryStatus.name}'); debugPrint(' isSentMessage: ${message.isSentMessage}'); debugPrint(' isOwnMessage: $isOwnMessage'); - debugPrint(' Has recipientPublicKey: ${message.recipientPublicKey != null}'); + debugPrint( + ' Has recipientPublicKey: ${message.recipientPublicKey != null}', + ); if (message.recipientPublicKey != null) { - debugPrint(' Recipient key (first 12 hex): ${message.recipientPublicKey!.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'); + debugPrint( + ' Recipient key (first 12 hex): ${message.recipientPublicKey!.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', + ); } } @@ -654,7 +694,12 @@ class _MessageBubble extends StatelessWidget { if (message.senderPublicKeyPrefix != null && !isOwnMessage) { // Find contact by public key prefix (first 6 bytes) final senderKeyHex = message.senderPublicKeyPrefix! - .sublist(0, message.senderPublicKeyPrefix!.length < 6 ? message.senderPublicKeyPrefix!.length : 6) + .sublist( + 0, + message.senderPublicKeyPrefix!.length < 6 + ? message.senderPublicKeyPrefix!.length + : 6, + ) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); @@ -671,10 +716,17 @@ class _MessageBubble extends StatelessWidget { // For sent direct messages, look up recipient contact dynamic recipientContact; String? recipientDisplayName; - if (isOwnMessage && message.isContactMessage && message.recipientPublicKey != null) { + if (isOwnMessage && + message.isContactMessage && + message.recipientPublicKey != null) { // Find recipient by public key final recipientKeyHex = message.recipientPublicKey! - .sublist(0, message.recipientPublicKey!.length < 6 ? message.recipientPublicKey!.length : 6) + .sublist( + 0, + message.recipientPublicKey!.length < 6 + ? message.recipientPublicKey!.length + : 6, + ) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); @@ -686,8 +738,12 @@ class _MessageBubble extends StatelessWidget { for (final c in contactsProvider.contacts) { debugPrint(' Contact: ${c.displayName ?? c.advName}'); debugPrint(' Key: ${c.publicKeyHex}'); - debugPrint(' First 12 chars: ${c.publicKeyHex.substring(0, c.publicKeyHex.length >= 12 ? 12 : c.publicKeyHex.length)}'); - debugPrint(' Matches: ${c.publicKeyHex.startsWith(recipientKeyHex)}'); + debugPrint( + ' First 12 chars: ${c.publicKeyHex.substring(0, c.publicKeyHex.length >= 12 ? 12 : c.publicKeyHex.length)}', + ); + debugPrint( + ' Matches: ${c.publicKeyHex.startsWith(recipientKeyHex)}', + ); } recipientContact = contactsProvider.contacts.where((c) { @@ -704,7 +760,8 @@ class _MessageBubble extends StatelessWidget { if (roleEmoji != null && roleEmoji.isNotEmpty) { recipientDisplayName = '$roleEmoji ${recipientContact.displayName}'; } else { - recipientDisplayName = recipientContact.displayName ?? recipientContact.advName; + recipientDisplayName = + recipientContact.displayName ?? recipientContact.advName; } debugPrint(' Final recipient name: $recipientDisplayName'); } else { @@ -737,20 +794,24 @@ class _MessageBubble extends StatelessWidget { width: 2, ) : isOwnMessage - ? Border.all( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3), - width: 1.5, - ) - : !message.isRead && !message.isSentMessage && !message.isSystemMessage - ? Border.all( - color: Colors.blue, - width: 1.5, - ) - : null, + ? Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), + width: 1.5, + ) + : !message.isRead && + !message.isSentMessage && + !message.isSystemMessage + ? Border.all(color: Colors.blue, width: 1.5) + : null, boxShadow: isSarMarker ? [ BoxShadow( - color: _getSarMarkerBorderColor(context, isDarkMode).withValues(alpha: 0.3), + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ).withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2), ), @@ -764,7 +825,10 @@ class _MessageBubble extends StatelessWidget { Row( children: [ // Unread indicator badge - if (!message.isRead && !message.isSentMessage && !message.isSystemMessage && !isSarMarker) + if (!message.isRead && + !message.isSentMessage && + !message.isSystemMessage && + !isSarMarker) Container( width: 8, height: 8, @@ -795,7 +859,8 @@ class _MessageBubble extends StatelessWidget { const SizedBox(width: 4), Text( 'SAR ALERT', - style: Theme.of(context).textTheme.labelSmall?.copyWith( + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 0.5, @@ -806,7 +871,11 @@ class _MessageBubble extends StatelessWidget { ) else ...[ if (isOwnMessage) - Icon(Icons.account_circle, size: 16, color: Theme.of(context).colorScheme.primary) + Icon( + Icons.account_circle, + size: 16, + color: Theme.of(context).colorScheme.primary, + ) else if (message.isChannelMessage) const Icon(Icons.tag, size: 16) else @@ -815,25 +884,33 @@ class _MessageBubble extends StatelessWidget { Text( displayName, style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - color: isOwnMessage ? Theme.of(context).colorScheme.primary : null, - ), + fontWeight: FontWeight.bold, + color: isOwnMessage + ? Theme.of(context).colorScheme.primary + : null, + ), ), // Show recipient for sent direct messages - if (isOwnMessage && message.isContactMessage && recipientDisplayName != null) ...[ + if (isOwnMessage && + message.isContactMessage && + recipientDisplayName != null) ...[ const SizedBox(width: 4), Icon( Icons.arrow_forward, size: 14, - color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.6), + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.6), ), const SizedBox(width: 4), Text( recipientDisplayName, style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.7), - fontStyle: FontStyle.italic, - ), + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.7), + fontStyle: FontStyle.italic, + ), ), ], ], @@ -841,8 +918,10 @@ class _MessageBubble extends StatelessWidget { Text( message.timeAgo, style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: isSarMarker ? FontWeight.w600 : FontWeight.normal, - ), + fontWeight: isSarMarker + ? FontWeight.w600 + : FontWeight.normal, + ), ), ], ), @@ -863,16 +942,14 @@ class _MessageBubble extends StatelessWidget { children: [ Text( message.sarMarkerType!.displayName, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), ), if (message.sarGpsCoordinates != null) Text( '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontFamily: 'monospace', - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(fontFamily: 'monospace'), ), ], ), @@ -884,13 +961,25 @@ class _MessageBubble extends StatelessWidget { ), ], ), + // Display SAR notes/message if present + if (message.sarNotes != null && message.sarNotes!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + message.sarNotes!, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], ] // Regular message content else - Text( - message.text, - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(message.text, style: Theme.of(context).textTheme.bodyMedium), // Delivery status for sent messages if (message.isSentMessage) ...[ @@ -907,17 +996,21 @@ class _MessageBubble extends StatelessWidget { Text( message.deliveryStatusText, style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: _getDeliveryStatusColor(message.deliveryStatus), - fontStyle: FontStyle.italic, - ), + color: _getDeliveryStatusColor(message.deliveryStatus), + fontStyle: FontStyle.italic, + ), ), // Show retry button for failed messages - if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[ + if (message.deliveryStatus == + MessageDeliveryStatus.failed) ...[ const SizedBox(width: 6), GestureDetector( onTap: () => _retryFailedMessage(context, message), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: Colors.orange.withOpacity(0.2), borderRadius: BorderRadius.circular(4), @@ -926,11 +1019,16 @@ class _MessageBubble extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.refresh, size: 12, color: Colors.orange), + const Icon( + Icons.refresh, + size: 12, + color: Colors.orange, + ), const SizedBox(width: 4), Text( 'Retry', - style: Theme.of(context).textTheme.labelSmall?.copyWith( + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( color: Colors.orange, fontWeight: FontWeight.bold, ), @@ -979,12 +1077,20 @@ class _MessageBubble extends StatelessWidget { } } - Color _getMessageBubbleColor(BuildContext context, bool isOwnMessage, bool isDarkMode) { + Color _getMessageBubbleColor( + BuildContext context, + bool isOwnMessage, + bool isDarkMode, + ) { if (isOwnMessage) { // Own messages: slightly highlighted with primary color tint return isDarkMode - ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) - : Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.15); + ? Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.3) + : Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.15); } else { // Others' messages: default surface color return Theme.of(context).colorScheme.surfaceVariant; @@ -1000,24 +1106,24 @@ class _MessageBubble extends StatelessWidget { switch (message.sarMarkerType!) { case SarMarkerType.foundPerson: return isDarkMode - ? const Color(0xFF1B5E20).withValues(alpha: 0.4) // Dark green - : const Color(0xFFC8E6C9).withValues(alpha: 0.9); // Light green + ? const Color(0xFF1B5E20).withValues(alpha: 0.4) // Dark green + : const Color(0xFFC8E6C9).withValues(alpha: 0.9); // Light green case SarMarkerType.fire: return isDarkMode - ? const Color(0xFFB71C1C).withValues(alpha: 0.4) // Dark red - : const Color(0xFFFFCDD2).withValues(alpha: 0.9); // Light red + ? const Color(0xFFB71C1C).withValues(alpha: 0.4) // Dark red + : const Color(0xFFFFCDD2).withValues(alpha: 0.9); // Light red case SarMarkerType.stagingArea: return isDarkMode - ? const Color(0xFF0D47A1).withValues(alpha: 0.4) // Dark blue - : const Color(0xFFBBDEFB).withValues(alpha: 0.9); // Light blue + ? const Color(0xFF0D47A1).withValues(alpha: 0.4) // Dark blue + : const Color(0xFFBBDEFB).withValues(alpha: 0.9); // Light blue case SarMarkerType.object: return isDarkMode - ? const Color(0xFF4A148C).withValues(alpha: 0.4) // Dark purple - : const Color(0xFFE1BEE7).withValues(alpha: 0.9); // Light purple + ? const Color(0xFF4A148C).withValues(alpha: 0.4) // Dark purple + : const Color(0xFFE1BEE7).withValues(alpha: 0.9); // Light purple case SarMarkerType.unknown: return isDarkMode - ? const Color(0xFF424242).withValues(alpha: 0.4) // Dark gray - : const Color(0xFFEEEEEE).withValues(alpha: 0.9); // Light gray + ? const Color(0xFF424242).withValues(alpha: 0.4) // Dark gray + : const Color(0xFFEEEEEE).withValues(alpha: 0.9); // Light gray } } @@ -1029,15 +1135,15 @@ class _MessageBubble extends StatelessWidget { // Use vibrant type-specific colors for borders switch (message.sarMarkerType!) { case SarMarkerType.foundPerson: - return const Color(0xFF4CAF50); // Green + return const Color(0xFF4CAF50); // Green case SarMarkerType.fire: - return const Color(0xFFF44336); // Red + return const Color(0xFFF44336); // Red case SarMarkerType.stagingArea: - return const Color(0xFF2196F3); // Blue + return const Color(0xFF2196F3); // Blue case SarMarkerType.object: - return const Color(0xFF9C27B0); // Purple + return const Color(0xFF9C27B0); // Purple case SarMarkerType.unknown: - return const Color(0xFF9E9E9E); // Gray + return const Color(0xFF9E9E9E); // Gray } } @@ -1127,27 +1233,27 @@ class _SystemMessageBubble extends StatelessWidget { ), child: Row( children: [ - Icon( - _getLevelIcon(level), - size: 14, - color: levelColor, - ), + Icon(_getLevelIcon(level), size: 14, color: levelColor), const SizedBox(width: 6), Text( message.timeAgo, style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6), - fontSize: 10, - ), + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + fontSize: 10, + ), ), const SizedBox(width: 8), Expanded( child: Text( message.text, style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.8), - ), + fontSize: 11, + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.8), + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -1157,4 +1263,3 @@ class _SystemMessageBubble extends StatelessWidget { ); } } - diff --git a/lib/services/ble/ble_connection_manager.dart b/lib/services/ble/ble_connection_manager.dart index ed4c602..3a76f95 100644 --- a/lib/services/ble/ble_connection_manager.dart +++ b/lib/services/ble/ble_connection_manager.dart @@ -6,6 +6,7 @@ import '../meshcore_constants.dart'; typedef OnConnectionStateCallback = void Function(bool isConnected); typedef OnErrorCallback = void Function(String error); typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts); +typedef OnRssiUpdateCallback = void Function(int rssi); /// Manages BLE connection lifecycle with automatic reconnection class BleConnectionManager { @@ -21,6 +22,10 @@ class BleConnectionManager { Timer? _reconnectionTimer; StreamSubscription? _connectionStateSubscription; + // RSSI monitoring + Timer? _rssiTimer; + int? _lastRssi; + // SAR-optimized reconnection: ~15 minutes total // Pattern: Fast retries first (for temporary issues), then slower retries (for extended disconnections) static const int _maxReconnectionAttempts = 30; @@ -38,6 +43,7 @@ class BleConnectionManager { OnConnectionStateCallback? onConnectionStateChanged; OnErrorCallback? onError; OnReconnectionAttemptCallback? onReconnectionAttempt; + OnRssiUpdateCallback? onRssiUpdate; // Getters bool get isConnected => _isConnected; @@ -49,7 +55,7 @@ class BleConnectionManager { BluetoothCharacteristic? get txCharacteristic => _txCharacteristic; /// Scan for MeshCore devices - Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* { + Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* { try { print('🔍 [BLE] Starting scan for MeshCore devices...'); print(' Service UUID: ${MeshCoreConstants.bleServiceUuid}'); @@ -73,7 +79,7 @@ class BleConnectionManager { .contains(Guid(MeshCoreConstants.bleServiceUuid))) { deviceCount++; print(' ✅ MeshCore device found! Total: $deviceCount'); - yield result.device; + yield result; } else { print(' ❌ Not a MeshCore device (service UUID mismatch)'); } @@ -170,6 +176,9 @@ class BleConnectionManager { // Monitor connection state for automatic reconnection _setupConnectionMonitoring(); + // Start RSSI monitoring + _startRssiMonitoring(); + print('✅✅✅ [BLE] Connection completed successfully!'); return true; } catch (e) { @@ -189,6 +198,7 @@ class BleConnectionManager { // Disable reconnection before disconnecting _reconnectionEnabled = false; _cancelReconnection(); + _stopRssiMonitoring(); await _device?.disconnect(); _isConnected = false; @@ -317,10 +327,40 @@ class BleConnectionManager { _reconnectionEnabled = true; } + /// Start monitoring RSSI in the background + void _startRssiMonitoring() { + print('📡 [BLE] Starting RSSI monitoring (every 5 seconds)'); + _stopRssiMonitoring(); // Cancel any existing timer + + _rssiTimer = Timer.periodic(const Duration(seconds: 5), (timer) async { + if (_device != null && _isConnected) { + try { + final rssi = await _device!.readRssi(); + if (_lastRssi != rssi) { + _lastRssi = rssi; + print('📡 [BLE] RSSI updated: $rssi dBm'); + onRssiUpdate?.call(rssi); + } + } catch (e) { + print('⚠️ [BLE] Failed to read RSSI: $e'); + } + } + }); + } + + /// Stop RSSI monitoring + void _stopRssiMonitoring() { + _rssiTimer?.cancel(); + _rssiTimer = null; + _lastRssi = null; + print('📡 [BLE] RSSI monitoring stopped'); + } + /// Dispose resources void dispose() { print('🔴 [BLE] Disposing BLE connection manager'); _cancelReconnection(); + _stopRssiMonitoring(); _device = null; _rxCharacteristic = null; _txCharacteristic = null; diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 4f0da3e..4c7e5f9 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -31,6 +31,7 @@ typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, typedef OnErrorCallback = void Function(String error); typedef OnConnectionStateCallback = void Function(bool isConnected); typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts); +typedef OnRssiUpdateCallback = void Function(int rssi); /// MeshCore BLE Service - coordinates BLE communication components class MeshCoreBleService { @@ -42,6 +43,7 @@ class MeshCoreBleService { // Event callbacks OnConnectionStateCallback? onConnectionStateChanged; OnReconnectionAttemptCallback? onReconnectionAttempt; + OnRssiUpdateCallback? onRssiUpdate; OnContactCallback? onContactReceived; OnContactsCompleteCallback? onContactsComplete; OnMessageCallback? onMessageReceived; @@ -83,6 +85,9 @@ class MeshCoreBleService { print('🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts'); onReconnectionAttempt?.call(attemptNumber, maxAttempts); }; + _connectionManager.onRssiUpdate = (rssi) { + onRssiUpdate?.call(rssi); + }; // Command sender callbacks _commandSender.onError = (error) { @@ -167,7 +172,7 @@ class MeshCoreBleService { } /// Scan for MeshCore devices - Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) { + Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) { return _connectionManager.scanForDevices(timeout: timeout); } diff --git a/lib/utils/drawing_message_parser.dart b/lib/utils/drawing_message_parser.dart index ae3a442..981def2 100644 --- a/lib/utils/drawing_message_parser.dart +++ b/lib/utils/drawing_message_parser.dart @@ -12,8 +12,9 @@ class DrawingMessageParser { } /// Parse drawing message text into MapDrawing object + /// senderName should be extracted from packet metadata /// Returns null if parsing fails - static MapDrawing? parseDrawingMessage(String text) { + static MapDrawing? parseDrawingMessage(String text, {String? senderName}) { if (!isDrawingMessage(text)) { return null; } @@ -25,17 +26,18 @@ class DrawingMessageParser { // Parse JSON final json = jsonDecode(jsonStr) as Map; - // Use compact network format parser - return MapDrawing.fromNetworkJson(json); + // Use ultra-compact network format parser + // Sender name comes from packet metadata, not JSON + return MapDrawing.fromNetworkJson(json, senderName: senderName); } catch (e) { return null; } } /// Create drawing message text from MapDrawing object - /// Includes sender name in the message - static String createDrawingMessage(MapDrawing drawing, String senderName) { - final json = drawing.toNetworkJson(senderName); + /// Sender will be determined from packet metadata on receiving end + static String createDrawingMessage(MapDrawing drawing) { + final json = drawing.toNetworkJson(); final jsonStr = jsonEncode(json); return '$prefix$jsonStr'; } diff --git a/lib/utils/sar_message_parser.dart b/lib/utils/sar_message_parser.dart index a44dd99..e88cce1 100644 --- a/lib/utils/sar_message_parser.dart +++ b/lib/utils/sar_message_parser.dart @@ -3,17 +3,17 @@ import '../models/sar_marker.dart'; import '../models/message.dart'; /// Parser for SAR (Search & Rescue) special messages -/// Format: S::, +/// Format: S::,: /// Examples: /// S:🧑:37.7749,-122.4194 -/// S:🔥:40.7128,-74.0060 -/// S:🏕️:34.0522,-118.2437 +/// S:🔥:40.7128,-74.0060:Large wildfire spreading +/// S:🏕️:34.0522,-118.2437:Base camp established class SarMessageParser { - // Updated regex to allow optional notes after coordinates - // Captures: emoji (one or more non-colon chars), latitude, longitude + // Updated regex to capture optional message after coordinates + // Captures: emoji (one or more non-colon chars), latitude, longitude, optional message // Note: Emojis are multi-byte characters, so we use [^:]+ instead of . static final RegExp _sarPattern = RegExp( - r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*)', + r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)', multiLine: false, ); @@ -39,6 +39,7 @@ class SarMessageParser { final emoji = match.group(1)!; final latitude = double.parse(match.group(2)!); final longitude = double.parse(match.group(3)!); + final inlineMessage = match.group(4)?.trim(); // Optional message after colon // Validate coordinates if (latitude < -90 || latitude > 90) return null; @@ -47,14 +48,13 @@ class SarMessageParser { final markerType = SarMarkerType.fromEmoji(emoji); final location = LatLng(latitude, longitude); - // Extract notes if present (everything after coordinates on first line, or subsequent lines) + // Combine inline message with multi-line notes String? notes; - final coordsEnd = match.end; - if (coordsEnd < firstLine.length) { - // Notes on same line after coordinates - notes = firstLine.substring(coordsEnd).trim(); + if (inlineMessage != null && inlineMessage.isNotEmpty) { + notes = inlineMessage; } - // Check for multi-line notes + + // Check for multi-line notes (lines after the first line) final additionalNotes = extractNotes(text); if (additionalNotes != null) { notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes; @@ -80,6 +80,7 @@ class SarMessageParser { isSarMarker: true, sarMarkerType: sarInfo.type, sarGpsCoordinates: sarInfo.location, + sarNotes: sarInfo.notes, // Extract and store notes ); } @@ -91,7 +92,8 @@ class SarMessageParser { }) { final text = 'S:${type.emoji}:${location.latitude},${location.longitude}'; if (notes != null && notes.isNotEmpty) { - return '$text\n$notes'; + // Use colon-separated format for inline message + return '$text:$notes'; } return text; } diff --git a/lib/widgets/map/drawing_toolbar.dart b/lib/widgets/map/drawing_toolbar.dart index 3d118d7..e65654e 100644 --- a/lib/widgets/map/drawing_toolbar.dart +++ b/lib/widgets/map/drawing_toolbar.dart @@ -453,10 +453,8 @@ class DrawingToolbar extends StatelessWidget { for (final drawing in drawings) { try { debugPrint(' Creating message for drawing ${drawing.id}...'); - final message = drawingProvider.createDrawingBroadcastMessage( - drawing, - senderName, - ); + // Sender name is no longer included in JSON - will be extracted from packet metadata + final message = drawingProvider.createDrawingBroadcastMessage(drawing); debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...'); debugPrint(' Sending to channel 0...'); await connectionProvider.sendChannelMessage( @@ -525,10 +523,8 @@ class DrawingToolbar extends StatelessWidget { for (final drawing in drawings) { try { debugPrint(' Creating message for drawing ${drawing.id}...'); - final message = drawingProvider.createDrawingBroadcastMessage( - drawing, - senderName, - ); + // Sender name is no longer included in JSON - will be extracted from packet metadata + final message = drawingProvider.createDrawingBroadcastMessage(drawing); debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...'); debugPrint(' Sending to room ${room.advName}...'); await connectionProvider.sendTextMessage(