diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 039d446..d176704 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,8 @@ "WebFetch(domain:github.com)", "WebFetch(domain:raw.githubusercontent.com)", "Bash(dart run:*)", - "Bash(dart test_sar_debug.dart:*)" + "Bash(dart test_sar_debug.dart:*)", + "Read(//Users/dz0ny/meshcore-sar/MeshCore/**)" ], "deny": [], "ask": [] diff --git a/ADVERT_SYSTEM.md b/ADVERT_SYSTEM.md new file mode 100644 index 0000000..e48ac6f --- /dev/null +++ b/ADVERT_SYSTEM.md @@ -0,0 +1,303 @@ +# 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/CLAUDE.md b/CLAUDE.md index f8f1570..54ae221 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -560,7 +560,68 @@ The companion radio acts as a 'server', responding to requests from the connecte - `0` - ADV_TYPE_NONE (unknown/invalid) - `1` - ADV_TYPE_CHAT (team member, shown on map) - `2` - ADV_TYPE_REPEATER (network repeater node) -- `3` - ADV_TYPE_ROOM (communication channel/room) +- `3` - ADV_TYPE_ROOM (communication room/server - NOT the same as channel index!) + +**IMPORTANT: Channels vs. Rooms**: +- **Channels** (channel index): Numeric identifiers used with `CMD_SEND_CHANNEL_TXT_MSG` for flood-mode broadcasts + - Channel 0 = "Public Channel" (default flood-mode broadcast to all nodes) + - Channel 1+ = Reserved for future use (not currently mapped to room contacts) + - **Channels are ephemeral** - messages broadcast over the air are NOT persisted +- **Rooms** (ADV_TYPE_ROOM): Actual named contacts with public keys that provide persistent message storage + - Rooms appear in the Contacts tab as ContactType.room + - **Rooms provide persistent and immutable storage** - messages are stored even when offline + - To communicate with a room, send direct messages using `CMD_SEND_TXT_MSG` with the room's public key + - Optional: Login to rooms using `CMD_SEND_LOGIN` with password to read stored messages + +**Room Login Protocol Flow (CRITICAL - Follow Exactly)**: + +1. **Client sends login request** (`CMD_SEND_LOGIN`, code 26): + ``` + [0x1A] - Command code (26) + [4 bytes] - Sender timestamp (uint32, current epoch seconds) + [4 bytes] - sync_since timestamp (uint32, epoch seconds - 0 for all messages) + [32 bytes] - Room public key + [N bytes] - Password (max 15 bytes, null-terminated) + ``` + +2. **Room server processes login** (C++ code: `MyMesh::onAnonDataRecv()`): + - Validates password against `_prefs.password` (admin) or `_prefs.guest_password` (read/write) + - Stores `client->extra.room.sync_since = sender_sync_since` (line 324 of MyMesh.cpp) + - Responds with `PAYLOAD_TYPE_RESPONSE` containing login result + - Sets `next_push = futureMillis(PUSH_NOTIFY_DELAY_MILLIS)` to delay first push by 2000ms (line 346) + +3. **Client receives login response**: + - Success: `PUSH_CODE_LOGIN_SUCCESS` (0x85) with permissions, admin flag, tag + - Failure: `PUSH_CODE_LOGIN_FAIL` (0x86) if password incorrect + +4. **Room server automatically pushes messages** (C++ code: `MyMesh::loop()` lines 498-542): + - Server runs round-robin polling every `SYNC_PUSH_INTERVAL` (1200ms) + - For each logged-in client, checks if `post_timestamp > client->extra.room.sync_since` + - Calls `pushPostToClient()` which sends `PAYLOAD_TYPE_TXT_MSG` directly to client + - Waits for ACK, then advances `client->extra.room.sync_since` to next post + - Continues until all messages where `post_timestamp > sync_since` are pushed + +5. **Client receives pushed messages as they arrive**: + - Each push triggers `PUSH_CODE_MSG_WAITING` (0x83) + - App's `onMessageWaiting` callback fires automatically + - App then calls `CMD_SYNC_NEXT_MESSAGE` (10) to fetch each message from device queue + - Repeats until `RESP_CODE_NO_MORE_MESSAGES` (10) received + +**CRITICAL IMPLEMENTATION RULES**: +- ❌ **DO NOT** call `syncAllMessages()` immediately after `PUSH_CODE_LOGIN_SUCCESS` +- ✅ **DO** wait for `PUSH_CODE_MSG_WAITING` push notifications +- ✅ **DO** call `syncNextMessage()` when `onMessageWaiting` callback fires +- The room server pushes messages **automatically** - the app only needs to listen and fetch when notified +- Server delays first push by 2000ms to allow login response to arrive first +- Server uses round-robin with 1200ms intervals between push attempts +- Each pushed message requires ACK before server advances to next message + +**SAR Message Routing**: +- **SAR markers MUST be sent to rooms, NOT to public channel** +- Use `CMD_SEND_TXT_MSG` with the room's public key (direct message to room) +- This ensures SAR markers are **persisted and immutable** in the room's storage +- Public channel (`CMD_SEND_CHANNEL_TXT_MSG`) is ephemeral over-the-air only +- Rooms provide reliable message delivery and storage for critical SAR data **TXT_TYPE (Text Message Type)**: - `0` - TXT_TYPE_PLAIN (plain text message) diff --git a/CLOCK_DRIFT_DETECTION.md b/CLOCK_DRIFT_DETECTION.md new file mode 100644 index 0000000..af1d3de --- /dev/null +++ b/CLOCK_DRIFT_DETECTION.md @@ -0,0 +1,276 @@ +# 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 new file mode 100644 index 0000000..ea35090 --- /dev/null +++ b/DEBUG_CONTACTS.md @@ -0,0 +1,238 @@ +# 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 new file mode 100644 index 0000000..f68eb04 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,215 @@ +# 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/ROOM_LOGIN_FIX.md b/ROOM_LOGIN_FIX.md new file mode 100644 index 0000000..8a4e231 --- /dev/null +++ b/ROOM_LOGIN_FIX.md @@ -0,0 +1,268 @@ +# Fixing "Not Found" Error When Logging Into Room + +## Your Current Situation + +You're seeing this sequence: +``` +✅ Room "Repetitor" found in app contacts +📤 Sending SEND_LOGIN command +❌ Companion radio responds: ERR_CODE_NOT_FOUND (2) +``` + +## Root Cause + +Your **Flutter app** and the **companion radio firmware** maintain **separate contact lists**: + +``` +┌─────────────────────────┐ ┌──────────────────────────┐ +│ Flutter App │ │ Companion Radio │ +│ (ContactsProvider) │ │ (Firmware Storage) │ +├─────────────────────────┤ ├──────────────────────────┤ +│ │ │ │ +│ ✅ Repetitor │ │ ❌ Repetitor │ +│ 15:59:89:54:b4:d4 │ BLE │ (NOT FOUND!) │ +│ │ <───> │ │ +│ Other contacts... │ │ Other contacts... │ +│ │ │ │ +└─────────────────────────┘ └──────────────────────────┘ +``` + +When you call `getContacts()`: +- The companion radio sends you a **snapshot** of its contact table +- Your app stores these contacts locally +- But if the radio's contact table changes, your app doesn't know + +**The problem:** The room "Repetitor" exists in your app (from an old sync), but NOT in the radio's firmware anymore. + +## Why This Happens + +1. **Companion radio was factory reset** - Erased all contacts +2. **Contact was manually removed** - Via serial console or config tool +3. **Room never advertised** - Contact was temporary, never saved persistently +4. **Firmware bug** - Contact wasn't properly persisted to flash storage + +## Solutions + +### Solution 1: Force Re-Sync Contacts (Quick Test) + +This will clear your app's contacts and re-fetch from the radio: + +```dart +// In your app, add a button to force full sync: +await contactsProvider.clearContacts(); +await connectionProvider.getContacts(); +await Future.delayed(Duration(milliseconds: 1000)); + +// Now check what rooms exist: +final rooms = contactsProvider.rooms; +print('Rooms on device: ${rooms.length}'); +for (final room in rooms) { + print(' - ${room.advName}'); +} +``` + +If "Repetitor" is NOT in the list after this sync, then the radio truly doesn't have it. + +### Solution 2: Wait for Room to Advertise (Automatic) + +If the room server is running and broadcasting: + +1. The companion radio will receive the advertisement over LoRa +2. If `manual_add_contacts=0`, you'll automatically receive `PUSH_CODE_NEW_ADVERT` (0x8A) +3. The room will be added to both the radio AND your app +4. Then you can login + +**Expected flow:** +``` +Room broadcasts → Companion receives → PUSH_CODE_NEW_ADVERT → Contact added → Login works +``` + +### Solution 3: Manually Add Room Contact (CMD_ADD_UPDATE_CONTACT) + +This requires implementing `CMD_ADD_UPDATE_CONTACT` (command code 9) in your app. + +**Add this to `MeshCoreBleService`:** + +```dart +/// Manually add or update a contact on the companion radio +/// +/// This is useful when you need to add a room that hasn't advertised yet, +/// or restore a contact that was deleted from the radio's table. +/// +/// Protocol format (CMD_ADD_UPDATE_CONTACT): +/// - 1 byte: command code (9) +/// - 32 bytes: public key +/// - 1 byte: type (ADV_TYPE_*) +/// - 1 byte: flags +/// - 1 byte: out path length (signed) +/// - 64 bytes: out path +/// - 32 bytes: advertised name (null-terminated) +/// - 4 bytes: last advert timestamp (uint32) +/// - 4 bytes: (optional) advert latitude * 1E6 (int32) +/// - 4 bytes: (optional) advert longitude * 1E6 (int32) +Future addOrUpdateContact(Contact contact) async { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09 + writer.writeBytes(contact.publicKey); // 32 bytes + writer.writeByte(contact.type.value); // ADV_TYPE_* + writer.writeByte(contact.flags); // flags + writer.writeInt8(contact.outPathLen); // path length (signed byte) + writer.writeBytes(contact.outPath); // 64 bytes + + // Write name as null-terminated string in 32-byte field + final nameBytes = Uint8List(32); + final encoded = utf8.encode(contact.advName); + final copyLen = encoded.length > 31 ? 31 : encoded.length; + nameBytes.setRange(0, copyLen, encoded); + writer.writeBytes(nameBytes); + + writer.writeUInt32LE(contact.lastAdvert); // timestamp + writer.writeInt32LE(contact.advLat); // latitude * 1E6 + writer.writeInt32LE(contact.advLon); // longitude * 1E6 + + await _writeData(writer.toBytes()); + + print('✅ [BLE] Sent CMD_ADD_UPDATE_CONTACT for ${contact.advName}'); + print(' This adds the contact to the radio\'s internal table'); +} +``` + +Add the constant: +```dart +// In meshcore_constants.dart +static const int cmdAddUpdateContact = 9; +``` + +Then in your app, before login: +```dart +// Add the room contact to the radio's table +await connectionProvider.bleService.addOrUpdateContact(widget.contact); + +// Small delay to allow radio to save +await Future.delayed(Duration(milliseconds: 300)); + +// Now login should work +await connectionProvider.loginToRoom(...); +``` + +### Solution 4: Import Room Contact Card + +If you have the room's "business card" (from `CMD_EXPORT_CONTACT`): + +1. Get the card data (usually starts with `meshcore://`) +2. Implement `CMD_IMPORT_CONTACT` (command code 18) +3. Import the card + +```dart +Future importContact(String cardData) async { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdImportContact); // 0x12 + writer.writeString(cardData); // meshcore:// card data + await _writeData(writer.toBytes()); +} +``` + +## Recommended Approach + +**Step 1:** Force re-sync to see current state +```dart +await contactsProvider.clearContacts(); +await connectionProvider.getContacts(); +``` + +**Step 2:** Check if room exists +```dart +final roomExists = contactsProvider.rooms.any( + (r) => r.publicKeyPrefix.matches(targetPrefix) +); +``` + +**Step 3a:** If room doesn't exist → **Implement CMD_ADD_UPDATE_CONTACT** (Solution 3) + +**Step 3b:** Or wait for room to advertise (Solution 2) + +## Implementation Priority + +### Immediate Fix (Easiest) +1. ✅ Add contact sync verification (already done!) +2. ✅ Show helpful error messages (already done!) + +### Short Term (Recommended) +3. 🔧 **Implement `CMD_ADD_UPDATE_CONTACT`** - This lets you manually restore contacts +4. 🔧 **Implement `CMD_EXPORT_CONTACT`** - This lets you backup/share room contacts + +### Long Term (Optional) +5. 📱 Add UI to manually add rooms by public key +6. 💾 Cache room contacts in SharedPreferences +7. 🔄 Auto-restore cached rooms on connect + +## Testing Your Fix + +1. **Clear app contacts:** + ```dart + await contactsProvider.clearContacts(); + ``` + +2. **Force fresh sync:** + ```dart + await connectionProvider.getContacts(); + await Future.delayed(Duration(seconds: 1)); + ``` + +3. **List actual rooms on device:** + ```dart + final rooms = contactsProvider.rooms; + print('═══════════════════════════════'); + print('Rooms on companion radio: ${rooms.length}'); + for (final room in rooms) { + print('📍 ${room.advName}'); + print(' PK: ${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + print(' Type: ${room.type}'); + } + print('═══════════════════════════════'); + ``` + +4. **If "Repetitor" is NOT in the list:** + - The radio truly doesn't have it + - You need to add it using CMD_ADD_UPDATE_CONTACT + - Or wait for it to advertise + +## Expected Logs After Fix + +**Before (Current - Broken):** +``` +🔍 Checking room "Repetitor"... + App contacts: ✅ Found + Radio contacts: ❌ Not found +📤 Sending LOGIN +❌ ERROR: Not found +``` + +**After (Fixed - Option 1: Re-sync):** +``` +🔍 Checking room "Repetitor"... +📤 Clearing app contacts +📤 Syncing from radio +📥 Got 5 contacts + Room "Repetitor": ❌ NOT on radio +⚠️ Room needs to be added to radio first +``` + +**After (Fixed - Option 2: Manual add):** +``` +🔍 Checking room "Repetitor"... + App contacts: ✅ Found + Radio contacts: ❌ Not found +📤 Sending CMD_ADD_UPDATE_CONTACT +✅ Contact added to radio +📤 Sending LOGIN +✅ LOGIN_SUCCESS +``` + +## Next Steps + +1. Try Solution 1 (force re-sync) to confirm the issue +2. Implement Solution 3 (CMD_ADD_UPDATE_CONTACT) for permanent fix +3. Test by adding the room contact manually before login + +Would you like me to implement `CMD_ADD_UPDATE_CONTACT` for you? diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 7b890fa..5133681 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -8,7 +8,8 @@ enum ContactType { none(0), chat(1), repeater(2), - room(3); + room(3), + channel(99); // Virtual type for public channel (not from protocol) const ContactType(this.value); final int value; @@ -28,6 +29,8 @@ enum ContactType { return 'Repeater'; case ContactType.room: return 'Room'; + case ContactType.channel: + return 'Channel'; default: return 'Unknown'; } @@ -75,6 +78,12 @@ class Contact { return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); } + /// Get public key prefix (first 6 bytes) for room login matching + Uint8List get publicKeyPrefix { + if (publicKey.length < 6) return publicKey; + return publicKey.sublist(0, 6); + } + /// Convert advLat/advLon to LatLng LatLng? get advertLocation { if (advLat == 0 && advLon == 0) return null; @@ -103,9 +112,12 @@ class Contact { /// Check if contact is a repeater bool get isRepeater => type == ContactType.repeater; - /// Check if contact is a room/channel + /// Check if contact is a room (persistent storage) bool get isRoom => type == ContactType.room; + /// Check if contact is a channel (ephemeral broadcast) + bool get isChannel => type == ContactType.channel; + /// Get last seen time DateTime get lastSeenTime { return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000); diff --git a/lib/models/room_login_state.dart b/lib/models/room_login_state.dart new file mode 100644 index 0000000..0abd52e --- /dev/null +++ b/lib/models/room_login_state.dart @@ -0,0 +1,111 @@ +import 'dart:typed_data'; + +/// Represents the login state for a room +class RoomLoginState { + final Uint8List publicKeyPrefix; + final bool isLoggedIn; + final bool isAdmin; + final int permissions; + final int? tag; + final DateTime? loginTime; + final bool hasPassword; // Whether we have a saved password + + const RoomLoginState({ + required this.publicKeyPrefix, + this.isLoggedIn = false, + this.isAdmin = false, + this.permissions = 0, + this.tag, + this.loginTime, + this.hasPassword = false, + }); + + /// Create a logged-in state + factory RoomLoginState.loggedIn({ + required Uint8List publicKeyPrefix, + required int permissions, + required bool isAdmin, + required int tag, + required bool hasPassword, + }) { + return RoomLoginState( + publicKeyPrefix: publicKeyPrefix, + isLoggedIn: true, + isAdmin: isAdmin, + permissions: permissions, + tag: tag, + loginTime: DateTime.now(), + hasPassword: hasPassword, + ); + } + + /// Create a logged-out state + factory RoomLoginState.loggedOut({ + required Uint8List publicKeyPrefix, + bool hasPassword = false, + }) { + return RoomLoginState( + publicKeyPrefix: publicKeyPrefix, + isLoggedIn: false, + hasPassword: hasPassword, + ); + } + + /// Copy with modified fields + RoomLoginState copyWith({ + Uint8List? publicKeyPrefix, + bool? isLoggedIn, + bool? isAdmin, + int? permissions, + int? tag, + DateTime? loginTime, + bool? hasPassword, + }) { + return RoomLoginState( + publicKeyPrefix: publicKeyPrefix ?? this.publicKeyPrefix, + isLoggedIn: isLoggedIn ?? this.isLoggedIn, + isAdmin: isAdmin ?? this.isAdmin, + permissions: permissions ?? this.permissions, + tag: tag ?? this.tag, + loginTime: loginTime ?? this.loginTime, + hasPassword: hasPassword ?? this.hasPassword, + ); + } + + /// Get formatted public key prefix (e.g., "15:59:89:54:b4:d4") + String get publicKeyPrefixHex { + return publicKeyPrefix + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); + } + + /// Get login duration if logged in + Duration? get loginDuration { + if (!isLoggedIn || loginTime == null) return null; + return DateTime.now().difference(loginTime!); + } + + /// Get formatted login duration (e.g., "2h 15m ago") + String? get loginDurationFormatted { + final duration = loginDuration; + if (duration == null) return null; + + if (duration.inMinutes < 1) { + return 'just now'; + } else if (duration.inMinutes < 60) { + return '${duration.inMinutes}m ago'; + } else if (duration.inHours < 24) { + final hours = duration.inHours; + final minutes = duration.inMinutes % 60; + return minutes > 0 ? '${hours}h ${minutes}m ago' : '${hours}h ago'; + } else { + final days = duration.inDays; + return '${days}d ago'; + } + } + + @override + String toString() { + return 'RoomLoginState(prefix: $publicKeyPrefixHex, loggedIn: $isLoggedIn, admin: $isAdmin, hasPassword: $hasPassword)'; + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 3948138..a4ecbcd 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1,8 +1,11 @@ +import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'connection_provider.dart'; import 'contacts_provider.dart'; import 'messages_provider.dart'; import '../services/tile_cache_service.dart'; +import '../models/contact.dart'; /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { @@ -80,6 +83,12 @@ class AppProvider with ChangeNotifier { // Load contacts await connectionProvider.getContacts(); + // Small delay to ensure contacts are fully loaded + await Future.delayed(const Duration(milliseconds: 500)); + + // Automatically login to all saved rooms + await _autoLoginToRooms(); + // Sync any waiting messages from device queue await _syncMessages(); @@ -89,6 +98,103 @@ class AppProvider with ChangeNotifier { } } + /// Automatically login to all rooms with saved passwords on cold connect + Future _autoLoginToRooms() async { + if (!connectionProvider.deviceInfo.isConnected) return; + + try { + // Get all room contacts (excluding Public Channel) + final rooms = contactsProvider.rooms + .where((room) => room.advName != 'Public Channel') + .toList(); + + if (rooms.isEmpty) { + debugPrint('📂 [AppProvider] No rooms found to auto-login'); + return; + } + + debugPrint('📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...'); + + final prefs = await SharedPreferences.getInstance(); + + for (final room in rooms) { + try { + // Load saved password for this room + final roomKey = 'room_password_${room.publicKeyHex}'; + final savedPassword = prefs.getString(roomKey) ?? 'hello'; + + debugPrint('🔑 [AppProvider] Auto-logging into room: ${room.advName}'); + + // Set up one-time callbacks for this room login + await _loginToRoomWithCallback(room, savedPassword); + + // Small delay between logins to avoid overwhelming the device + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + debugPrint('❌ [AppProvider] Failed to auto-login to ${room.advName}: $e'); + } + } + } catch (e) { + debugPrint('❌ [AppProvider] Auto-login error: $e'); + } + } + + /// Login to a specific room with callback handling + Future _loginToRoomWithCallback(Contact room, String password) async { + // Create a completer to wait for login result + final completer = Completer(); + + // Store original callbacks + final originalOnSuccess = connectionProvider.onLoginSuccess; + final originalOnFail = connectionProvider.onLoginFail; + + // Set up temporary callbacks + connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { + // Restore original callbacks + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + debugPrint('✅ [AppProvider] Auto-login successful for ${room.advName}'); + debugPrint('📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING'); + + completer.complete(true); + }; + + connectionProvider.onLoginFail = (publicKeyPrefix) { + // Restore original callbacks + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + debugPrint('❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)'); + completer.complete(false); + }; + + try { + // Send login request + await connectionProvider.loginToRoom( + roomPublicKey: room.publicKey, + password: password, + ); + + // Wait for login result with timeout + await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + // Restore callbacks on timeout + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + debugPrint('⏱️ [AppProvider] Auto-login timeout for ${room.advName}'); + return false; + }, + ); + } catch (e) { + // Restore callbacks on error + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + debugPrint('❌ [AppProvider] Error during auto-login to ${room.advName}: $e'); + } + } + /// Sync messages from device queue Future _syncMessages() async { if (!connectionProvider.deviceInfo.isConnected) return; diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index a435732..6999cb5 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../models/device_info.dart'; import '../models/contact.dart'; import '../models/message.dart'; +import '../models/room_login_state.dart'; import '../services/meshcore_ble_service.dart'; import '../services/cayenne_lpp_parser.dart'; import '../utils/sar_message_parser.dart'; @@ -44,6 +46,10 @@ class ConnectionProvider with ChangeNotifier { // Message sync state bool _noMoreMessages = false; + // Room login state tracking + final Map _roomLoginStates = {}; + Map get roomLoginStates => Map.unmodifiable(_roomLoginStates); + // Callbacks for other providers Function(Contact)? onContactReceived; Function(List)? onContactsComplete; @@ -120,19 +126,49 @@ class ConnectionProvider with ChangeNotifier { syncAllMessages(); }; - _bleService.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) { + _bleService.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { print('📥 [Provider] Login successful to room'); print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); print(' Permissions: $permissions, Admin: $isAdmin, Tag: $tag'); + + // Update room login state + final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + final hasPassword = await _hasPasswordForRoom(publicKeyPrefix); + _roomLoginStates[prefixHex] = RoomLoginState.loggedIn( + publicKeyPrefix: publicKeyPrefix, + permissions: permissions, + isAdmin: isAdmin, + tag: tag, + hasPassword: hasPassword, + ); + notifyListeners(); + onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag); }; _bleService.onLoginFail = (publicKeyPrefix) { print('📥 [Provider] Login failed to room'); print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + + // Update room login state to logged out + final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + _roomLoginStates[prefixHex] = RoomLoginState.loggedOut( + publicKeyPrefix: publicKeyPrefix, + hasPassword: false, // Password was incorrect + ); + notifyListeners(); + onLoginFail?.call(publicKeyPrefix); }; + _bleService.onAdvertReceived = (publicKey) { + print('📥 [Provider] Advert received from node'); + print(' Public key: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...'); + print(' Note: Waiting for PUSH_CODE_NEW_ADVERT (0x8A) with full contact details'); + // The companion radio will automatically send PUSH_CODE_NEW_ADVERT if manual_add_contacts=0 + // which will trigger onContactReceived callback and add/update the contact + }; + _bleService.onDeviceInfoReceived = (deviceInfo) { print('📥 [Provider] Received DeviceInfo:'); print(' Firmware Version: ${deviceInfo['firmwareVersion']}'); @@ -285,6 +321,7 @@ class ConnectionProvider with ChangeNotifier { _deviceInfo = DeviceInfo( connectionState: ConnectionState.disconnected, ); + clearRoomLoginStates(); // Clear login states on disconnect notifyListeners(); } @@ -304,6 +341,25 @@ class ConnectionProvider with ChangeNotifier { } } + /// Add or update a contact on the companion radio + /// + /// This manually adds a contact to the radio's internal contact table. + /// Useful when a room contact was deleted or never advertised yet. + Future addOrUpdateContact(Contact contact) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _bleService.addOrUpdateContact(contact); + } catch (e) { + _error = 'Failed to add/update contact: $e'; + notifyListeners(); + } + } + /// Send text message to contact Future sendTextMessage({ required Uint8List contactPublicKey, @@ -365,6 +421,22 @@ class ConnectionProvider with ChangeNotifier { } } + /// 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(); + } + } + /// Set device time to current time Future syncDeviceTime() async { if (!_bleService.isConnected) return; @@ -554,29 +626,37 @@ class ConnectionProvider with ChangeNotifier { _noMoreMessages = false; // Reset flag try { - print('🔄 [Provider] Starting message sync...'); + print('🔄 [Provider] Starting message sync loop...'); + print(' Initial _noMoreMessages state: $_noMoreMessages'); + // Keep syncing until we get NoMoreMessages response // The device will send ContactMsgRecv or ChannelMsgRecv responses // until it sends NoMoreMessages for (int i = 0; i < 100; i++) { // Safety limit if (_noMoreMessages) { - print('✅ [Provider] Message sync complete - NoMoreMessages received after $count requests'); + print('✅ [Provider] Message sync complete - NoMoreMessages flag set after $count requests'); break; } + print('📤 [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE'); + await _bleService.syncNextMessage(); count++; // Small delay to allow response to be processed - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 150)); + + print(' After iteration ${i + 1}: _noMoreMessages=$_noMoreMessages'); } if (!_noMoreMessages && count >= 100) { - print('⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests'); + print('⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages'); } + print('🏁 [Provider] Message sync finished: sent $count sync requests, _noMoreMessages=$_noMoreMessages'); return count; } catch (e) { + print('❌ [Provider] Failed to sync messages: $e'); _error = 'Failed to sync messages: $e'; notifyListeners(); return count; @@ -628,6 +708,38 @@ class ConnectionProvider with ChangeNotifier { notifyListeners(); } + /// Check if a password exists for a room (by public key prefix) + Future _hasPasswordForRoom(Uint8List publicKeyPrefix) async { + try { + final prefs = await SharedPreferences.getInstance(); + // Convert prefix to hex string for storage key + final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + final roomKey = 'room_password_$prefixHex'; + return prefs.getString(roomKey) != null; + } catch (e) { + debugPrint('Error checking password for room: $e'); + return false; + } + } + + /// Get login state for a room by public key prefix + RoomLoginState? getRoomLoginState(Uint8List publicKeyPrefix) { + final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + return _roomLoginStates[prefixHex]; + } + + /// Check if logged into a specific room + bool isLoggedIntoRoom(Uint8List publicKeyPrefix) { + final state = getRoomLoginState(publicKeyPrefix); + return state?.isLoggedIn ?? false; + } + + /// Clear all room login states (call on disconnect) + void clearRoomLoginStates() { + _roomLoginStates.clear(); + notifyListeners(); + } + @override void dispose() { _rxActivityTimer?.cancel(); diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 269960a..057d2dd 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -18,10 +18,10 @@ class ContactsProvider with ChangeNotifier { void _ensurePublicChannelExists() { const publicChannelKey = 'public_channel_0'; if (!_contacts.containsKey(publicChannelKey)) { - // Create a pseudo-contact for the public channel + // Create a pseudo-contact for the public channel (ephemeral broadcast) _contacts[publicChannelKey] = Contact( publicKey: Uint8List.fromList(List.filled(32, 0)), // Zero key for public - type: ContactType.room, + type: ContactType.channel, // Channel type (not room!) flags: 0, outPathLen: 0, outPath: Uint8List(64), @@ -42,10 +42,19 @@ class ContactsProvider with ChangeNotifier { List get repeaters => contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen); - List get rooms { - // Always ensure public channel exists when getting rooms + List get rooms => + contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen); + + List get channels { + // Always ensure public channel exists when getting channels _ensurePublicChannelExists(); - return contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen); + return contacts.where((c) => c.isChannel).toList()..sort(_sortByLastSeen); + } + + /// Get both rooms and channels (destinations for SAR markers) + List get roomsAndChannels { + _ensurePublicChannelExists(); + return contacts.where((c) => c.isRoom || c.isChannel).toList()..sort(_sortByLastSeen); } /// Get contacts with location (for map display) diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index a906e64..62624bc 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -1,10 +1,13 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../providers/contacts_provider.dart'; import '../providers/connection_provider.dart'; import '../providers/app_provider.dart'; import '../models/contact.dart'; +import '../models/room_login_state.dart'; class ContactsTab extends StatefulWidget { const ContactsTab({super.key}); @@ -151,40 +154,132 @@ class _ContactTile extends StatelessWidget { final battery = contact.displayBattery; final location = contact.displayLocation; + // Get room login state if this is a room + final connectionProvider = context.watch(); + final roomLoginState = contact.type == ContactType.room + ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) + : null; + return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( - leading: CircleAvatar( - backgroundColor: _getTypeColor(contact.type, context), - child: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 24), - ) - : Icon( - _getTypeIcon(contact.type), - color: Colors.white, - ), - ), - title: Row( + leading: Stack( children: [ - Expanded( - child: Text( - contact.displayName, - style: const TextStyle(fontWeight: FontWeight.bold), - ), + CircleAvatar( + backgroundColor: _getTypeColor(contact.type, context), + child: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 24), + ) + : Icon( + _getTypeIcon(contact.type), + color: Colors.white, + ), ), - // Battery indicator - if (battery != null) ...[ - Icon( - _getBatteryIcon(battery), - size: 16, - color: _getBatteryColor(battery), + // Room login status indicator badge + if (contact.type == ContactType.room && roomLoginState != null) + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: _getRoomStatusColor(roomLoginState), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: Icon( + _getRoomStatusIcon(roomLoginState), + size: 12, + color: Colors.white, + ), + ), ), - const SizedBox(width: 4), - Text( - '${battery.round()}%', - style: Theme.of(context).textTheme.labelSmall, + ], + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Room name with battery indicator + Row( + children: [ + Expanded( + child: Text( + contact.displayName, + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + // Battery indicator + if (battery != null) ...[ + Icon( + _getBatteryIcon(battery), + size: 16, + color: _getBatteryColor(battery), + ), + const SizedBox(width: 4), + Text( + '${battery.round()}%', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ], + ), + // Room login status badges on second line + if (roomLoginState != null && roomLoginState.isLoggedIn) ...[ + const SizedBox(height: 4), + Row( + children: [ + if (roomLoginState.isAdmin) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.red, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red), + const SizedBox(width: 2), + Text( + 'Admin', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.red, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + ), + if (roomLoginState.isAdmin) const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.green, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle, size: 10, color: Colors.green), + const SizedBox(width: 2), + Text( + 'Logged In', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.green, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + ), + ], ), ], ], @@ -259,13 +354,6 @@ class _ContactTile extends StatelessWidget { onPressed: () => _showDirectMessageDialog(context, contact), tooltip: 'Send direct message', ), - // Login button for rooms (except public channel) - if (contact.type == ContactType.room && contact.advName != 'Public Channel') - IconButton( - icon: const Icon(Icons.login, size: 20), - onPressed: () => _showRoomLoginDialog(context, contact), - tooltip: 'Login to room', - ), // Telemetry refresh button IconButton( icon: const Icon(Icons.refresh, size: 20), @@ -317,6 +405,12 @@ class _ContactTile extends StatelessWidget { } void _showContactDetails(BuildContext context, Contact contact) { + // Get room login state + final connectionProvider = context.read(); + final roomLoginState = contact.type == ContactType.room + ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) + : null; + showModalBottomSheet( context: context, isScrollControlled: true, @@ -385,6 +479,41 @@ class _ContactTile extends StatelessWidget { _DetailRow('Public Key', contact.publicKeyShort), _DetailRow('Last Seen', contact.timeSinceLastSeen), const SizedBox(height: 16), + // Room Login Status + if (roomLoginState != null) ...[ + const Text( + 'Room Status:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + _DetailRow( + 'Login Status', + roomLoginState.isLoggedIn ? 'Logged In' : 'Not Logged In', + ), + if (roomLoginState.isLoggedIn) ...[ + _DetailRow( + 'Admin Access', + roomLoginState.isAdmin ? 'Yes' : 'No', + ), + _DetailRow( + 'Permissions', + roomLoginState.permissions.toString(), + ), + if (roomLoginState.loginDurationFormatted != null) + _DetailRow( + 'Logged In', + roomLoginState.loginDurationFormatted!, + ), + ], + _DetailRow( + 'Password Saved', + roomLoginState.hasPassword ? 'Yes' : 'No', + ), + const SizedBox(height: 16), + ], if (contact.displayLocation != null) ...[ const Text( 'Location:', @@ -431,6 +560,26 @@ class _ContactTile extends StatelessWidget { '${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})', ), ], + // Room Login button for room contacts (except Public Channel) + if (contact.type == ContactType.room && contact.advName != 'Public Channel') ...[ + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); // Close details first + _showRoomLoginDialog(context, contact); + }, + icon: const Icon(Icons.login), + label: Text(roomLoginState?.isLoggedIn == true ? 'Re-Login to Room' : 'Login to Room'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _getTypeColor(contact.type, context), + foregroundColor: Colors.white, + ), + ), + ), + ], ], ), ), @@ -500,6 +649,28 @@ class _ContactTile extends StatelessWidget { return Colors.red; } + /// Get room login status color + Color _getRoomStatusColor(RoomLoginState state) { + if (!state.isLoggedIn) { + return Colors.grey; // Grey for not logged in + } + if (state.isAdmin) { + return Colors.red; // Red for admin + } + return Colors.green; // Green for logged in (non-admin) + } + + /// Get room login status icon + IconData _getRoomStatusIcon(RoomLoginState state) { + if (!state.isLoggedIn) { + return Icons.lock; // Lock for not logged in + } + if (state.isAdmin) { + return Icons.admin_panel_settings; // Admin icon for admin + } + return Icons.check; // Check for logged in (non-admin) + } + String _formatTimestamp(DateTime timestamp) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -781,6 +952,12 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> { bool _isLoggingIn = false; bool _obscurePassword = true; + @override + void initState() { + super.initState(); + _loadSavedPassword(); + } + @override void dispose() { _passwordController.dispose(); @@ -788,19 +965,28 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> { super.dispose(); } + /// Load saved password for this room, or use default "hello" + Future _loadSavedPassword() async { + final prefs = await SharedPreferences.getInstance(); + final roomKey = 'room_password_${widget.contact.publicKeyHex}'; + final savedPassword = prefs.getString(roomKey) ?? 'hello'; + _passwordController.text = savedPassword; + } + + /// Save password for this room + Future _savePassword(String password) async { + final prefs = await SharedPreferences.getInstance(); + final roomKey = 'room_password_${widget.contact.publicKeyHex}'; + await prefs.setString(roomKey, password); + } + Future _loginToRoom() async { - final password = _passwordController.text.trim(); - if (password.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please enter a password'), - backgroundColor: Colors.orange, - ), - ); - return; - } + final password = _passwordController.text.trim().isEmpty + ? 'hello' + : _passwordController.text.trim(); final connectionProvider = context.read(); + final contactsProvider = context.read(); if (!connectionProvider.deviceInfo.isConnected) { if (!mounted) return; @@ -817,6 +1003,163 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> { _isLoggingIn = true; }); + // 🕐 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 + } + + // 🔍 PRE-LOGIN CHECK: Ensure room contact exists in device + print('🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...'); + print(' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + + // Check if the room exists in our local contacts + bool roomExists = contactsProvider.rooms.any( + (room) => room.publicKeyHex == widget.contact.publicKeyHex, + ); + + print(' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}'); + + if (!roomExists) { + print('⚠️ [RoomLogin] Room not in local contacts - syncing with device...'); + + try { + // Sync contacts from device + await connectionProvider.getContacts(); + + // Give time for contacts to be processed + await Future.delayed(const Duration(milliseconds: 800)); + + // Check again after sync + roomExists = contactsProvider.rooms.any( + (room) => room.publicKeyHex == widget.contact.publicKeyHex, + ); + + print(' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}'); + + if (!roomExists) { + // Room still doesn't exist on the device - try to add it manually + print('❌ [RoomLogin] Room still not found after sync'); + print('🔧 [RoomLogin] Attempting to add room contact to companion radio...'); + + try { + // Manually add the room contact to the radio's flash storage + await connectionProvider.addOrUpdateContact(widget.contact); + + print('✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT'); + print(' Waiting 500ms for radio to save to flash...'); + + // Give the radio time to save the contact to flash + await Future.delayed(const Duration(milliseconds: 500)); + + print('✅ [RoomLogin] Room contact should now be available - proceeding with login'); + } catch (e) { + print('❌ [RoomLogin] Failed to add room contact: $e'); + + if (!mounted) return; + + setState(() { + _isLoggingIn = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to add room to device: $e\n\n' + 'The room may not have advertised yet.\n' + 'Try waiting for the room to broadcast.', + ), + backgroundColor: Colors.red, + duration: const Duration(seconds: 7), + ), + ); + + // Log available rooms for debugging + final availableRooms = contactsProvider.rooms; + print('📋 [RoomLogin] Available rooms on device (${availableRooms.length}):'); + for (final room in availableRooms) { + print(' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})'); + } + + return; + } + } + + print('✅ [RoomLogin] Room contact found after sync - proceeding with login'); + } catch (e) { + print('❌ [RoomLogin] Contact sync failed: $e'); + + if (!mounted) return; + + setState(() { + _isLoggingIn = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to sync contacts: $e'), + backgroundColor: Colors.red, + ), + ); + return; + } + } else { + print('✅ [RoomLogin] Room contact found in local contacts - proceeding with login'); + } + + // Save password before sending + await _savePassword(password); + + // Set up login callbacks + Function(Uint8List, int, bool, int)? originalOnSuccess; + Function(Uint8List)? originalOnFail; + + originalOnSuccess = connectionProvider.onLoginSuccess; + originalOnFail = connectionProvider.onLoginFail; + + connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { + // Restore original callback + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + print('✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin'); + print('📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING'); + print(' Messages will be fetched when onMessageWaiting callback is triggered'); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logged in successfully! Waiting for room messages...'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } + }; + + connectionProvider.onLoginFail = (publicKeyPrefix) { + // Restore original callback + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + print('❌ [RoomLogin] Login failed - incorrect password'); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Login failed - incorrect password'), + backgroundColor: Colors.red, + duration: Duration(seconds: 3), + ), + ); + } + }; + try { // Send login request to room await connectionProvider.loginToRoom( @@ -824,7 +1167,6 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> { password: password, ); - _passwordController.clear(); _focusNode.unfocus(); if (!mounted) return; @@ -832,16 +1174,20 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Login request sent to ${widget.contact.displayName}'), - backgroundColor: Colors.green, + content: Text('Logging in to ${widget.contact.displayName}...'), + backgroundColor: Colors.blue, duration: const Duration(seconds: 2), ), ); } catch (e) { + // Restore original callbacks on error + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to login: $e'), + content: Text('Failed to send login: $e'), backgroundColor: Colors.red, ), ); @@ -857,89 +1203,100 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> { @override Widget build(BuildContext context) { return Container( - height: MediaQuery.of(context).size.height * 0.6, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), decoration: const BoxDecoration( color: Color(0xFF1E1E1E), borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - child: Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - Expanded( - child: Column( - children: [ - const Text( - 'Login to Room', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - widget.contact.displayName, - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), - ], + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), ), - ), - const SizedBox(width: 48), // Balance the back button - ], - ), - ), - - // Info banner - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Enter the password to access this room. You will receive a confirmation once logged in.', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontSize: 13, + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Login to Room', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.contact.displayName, + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], ), ), + const SizedBox(width: 48), // Balance the back button + ], + ), + ), + + // Scrollable content area + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + // Info banner + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Enter the password to access this room. Password defaults to "hello" and will be saved for future use.', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 12, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], ), - ], + ), ), - ), - const SizedBox(height: 24), - - const Spacer(), - - // Password input - Container( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewInsets.bottom, - ), - decoration: const BoxDecoration( - color: Color(0xFF2D2D2D), - ), - child: Column( + // Password input (fixed at bottom) + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0xFF2D2D2D), + ), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _passwordController, @@ -952,7 +1309,7 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> { decoration: InputDecoration( labelText: 'Password', labelStyle: const TextStyle(color: Colors.grey), - hintText: 'Enter room password', + hintText: 'Enter room password (default: hello)', hintStyle: const TextStyle(color: Colors.grey), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), @@ -986,9 +1343,7 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> { SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: _isLoggingIn || _passwordController.text.trim().isEmpty - ? null - : _loginToRoom, + onPressed: _isLoggingIn ? null : _loginToRoom, icon: _isLoggingIn ? const SizedBox( width: 20, @@ -1007,6 +1362,7 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> { ), ], ), + ), ); } } diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 14dc976..d5ea6a6 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -1,3 +1,4 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -9,6 +10,7 @@ import '../providers/connection_provider.dart'; import '../providers/app_provider.dart'; import '../models/message.dart'; import '../models/sar_marker.dart'; +import '../models/contact.dart'; class MessagesTab extends StatefulWidget { final VoidCallback onNavigateToMap; @@ -97,8 +99,8 @@ class _MessagesTabState extends State { isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => _SarUpdateSheet( - onSend: (sarType, position, notes, channelIdx) async { - await _sendSarMessage(sarType, position, notes, channelIdx); + onSend: (sarType, position, notes, roomPublicKey, sendToChannel) async { + await _sendSarMessage(sarType, position, notes, roomPublicKey, sendToChannel); }, ), ); @@ -108,7 +110,8 @@ class _MessagesTabState extends State { SarMarkerType sarType, Position position, String? notes, - int channelIdx, + Uint8List? roomPublicKey, + bool sendToChannel, ) async { final connectionProvider = context.read(); @@ -123,6 +126,17 @@ class _MessagesTabState extends State { return; } + if (!sendToChannel && roomPublicKey == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please select a room to send SAR marker'), + backgroundColor: Colors.red, + ), + ); + return; + } + try { // Format: S::, final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}'; @@ -132,21 +146,37 @@ class _MessagesTabState extends State { ? '$sarMessage $notes' : sarMessage; - // Send SAR message to selected room/channel - await connectionProvider.sendChannelMessage( - channelIdx: channelIdx, - text: fullMessage, - ); + if (sendToChannel) { + // Send to public channel (ephemeral, over-the-air only) + await connectionProvider.sendChannelMessage( + channelIdx: 0, + text: fullMessage, + ); - if (!mounted) return; - final channelName = channelIdx == 0 ? 'Public Channel' : 'Channel $channelIdx'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${sarType.displayName} marker sent to $channelName'), - backgroundColor: Colors.green, - duration: const Duration(seconds: 2), - ), - ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${sarType.displayName} marker broadcast to public channel'), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 2), + ), + ); + } else { + // Send SAR message to selected room (persisted and immutable) + await connectionProvider.sendTextMessage( + contactPublicKey: roomPublicKey!, + text: fullMessage, + ); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${sarType.displayName} marker sent to room'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -577,7 +607,7 @@ class _MessageBubble extends StatelessWidget { // SAR Update Sheet class _SarUpdateSheet extends StatefulWidget { - final Future Function(SarMarkerType, Position, String?, int) onSend; + final Future Function(SarMarkerType, Position, String?, Uint8List?, bool) onSend; const _SarUpdateSheet({required this.onSend}); @@ -590,7 +620,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { Position? _currentPosition; bool _loadingLocation = false; String? _locationError; - int _selectedChannelIdx = 0; // Default to Public Channel (channel 0) + Contact? _selectedContact; // Can be room or channel (public channel is in contacts) final TextEditingController _notesController = TextEditingController(); @override @@ -756,7 +786,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { ), const SizedBox(height: 24), - // Room/Channel selection + // Destination selection (compact dropdown with rooms and channel) const Text( 'Send To', style: TextStyle( @@ -768,8 +798,37 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { const SizedBox(height: 12), Consumer( builder: (context, contactsProvider, child) { - // Build list of available rooms/channels - final rooms = contactsProvider.rooms; + // Get all valid destinations (rooms + channels) + final destinations = contactsProvider.roomsAndChannels; + + if (destinations.isEmpty) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + width: 1, + ), + ), + child: const Row( + children: [ + Icon(Icons.error_outline, color: Colors.red, size: 20), + SizedBox(width: 8), + Expanded( + child: Text( + 'No destinations available.', + style: TextStyle( + color: Colors.white70, + fontSize: 11, + ), + ), + ), + ], + ), + ); + } return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), @@ -778,8 +837,18 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { borderRadius: BorderRadius.circular(8), ), child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedChannelIdx, + child: DropdownButton( + value: _selectedContact, + hint: const Row( + children: [ + Icon(Icons.arrow_drop_down_circle, size: 18, color: Colors.grey), + SizedBox(width: 12), + Text( + 'Select destination...', + style: TextStyle(color: Colors.grey), + ), + ], + ), dropdownColor: const Color(0xFF2D2D2D), isExpanded: true, style: const TextStyle( @@ -787,49 +856,80 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { fontSize: 14, ), icon: const Icon(Icons.arrow_drop_down, color: Colors.white), - items: [ - // Public Channel (always available) - const DropdownMenuItem( - value: 0, + items: destinations.map((contact) { + return DropdownMenuItem( + value: contact, child: Row( children: [ - Icon(Icons.public, size: 18, color: Colors.white), - SizedBox(width: 12), - Text('Public Channel'), + Icon( + contact.isChannel ? Icons.public : Icons.storage, + size: 18, + color: Colors.white, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + contact.displayName, + overflow: TextOverflow.ellipsis, + ), + ), ], ), - ), - // Room channels - ...rooms.asMap().entries.map((entry) { - final idx = entry.key + 1; // Rooms start at channel 1 - final room = entry.value; - return DropdownMenuItem( - value: idx, - child: Row( - children: [ - const Icon(Icons.tag, size: 18, color: Colors.white), - const SizedBox(width: 12), - Expanded( - child: Text( - room.displayName, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - }).toList(), - ], + ); + }).toList(), onChanged: (value) { - if (value != null) { - setState(() => _selectedChannelIdx = value); - } + setState(() => _selectedContact = value); }, ), ), ); }, ), + const SizedBox(height: 12), + + // Compact info banner + if (_selectedContact != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _selectedContact!.isChannel + ? Colors.orange.withValues(alpha: 0.1) + : Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _selectedContact!.isChannel + ? Colors.orange.withValues(alpha: 0.3) + : Colors.blue.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _selectedContact!.isChannel + ? Icons.warning_amber + : Icons.check_circle_outline, + color: _selectedContact!.isChannel + ? Colors.orange + : Colors.blue, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _selectedContact!.isChannel + ? 'Ephemeral: Broadcast over-the-air only. Not stored - nodes must be online.' + : 'Persistent: Stored immutably in room. Synced automatically and preserved offline.', + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + ), + ), + ), + ], + ), + ), const SizedBox(height: 24), // Location display @@ -1016,7 +1116,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { child: SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: _currentPosition == null + onPressed: _currentPosition == null || _selectedContact == null ? null : () async { await widget.onSend( @@ -1025,7 +1125,10 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), - _selectedChannelIdx, + _selectedContact!.isChannel + ? null + : _selectedContact!.publicKey, + _selectedContact!.isChannel, ); if (context.mounted) { Navigator.pop(context); @@ -1128,3 +1231,4 @@ class _MarkerTypeChip extends StatelessWidget { } } + diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index bd6c233..34270e6 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -22,6 +23,7 @@ typedef OnNoMoreMessagesCallback = void Function(); typedef OnMessageWaitingCallback = void Function(); typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag); typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix); +typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey); typedef OnErrorCallback = void Function(String error); typedef OnConnectionStateCallback = void Function(bool isConnected); @@ -44,6 +46,7 @@ class MeshCoreBleService { OnMessageWaitingCallback? onMessageWaiting; OnLoginSuccessCallback? onLoginSuccess; OnLoginFailCallback? onLoginFail; + OnAdvertReceivedCallback? onAdvertReceived; OnErrorCallback? onError; // Internal state @@ -370,6 +373,10 @@ class MeshCoreBleService { print(' → Handling LoginFail push'); _handleLoginFail(reader); break; + case MeshCoreConstants.respCurrTime: + print(' → Handling CurrentTime'); + _handleCurrentTime(reader); + break; case MeshCoreConstants.respNoMoreMessages: print(' → Response: No More Messages'); onNoMoreMessages?.call(); @@ -871,21 +878,53 @@ class MeshCoreBleService { } } - /// Handle Advert push + /// Handle Advert push (PUSH_CODE_ADVERT) + /// + /// This push notification indicates that a node in the mesh network + /// has broadcast an advertisement packet. The companion radio received + /// this over-the-air and is notifying the app. + /// + /// Protocol format: + /// - 32 bytes: public key of the advertising node + /// + /// Note: This is a passive notification - the companion radio handles + /// updating the contact automatically. The app can use this to show + /// real-time network activity or trigger UI updates. + /// + /// Behavior: + /// - If manual_add_contacts=0: Companion radio auto-updates contact, then sends PUSH_CODE_NEW_ADVERT with full details + /// - If manual_add_contacts=1: App must call CMD_GET_CONTACTS to sync updated contact void _handleAdvert(BufferReader reader) { try { - print(' [Advert] Parsing advert...'); + print(' [Advert] Parsing advert push notification...'); print(' Remaining bytes: ${reader.remainingBytesCount}'); // Advert format: 32 bytes public key if (reader.remainingBytesCount >= 32) { final publicKey = reader.readBytes(32); - print(' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + final publicKeyPrefix = publicKey.sublist(0, 6); + final publicKeyFull = publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + + print(' 📡 ADVERT RECEIVED FROM NODE:'); + print(' Public key prefix (6 bytes): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + print(' Public key (full 32 bytes): $publicKeyFull'); + print(' ℹ️ This indicates the node is broadcasting its presence on the mesh network'); + print(' ℹ️ The companion radio will automatically update contact info for this node'); + print(' ℹ️ Expected follow-up:'); + print(' - If manual_add_contacts=0: You will receive PUSH_CODE_NEW_ADVERT (0x8A) with full contact details'); + print(' - If manual_add_contacts=1: Call CMD_GET_CONTACTS to sync updated contact'); + + // Notify callback so app can trigger contact sync if desired + onAdvertReceived?.call(publicKey); + } else { + print(' ⚠️ [Advert] Insufficient data: expected 32 bytes, got ${reader.remainingBytesCount}'); } // Consume any remaining bytes if (reader.hasRemaining) { - reader.readRemainingBytes(); + final extraBytes = reader.readRemainingBytes(); + print(' ⚠️ [Advert] Extra bytes found: ${extraBytes.length} bytes'); + print(' Extra data (hex): ${extraBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); } print(' ✅ [Advert] Parsed successfully'); @@ -895,15 +934,113 @@ class MeshCoreBleService { } } - /// Handle LogRxData push + /// Handle LogRxData push (PUSH_CODE_LOG_RX_DATA) + /// + /// This push notification contains diagnostic/debug data from the companion radio + /// about packets it received over the air. The format is device-specific and may + /// contain encrypted or encoded data from the radio firmware. void _handleLogRxData(BufferReader reader) { try { print(' [LogRxData] Parsing log rx data...'); print(' Remaining bytes: ${reader.remainingBytesCount}'); - // This is encrypted/encoded data - just consume it final data = reader.readRemainingBytes(); print(' Data length: ${data.length} bytes'); + + // Enhanced hex dump with 16 bytes per line for readability + print(' 📊 HEX DUMP:'); + for (int i = 0; i < data.length; i += 16) { + final end = (i + 16 < data.length) ? i + 16 : data.length; + final chunk = data.sublist(i, end); + + // Offset column (4 hex digits) + final offset = i.toRadixString(16).padLeft(4, '0'); + + // Hex bytes (2 hex digits per byte, space separated) + final hexBytes = chunk.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); + + // ASCII representation (printable chars or '.') + final ascii = chunk.map((b) { + if (b >= 32 && b <= 126) { + return String.fromCharCode(b); + } else { + return '.'; + } + }).join(''); + + // Print formatted line: OFFSET: HEX_BYTES | ASCII + print(' $offset: ${hexBytes.padRight(47)} | $ascii'); + } + + // Attempt to decode structure + print(' 🔍 STRUCTURE ANALYSIS:'); + + if (data.length >= 4) { + // Try to parse potential timestamp at beginning (uint32 LE) + final timestamp = ByteData.sublistView(Uint8List.fromList(data.sublist(0, 4))) + .getUint32(0, Endian.little); + print(' [Bytes 0-3] Potential timestamp (uint32 LE): $timestamp'); + + // Check if timestamp is reasonable (between 2020 and 2030) + const minTimestamp = 1577836800; // 2020-01-01 + const maxTimestamp = 1893456000; // 2030-01-01 + if (timestamp >= minTimestamp && timestamp <= maxTimestamp) { + print(' As epoch: ${DateTime.fromMillisecondsSinceEpoch(timestamp * 1000)}'); + print(' ✅ Valid timestamp!'); + } else { + print(' ⚠️ Timestamp out of reasonable range (not epoch seconds)'); + } + } + + // Check if this contains a public key (32-byte sequence starting around byte 4) + if (data.length >= 36) { + final potentialPubKey = data.sublist(4, 36); + final pubKeyPrefix = potentialPubKey.sublist(0, 6); + print(' [Bytes 4-35] Potential public key (32 bytes):'); + print(' Prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + print(' Full: ${potentialPubKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + print(' ℹ️ This might be the sender\'s public key from over-the-air packet'); + } + + // Look for printable strings (runs of 4+ printable characters) + final strings = []; + StringBuffer currentString = StringBuffer(); + + for (int i = 0; i < data.length; i++) { + final byte = data[i]; + if (byte >= 32 && byte <= 126) { + // Printable ASCII + currentString.write(String.fromCharCode(byte)); + } else { + // Non-printable - end current string if long enough + if (currentString.length >= 4) { + strings.add(currentString.toString()); + } + currentString.clear(); + } + } + // Catch final string + if (currentString.length >= 4) { + strings.add(currentString.toString()); + } + + if (strings.isNotEmpty) { + print(' Embedded strings found:'); + for (final str in strings) { + print(' → "$str"'); + } + } else { + print(' No printable strings found (likely encrypted/binary data)'); + } + + // Check if this might be an encrypted packet (high entropy) + final uniqueBytes = data.toSet().length; + final entropy = uniqueBytes / data.length; + print(' Entropy: ${(entropy * 100).toStringAsFixed(1)}% (${uniqueBytes}/${data.length} unique bytes)'); + if (entropy > 0.7) { + print(' ℹ️ High entropy suggests encrypted or compressed data'); + } + print(' ✅ [LogRxData] Parsed successfully'); } catch (e) { print(' ❌ [LogRxData] Parsing error: $e'); @@ -1097,6 +1234,45 @@ class MeshCoreBleService { } } + /// 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'); + } + } + /// Handle Error response (RESP_CODE_ERR) /// /// Protocol format: @@ -1170,6 +1346,57 @@ class MeshCoreBleService { await _writeData(writer.toBytes()); } + /// Manually add or update a contact on the companion radio + /// + /// This is useful when you need to add a room that hasn't advertised yet, + /// or restore a contact that was deleted from the radio's table. + /// + /// **Use case:** If you get ERR_CODE_NOT_FOUND when logging into a room, + /// use this to add the room contact to the radio's internal table first. + /// + /// Protocol format (CMD_ADD_UPDATE_CONTACT): + /// - 1 byte: command code (9) + /// - 32 bytes: public key + /// - 1 byte: type (ADV_TYPE_*) + /// - 1 byte: flags + /// - 1 byte: out path length (signed) + /// - 64 bytes: out path + /// - 32 bytes: advertised name (null-terminated) + /// - 4 bytes: last advert timestamp (uint32) + /// - 4 bytes: (optional) advert latitude * 1E6 (int32) + /// - 4 bytes: (optional) advert longitude * 1E6 (int32) + Future addOrUpdateContact(Contact contact) async { + print('📝 [BLE] Adding/updating contact on companion radio:'); + print(' Name: ${contact.advName}'); + print(' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + print(' Type: ${contact.type} (${contact.type.value})'); + + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09 + writer.writeBytes(contact.publicKey); // 32 bytes + writer.writeByte(contact.type.value); // ADV_TYPE_* + writer.writeByte(contact.flags); // flags + writer.writeInt8(contact.outPathLen); // path length (signed byte) + writer.writeBytes(contact.outPath); // 64 bytes + + // Write name as null-terminated string in 32-byte field + final nameBytes = Uint8List(32); + final encoded = utf8.encode(contact.advName); + final copyLen = encoded.length > 31 ? 31 : encoded.length; + nameBytes.setRange(0, copyLen, encoded); + writer.writeBytes(nameBytes); + + writer.writeUInt32LE(contact.lastAdvert); // timestamp + writer.writeInt32LE(contact.advLat); // latitude * 1E6 + writer.writeInt32LE(contact.advLon); // longitude * 1E6 + + await _writeData(writer.toBytes()); + + print('✅ [BLE] CMD_ADD_UPDATE_CONTACT sent'); + print(' This adds/updates the contact in the radio\'s internal flash storage'); + print(' The contact will persist across reboots and can be used for login'); + } + /// Send text message to contact (DM) /// /// Protocol format (CMD_SEND_TXT_MSG): @@ -1255,6 +1482,19 @@ class MeshCoreBleService { await _writeData(writer.toBytes()); } + /// 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()); + } + /// Set device time Future setDeviceTime() async { final writer = BufferWriter(); @@ -1352,24 +1592,52 @@ class MeshCoreBleService { /// Send login request to room or repeater /// + /// This sends a PAYLOAD_TYPE_ANON_REQ packet via the companion radio. + /// The companion radio encodes it and sends it to the room server. + /// /// Protocol format (CMD_SEND_LOGIN): /// - 1 byte: command code (26) - /// - 32 bytes: public key (room or repeater) - /// - N bytes: password (remainder of frame, varchar, max 15 bytes) + /// - 4 bytes: sender timestamp (uint32, epoch seconds - current time) + /// - 4 bytes: sync_since timestamp (uint32, epoch seconds - 0 for all messages) + /// - 32 bytes: room public key + /// - N bytes: password (varchar, max 15 bytes, null-terminated) /// /// Response: PUSH_CODE_LOGIN_SUCCESS (0x85) or PUSH_CODE_LOGIN_FAIL (0x86) + /// + /// After successful login, the room server will PUSH messages where + /// post_timestamp > sync_since directly to the companion radio. + /// + /// IMPORTANT: The companion radio must have the room contact in its own + /// internal contact table. If you get ERR_CODE_NOT_FOUND (2), the radio + /// doesn't know about this room. You need to: + /// 1. Wait for the room to advertise (it will be added automatically) + /// 2. Import the room contact using CMD_IMPORT_CONTACT + /// 3. Manually add the room contact using CMD_ADD_UPDATE_CONTACT 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 + + print('🔐 [BLE] Preparing login request:'); + print(' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + print(' Password: ${"*" * password.length} (${password.length} chars)'); + print(' Sender timestamp: $now (${DateTime.fromMillisecondsSinceEpoch(now * 1000)})'); + print(' Sync since: $syncSince (${syncSince == 0 ? "all messages" : "messages after timestamp $syncSince"})'); + print(' ⚠️ NOTE: The companion radio must have this room in its contact table'); + print(' If you get ERR_CODE_NOT_FOUND, the room needs to advertise first or use CMD_ADD_UPDATE_CONTACT'); + final writer = BufferWriter(); writer.writeByte(MeshCoreConstants.cmdSendLogin); + 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 + writer.writeString(password); // Max 15 bytes, null-terminated await _writeData(writer.toBytes()); } diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart index ea2df4a..9a0d7d4 100644 --- a/lib/widgets/map_markers.dart +++ b/lib/widgets/map_markers.dart @@ -312,7 +312,9 @@ class MapMarkers { case ContactType.repeater: return Colors.deepPurple; // Purple for repeaters case ContactType.room: - return Colors.teal; // Teal for rooms/channels + return Colors.teal; // Teal for rooms + case ContactType.channel: + return Colors.orange; // Orange for channels case ContactType.none: return Colors.grey; } @@ -326,6 +328,8 @@ class MapMarkers { return Icons.router; // Router icon for repeaters case ContactType.room: return Icons.forum; // Forum/chat icon for rooms + case ContactType.channel: + return Icons.public; // Public icon for channels case ContactType.none: return Icons.help_outline; }