mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Implement room login state management and enhance SAR message handling
This commit is contained in:
@@ -17,7 +17,8 @@
|
|||||||
"WebFetch(domain:github.com)",
|
"WebFetch(domain:github.com)",
|
||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
"Bash(dart run:*)",
|
"Bash(dart run:*)",
|
||||||
"Bash(dart test_sar_debug.dart:*)"
|
"Bash(dart test_sar_debug.dart:*)",
|
||||||
|
"Read(//Users/dz0ny/meshcore-sar/MeshCore/**)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
303
ADVERT_SYSTEM.md
Normal file
303
ADVERT_SYSTEM.md
Normal file
@@ -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
|
||||||
63
CLAUDE.md
63
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)
|
- `0` - ADV_TYPE_NONE (unknown/invalid)
|
||||||
- `1` - ADV_TYPE_CHAT (team member, shown on map)
|
- `1` - ADV_TYPE_CHAT (team member, shown on map)
|
||||||
- `2` - ADV_TYPE_REPEATER (network repeater node)
|
- `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)**:
|
**TXT_TYPE (Text Message Type)**:
|
||||||
- `0` - TXT_TYPE_PLAIN (plain text message)
|
- `0` - TXT_TYPE_PLAIN (plain text message)
|
||||||
|
|||||||
276
CLOCK_DRIFT_DETECTION.md
Normal file
276
CLOCK_DRIFT_DETECTION.md
Normal file
@@ -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<void> 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<void> 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! 🎉
|
||||||
238
DEBUG_CONTACTS.md
Normal file
238
DEBUG_CONTACTS.md
Normal file
@@ -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<void> _loginToRoom() async {
|
||||||
|
final password = _passwordController.text.trim().isEmpty
|
||||||
|
? 'hello'
|
||||||
|
: _passwordController.text.trim();
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
|
||||||
|
// ✅ 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<ConnectionProvider>(
|
||||||
|
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<ContactsProvider>();
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
|
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
|
||||||
215
IMPLEMENTATION_SUMMARY.md
Normal file
215
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -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<void> 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! 🎉
|
||||||
268
ROOM_LOGIN_FIX.md
Normal file
268
ROOM_LOGIN_FIX.md
Normal file
@@ -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<void> addOrUpdateContact(Contact contact) async {
|
||||||
|
final writer = BufferWriter();
|
||||||
|
writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09
|
||||||
|
writer.writeBytes(contact.publicKey); // 32 bytes
|
||||||
|
writer.writeByte(contact.type.value); // ADV_TYPE_*
|
||||||
|
writer.writeByte(contact.flags); // flags
|
||||||
|
writer.writeInt8(contact.outPathLen); // path length (signed byte)
|
||||||
|
writer.writeBytes(contact.outPath); // 64 bytes
|
||||||
|
|
||||||
|
// Write name as null-terminated string in 32-byte field
|
||||||
|
final nameBytes = Uint8List(32);
|
||||||
|
final encoded = utf8.encode(contact.advName);
|
||||||
|
final copyLen = encoded.length > 31 ? 31 : encoded.length;
|
||||||
|
nameBytes.setRange(0, copyLen, encoded);
|
||||||
|
writer.writeBytes(nameBytes);
|
||||||
|
|
||||||
|
writer.writeUInt32LE(contact.lastAdvert); // timestamp
|
||||||
|
writer.writeInt32LE(contact.advLat); // latitude * 1E6
|
||||||
|
writer.writeInt32LE(contact.advLon); // longitude * 1E6
|
||||||
|
|
||||||
|
await _writeData(writer.toBytes());
|
||||||
|
|
||||||
|
print('✅ [BLE] Sent CMD_ADD_UPDATE_CONTACT for ${contact.advName}');
|
||||||
|
print(' This adds the contact to the radio\'s internal table');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the constant:
|
||||||
|
```dart
|
||||||
|
// In meshcore_constants.dart
|
||||||
|
static const int cmdAddUpdateContact = 9;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in your app, before login:
|
||||||
|
```dart
|
||||||
|
// Add the room contact to the radio's table
|
||||||
|
await connectionProvider.bleService.addOrUpdateContact(widget.contact);
|
||||||
|
|
||||||
|
// Small delay to allow radio to save
|
||||||
|
await Future.delayed(Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
// Now login should work
|
||||||
|
await connectionProvider.loginToRoom(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution 4: Import Room Contact Card
|
||||||
|
|
||||||
|
If you have the room's "business card" (from `CMD_EXPORT_CONTACT`):
|
||||||
|
|
||||||
|
1. Get the card data (usually starts with `meshcore://`)
|
||||||
|
2. Implement `CMD_IMPORT_CONTACT` (command code 18)
|
||||||
|
3. Import the card
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> importContact(String cardData) async {
|
||||||
|
final writer = BufferWriter();
|
||||||
|
writer.writeByte(MeshCoreConstants.cmdImportContact); // 0x12
|
||||||
|
writer.writeString(cardData); // meshcore:// card data
|
||||||
|
await _writeData(writer.toBytes());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
**Step 1:** Force re-sync to see current state
|
||||||
|
```dart
|
||||||
|
await contactsProvider.clearContacts();
|
||||||
|
await connectionProvider.getContacts();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2:** Check if room exists
|
||||||
|
```dart
|
||||||
|
final roomExists = contactsProvider.rooms.any(
|
||||||
|
(r) => r.publicKeyPrefix.matches(targetPrefix)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3a:** If room doesn't exist → **Implement CMD_ADD_UPDATE_CONTACT** (Solution 3)
|
||||||
|
|
||||||
|
**Step 3b:** Or wait for room to advertise (Solution 2)
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
### Immediate Fix (Easiest)
|
||||||
|
1. ✅ Add contact sync verification (already done!)
|
||||||
|
2. ✅ Show helpful error messages (already done!)
|
||||||
|
|
||||||
|
### Short Term (Recommended)
|
||||||
|
3. 🔧 **Implement `CMD_ADD_UPDATE_CONTACT`** - This lets you manually restore contacts
|
||||||
|
4. 🔧 **Implement `CMD_EXPORT_CONTACT`** - This lets you backup/share room contacts
|
||||||
|
|
||||||
|
### Long Term (Optional)
|
||||||
|
5. 📱 Add UI to manually add rooms by public key
|
||||||
|
6. 💾 Cache room contacts in SharedPreferences
|
||||||
|
7. 🔄 Auto-restore cached rooms on connect
|
||||||
|
|
||||||
|
## Testing Your Fix
|
||||||
|
|
||||||
|
1. **Clear app contacts:**
|
||||||
|
```dart
|
||||||
|
await contactsProvider.clearContacts();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Force fresh sync:**
|
||||||
|
```dart
|
||||||
|
await connectionProvider.getContacts();
|
||||||
|
await Future.delayed(Duration(seconds: 1));
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **List actual rooms on device:**
|
||||||
|
```dart
|
||||||
|
final rooms = contactsProvider.rooms;
|
||||||
|
print('═══════════════════════════════');
|
||||||
|
print('Rooms on companion radio: ${rooms.length}');
|
||||||
|
for (final room in rooms) {
|
||||||
|
print('📍 ${room.advName}');
|
||||||
|
print(' PK: ${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||||
|
print(' Type: ${room.type}');
|
||||||
|
}
|
||||||
|
print('═══════════════════════════════');
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **If "Repetitor" is NOT in the list:**
|
||||||
|
- The radio truly doesn't have it
|
||||||
|
- You need to add it using CMD_ADD_UPDATE_CONTACT
|
||||||
|
- Or wait for it to advertise
|
||||||
|
|
||||||
|
## Expected Logs After Fix
|
||||||
|
|
||||||
|
**Before (Current - Broken):**
|
||||||
|
```
|
||||||
|
🔍 Checking room "Repetitor"...
|
||||||
|
App contacts: ✅ Found
|
||||||
|
Radio contacts: ❌ Not found
|
||||||
|
📤 Sending LOGIN
|
||||||
|
❌ ERROR: Not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (Fixed - Option 1: Re-sync):**
|
||||||
|
```
|
||||||
|
🔍 Checking room "Repetitor"...
|
||||||
|
📤 Clearing app contacts
|
||||||
|
📤 Syncing from radio
|
||||||
|
📥 Got 5 contacts
|
||||||
|
Room "Repetitor": ❌ NOT on radio
|
||||||
|
⚠️ Room needs to be added to radio first
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (Fixed - Option 2: Manual add):**
|
||||||
|
```
|
||||||
|
🔍 Checking room "Repetitor"...
|
||||||
|
App contacts: ✅ Found
|
||||||
|
Radio contacts: ❌ Not found
|
||||||
|
📤 Sending CMD_ADD_UPDATE_CONTACT
|
||||||
|
✅ Contact added to radio
|
||||||
|
📤 Sending LOGIN
|
||||||
|
✅ LOGIN_SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Try Solution 1 (force re-sync) to confirm the issue
|
||||||
|
2. Implement Solution 3 (CMD_ADD_UPDATE_CONTACT) for permanent fix
|
||||||
|
3. Test by adding the room contact manually before login
|
||||||
|
|
||||||
|
Would you like me to implement `CMD_ADD_UPDATE_CONTACT` for you?
|
||||||
@@ -8,7 +8,8 @@ enum ContactType {
|
|||||||
none(0),
|
none(0),
|
||||||
chat(1),
|
chat(1),
|
||||||
repeater(2),
|
repeater(2),
|
||||||
room(3);
|
room(3),
|
||||||
|
channel(99); // Virtual type for public channel (not from protocol)
|
||||||
|
|
||||||
const ContactType(this.value);
|
const ContactType(this.value);
|
||||||
final int value;
|
final int value;
|
||||||
@@ -28,6 +29,8 @@ enum ContactType {
|
|||||||
return 'Repeater';
|
return 'Repeater';
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return 'Room';
|
return 'Room';
|
||||||
|
case ContactType.channel:
|
||||||
|
return 'Channel';
|
||||||
default:
|
default:
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
@@ -75,6 +78,12 @@ class Contact {
|
|||||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
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
|
/// Convert advLat/advLon to LatLng
|
||||||
LatLng? get advertLocation {
|
LatLng? get advertLocation {
|
||||||
if (advLat == 0 && advLon == 0) return null;
|
if (advLat == 0 && advLon == 0) return null;
|
||||||
@@ -103,9 +112,12 @@ class Contact {
|
|||||||
/// Check if contact is a repeater
|
/// Check if contact is a repeater
|
||||||
bool get isRepeater => type == ContactType.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;
|
bool get isRoom => type == ContactType.room;
|
||||||
|
|
||||||
|
/// Check if contact is a channel (ephemeral broadcast)
|
||||||
|
bool get isChannel => type == ContactType.channel;
|
||||||
|
|
||||||
/// Get last seen time
|
/// Get last seen time
|
||||||
DateTime get lastSeenTime {
|
DateTime get lastSeenTime {
|
||||||
return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000);
|
return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000);
|
||||||
|
|||||||
111
lib/models/room_login_state.dart
Normal file
111
lib/models/room_login_state.dart
Normal file
@@ -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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'connection_provider.dart';
|
import 'connection_provider.dart';
|
||||||
import 'contacts_provider.dart';
|
import 'contacts_provider.dart';
|
||||||
import 'messages_provider.dart';
|
import 'messages_provider.dart';
|
||||||
import '../services/tile_cache_service.dart';
|
import '../services/tile_cache_service.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
/// Main App Provider - coordinates all other providers
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
@@ -80,6 +83,12 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Load contacts
|
// Load contacts
|
||||||
await connectionProvider.getContacts();
|
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
|
// Sync any waiting messages from device queue
|
||||||
await _syncMessages();
|
await _syncMessages();
|
||||||
|
|
||||||
@@ -89,6 +98,103 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Automatically login to all rooms with saved passwords on cold connect
|
||||||
|
Future<void> _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<void> _loginToRoomWithCallback(Contact room, String password) async {
|
||||||
|
// Create a completer to wait for login result
|
||||||
|
final completer = Completer<bool>();
|
||||||
|
|
||||||
|
// 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
|
/// Sync messages from device queue
|
||||||
Future<void> _syncMessages() async {
|
Future<void> _syncMessages() async {
|
||||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import 'dart:async';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/device_info.dart';
|
import '../models/device_info.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
|
import '../models/room_login_state.dart';
|
||||||
import '../services/meshcore_ble_service.dart';
|
import '../services/meshcore_ble_service.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../utils/sar_message_parser.dart';
|
import '../utils/sar_message_parser.dart';
|
||||||
@@ -44,6 +46,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// Message sync state
|
// Message sync state
|
||||||
bool _noMoreMessages = false;
|
bool _noMoreMessages = false;
|
||||||
|
|
||||||
|
// Room login state tracking
|
||||||
|
final Map<String, RoomLoginState> _roomLoginStates = {};
|
||||||
|
Map<String, RoomLoginState> get roomLoginStates => Map.unmodifiable(_roomLoginStates);
|
||||||
|
|
||||||
// Callbacks for other providers
|
// Callbacks for other providers
|
||||||
Function(Contact)? onContactReceived;
|
Function(Contact)? onContactReceived;
|
||||||
Function(List<Contact>)? onContactsComplete;
|
Function(List<Contact>)? onContactsComplete;
|
||||||
@@ -120,19 +126,49 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
syncAllMessages();
|
syncAllMessages();
|
||||||
};
|
};
|
||||||
|
|
||||||
_bleService.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) {
|
_bleService.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
|
||||||
print('📥 [Provider] Login successful to room');
|
print('📥 [Provider] Login successful to room');
|
||||||
print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||||
print(' Permissions: $permissions, Admin: $isAdmin, Tag: $tag');
|
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);
|
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
|
||||||
};
|
};
|
||||||
|
|
||||||
_bleService.onLoginFail = (publicKeyPrefix) {
|
_bleService.onLoginFail = (publicKeyPrefix) {
|
||||||
print('📥 [Provider] Login failed to room');
|
print('📥 [Provider] Login failed to room');
|
||||||
print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
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);
|
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) {
|
_bleService.onDeviceInfoReceived = (deviceInfo) {
|
||||||
print('📥 [Provider] Received DeviceInfo:');
|
print('📥 [Provider] Received DeviceInfo:');
|
||||||
print(' Firmware Version: ${deviceInfo['firmwareVersion']}');
|
print(' Firmware Version: ${deviceInfo['firmwareVersion']}');
|
||||||
@@ -285,6 +321,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
_deviceInfo = DeviceInfo(
|
_deviceInfo = DeviceInfo(
|
||||||
connectionState: ConnectionState.disconnected,
|
connectionState: ConnectionState.disconnected,
|
||||||
);
|
);
|
||||||
|
clearRoomLoginStates(); // Clear login states on disconnect
|
||||||
notifyListeners();
|
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<void> 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
|
/// Send text message to contact
|
||||||
Future<void> sendTextMessage({
|
Future<void> sendTextMessage({
|
||||||
required Uint8List contactPublicKey,
|
required Uint8List contactPublicKey,
|
||||||
@@ -365,6 +421,22 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get device time from companion radio to detect clock drift
|
||||||
|
Future<void> 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
|
/// Set device time to current time
|
||||||
Future<void> syncDeviceTime() async {
|
Future<void> syncDeviceTime() async {
|
||||||
if (!_bleService.isConnected) return;
|
if (!_bleService.isConnected) return;
|
||||||
@@ -554,29 +626,37 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
_noMoreMessages = false; // Reset flag
|
_noMoreMessages = false; // Reset flag
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('🔄 [Provider] Starting message sync...');
|
print('🔄 [Provider] Starting message sync loop...');
|
||||||
|
print(' Initial _noMoreMessages state: $_noMoreMessages');
|
||||||
|
|
||||||
// Keep syncing until we get NoMoreMessages response
|
// Keep syncing until we get NoMoreMessages response
|
||||||
// The device will send ContactMsgRecv or ChannelMsgRecv responses
|
// The device will send ContactMsgRecv or ChannelMsgRecv responses
|
||||||
// until it sends NoMoreMessages
|
// until it sends NoMoreMessages
|
||||||
for (int i = 0; i < 100; i++) { // Safety limit
|
for (int i = 0; i < 100; i++) { // Safety limit
|
||||||
if (_noMoreMessages) {
|
if (_noMoreMessages) {
|
||||||
print('✅ [Provider] Message sync complete - NoMoreMessages received after $count requests');
|
print('✅ [Provider] Message sync complete - NoMoreMessages flag set after $count requests');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print('📤 [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE');
|
||||||
|
|
||||||
await _bleService.syncNextMessage();
|
await _bleService.syncNextMessage();
|
||||||
count++;
|
count++;
|
||||||
|
|
||||||
// Small delay to allow response to be processed
|
// 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) {
|
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;
|
return count;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
print('❌ [Provider] Failed to sync messages: $e');
|
||||||
_error = 'Failed to sync messages: $e';
|
_error = 'Failed to sync messages: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return count;
|
return count;
|
||||||
@@ -628,6 +708,38 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a password exists for a room (by public key prefix)
|
||||||
|
Future<bool> _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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_rxActivityTimer?.cancel();
|
_rxActivityTimer?.cancel();
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
void _ensurePublicChannelExists() {
|
void _ensurePublicChannelExists() {
|
||||||
const publicChannelKey = 'public_channel_0';
|
const publicChannelKey = 'public_channel_0';
|
||||||
if (!_contacts.containsKey(publicChannelKey)) {
|
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(
|
_contacts[publicChannelKey] = Contact(
|
||||||
publicKey: Uint8List.fromList(List.filled(32, 0)), // Zero key for public
|
publicKey: Uint8List.fromList(List.filled(32, 0)), // Zero key for public
|
||||||
type: ContactType.room,
|
type: ContactType.channel, // Channel type (not room!)
|
||||||
flags: 0,
|
flags: 0,
|
||||||
outPathLen: 0,
|
outPathLen: 0,
|
||||||
outPath: Uint8List(64),
|
outPath: Uint8List(64),
|
||||||
@@ -42,10 +42,19 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
List<Contact> get repeaters =>
|
List<Contact> get repeaters =>
|
||||||
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
|
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
|
||||||
|
|
||||||
List<Contact> get rooms {
|
List<Contact> get rooms =>
|
||||||
// Always ensure public channel exists when getting rooms
|
contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
|
||||||
|
|
||||||
|
List<Contact> get channels {
|
||||||
|
// Always ensure public channel exists when getting channels
|
||||||
_ensurePublicChannelExists();
|
_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<Contact> get roomsAndChannels {
|
||||||
|
_ensurePublicChannelExists();
|
||||||
|
return contacts.where((c) => c.isRoom || c.isChannel).toList()..sort(_sortByLastSeen);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get contacts with location (for map display)
|
/// Get contacts with location (for map display)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/room_login_state.dart';
|
||||||
|
|
||||||
class ContactsTab extends StatefulWidget {
|
class ContactsTab extends StatefulWidget {
|
||||||
const ContactsTab({super.key});
|
const ContactsTab({super.key});
|
||||||
@@ -151,40 +154,132 @@ class _ContactTile extends StatelessWidget {
|
|||||||
final battery = contact.displayBattery;
|
final battery = contact.displayBattery;
|
||||||
final location = contact.displayLocation;
|
final location = contact.displayLocation;
|
||||||
|
|
||||||
|
// Get room login state if this is a room
|
||||||
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
|
final roomLoginState = contact.type == ContactType.room
|
||||||
|
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
|
||||||
|
: null;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: Stack(
|
||||||
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(
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
CircleAvatar(
|
||||||
child: Text(
|
backgroundColor: _getTypeColor(contact.type, context),
|
||||||
contact.displayName,
|
child: contact.roleEmoji != null
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
? Text(
|
||||||
),
|
contact.roleEmoji!,
|
||||||
|
style: const TextStyle(fontSize: 24),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
_getTypeIcon(contact.type),
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// Battery indicator
|
// Room login status indicator badge
|
||||||
if (battery != null) ...[
|
if (contact.type == ContactType.room && roomLoginState != null)
|
||||||
Icon(
|
Positioned(
|
||||||
_getBatteryIcon(battery),
|
bottom: 0,
|
||||||
size: 16,
|
right: 0,
|
||||||
color: _getBatteryColor(battery),
|
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()}%',
|
title: Column(
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
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),
|
onPressed: () => _showDirectMessageDialog(context, contact),
|
||||||
tooltip: 'Send direct message',
|
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
|
// Telemetry refresh button
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh, size: 20),
|
icon: const Icon(Icons.refresh, size: 20),
|
||||||
@@ -317,6 +405,12 @@ class _ContactTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showContactDetails(BuildContext context, Contact contact) {
|
void _showContactDetails(BuildContext context, Contact contact) {
|
||||||
|
// Get room login state
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final roomLoginState = contact.type == ContactType.room
|
||||||
|
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
|
||||||
|
: null;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
@@ -385,6 +479,41 @@ class _ContactTile extends StatelessWidget {
|
|||||||
_DetailRow('Public Key', contact.publicKeyShort),
|
_DetailRow('Public Key', contact.publicKeyShort),
|
||||||
_DetailRow('Last Seen', contact.timeSinceLastSeen),
|
_DetailRow('Last Seen', contact.timeSinceLastSeen),
|
||||||
const SizedBox(height: 16),
|
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) ...[
|
if (contact.displayLocation != null) ...[
|
||||||
const Text(
|
const Text(
|
||||||
'Location:',
|
'Location:',
|
||||||
@@ -431,6 +560,26 @@ class _ContactTile extends StatelessWidget {
|
|||||||
'${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})',
|
'${_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;
|
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) {
|
String _formatTimestamp(DateTime timestamp) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
@@ -781,6 +952,12 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> {
|
|||||||
bool _isLoggingIn = false;
|
bool _isLoggingIn = false;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSavedPassword();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
@@ -788,19 +965,28 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load saved password for this room, or use default "hello"
|
||||||
|
Future<void> _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<void> _savePassword(String password) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final roomKey = 'room_password_${widget.contact.publicKeyHex}';
|
||||||
|
await prefs.setString(roomKey, password);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loginToRoom() async {
|
Future<void> _loginToRoom() async {
|
||||||
final password = _passwordController.text.trim();
|
final password = _passwordController.text.trim().isEmpty
|
||||||
if (password.isEmpty) {
|
? 'hello'
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
: _passwordController.text.trim();
|
||||||
const SnackBar(
|
|
||||||
content: Text('Please enter a password'),
|
|
||||||
backgroundColor: Colors.orange,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -817,6 +1003,163 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> {
|
|||||||
_isLoggingIn = true;
|
_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 {
|
try {
|
||||||
// Send login request to room
|
// Send login request to room
|
||||||
await connectionProvider.loginToRoom(
|
await connectionProvider.loginToRoom(
|
||||||
@@ -824,7 +1167,6 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> {
|
|||||||
password: password,
|
password: password,
|
||||||
);
|
);
|
||||||
|
|
||||||
_passwordController.clear();
|
|
||||||
_focusNode.unfocus();
|
_focusNode.unfocus();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -832,16 +1174,20 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> {
|
|||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Login request sent to ${widget.contact.displayName}'),
|
content: Text('Logging in to ${widget.contact.displayName}...'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.blue,
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Restore original callbacks on error
|
||||||
|
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||||
|
connectionProvider.onLoginFail = originalOnFail;
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to login: $e'),
|
content: Text('Failed to send login: $e'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -857,89 +1203,100 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
height: MediaQuery.of(context).size.height * 0.6,
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.75,
|
||||||
|
),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Color(0xFF1E1E1E),
|
color: Color(0xFF1E1E1E),
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Padding(
|
||||||
children: [
|
padding: EdgeInsets.only(
|
||||||
// Header
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
Container(
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
child: Column(
|
||||||
child: Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
// Header
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
Container(
|
||||||
onPressed: () => Navigator.pop(context),
|
padding: const EdgeInsets.all(16),
|
||||||
),
|
child: Row(
|
||||||
Expanded(
|
children: [
|
||||||
child: Column(
|
IconButton(
|
||||||
children: [
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
const Text(
|
onPressed: () => Navigator.pop(context),
|
||||||
'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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
const SizedBox(width: 48), // Balance the back button
|
child: Column(
|
||||||
],
|
mainAxisSize: MainAxisSize.min,
|
||||||
),
|
children: [
|
||||||
),
|
const Text(
|
||||||
|
'Login to Room',
|
||||||
// Info banner
|
style: TextStyle(
|
||||||
Container(
|
color: Colors.white,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
fontSize: 18,
|
||||||
padding: const EdgeInsets.all(16),
|
fontWeight: FontWeight.bold,
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
Text(
|
||||||
),
|
widget.contact.displayName,
|
||||||
child: Row(
|
style: const TextStyle(
|
||||||
children: [
|
color: Colors.grey,
|
||||||
Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer),
|
fontSize: 14,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
// Password input (fixed at bottom)
|
||||||
|
Container(
|
||||||
const Spacer(),
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
// Password input
|
color: Color(0xFF2D2D2D),
|
||||||
Container(
|
),
|
||||||
padding: EdgeInsets.only(
|
child: Column(
|
||||||
left: 16,
|
mainAxisSize: MainAxisSize.min,
|
||||||
right: 16,
|
|
||||||
top: 16,
|
|
||||||
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Color(0xFF2D2D2D),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
@@ -952,7 +1309,7 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Password',
|
labelText: 'Password',
|
||||||
labelStyle: const TextStyle(color: Colors.grey),
|
labelStyle: const TextStyle(color: Colors.grey),
|
||||||
hintText: 'Enter room password',
|
hintText: 'Enter room password (default: hello)',
|
||||||
hintStyle: const TextStyle(color: Colors.grey),
|
hintStyle: const TextStyle(color: Colors.grey),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -986,9 +1343,7 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isLoggingIn || _passwordController.text.trim().isEmpty
|
onPressed: _isLoggingIn ? null : _loginToRoom,
|
||||||
? null
|
|
||||||
: _loginToRoom,
|
|
||||||
icon: _isLoggingIn
|
icon: _isLoggingIn
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -1007,6 +1362,7 @@ class _RoomLoginSheetState extends State<_RoomLoginSheet> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -9,6 +10,7 @@ import '../providers/connection_provider.dart';
|
|||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
class MessagesTab extends StatefulWidget {
|
class MessagesTab extends StatefulWidget {
|
||||||
final VoidCallback onNavigateToMap;
|
final VoidCallback onNavigateToMap;
|
||||||
@@ -97,8 +99,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (context) => _SarUpdateSheet(
|
builder: (context) => _SarUpdateSheet(
|
||||||
onSend: (sarType, position, notes, channelIdx) async {
|
onSend: (sarType, position, notes, roomPublicKey, sendToChannel) async {
|
||||||
await _sendSarMessage(sarType, position, notes, channelIdx);
|
await _sendSarMessage(sarType, position, notes, roomPublicKey, sendToChannel);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -108,7 +110,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
SarMarkerType sarType,
|
SarMarkerType sarType,
|
||||||
Position position,
|
Position position,
|
||||||
String? notes,
|
String? notes,
|
||||||
int channelIdx,
|
Uint8List? roomPublicKey,
|
||||||
|
bool sendToChannel,
|
||||||
) async {
|
) async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
@@ -123,6 +126,17 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return;
|
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 {
|
try {
|
||||||
// Format: S:<emoji>:<latitude>,<longitude>
|
// Format: S:<emoji>:<latitude>,<longitude>
|
||||||
final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}';
|
final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}';
|
||||||
@@ -132,21 +146,37 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
? '$sarMessage $notes'
|
? '$sarMessage $notes'
|
||||||
: sarMessage;
|
: sarMessage;
|
||||||
|
|
||||||
// Send SAR message to selected room/channel
|
if (sendToChannel) {
|
||||||
await connectionProvider.sendChannelMessage(
|
// Send to public channel (ephemeral, over-the-air only)
|
||||||
channelIdx: channelIdx,
|
await connectionProvider.sendChannelMessage(
|
||||||
text: fullMessage,
|
channelIdx: 0,
|
||||||
);
|
text: fullMessage,
|
||||||
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final channelName = channelIdx == 0 ? 'Public Channel' : 'Channel $channelIdx';
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
SnackBar(
|
||||||
SnackBar(
|
content: Text('${sarType.displayName} marker broadcast to public channel'),
|
||||||
content: Text('${sarType.displayName} marker sent to $channelName'),
|
backgroundColor: Colors.orange,
|
||||||
backgroundColor: Colors.green,
|
duration: const Duration(seconds: 2),
|
||||||
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) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -577,7 +607,7 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
// SAR Update Sheet
|
// SAR Update Sheet
|
||||||
class _SarUpdateSheet extends StatefulWidget {
|
class _SarUpdateSheet extends StatefulWidget {
|
||||||
final Future<void> Function(SarMarkerType, Position, String?, int) onSend;
|
final Future<void> Function(SarMarkerType, Position, String?, Uint8List?, bool) onSend;
|
||||||
|
|
||||||
const _SarUpdateSheet({required this.onSend});
|
const _SarUpdateSheet({required this.onSend});
|
||||||
|
|
||||||
@@ -590,7 +620,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
Position? _currentPosition;
|
Position? _currentPosition;
|
||||||
bool _loadingLocation = false;
|
bool _loadingLocation = false;
|
||||||
String? _locationError;
|
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();
|
final TextEditingController _notesController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -756,7 +786,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Room/Channel selection
|
// Destination selection (compact dropdown with rooms and channel)
|
||||||
const Text(
|
const Text(
|
||||||
'Send To',
|
'Send To',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -768,8 +798,37 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Consumer<ContactsProvider>(
|
Consumer<ContactsProvider>(
|
||||||
builder: (context, contactsProvider, child) {
|
builder: (context, contactsProvider, child) {
|
||||||
// Build list of available rooms/channels
|
// Get all valid destinations (rooms + channels)
|
||||||
final rooms = contactsProvider.rooms;
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
@@ -778,8 +837,18 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: DropdownButtonHideUnderline(
|
child: DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<int>(
|
child: DropdownButton<Contact>(
|
||||||
value: _selectedChannelIdx,
|
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),
|
dropdownColor: const Color(0xFF2D2D2D),
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -787,49 +856,80 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.arrow_drop_down, color: Colors.white),
|
icon: const Icon(Icons.arrow_drop_down, color: Colors.white),
|
||||||
items: [
|
items: destinations.map((contact) {
|
||||||
// Public Channel (always available)
|
return DropdownMenuItem<Contact>(
|
||||||
const DropdownMenuItem<int>(
|
value: contact,
|
||||||
value: 0,
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.public, size: 18, color: Colors.white),
|
Icon(
|
||||||
SizedBox(width: 12),
|
contact.isChannel ? Icons.public : Icons.storage,
|
||||||
Text('Public Channel'),
|
size: 18,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
contact.displayName,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
// Room channels
|
}).toList(),
|
||||||
...rooms.asMap().entries.map((entry) {
|
|
||||||
final idx = entry.key + 1; // Rooms start at channel 1
|
|
||||||
final room = entry.value;
|
|
||||||
return DropdownMenuItem<int>(
|
|
||||||
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(),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
setState(() => _selectedContact = value);
|
||||||
setState(() => _selectedChannelIdx = 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),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Location display
|
// Location display
|
||||||
@@ -1016,7 +1116,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _currentPosition == null
|
onPressed: _currentPosition == null || _selectedContact == null
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
await widget.onSend(
|
await widget.onSend(
|
||||||
@@ -1025,7 +1125,10 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
_notesController.text.trim().isEmpty
|
_notesController.text.trim().isEmpty
|
||||||
? null
|
? null
|
||||||
: _notesController.text.trim(),
|
: _notesController.text.trim(),
|
||||||
_selectedChannelIdx,
|
_selectedContact!.isChannel
|
||||||
|
? null
|
||||||
|
: _selectedContact!.publicKey,
|
||||||
|
_selectedContact!.isChannel,
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -1128,3 +1231,4 @@ class _MarkerTypeChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
@@ -22,6 +23,7 @@ typedef OnNoMoreMessagesCallback = void Function();
|
|||||||
typedef OnMessageWaitingCallback = void Function();
|
typedef OnMessageWaitingCallback = void Function();
|
||||||
typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag);
|
typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag);
|
||||||
typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix);
|
typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix);
|
||||||
|
typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey);
|
||||||
typedef OnErrorCallback = void Function(String error);
|
typedef OnErrorCallback = void Function(String error);
|
||||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ class MeshCoreBleService {
|
|||||||
OnMessageWaitingCallback? onMessageWaiting;
|
OnMessageWaitingCallback? onMessageWaiting;
|
||||||
OnLoginSuccessCallback? onLoginSuccess;
|
OnLoginSuccessCallback? onLoginSuccess;
|
||||||
OnLoginFailCallback? onLoginFail;
|
OnLoginFailCallback? onLoginFail;
|
||||||
|
OnAdvertReceivedCallback? onAdvertReceived;
|
||||||
OnErrorCallback? onError;
|
OnErrorCallback? onError;
|
||||||
|
|
||||||
// Internal state
|
// Internal state
|
||||||
@@ -370,6 +373,10 @@ class MeshCoreBleService {
|
|||||||
print(' → Handling LoginFail push');
|
print(' → Handling LoginFail push');
|
||||||
_handleLoginFail(reader);
|
_handleLoginFail(reader);
|
||||||
break;
|
break;
|
||||||
|
case MeshCoreConstants.respCurrTime:
|
||||||
|
print(' → Handling CurrentTime');
|
||||||
|
_handleCurrentTime(reader);
|
||||||
|
break;
|
||||||
case MeshCoreConstants.respNoMoreMessages:
|
case MeshCoreConstants.respNoMoreMessages:
|
||||||
print(' → Response: No More Messages');
|
print(' → Response: No More Messages');
|
||||||
onNoMoreMessages?.call();
|
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) {
|
void _handleAdvert(BufferReader reader) {
|
||||||
try {
|
try {
|
||||||
print(' [Advert] Parsing advert...');
|
print(' [Advert] Parsing advert push notification...');
|
||||||
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
||||||
|
|
||||||
// Advert format: 32 bytes public key
|
// Advert format: 32 bytes public key
|
||||||
if (reader.remainingBytesCount >= 32) {
|
if (reader.remainingBytesCount >= 32) {
|
||||||
final publicKey = reader.readBytes(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
|
// Consume any remaining bytes
|
||||||
if (reader.hasRemaining) {
|
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');
|
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) {
|
void _handleLogRxData(BufferReader reader) {
|
||||||
try {
|
try {
|
||||||
print(' [LogRxData] Parsing log rx data...');
|
print(' [LogRxData] Parsing log rx data...');
|
||||||
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
||||||
|
|
||||||
// This is encrypted/encoded data - just consume it
|
|
||||||
final data = reader.readRemainingBytes();
|
final data = reader.readRemainingBytes();
|
||||||
print(' Data length: ${data.length} bytes');
|
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 = <String>[];
|
||||||
|
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');
|
print(' ✅ [LogRxData] Parsed successfully');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(' ❌ [LogRxData] Parsing error: $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)
|
/// Handle Error response (RESP_CODE_ERR)
|
||||||
///
|
///
|
||||||
/// Protocol format:
|
/// Protocol format:
|
||||||
@@ -1170,6 +1346,57 @@ class MeshCoreBleService {
|
|||||||
await _writeData(writer.toBytes());
|
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<void> 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)
|
/// Send text message to contact (DM)
|
||||||
///
|
///
|
||||||
/// Protocol format (CMD_SEND_TXT_MSG):
|
/// Protocol format (CMD_SEND_TXT_MSG):
|
||||||
@@ -1255,6 +1482,19 @@ class MeshCoreBleService {
|
|||||||
await _writeData(writer.toBytes());
|
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<void> getDeviceTime() async {
|
||||||
|
final writer = BufferWriter();
|
||||||
|
writer.writeByte(MeshCoreConstants.cmdGetDeviceTime);
|
||||||
|
await _writeData(writer.toBytes());
|
||||||
|
}
|
||||||
|
|
||||||
/// Set device time
|
/// Set device time
|
||||||
Future<void> setDeviceTime() async {
|
Future<void> setDeviceTime() async {
|
||||||
final writer = BufferWriter();
|
final writer = BufferWriter();
|
||||||
@@ -1352,24 +1592,52 @@ class MeshCoreBleService {
|
|||||||
|
|
||||||
/// Send login request to room or repeater
|
/// 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):
|
/// Protocol format (CMD_SEND_LOGIN):
|
||||||
/// - 1 byte: command code (26)
|
/// - 1 byte: command code (26)
|
||||||
/// - 32 bytes: public key (room or repeater)
|
/// - 4 bytes: sender timestamp (uint32, epoch seconds - current time)
|
||||||
/// - N bytes: password (remainder of frame, varchar, max 15 bytes)
|
/// - 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)
|
/// 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<void> loginToRoom({
|
Future<void> loginToRoom({
|
||||||
required Uint8List roomPublicKey,
|
required Uint8List roomPublicKey,
|
||||||
required String password,
|
required String password,
|
||||||
|
int syncSince = 0, // 0 = get all messages
|
||||||
}) async {
|
}) async {
|
||||||
if (password.length > 15) {
|
if (password.length > 15) {
|
||||||
throw ArgumentError('Password exceeds 15 character limit');
|
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();
|
final writer = BufferWriter();
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendLogin);
|
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.writeBytes(roomPublicKey); // 32 bytes
|
||||||
writer.writeString(password); // Max 15 bytes
|
writer.writeString(password); // Max 15 bytes, null-terminated
|
||||||
await _writeData(writer.toBytes());
|
await _writeData(writer.toBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,9 @@ class MapMarkers {
|
|||||||
case ContactType.repeater:
|
case ContactType.repeater:
|
||||||
return Colors.deepPurple; // Purple for repeaters
|
return Colors.deepPurple; // Purple for repeaters
|
||||||
case ContactType.room:
|
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:
|
case ContactType.none:
|
||||||
return Colors.grey;
|
return Colors.grey;
|
||||||
}
|
}
|
||||||
@@ -326,6 +328,8 @@ class MapMarkers {
|
|||||||
return Icons.router; // Router icon for repeaters
|
return Icons.router; // Router icon for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Icons.forum; // Forum/chat icon for rooms
|
return Icons.forum; // Forum/chat icon for rooms
|
||||||
|
case ContactType.channel:
|
||||||
|
return Icons.public; // Public icon for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
return Icons.help_outline;
|
return Icons.help_outline;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user