mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Refactor message handling and enhance BLE connection management
- Cleaned up message formatting in MessagesTab for better readability. - Improved SAR message parsing to include optional inline messages. - Updated BLE connection manager to monitor RSSI values and added callback for RSSI updates. - Adjusted drawing message parser to remove sender name from JSON and extract it from packet metadata. - Enhanced drawing toolbar to reflect changes in message creation without sender name. - Ensured consistent error handling and logging across BLE operations.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -43,3 +43,5 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
*.zip
|
||||
*.ipa
|
||||
@@ -1,257 +0,0 @@
|
||||
# Advertisement Path Tracking Implementation
|
||||
|
||||
## Overview
|
||||
Contacts now automatically track their advertisement location history, allowing visualization of movement paths on the map.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Data Model (lib/models/)
|
||||
|
||||
#### AdvertLocation Model
|
||||
**File**: `lib/models/advert_location.dart`
|
||||
|
||||
- Stores a single GPS point with timestamp
|
||||
- Provides `timeAgo` display formatting
|
||||
- Immutable value object with proper equality implementation
|
||||
|
||||
#### Contact Model Updates
|
||||
**File**: `lib/models/contact.dart`
|
||||
|
||||
**New fields:**
|
||||
- `advertHistory: List<AdvertLocation>` - Stores up to 100 most recent location points
|
||||
|
||||
**New methods:**
|
||||
- `addAdvertLocation(LatLng, DateTime)` - Intelligently adds location to history
|
||||
- Deduplicates points (skips if <5m apart and <60s interval)
|
||||
- Maintains max 100 points (oldest removed first)
|
||||
- Uses Haversine formula for distance calculation
|
||||
|
||||
### 2. State Management (lib/providers/)
|
||||
|
||||
#### ContactsProvider Updates
|
||||
**File**: `lib/providers/contacts_provider.dart`
|
||||
|
||||
**Modified**: `addOrUpdateContact()` method
|
||||
- Automatically adds advertisement location to history when contact is updated
|
||||
- Preserves existing history when updating contacts
|
||||
- Timestamp extracted from `lastAdvert` field (Unix seconds)
|
||||
|
||||
#### MapProvider Updates
|
||||
**File**: `lib/providers/map_provider.dart`
|
||||
|
||||
**New state:**
|
||||
- `_visibleContactPaths: Set<String>` - Tracks which paths are currently visible
|
||||
|
||||
**New methods:**
|
||||
- `toggleContactPath(publicKeyHex)` - Show/hide path for specific contact
|
||||
- `isContactPathVisible(publicKeyHex)` - Check path visibility
|
||||
- `hideAllPaths()` - Clear all visible paths
|
||||
- `showOnlyPath(publicKeyHex)` - Isolate single contact's path
|
||||
|
||||
### 3. Map Rendering (lib/screens/)
|
||||
|
||||
#### MapTab Updates
|
||||
**File**: `lib/screens/map_tab.dart`
|
||||
|
||||
**Added**: `PolylineLayer` before `MarkerLayer`
|
||||
- Renders blue polylines with white borders (3px stroke + 1px border)
|
||||
- Only renders paths for contacts marked as visible in MapProvider
|
||||
- Only renders if contact has ≥2 location points
|
||||
- Uses `Consumer<MapProvider>` to reactively update when visibility changes
|
||||
|
||||
**Visual styling:**
|
||||
- Color: `Colors.blue` with 70% opacity
|
||||
- Border: `Colors.white` with 50% opacity
|
||||
- Stroke width: 3px (main), 1px (border)
|
||||
|
||||
### 4. User Interface (lib/widgets/)
|
||||
|
||||
#### DetailedCompassDialog Updates
|
||||
**File**: `lib/widgets/map/detailed_compass_dialog.dart`
|
||||
|
||||
**Added**: Path toggle button in contact detail view
|
||||
- Only visible when contact has ≥2 location points
|
||||
- Shows route icon (filled when active, outlined when inactive)
|
||||
- Tooltip displays current state and point count
|
||||
- Primary color when path is visible
|
||||
- Uses `Consumer<MapProvider>` for reactivity
|
||||
|
||||
**User flow:**
|
||||
1. Tap contact marker on map → opens detailed compass dialog
|
||||
2. If contact has movement history (≥2 points), path toggle button appears
|
||||
3. Tap button → path appears on map as blue polyline
|
||||
4. Tap again → path disappears
|
||||
|
||||
### 5. Data Flow
|
||||
|
||||
```
|
||||
BLE Device → PUSH_CODE_NEW_ADVERT (0x8A)
|
||||
↓
|
||||
FrameParser.parseContact()
|
||||
↓
|
||||
ContactsProvider.addOrUpdateContact()
|
||||
↓
|
||||
Contact.addAdvertLocation() [auto-deduplication]
|
||||
↓
|
||||
advertHistory updated (max 100 points)
|
||||
↓
|
||||
MapTab.PolylineLayer renders if path visible
|
||||
```
|
||||
|
||||
### 6. Storage Behavior
|
||||
|
||||
**Persistence**: Advertisement history is persisted via `ContactStorageService`
|
||||
- Uses `contact_storage.json` in app documents directory
|
||||
- Automatically saved when contacts are updated
|
||||
- Loaded on app startup
|
||||
|
||||
**Capacity**: Each contact stores up to 100 location points
|
||||
- Oldest points automatically removed when limit reached
|
||||
- Prevents unbounded memory growth
|
||||
|
||||
## Usage Example
|
||||
|
||||
1. **Automatic tracking** - No user action required:
|
||||
```
|
||||
Contact broadcasts location → History automatically updated
|
||||
```
|
||||
|
||||
2. **View path on map**:
|
||||
```
|
||||
Tap contact marker → Path toggle button → Tap to show → Blue line appears
|
||||
```
|
||||
|
||||
3. **Multiple paths**:
|
||||
```
|
||||
Each contact has independent path visibility
|
||||
Can show multiple paths simultaneously
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Deduplication**: Prevents excessive data accumulation for stationary contacts
|
||||
- Skip if <5 meters apart AND <60 seconds interval
|
||||
|
||||
2. **Bounded history**: Max 100 points per contact
|
||||
- Typical SAR operation: 1 point/minute × 8 hours = 480 points (trimmed to 100)
|
||||
|
||||
3. **Conditional rendering**: Polylines only rendered when:
|
||||
- Path visibility enabled via MapProvider
|
||||
- Contact has ≥2 location points
|
||||
|
||||
4. **Memory efficiency**:
|
||||
- Each AdvertLocation: ~48 bytes (2 doubles + DateTime)
|
||||
- Max per contact: 100 × 48 = ~4.8KB
|
||||
- 50 contacts: ~240KB total
|
||||
|
||||
## Future: GPX Export (Not Implemented)
|
||||
|
||||
### Rationale for Deferring
|
||||
GPX export was intentionally NOT implemented in this iteration to:
|
||||
1. Validate path tracking UX first
|
||||
2. Gather user feedback on data granularity needs
|
||||
3. Determine preferred export formats (GPX vs KML vs GeoJSON)
|
||||
|
||||
### Implementation Considerations
|
||||
|
||||
When implementing GPX export, consider:
|
||||
|
||||
1. **Track segmentation**:
|
||||
```xml
|
||||
<trk>
|
||||
<name>Contact Name - YYYY-MM-DD</name>
|
||||
<trkseg>
|
||||
<trkpt lat="46.0569" lon="14.5058">
|
||||
<time>2025-01-15T10:30:00Z</time>
|
||||
</trkpt>
|
||||
<!-- More points -->
|
||||
</trkseg>
|
||||
</trk>
|
||||
```
|
||||
|
||||
2. **Metadata**:
|
||||
- Contact name
|
||||
- Date range of track
|
||||
- Device type (from contact telemetry)
|
||||
- Total distance traveled
|
||||
- Duration
|
||||
|
||||
3. **Gap handling**:
|
||||
- Break into segments if gap >15 minutes between points
|
||||
- Prevents drawing straight lines across large time gaps
|
||||
|
||||
4. **Multi-contact export**:
|
||||
- Option to export all visible paths as separate tracks
|
||||
- Single GPX file with multiple `<trk>` elements
|
||||
|
||||
5. **UI integration**:
|
||||
- Add "Export Path" button in detailed compass dialog
|
||||
- Share sheet for exporting GPX file
|
||||
- Option to select date range
|
||||
|
||||
### Recommended Package
|
||||
```yaml
|
||||
dependencies:
|
||||
gpx: ^2.2.0 # GPX file generation and parsing
|
||||
```
|
||||
|
||||
### Sample Implementation (Future)
|
||||
```dart
|
||||
import 'package:gpx/gpx.dart';
|
||||
|
||||
String exportContactPathToGpx(Contact contact) {
|
||||
final gpx = Gpx();
|
||||
gpx.creator = 'MeshCore SAR';
|
||||
|
||||
final track = Trk();
|
||||
track.name = '${contact.advName} - ${DateTime.now().toIso8601String()}';
|
||||
|
||||
final segment = Trkseg();
|
||||
for (final point in contact.advertHistory.reversed) {
|
||||
segment.trkpts.add(Wpt(
|
||||
lat: point.location.latitude,
|
||||
lon: point.location.longitude,
|
||||
time: point.timestamp,
|
||||
));
|
||||
}
|
||||
|
||||
track.trksegs.add(segment);
|
||||
gpx.trks.add(track);
|
||||
|
||||
return GpxWriter().asString(gpx, pretty: true);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Advertisement locations automatically tracked when contact updates
|
||||
- [x] Deduplication prevents duplicate points for stationary contacts
|
||||
- [x] History limited to 100 points per contact
|
||||
- [x] Path toggle button appears only when ≥2 points exist
|
||||
- [x] Polyline renders correctly on map
|
||||
- [x] Path visibility persists across dialog open/close
|
||||
- [x] Multiple contact paths can be visible simultaneously
|
||||
- [ ] GPX export (deferred to future iteration)
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **No manual path clearing**: Users cannot manually clear a contact's path history
|
||||
- Workaround: Path auto-trims to 100 points
|
||||
|
||||
2. **No date range filtering**: Cannot view path for specific time period
|
||||
- All points always rendered (up to 100)
|
||||
|
||||
3. **No distance/duration display**: Path metadata not calculated
|
||||
- Future enhancement: Show "Total: 2.4km over 3h"
|
||||
|
||||
## Migration Notes
|
||||
|
||||
**Existing contacts**: No migration required
|
||||
- Existing contacts start with empty `advertHistory`
|
||||
- History begins accumulating from first update after app upgrade
|
||||
- No data loss or corruption risk
|
||||
|
||||
**Storage format**: JSON-compatible
|
||||
- `advertHistory` serialized as array of objects
|
||||
- Standard DateTime ISO-8601 strings
|
||||
- LatLng as lat/lon decimal degrees
|
||||
303
ADVERT_SYSTEM.md
303
ADVERT_SYSTEM.md
@@ -1,303 +0,0 @@
|
||||
# MeshCore Advertisement System
|
||||
|
||||
This document explains how the MeshCore mesh network advertisement system works and how your app receives and processes contact updates.
|
||||
|
||||
## Overview
|
||||
|
||||
The MeshCore mesh network uses a broadcast advertisement system where nodes periodically announce their presence, location, and metadata to the network. Your Flutter app receives these advertisements and automatically updates the contact list.
|
||||
|
||||
## Advertisement Flow
|
||||
|
||||
### 1. Node Broadcasts Advertisement
|
||||
|
||||
When a node in the mesh network wants to announce its presence:
|
||||
- The node broadcasts an advertisement packet over LoRa
|
||||
- Advertisement contains: public key, name, location, type, etc.
|
||||
- Advertisements are typically sent every few minutes or when data changes
|
||||
|
||||
### 2. Companion Radio Receives Advertisement
|
||||
|
||||
Your BLE-connected companion radio listens to the mesh network and receives these advertisements:
|
||||
|
||||
```
|
||||
[Mesh Network] ───(LoRa)──→ [Companion Radio]
|
||||
```
|
||||
|
||||
### 3. Companion Radio Notifies App
|
||||
|
||||
The companion radio forwards advertisement notifications to your app via BLE:
|
||||
|
||||
#### Step 3a: PUSH_CODE_ADVERT (0x80)
|
||||
First, you receive a simple notification that an advert was received:
|
||||
|
||||
```dart
|
||||
flutter: 📥 [RX] Received: ADVERT (0x80)
|
||||
flutter: Data size: 33 bytes
|
||||
flutter: Payload: 32 bytes
|
||||
flutter: → Handling Advert push
|
||||
flutter: [Advert] Parsing advert push notification...
|
||||
flutter: 📡 ADVERT RECEIVED FROM NODE:
|
||||
flutter: Public key prefix (6 bytes): a5:9c:36:02:c0:d7
|
||||
flutter: Public key (full 32 bytes): a5:9c:36:02:c0:d7:e4:c3:...
|
||||
flutter: ℹ️ This indicates the node is broadcasting its presence
|
||||
flutter: ℹ️ The companion radio will automatically update contact info
|
||||
flutter: ℹ️ Expected follow-up:
|
||||
flutter: - If manual_add_contacts=0: PUSH_CODE_NEW_ADVERT with full details
|
||||
flutter: - If manual_add_contacts=1: Call CMD_GET_CONTACTS to sync
|
||||
```
|
||||
|
||||
**Protocol Format:**
|
||||
```
|
||||
[0x80] - PUSH_CODE_ADVERT
|
||||
[32 bytes] - Public key of advertising node
|
||||
```
|
||||
|
||||
#### Step 3b: PUSH_CODE_NEW_ADVERT (0x8A) - Automatic Contact Update
|
||||
|
||||
If your device has `manual_add_contacts=0` (automatic mode), the companion radio automatically sends the full contact details:
|
||||
|
||||
```dart
|
||||
flutter: 📥 [RX] Received: NEW_ADVERT (0x8A)
|
||||
flutter: Data size: 145 bytes
|
||||
flutter: Payload: 144 bytes
|
||||
flutter: → Handling NewAdvert push
|
||||
flutter: [NewAdvert] Parsing new advertisement...
|
||||
flutter: Public key prefix: a5:9c:36:02:c0:d7
|
||||
flutter: Type byte: 1 → Type: ContactType.chat
|
||||
flutter: Advertised name: "SAR Team Alpha"
|
||||
flutter: Latitude: 46.056900°
|
||||
flutter: Longitude: 14.505800°
|
||||
flutter: ✅ [NewAdvert] Parsed successfully - new contact advertised on network
|
||||
```
|
||||
|
||||
**Protocol Format:**
|
||||
```
|
||||
[0x8A] - PUSH_CODE_NEW_ADVERT
|
||||
[32 bytes] - Public key
|
||||
[1 byte] - Type (ADV_TYPE_*)
|
||||
[1 byte] - Flags
|
||||
[1 byte] - Out path length
|
||||
[64 bytes] - Out path
|
||||
[32 bytes] - Advertised name (null-terminated)
|
||||
[4 bytes] - Last advert timestamp (uint32)
|
||||
[4 bytes] - Latitude * 1E6 (int32)
|
||||
[4 bytes] - Longitude * 1E6 (int32)
|
||||
[4 bytes] - Last modified timestamp (uint32)
|
||||
```
|
||||
|
||||
The app automatically adds/updates this contact via the `onContactReceived` callback!
|
||||
|
||||
## Manual vs Automatic Contact Management
|
||||
|
||||
Your companion radio has a setting called `manual_add_contacts`:
|
||||
|
||||
### Automatic Mode (manual_add_contacts=0) - RECOMMENDED
|
||||
|
||||
**Behavior:**
|
||||
1. ✅ PUSH_CODE_ADVERT (0x80) received → just informational
|
||||
2. ✅ PUSH_CODE_NEW_ADVERT (0x8A) received → **contact automatically added to app**
|
||||
3. ✅ No action needed from app
|
||||
|
||||
**Advantages:**
|
||||
- Contacts appear instantly when they advertise
|
||||
- No need to manually sync
|
||||
- Perfect for SAR operations where team members join dynamically
|
||||
|
||||
### Manual Mode (manual_add_contacts=1)
|
||||
|
||||
**Behavior:**
|
||||
1. ✅ PUSH_CODE_ADVERT (0x80) received → informational
|
||||
2. ❌ PUSH_CODE_NEW_ADVERT (0x8A) NOT sent
|
||||
3. 📞 App must call `CMD_GET_CONTACTS` to sync
|
||||
|
||||
**When to use:**
|
||||
- When you want control over which contacts are added
|
||||
- When bandwidth is very limited
|
||||
- When you have a static team roster
|
||||
|
||||
## Implementation in Your App
|
||||
|
||||
### Current Implementation
|
||||
|
||||
The app is already fully configured to handle advertisements automatically:
|
||||
|
||||
```dart
|
||||
// In MeshCoreBleService (_handleNewAdvert)
|
||||
final contact = Contact(
|
||||
publicKey: publicKey,
|
||||
type: type,
|
||||
flags: flags,
|
||||
outPathLen: outPathLen,
|
||||
outPath: outPath,
|
||||
advName: advName,
|
||||
lastAdvert: lastAdvert,
|
||||
advLat: advLat,
|
||||
advLon: advLon,
|
||||
lastMod: lastMod,
|
||||
);
|
||||
|
||||
// This callback automatically updates the contact list
|
||||
onContactReceived?.call(contact);
|
||||
```
|
||||
|
||||
```dart
|
||||
// In AppProvider (_setupCallbacks)
|
||||
connectionProvider.onContactReceived = (contact) {
|
||||
// Automatically add or update contact in the list
|
||||
contactsProvider.addOrUpdateContact(contact);
|
||||
};
|
||||
```
|
||||
|
||||
### Event Flow
|
||||
|
||||
```
|
||||
[Mesh Node Advertises]
|
||||
↓
|
||||
[Companion Radio Receives via LoRa]
|
||||
↓
|
||||
[PUSH_CODE_ADVERT (0x80) sent to app]
|
||||
↓ (if manual_add_contacts=0)
|
||||
[PUSH_CODE_NEW_ADVERT (0x8A) sent to app]
|
||||
↓
|
||||
[onContactReceived callback fired]
|
||||
↓
|
||||
[contactsProvider.addOrUpdateContact(contact)]
|
||||
↓
|
||||
[UI automatically updates via notifyListeners()]
|
||||
```
|
||||
|
||||
## Checking Your Device Settings
|
||||
|
||||
To see if your device is in automatic or manual mode:
|
||||
|
||||
```dart
|
||||
// Check the manualAddContacts field from SelfInfo
|
||||
final manualMode = connectionProvider.deviceInfo.manualAddContacts;
|
||||
|
||||
if (manualMode == false) {
|
||||
print('✅ Automatic mode: Contacts will appear automatically');
|
||||
} else {
|
||||
print('⚠️ Manual mode: You need to call getContacts() after adverts');
|
||||
}
|
||||
```
|
||||
|
||||
You can change this setting:
|
||||
|
||||
```dart
|
||||
await connectionProvider.setOtherParams(
|
||||
manualAddContacts: 0, // 0 = automatic, 1 = manual
|
||||
telemetryModes: currentTelemetryModes,
|
||||
advertLocationPolicy: currentLocationPolicy,
|
||||
);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "I receive ADVERT (0x80) but no NEW_ADVERT (0x8A)"
|
||||
|
||||
**Cause:** Your device is in manual mode (`manual_add_contacts=1`)
|
||||
|
||||
**Solution:**
|
||||
1. Check device settings via SelfInfo
|
||||
2. Change to automatic mode, OR
|
||||
3. Call `CMD_GET_CONTACTS` after receiving adverts
|
||||
|
||||
### "Contacts don't appear on the map"
|
||||
|
||||
**Possible causes:**
|
||||
1. Contact type is not `ContactType.chat` (only chat contacts show on map)
|
||||
2. Contact has no GPS coordinates (lat/lon = 0)
|
||||
3. Contact hasn't advertised recently
|
||||
|
||||
**Debug:**
|
||||
```dart
|
||||
// Check contact properties
|
||||
print('Contact: ${contact.advName}');
|
||||
print('Type: ${contact.type}'); // Should be ContactType.chat
|
||||
print('Lat: ${contact.latitude}'); // Should not be null
|
||||
print('Lon: ${contact.longitude}'); // Should not be null
|
||||
```
|
||||
|
||||
### "Room contact not found for login"
|
||||
|
||||
**Cause:** Room hasn't advertised yet or wasn't synced
|
||||
|
||||
**Solution:**
|
||||
```dart
|
||||
// Force sync contacts first
|
||||
await connectionProvider.getContacts();
|
||||
|
||||
// Small delay to ensure contacts are loaded
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Then try login
|
||||
await connectionProvider.loginToRoom(
|
||||
roomPublicKey: roomContact.publicKey,
|
||||
password: 'your_password',
|
||||
);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use automatic mode for SAR operations** - Team members will appear as they join
|
||||
2. **Sync contacts on first connect** - Always call `getContacts()` after connecting
|
||||
3. **Handle both modes gracefully** - Check `manual_add_contacts` setting
|
||||
4. **Monitor advert activity** - Use `onAdvertReceived` callback to show network activity
|
||||
5. **Cache contacts locally** - Don't rely solely on live advertisements
|
||||
|
||||
## Protocol Summary
|
||||
|
||||
| Push Code | Name | When Sent | Contains | Action Required |
|
||||
|-----------|------|-----------|----------|-----------------|
|
||||
| 0x80 | PUSH_CODE_ADVERT | When any node advertises | Just public key | None (informational) |
|
||||
| 0x8A | PUSH_CODE_NEW_ADVERT | After ADVERT, if manual_add_contacts=0 | Full contact details | None (auto-added) |
|
||||
|
||||
## Example Logs
|
||||
|
||||
### Successful Automatic Flow
|
||||
|
||||
```
|
||||
📥 [RX] Received: ADVERT (0x80)
|
||||
📡 ADVERT RECEIVED FROM NODE:
|
||||
Public key prefix (6 bytes): a5:9c:36:02:c0:d7
|
||||
📥 [Provider] Advert received from node
|
||||
Note: Waiting for PUSH_CODE_NEW_ADVERT (0x8A) with full contact details
|
||||
|
||||
📥 [RX] Received: NEW_ADVERT (0x8A)
|
||||
[NewAdvert] Parsing new advertisement...
|
||||
Advertised name: "SAR Team Alpha"
|
||||
Latitude: 46.056900°
|
||||
Longitude: 14.505800°
|
||||
✅ [NewAdvert] Parsed successfully
|
||||
✅ [Provider] Contact added: SAR Team Alpha
|
||||
```
|
||||
|
||||
### Manual Mode Flow
|
||||
|
||||
```
|
||||
📥 [RX] Received: ADVERT (0x80)
|
||||
📡 ADVERT RECEIVED FROM NODE:
|
||||
Public key prefix (6 bytes): a5:9c:36:02:c0:d7
|
||||
📥 [Provider] Advert received from node
|
||||
Note: manual_add_contacts=1, you need to call CMD_GET_CONTACTS
|
||||
|
||||
📤 [TX] Sending command: GET_CONTACTS (0x04)
|
||||
📥 [RX] Received: CONTACTS_START (0x02)
|
||||
📥 [RX] Received: CONTACT (0x03)
|
||||
[Contact] Parsing contact...
|
||||
Advertised name: "SAR Team Alpha"
|
||||
✅ [Provider] Contact added: SAR Team Alpha
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
- `lib/services/meshcore_ble_service.dart:876` - `_handleAdvert()` implementation
|
||||
- `lib/services/meshcore_ble_service.dart:924` - `_handleNewAdvert()` implementation
|
||||
- `lib/providers/connection_provider.dart:164` - `onAdvertReceived` callback setup
|
||||
- `lib/providers/app_provider.dart:44` - Contact sync setup
|
||||
- `lib/models/contact.dart` - Contact data model
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [MeshCore Protocol Documentation](CLAUDE.md) - Full protocol specification
|
||||
- [Contact Management](lib/providers/contacts_provider.dart) - Contact provider implementation
|
||||
@@ -1,638 +0,0 @@
|
||||
# BLE Packet Log Analysis - Message Send/Receive Flow
|
||||
|
||||
**Date**: 2025-01-15
|
||||
**Purpose**: Analyze BLE packet logs to understand message transmission and delivery
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains how to use the **BLE Packet Log** feature (already implemented in the app) to diagnose message send/receive issues. The app automatically logs ALL BLE communication between the Flutter app and the MeshCore companion device.
|
||||
|
||||
## Quick Start: Viewing Packet Logs
|
||||
|
||||
### Access the Packet Log Screen
|
||||
|
||||
**Currently**: The packet log screen exists but is not accessible from the main UI.
|
||||
|
||||
**Location**: `lib/screens/packet_log_screen.dart`
|
||||
|
||||
### How to Add Navigation (Quick Fix)
|
||||
|
||||
**Option 1: Add to Home Screen AppBar** (`lib/screens/home_screen.dart`):
|
||||
|
||||
```dart
|
||||
// In HomeScreen's AppBar actions:
|
||||
actions: [
|
||||
// ... existing RX/TX indicators ...
|
||||
|
||||
// NEW: Packet log button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.list_alt),
|
||||
tooltip: 'BLE Packet Logs',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PacketLogScreen(
|
||||
bleService: widget.connectionProvider.bleService,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// ... existing long press indicator ...
|
||||
],
|
||||
```
|
||||
|
||||
**Option 2: Add to Debug Menu** (if you have one):
|
||||
|
||||
```dart
|
||||
ListTile(
|
||||
leading: const Icon(Icons.bug_report),
|
||||
title: const Text('BLE Packet Logs'),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PacketLogScreen(
|
||||
bleService: connectionProvider.bleService,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
### Packet Log Features (Already Implemented)
|
||||
|
||||
1. **Auto-logging**: Every BLE packet automatically logged
|
||||
2. **Direction indicators**: RX (received) vs TX (sent) with color coding
|
||||
3. **Opcode names**: Human-readable names (e.g., "CONTACT_MSG_RECV" instead of "0x07")
|
||||
4. **Hex dump**: Full packet data in hexadecimal
|
||||
5. **Search/filter**: Search by hex data, description, or opcode name
|
||||
6. **Export**: Export logs as CSV or TXT for analysis
|
||||
7. **Auto-scroll**: Option to automatically scroll to newest packets
|
||||
|
||||
## Message Send/Receive Protocol Flow
|
||||
|
||||
### Complete Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ USER SENDS MESSAGE │
|
||||
└────────────────┬────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. TX: CMD_SEND_TXT_MSG (0x02) or CMD_SEND_CHANNEL_TXT_MSG (0x03) │
|
||||
│ - Contains: message text, recipient pub key, timestamp │
|
||||
│ - Logged as: PacketDirection.tx │
|
||||
└────────────────┬────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. RX: RESP_CODE_SENT (0x06) │
|
||||
│ - Contains: expected ACK tag, suggested timeout (e.g., 30000ms) │
|
||||
│ - Message status: sending → sent │
|
||||
│ - Logged as: PacketDirection.rx │
|
||||
└────────────────┬────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├──────────────────────────┬─────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
|
||||
│ 3a. SUCCESS PATH │ │ 3b. TIMEOUT PATH │ │ 3c. DIAGNOSTIC PATH │
|
||||
└──────────────────────┘ └──────────────────────┘ └─────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
|
||||
│ RX: PUSH_CODE_ │ │ Timer expires │ │ RX: PUSH_CODE_ │
|
||||
│ SEND_CONFIRMED │ │ (30000ms) │ │ LOG_RX_DATA │
|
||||
│ (0x82) │ │ │ │ (0x88) │
|
||||
│ │ │ Message status: │ │ │
|
||||
│ Contains: │ │ sent → failed │ │ Contains: │
|
||||
│ - ACK code │ │ │ │ - SNR, RSSI │
|
||||
│ - RTT (ms) │ │ No retry triggered │ │ - Raw packet data │
|
||||
│ │ │ (manual retry only) │ │ │
|
||||
│ Message status: │ └──────────────────────┘ │ Diagnostic only │
|
||||
│ sent → delivered │ │ (doesn't affect │
|
||||
└──────────────────────┘ │ message status) │
|
||||
└─────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ REMOTE USER SENDS MESSAGE │
|
||||
└────────────────┬────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. Message arrives at companion device over LoRa │
|
||||
│ - Device stores in internal queue │
|
||||
│ - May trigger LOG_RX_DATA (0x88) diagnostic push │
|
||||
└────────────────┬────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. RX: PUSH_CODE_MSG_WAITING (0x83) │
|
||||
│ - Asynchronous notification: "New message ready" │
|
||||
│ - Contains: no data (just notification) │
|
||||
│ - Logged as: PacketDirection.rx │
|
||||
└────────────────┬────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 3. TX: CMD_SYNC_NEXT_MESSAGE (0x0A) │
|
||||
│ - Request to fetch next message from queue │
|
||||
│ - Contains: no data (just command code) │
|
||||
│ - Logged as: PacketDirection.tx │
|
||||
└────────────────┬────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├──────────────────────────┬─────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
|
||||
│ 4a. DIRECT MESSAGE │ │ 4b. CHANNEL MESSAGE │ │ 4c. QUEUE EMPTY │
|
||||
└──────────────────────┘ └──────────────────────┘ └─────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
|
||||
│ RX: RESP_CODE_ │ │ RX: RESP_CODE_ │ │ RX: RESP_CODE_ │
|
||||
│ CONTACT_MSG_RECV │ │ CHANNEL_MSG_RECV │ │ NO_MORE_MESSAGES │
|
||||
│ (0x07) │ │ (0x08) │ │ (0x0A) │
|
||||
│ │ │ │ │ │
|
||||
│ Contains: │ │ Contains: │ │ Stop syncing loop │
|
||||
│ - Sender pub key │ │ - Channel index │ └─────────────────────┘
|
||||
│ (6 bytes) │ │ - Path length │
|
||||
│ - Path length │ │ - Text type │
|
||||
│ - Text type │ │ - Timestamp │
|
||||
│ - Timestamp │ │ - Text (format: │
|
||||
│ - Text (plain) │ │ "Name: Message") │
|
||||
│ │ │ │
|
||||
│ App displays message │ │ App displays message │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
└──────────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ Loop back to CMD_SYNC_NEXT_MSG │
|
||||
│ until RESP_CODE_NO_MORE_MESSAGES │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
## BLE Packet Log Interpretation Guide
|
||||
|
||||
### Sending a Direct Message
|
||||
|
||||
#### Expected Log Sequence
|
||||
|
||||
```
|
||||
1. [TX] SEND_TXT_MSG (0x02) - 18 bytes
|
||||
Hex: 02 00 00 e8 76 67 67 8b 33 f2 a1 4c d9 48 65 6c 6c 6f
|
||||
Breakdown:
|
||||
02 = CMD_SEND_TXT_MSG
|
||||
00 = TXT_TYPE_PLAIN
|
||||
00 = Attempt 0 (first send)
|
||||
e8 76 67 67 = Timestamp (Little Endian): 1734567912
|
||||
8b 33 f2 a1 4c d9 = Recipient public key prefix (6 bytes)
|
||||
48 65 6c 6c 6f = "Hello" (UTF-8)
|
||||
|
||||
2. [RX] SENT (0x06) - 9 bytes
|
||||
Hex: 06 00 d2 04 00 00 30 75 00 00
|
||||
Breakdown:
|
||||
06 = RESP_CODE_SENT
|
||||
00 = Send type: 0=direct route
|
||||
d2 04 00 00 = Expected ACK tag (Little Endian): 1234
|
||||
30 75 00 00 = Suggested timeout (Little Endian): 30000ms (30 seconds)
|
||||
|
||||
Result: Message now in "Sent" state, waiting for confirmation
|
||||
|
||||
3a. [RX] SEND_CONFIRMED (0x82) - 9 bytes (SUCCESS PATH)
|
||||
Hex: 82 d2 04 00 00 10 27 00 00
|
||||
Breakdown:
|
||||
82 = PUSH_CODE_SEND_CONFIRMED
|
||||
d2 04 00 00 = ACK code (Little Endian): 1234 (matches expected)
|
||||
10 27 00 00 = Round trip time (Little Endian): 10000ms
|
||||
|
||||
Result: Message marked "Delivered", timeout timer cancelled
|
||||
|
||||
3b. (No packet received, timeout after 30000ms) (TIMEOUT PATH)
|
||||
Result: Timeout timer expires, message marked "Failed"
|
||||
```
|
||||
|
||||
### Sending a Channel Message
|
||||
|
||||
#### Expected Log Sequence
|
||||
|
||||
```
|
||||
1. [TX] SEND_CHANNEL_TXT_MSG (0x03) - 13 bytes
|
||||
Hex: 03 00 00 e8 76 67 67 48 69 20 61 6c 6c
|
||||
Breakdown:
|
||||
03 = CMD_SEND_CHANNEL_TXT_MSG
|
||||
00 = TXT_TYPE_PLAIN
|
||||
00 = Channel index 0 (public)
|
||||
e8 76 67 67 = Timestamp (Little Endian): 1734567912
|
||||
48 69 20 61 6c 6c = "Hi all" (UTF-8)
|
||||
|
||||
2. [RX] SENT (0x06) - 9 bytes
|
||||
Hex: 06 01 e3 05 00 00 50 c3 00 00
|
||||
Breakdown:
|
||||
06 = RESP_CODE_SENT
|
||||
01 = Send type: 1=flood mode (broadcast)
|
||||
e3 05 00 00 = Expected ACK/TAG (Little Endian): 1507
|
||||
50 c3 00 00 = Suggested timeout (Little Endian): 50000ms
|
||||
|
||||
Result: Channel message broadcast, waiting for confirmation
|
||||
|
||||
3. [RX] SEND_CONFIRMED (0x82) - 9 bytes
|
||||
Hex: 82 e3 05 00 00 88 13 00 00
|
||||
Breakdown:
|
||||
82 = PUSH_CODE_SEND_CONFIRMED
|
||||
e3 05 00 00 = ACK code (Little Endian): 1507 (matches)
|
||||
88 13 00 00 = RTT (Little Endian): 5000ms
|
||||
|
||||
Result: Broadcast confirmed delivered
|
||||
```
|
||||
|
||||
### Receiving a Direct Message
|
||||
|
||||
#### Expected Log Sequence
|
||||
|
||||
```
|
||||
1. [RX] MSG_WAITING (0x83) - 1 byte
|
||||
Hex: 83
|
||||
Breakdown:
|
||||
83 = PUSH_CODE_MSG_WAITING
|
||||
|
||||
Result: App calls CMD_SYNC_NEXT_MESSAGE
|
||||
|
||||
2. [TX] SYNC_NEXT_MESSAGE (0x0A) - 1 byte
|
||||
Hex: 0a
|
||||
Breakdown:
|
||||
0a = CMD_SYNC_NEXT_MESSAGE
|
||||
|
||||
Result: Request next message from device queue
|
||||
|
||||
3. [RX] CONTACT_MSG_RECV (0x07) - 19 bytes
|
||||
Hex: 07 8b 33 f2 a1 4c d9 ff 00 e8 76 67 67 48 69
|
||||
Breakdown:
|
||||
07 = RESP_CODE_CONTACT_MSG_RECV
|
||||
8b 33 f2 a1 4c d9 = Sender public key prefix (6 bytes)
|
||||
ff = Path length: 0xFF = direct path (not flood)
|
||||
00 = TXT_TYPE_PLAIN
|
||||
e8 76 67 67 = Sender timestamp (Little Endian): 1734567912
|
||||
48 69 = "Hi" (UTF-8)
|
||||
|
||||
Result: Message displayed in app, matched to contact by pub key prefix
|
||||
|
||||
4. [TX] SYNC_NEXT_MESSAGE (0x0A) - 1 byte
|
||||
Hex: 0a
|
||||
|
||||
Result: Check for more messages
|
||||
|
||||
5. [RX] NO_MORE_MESSAGES (0x0A) - 1 byte
|
||||
Hex: 0a
|
||||
Breakdown:
|
||||
0a = RESP_CODE_NO_MORE_MESSAGES
|
||||
|
||||
Result: Stop syncing loop, all messages fetched
|
||||
```
|
||||
|
||||
### Receiving a Channel Message
|
||||
|
||||
#### Expected Log Sequence
|
||||
|
||||
```
|
||||
1. [RX] MSG_WAITING (0x83) - 1 byte
|
||||
Hex: 83
|
||||
|
||||
2. [TX] SYNC_NEXT_MESSAGE (0x0A) - 1 byte
|
||||
Hex: 0a
|
||||
|
||||
3. [RX] CHANNEL_MSG_RECV (0x08) - 22 bytes
|
||||
Hex: 08 00 03 00 e8 76 67 67 4a 6f 68 6e 3a 20 48 65 6c 6c 6f
|
||||
Breakdown:
|
||||
08 = RESP_CODE_CHANNEL_MSG_RECV
|
||||
00 = Channel index 0 (public)
|
||||
03 = Path length: 3 hops
|
||||
00 = TXT_TYPE_PLAIN
|
||||
e8 76 67 67 = Sender timestamp (Little Endian): 1734567912
|
||||
4a 6f 68 6e 3a 20 48 65 6c 6c 6f = "John: Hello" (UTF-8)
|
||||
|
||||
Result: Parse sender name from text ("John"), display message
|
||||
|
||||
4. [TX] SYNC_NEXT_MESSAGE (0x0A) - 1 byte
|
||||
Hex: 0a
|
||||
|
||||
5. [RX] NO_MORE_MESSAGES (0x0A) - 1 byte
|
||||
Hex: 0a
|
||||
```
|
||||
|
||||
## Diagnostic: LOG_RX_DATA Push (0x88)
|
||||
|
||||
### What is LOG_RX_DATA?
|
||||
|
||||
**Purpose**: Diagnostic push notification containing raw over-the-air LoRa packets
|
||||
|
||||
**When it triggers**: Every time the companion device receives a packet from another mesh node
|
||||
|
||||
**Frame format**:
|
||||
```
|
||||
[0x88] = PUSH_CODE_LOG_RX_DATA
|
||||
[1 byte] = SNR × 4 (signed int8, divide by 4 for dB)
|
||||
[1 byte] = RSSI (signed int8, in dBm)
|
||||
[N bytes] = Raw encrypted LoRa packet data
|
||||
```
|
||||
|
||||
### Example LOG_RX_DATA Packet
|
||||
|
||||
```
|
||||
Hex dump:
|
||||
88 = PUSH_CODE_LOG_RX_DATA
|
||||
14 = SNR: 20 (÷4 = 5.0 dB)
|
||||
d6 = RSSI: -42 dBm (signed)
|
||||
f3 e2 a1 9c 7f 3d 42 ... = Raw encrypted mesh packet (high entropy)
|
||||
```
|
||||
|
||||
### App's LOG_RX_DATA Handler
|
||||
|
||||
**Location**: `lib/services/meshcore_ble_service.dart:1017-1333`
|
||||
|
||||
The app already has EXTENSIVE decoding analysis for LOG_RX_DATA packets:
|
||||
|
||||
1. **Signal Quality Metrics**:
|
||||
- SNR (Signal-to-Noise Ratio) in dB
|
||||
- RSSI (Received Signal Strength) in dBm
|
||||
|
||||
2. **Hex Dump**: Formatted 16 bytes per line with ASCII view
|
||||
|
||||
3. **Forced Decoding** (9 different interpretations):
|
||||
- All uint32 values at each offset
|
||||
- All int32 values (GPS coordinates)
|
||||
- All uint16 values
|
||||
- Byte pair correlation (pattern detection)
|
||||
- Nibble distribution analysis
|
||||
- XOR pattern detection (simple encryption)
|
||||
- Checksum/CRC candidates
|
||||
- Bit-level analysis (entropy check)
|
||||
- LoRa modulation parameter detection
|
||||
|
||||
4. **Entropy Calculation**: Detect if packet is encrypted (>70% entropy)
|
||||
|
||||
5. **String Extraction**: Find embedded ASCII strings (4+ printable chars)
|
||||
|
||||
### Why LOG_RX_DATA Packets Don't Affect Messages
|
||||
|
||||
**Critical**: LOG_RX_DATA is **diagnostic only** - it does NOT affect message delivery status!
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Message Send Flow (affects delivery status) │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ TX: CMD_SEND_TXT_MSG (0x02) │
|
||||
│ ↓ │
|
||||
│ RX: RESP_CODE_SENT (0x06) ← Message now "Sent" │
|
||||
│ ↓ │
|
||||
│ RX: PUSH_CODE_SEND_CONFIRMED (0x82) ← Message "Delivered"│
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Diagnostic Flow (does NOT affect delivery status) │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ RX: PUSH_CODE_LOG_RX_DATA (0x88) │
|
||||
│ ↓ │
|
||||
│ Logged to packet log, analyzed for debugging │
|
||||
│ ↓ │
|
||||
│ No state change in MessagesProvider │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Use Cases for LOG_RX_DATA**:
|
||||
1. Monitor mesh network activity in real-time
|
||||
2. Analyze signal quality (SNR/RSSI) for received packets
|
||||
3. Debug packet reception issues
|
||||
4. Understand network topology
|
||||
5. Detect interference or poor RF conditions
|
||||
|
||||
**Note**: The raw packet data is typically encrypted (high entropy ~95%+), so direct decoding is not possible. The app's exhaustive analysis tries to extract any structured information.
|
||||
|
||||
## Troubleshooting Message Issues
|
||||
|
||||
### Symptom: Messages Stuck in "Sending" Status
|
||||
|
||||
**Check packet log for**:
|
||||
1. ✅ `[TX] SEND_TXT_MSG (0x02)` present → Message sent to device
|
||||
2. ❌ `[RX] SENT (0x06)` missing → Device not responding
|
||||
|
||||
**Possible causes**:
|
||||
- BLE connection dropped
|
||||
- Companion device frozen
|
||||
- BLE service not properly initialized
|
||||
|
||||
**Fix**:
|
||||
- Reconnect to device
|
||||
- Check device battery
|
||||
- Restart companion device
|
||||
|
||||
### Symptom: Messages Stuck in "Sent" Status (Never Delivered)
|
||||
|
||||
**Check packet log for**:
|
||||
1. ✅ `[TX] SEND_TXT_MSG (0x02)` present
|
||||
2. ✅ `[RX] SENT (0x06)` present → Message acknowledged by device
|
||||
3. ❌ `[RX] SEND_CONFIRMED (0x82)` missing → No delivery confirmation
|
||||
4. ⏱️ Timeout timer should fire after suggested timeout
|
||||
|
||||
**Check LOG_RX_DATA packets**:
|
||||
- If NO `[RX] LOG_RX_DATA (0x88)` packets: Network is silent, no mesh activity
|
||||
- If many `[RX] LOG_RX_DATA (0x88)` packets: Network is active
|
||||
- Check SNR/RSSI values (should be > -120 dBm)
|
||||
- Low SNR/RSSI indicates poor signal quality
|
||||
|
||||
**Possible causes**:
|
||||
- Recipient device out of range
|
||||
- No mesh route to recipient
|
||||
- Recipient device off/offline
|
||||
- Network congestion (many nodes transmitting)
|
||||
- Poor RF conditions (interference, obstacles)
|
||||
|
||||
**Fix**:
|
||||
- Check recipient device status
|
||||
- Move closer to establish direct line-of-sight
|
||||
- Wait for timeout, then retry
|
||||
- Check if other nodes are receiving messages
|
||||
|
||||
### Symptom: Messages Never Received (No MSG_WAITING)
|
||||
|
||||
**Check packet log for**:
|
||||
1. ❌ `[RX] MSG_WAITING (0x83)` missing → No messages in device queue
|
||||
|
||||
**Possible causes**:
|
||||
- No one sent you a message
|
||||
- Messages filtered by contact flags
|
||||
- Device message queue full (old messages overwritten)
|
||||
- Room not logged in (room messages require login)
|
||||
|
||||
**Fix**:
|
||||
- Verify sender actually sent message
|
||||
- Check contact flags (telemetry_modes, advert_location_policy)
|
||||
- Login to room if expecting room messages
|
||||
- Check device storage (CMD_GET_BATT_AND_STORAGE)
|
||||
|
||||
### Symptom: Channel Messages Not Received
|
||||
|
||||
**Check packet log for**:
|
||||
1. ❌ `[RX] CHANNEL_MSG_RECV (0x08)` never appears after MSG_WAITING
|
||||
|
||||
**Possible causes**:
|
||||
- Message queue only had direct messages, no channel messages
|
||||
- Channel message from unknown sender (name not in contacts)
|
||||
|
||||
**Fix**:
|
||||
- Call `CMD_SYNC_NEXT_MESSAGE` repeatedly until `NO_MORE_MESSAGES`
|
||||
- Check if message appears as `CONTACT_MSG_RECV (0x07)` instead
|
||||
|
||||
### Symptom: Room Messages Not Syncing After Login
|
||||
|
||||
**Check packet log for**:
|
||||
1. ✅ `[TX] SEND_LOGIN (0x1A)` present
|
||||
2. ✅ `[RX] LOGIN_SUCCESS (0x85)` present → Login succeeded
|
||||
3. ⚠️ Immediately called `CMD_SYNC_NEXT_MESSAGE`? → **WRONG!**
|
||||
|
||||
**Protocol compliance check**:
|
||||
```
|
||||
WRONG ❌:
|
||||
LOGIN_SUCCESS → CMD_SYNC_NEXT_MESSAGE → NO_MORE_MESSAGES
|
||||
(Room hasn't pushed messages yet, they arrive 2000ms later!)
|
||||
|
||||
CORRECT ✅:
|
||||
LOGIN_SUCCESS → wait for MSG_WAITING → CMD_SYNC_NEXT_MESSAGE
|
||||
(Room server pushes messages automatically every 1200ms)
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
- Don't call `syncAllMessages()` immediately after login
|
||||
- Wait for `PUSH_CODE_MSG_WAITING (0x83)` notifications
|
||||
- Room server pushes messages automatically (see MESSAGES.md lines 679-728)
|
||||
|
||||
## Export and Analysis
|
||||
|
||||
### Export Packet Logs
|
||||
|
||||
**CSV Export** (for spreadsheet analysis):
|
||||
```csv
|
||||
Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description
|
||||
2025-01-15T10:30:15.123,TX,18,SEND_TXT_MSG,2,"02 00 00 e8 76 67 67 8b 33 f2 a1 4c d9 48 65 6c 6c 6f","Send Text Message"
|
||||
2025-01-15T10:30:15.456,RX,9,SENT,6,"06 00 d2 04 00 00 30 75 00 00",""
|
||||
2025-01-15T10:30:25.789,RX,9,SEND_CONFIRMED,130,"82 d2 04 00 00 10 27 00 00",""
|
||||
```
|
||||
|
||||
**Text Export** (for log analysis):
|
||||
```
|
||||
MeshCore BLE Packet Logs
|
||||
================================================================================
|
||||
Exported: 2025-01-15T10:35:00.000Z
|
||||
Total packets: 127
|
||||
================================================================================
|
||||
|
||||
2025-01-15T10:30:15.123Z [TX] SEND_TXT_MSG (0x02) 18 bytes: 02 00 00 e8 76 67 67 8b 33 f2 a1 4c d9 48 65 6c 6c 6f - Send Text Message
|
||||
2025-01-15T10:30:15.456Z [RX] SENT (0x06) 9 bytes: 06 00 d2 04 00 00 30 75 00 00
|
||||
2025-01-15T10:30:25.789Z [RX] SEND_CONFIRMED (0x82) 9 bytes: 82 d2 04 00 00 10 27 00 00
|
||||
```
|
||||
|
||||
### Analyzing Exports
|
||||
|
||||
**Python script to analyze CSV**:
|
||||
|
||||
```python
|
||||
import csv
|
||||
from datetime import datetime
|
||||
|
||||
with open('ble_packets.csv') as f:
|
||||
reader = csv.DictReader(f)
|
||||
packets = list(reader)
|
||||
|
||||
# Find all sent messages with their ACK tags
|
||||
sent_messages = {}
|
||||
for packet in packets:
|
||||
if packet['Opcode Name'] == 'SENT':
|
||||
# Parse ACK tag from hex data
|
||||
hex_bytes = packet['Hex Data'].split()
|
||||
ack_tag = int.join(hex_bytes[2:6], '', 16) # Little Endian
|
||||
sent_messages[ack_tag] = {
|
||||
'sent_at': datetime.fromisoformat(packet['Timestamp']),
|
||||
'confirmed': False,
|
||||
}
|
||||
|
||||
# Match with confirmations
|
||||
for packet in packets:
|
||||
if packet['Opcode Name'] == 'SEND_CONFIRMED':
|
||||
hex_bytes = packet['Hex Data'].split()
|
||||
ack_tag = int.join(hex_bytes[1:5], '', 16)
|
||||
if ack_tag in sent_messages:
|
||||
sent_messages[ack_tag]['confirmed'] = True
|
||||
sent_messages[ack_tag]['confirmed_at'] = datetime.fromisoformat(packet['Timestamp'])
|
||||
rtt_ms = int.join(hex_bytes[5:9], '', 16)
|
||||
sent_messages[ack_tag]['rtt_ms'] = rtt_ms
|
||||
|
||||
# Report
|
||||
for ack_tag, info in sent_messages.items():
|
||||
if info['confirmed']:
|
||||
rtt = info['confirmed_at'] - info['sent_at']
|
||||
print(f"ACK {ack_tag}: Delivered in {rtt.total_seconds():.3f}s (RTT: {info['rtt_ms']}ms)")
|
||||
else:
|
||||
print(f"ACK {ack_tag}: NOT DELIVERED (timed out)")
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
### Key Takeaways
|
||||
|
||||
1. **Packet Log is Already Implemented**: Full BLE packet logging exists in `lib/screens/packet_log_screen.dart`
|
||||
2. **Just Needs Navigation**: Add a button to navigate to PacketLogScreen from HomeScreen
|
||||
3. **Comprehensive Diagnostics**: App already logs and analyzes everything
|
||||
4. **LOG_RX_DATA is Diagnostic Only**: Does NOT affect message delivery status
|
||||
5. **Timeout Handling Implemented**: Messages automatically fail after timeout (see MESSAGING_IMPROVEMENTS_IMPLEMENTED.md)
|
||||
6. **Retry Logic Implemented**: Manual retry for failed messages (see MESSAGING_IMPROVEMENTS_IMPLEMENTED.md)
|
||||
|
||||
### Quick Reference: Packet Codes
|
||||
|
||||
| Code | Name | Direction | Meaning |
|
||||
|------|------|-----------|---------|
|
||||
| 0x02 | SEND_TXT_MSG | TX | Sending direct message |
|
||||
| 0x03 | SEND_CHANNEL_TXT_MSG | TX | Sending channel message |
|
||||
| 0x06 | SENT | RX | Message accepted, ACK tag provided |
|
||||
| 0x07 | CONTACT_MSG_RECV | RX | Direct message received |
|
||||
| 0x08 | CHANNEL_MSG_RECV | RX | Channel message received |
|
||||
| 0x0A (CMD) | SYNC_NEXT_MESSAGE | TX | Fetch next message |
|
||||
| 0x0A (RESP) | NO_MORE_MESSAGES | RX | Message queue empty |
|
||||
| 0x1A | SEND_LOGIN | TX | Login to room |
|
||||
| 0x82 | SEND_CONFIRMED | RX | Delivery confirmed (with RTT) |
|
||||
| 0x83 | MSG_WAITING | RX | New message available |
|
||||
| 0x85 | LOGIN_SUCCESS | RX | Room login succeeded |
|
||||
| 0x86 | LOGIN_FAIL | RX | Room login failed |
|
||||
| 0x88 | LOG_RX_DATA | RX | Diagnostic: raw over-the-air packet |
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Add Navigation to Packet Log Screen**:
|
||||
- Update `lib/screens/home_screen.dart`
|
||||
- Add IconButton in AppBar actions
|
||||
- Wire to PacketLogScreen
|
||||
|
||||
2. **Test Message Flow**:
|
||||
- Send messages and watch packet log in real-time
|
||||
- Enable auto-scroll to see newest packets
|
||||
- Export logs for offline analysis
|
||||
|
||||
3. **Debug Failed Messages**:
|
||||
- Check for missing SEND_CONFIRMED packets
|
||||
- Analyze LOG_RX_DATA for signal quality issues
|
||||
- Verify timeout values from SENT responses
|
||||
|
||||
## References
|
||||
|
||||
- **BLE Packet Log Implementation**: `lib/screens/packet_log_screen.dart`
|
||||
- **BLE Packet Model**: `lib/models/ble_packet_log.dart`
|
||||
- **BLE Service (Logging)**: `lib/services/meshcore_ble_service.dart:275-319`
|
||||
- **Opcode Names**: `lib/services/meshcore_opcode_names.dart`
|
||||
- **Message Protocol**: `MESSAGES.md`
|
||||
- **Gap Analysis**: `MESSAGE_SEND_RECEIVE_GAP_ANALYSIS.md`
|
||||
- **Timeout/Retry**: `MESSAGING_IMPROVEMENTS_IMPLEMENTED.md`
|
||||
- **Protocol Spec**: `/Users/dz0ny/meshcore-sar/MeshCore/docs/companion.md`
|
||||
109
CLAUDE.md
109
CLAUDE.md
@@ -32,7 +32,10 @@ AI assistant guide for the MeshCore SAR Flutter application.
|
||||
|
||||
```
|
||||
lib/
|
||||
├── models/ # Data models (contact, message, sar_marker, device_info, room_login_state, map_layer)
|
||||
├── models/ # Data models
|
||||
│ ├── contact.dart, message.dart, sar_marker.dart
|
||||
│ ├── map_drawing.dart # MapDrawing, LineDrawing, RectangleDrawing
|
||||
│ ├── device_info.dart, room_login_state.dart, map_layer.dart
|
||||
├── services/ # Business logic
|
||||
│ ├── meshcore_ble_service.dart # BLE coordinator (399 lines)
|
||||
│ ├── protocol/ # Frame parsing & building (628 lines)
|
||||
@@ -40,10 +43,23 @@ lib/
|
||||
│ ├── location_tracking_service.dart # GPS + mesh broadcast (501 lines)
|
||||
│ ├── map_marker_service.dart # Marker generation + geodesic (518 lines)
|
||||
│ └── validation_service.dart # Form validation (511 lines)
|
||||
├── providers/ # State management (ConnectionProvider, ContactsProvider, MessagesProvider, MapProvider, AppProvider)
|
||||
├── providers/ # State management
|
||||
│ ├── connection_provider.dart # BLE connection state
|
||||
│ ├── contacts_provider.dart # Contact list
|
||||
│ ├── messages_provider.dart # Messages + SAR markers
|
||||
│ ├── map_provider.dart # Map navigation
|
||||
│ ├── drawing_provider.dart # Map drawing state
|
||||
│ └── app_provider.dart # Coordinator (uses all above)
|
||||
├── screens/ # UI screens (home, messages, contacts, map, settings, device_config, map_management, packet_log)
|
||||
├── widgets/ # Reusable components (map_markers, messages/, contacts/, map/)
|
||||
└── utils/ # Utilities (sar_message_parser)
|
||||
├── widgets/ # Reusable components
|
||||
│ ├── map_markers.dart # Map marker rendering
|
||||
│ ├── map/ # Map-specific widgets
|
||||
│ │ ├── drawing_layer.dart # Drawing rendering on map
|
||||
│ │ └── drawing_toolbar.dart # Drawing UI controls
|
||||
│ ├── messages/, contacts/ # Feature-specific widgets
|
||||
└── utils/ # Utilities
|
||||
├── sar_message_parser.dart # SAR marker parsing
|
||||
└── drawing_message_parser.dart # Drawing message parsing
|
||||
```
|
||||
|
||||
## MeshCore Protocol
|
||||
@@ -195,14 +211,64 @@ Format: `[Channel] [Type] [Data...]`
|
||||
|
||||
### SAR Message Format
|
||||
|
||||
Format: `S:<emoji>:<latitude>,<longitude>`
|
||||
Format: `S:<emoji>:<latitude>,<longitude>:<optional_message>`
|
||||
|
||||
**Recognized Emojis:**
|
||||
- 🧑 or 👤: Found Person
|
||||
- 🔥: Fire Location
|
||||
- 🏕️ or ⛺: Staging Area
|
||||
|
||||
**Rules:** Must start with `S:`, single emoji after first colon, comma-separated lat/lon, no spaces
|
||||
**Rules:**
|
||||
- Must start with `S:`
|
||||
- Single emoji after first colon
|
||||
- Comma-separated lat/lon coordinates
|
||||
- Optional message after third colon (displayed in message bubble)
|
||||
- No spaces in coordinates section
|
||||
|
||||
**Examples:**
|
||||
- `S:🧑:37.7749,-122.4194` - Basic SAR marker
|
||||
- `S:🔥:40.7128,-74.0060:Large wildfire spreading rapidly` - With message
|
||||
- `S:🏕️:34.0522,-118.2437:Base camp established, supplies available` - With detailed note
|
||||
|
||||
**Message Display:**
|
||||
- SAR markers shown with highlighted colored bubble
|
||||
- Emoji, type name, and coordinates always displayed
|
||||
- Optional message shown in secondary container below coordinates
|
||||
- Tap to navigate to location on map
|
||||
|
||||
### Map Drawing Message Format
|
||||
|
||||
Format: `D:<json>`
|
||||
|
||||
**Ultra-Compact JSON Format:**
|
||||
- **Prefix**: `D:` identifies drawing messages
|
||||
- **Sender**: Extracted from packet metadata (not in JSON)
|
||||
- **Type field (`t`)**: Shape type as integer
|
||||
- `0`: Line drawing
|
||||
- `1`: Rectangle drawing
|
||||
- **Color field (`c`)**: Color index (0-7)
|
||||
- `0`: Red, `1`: Blue, `2`: Green, `3`: Yellow
|
||||
- `4`: Orange, `5`: Purple, `6`: Pink, `7`: Cyan
|
||||
- **Points field (`p`)**: Flat array of coordinates `[lat1,lon1,lat2,lon2,...]`
|
||||
- **Bounds field (`b`)**: Rectangle bounds `[topLat,topLon,botLat,botLon]`
|
||||
|
||||
**Example Line Drawing (red, 2 points):**
|
||||
```json
|
||||
D:{"t":0,"c":0,"p":[45.123,-122.456,45.234,-122.567]}
|
||||
```
|
||||
|
||||
**Example Rectangle Drawing (blue):**
|
||||
```json
|
||||
D:{"t":1,"c":1,"b":[45.1,-122.5,45.2,-122.4]}
|
||||
```
|
||||
|
||||
**Implementation Details:**
|
||||
- Models: `lib/models/map_drawing.dart` (MapDrawing, LineDrawing, RectangleDrawing)
|
||||
- Parser: `lib/utils/drawing_message_parser.dart` (DrawingMessageParser)
|
||||
- Provider: `lib/providers/drawing_provider.dart` (DrawingProvider)
|
||||
- Colors: 8 predefined colors mapped to indices for bandwidth efficiency
|
||||
- Local persistence uses full JSON format with timestamps and IDs
|
||||
- Network transmission uses ultra-compact format (~37% size reduction)
|
||||
|
||||
## State Management Architecture
|
||||
|
||||
@@ -213,6 +279,7 @@ MultiProvider
|
||||
├── ContactsProvider # Contact list
|
||||
├── MessagesProvider # Messages + SAR markers
|
||||
├── MapProvider # Map navigation
|
||||
├── DrawingProvider # Map drawing state
|
||||
└── AppProvider # Coordinator (uses all above)
|
||||
```
|
||||
|
||||
@@ -222,10 +289,18 @@ BLE Device → MeshCoreBleService → ConnectionProvider → AppProvider
|
||||
↓
|
||||
ContactsProvider
|
||||
MessagesProvider
|
||||
DrawingProvider
|
||||
↓
|
||||
UI
|
||||
```
|
||||
|
||||
**Drawing Message Flow:**
|
||||
```
|
||||
User draws → DrawingProvider → DrawingToolbar (share) → ConnectionProvider (BLE)
|
||||
↓
|
||||
Remote User ← UI ← DrawingProvider ← AppProvider ← ConnectionProvider ← BLE Device
|
||||
```
|
||||
|
||||
**Contact Types:**
|
||||
- none(0): Unknown/invalid
|
||||
- chat(1): Team member (shown on map)
|
||||
@@ -321,6 +396,28 @@ Ultra-compact location display, tap to toggle DD/DMS formats, no close button (t
|
||||
2. Add to `allLayers` list
|
||||
3. Layer appears automatically in layer selector UI
|
||||
|
||||
### Working with Map Drawings
|
||||
**Drawing Flow:**
|
||||
1. User selects drawing mode (line/rectangle) → `DrawingProvider.setDrawingMode()`
|
||||
2. User taps map → touch events captured by `DrawingLayer`
|
||||
3. Preview rendered during drawing → `DrawingProvider.getPreviewDrawing()`
|
||||
4. User completes drawing → saved to `DrawingProvider._drawings` list
|
||||
5. User shares drawing → `DrawingToolbar._shareDrawingsToChannel()` or `_shareDrawingsToRoom()`
|
||||
6. Message sent via BLE → `ConnectionProvider.sendChannelMessage()` or `sendTextMessage()`
|
||||
7. Receiver parses message → `DrawingMessageParser.parseDrawingMessage()` with sender from packet
|
||||
8. Drawing added to map → `DrawingProvider.addReceivedDrawing()`
|
||||
|
||||
**Color Management:**
|
||||
- UI uses `DrawingColors.palette` (8 Flutter Color objects)
|
||||
- Network uses color indices (0-7) via `DrawingColors.colorToIndex()`/`indexToColor()`
|
||||
- Persistence uses full ARGB32 color values
|
||||
|
||||
**Key Files:**
|
||||
- Models: `lib/models/map_drawing.dart` (278 lines)
|
||||
- Parser: `lib/utils/drawing_message_parser.dart` (45 lines)
|
||||
- Provider: `lib/providers/drawing_provider.dart` (280 lines)
|
||||
- UI: `lib/widgets/map/drawing_toolbar.dart`, `lib/widgets/map/drawing_layer.dart`
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
# Clock Drift Detection - Implementation Summary
|
||||
|
||||
## Problem
|
||||
|
||||
The user suspected that timestamp-related issues might be causing room login failures. Specifically, clock drift between the Flutter app and the companion radio could affect:
|
||||
- The `sender_timestamp` parameter in `CMD_SEND_LOGIN`
|
||||
- The `sync_since` parameter for message synchronization
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
Implemented `CMD_GET_DEVICE_TIME` (0x05) functionality to query the companion radio's current time and compare it with the app's time to detect clock synchronization issues.
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. **lib/services/meshcore_ble_service.dart**
|
||||
|
||||
#### Added Command Method (lines 1446-1457)
|
||||
```dart
|
||||
/// Get device time from companion radio
|
||||
///
|
||||
/// Queries the companion radio's current time to detect clock drift.
|
||||
/// Response will be RESP_CODE_CURR_TIME (9).
|
||||
///
|
||||
/// Protocol format (CMD_GET_DEVICE_TIME):
|
||||
/// - 1 byte: command code (5)
|
||||
Future<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! 🎉
|
||||
@@ -1,238 +0,0 @@
|
||||
# Debugging "Not Found" Error When Logging Into Room
|
||||
|
||||
## The Problem
|
||||
|
||||
You're seeing this error:
|
||||
```
|
||||
📤 [TX] Sending command: SEND_LOGIN (0x1A)
|
||||
📥 [RX] Received: ERROR (0x01)
|
||||
❌ [Error] Not found
|
||||
```
|
||||
|
||||
This means the companion radio doesn't have a room contact with that public key in its contact table.
|
||||
|
||||
## What's Happening
|
||||
|
||||
When you send `CMD_SEND_LOGIN` with a public key, the companion radio needs to:
|
||||
1. Look up that public key in its internal contact table
|
||||
2. Find the matching room contact
|
||||
3. Send the login request to that room via the mesh network
|
||||
|
||||
**If the contact doesn't exist → ERR_CODE_NOT_FOUND (2)**
|
||||
|
||||
## Your Login Command Breakdown
|
||||
|
||||
From your hex dump:
|
||||
```
|
||||
1a d2 b3 ee 68 00 00 00 00 15 59 89 54 b4 d4 e1 d5 d3 12 a7 4e 44 ed d3 68 95 7c ee f3 3e 86 ec 88 b9 8f ab 62 24 6b ae c5 77 65 74 77 65 74
|
||||
```
|
||||
|
||||
Decoded:
|
||||
- `1a` = CMD_SEND_LOGIN
|
||||
- `d2 b3 ee 68` = timestamp (1754059730)
|
||||
- `00 00 00 00` = sync_since (0 = all messages)
|
||||
- `15 59 89 54 ... ae c5` = Room public key (32 bytes)
|
||||
- `77 65 74 77 65 74` = "wetwet" (password)
|
||||
|
||||
**You're trying to login to room with public key starting with: `15:59:89:54:b4:d4`**
|
||||
|
||||
## How to Fix
|
||||
|
||||
### Option 1: Sync Contacts First (RECOMMENDED)
|
||||
|
||||
Add this before trying to login:
|
||||
|
||||
```dart
|
||||
// In your UI code, before showing the login dialog:
|
||||
await connectionProvider.getContacts();
|
||||
await Future.delayed(Duration(milliseconds: 500));
|
||||
|
||||
// Now show the login dialog - the room should exist
|
||||
```
|
||||
|
||||
### Option 2: Check What Rooms You Have
|
||||
|
||||
Add debug logging to see what rooms are actually synced:
|
||||
|
||||
```dart
|
||||
// In contacts_tab.dart, add a debug button:
|
||||
FloatingActionButton(
|
||||
onPressed: () {
|
||||
final rooms = contactsProvider.rooms;
|
||||
print('📋 Available Rooms (${rooms.length}):');
|
||||
for (final room in rooms) {
|
||||
final pkHex = room.publicKey.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
print(' - ${room.advName}');
|
||||
print(' Public key prefix: $pkHex...');
|
||||
print(' Full public key: ${room.publicKeyHex}');
|
||||
}
|
||||
},
|
||||
child: Icon(Icons.bug_report),
|
||||
)
|
||||
```
|
||||
|
||||
### Option 3: Wait for Room to Advertise
|
||||
|
||||
If the room is actively broadcasting:
|
||||
1. Wait for `PUSH_CODE_ADVERT` (0x80) from the room
|
||||
2. If `manual_add_contacts=0`, you'll automatically receive `PUSH_CODE_NEW_ADVERT` (0x8A)
|
||||
3. The room will be added to your contacts
|
||||
4. Then you can login
|
||||
|
||||
### Option 4: Import Room Contact Manually
|
||||
|
||||
If you have the room's "business card" (from CMD_EXPORT_CONTACT):
|
||||
|
||||
```dart
|
||||
await connectionProvider.importContact(cardData);
|
||||
```
|
||||
|
||||
## Add Pre-Login Check
|
||||
|
||||
Modify your login dialog to check if the room exists first:
|
||||
|
||||
```dart
|
||||
// In _RoomLoginSheetState._loginToRoom()
|
||||
Future<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
|
||||
@@ -1,215 +0,0 @@
|
||||
# Implementation Summary - Room Login Fix
|
||||
|
||||
## Problem Solved
|
||||
|
||||
**Issue:** "Not Found" error when logging into rooms
|
||||
**Root Cause:** Room contact exists in app but not in companion radio's flash storage
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
### 1. **CMD_ADD_UPDATE_CONTACT** Command (meshcore_ble_service.dart)
|
||||
|
||||
Added method to manually add/update contacts on the companion radio:
|
||||
|
||||
```dart
|
||||
Future<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! 🎉
|
||||
@@ -1,686 +0,0 @@
|
||||
# Message Send & Receive - Gap Analysis
|
||||
|
||||
**Date**: 2025-01-14
|
||||
**Purpose**: Identify what's missing in the messaging implementation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Your messaging implementation is **90% complete**! The core functionality works correctly. Here's what's implemented vs what's missing:
|
||||
|
||||
### ✅ What Works (Already Implemented)
|
||||
|
||||
1. ✅ **Sending channel messages** (public broadcast)
|
||||
2. ✅ **Sending direct messages to rooms** (persistent SAR markers)
|
||||
3. ✅ **Receiving messages** via `PUSH_CODE_MSG_WAITING`
|
||||
4. ✅ **Message delivery tracking** (sending → sent → delivered)
|
||||
5. ✅ **SAR marker parsing and display**
|
||||
6. ✅ **Message persistence** (MessageStorageService)
|
||||
7. ✅ **Protocol compliance** (all frame formats correct)
|
||||
|
||||
### ❌ What's Missing (Gaps)
|
||||
|
||||
1. ❌ **Sending direct messages to individual contacts** (only room DMs work)
|
||||
2. ❌ **Timeout handling** for failed messages
|
||||
3. ❌ **Message retry logic** (automatic retries on failure)
|
||||
4. ❌ **User can't send regular messages to contacts** (only SAR markers to rooms)
|
||||
|
||||
---
|
||||
|
||||
## 1. Current Implementation Analysis
|
||||
|
||||
### 1.1 Sending Messages - What Works
|
||||
|
||||
#### ✅ Channel Messages (Public Broadcast)
|
||||
|
||||
**File**: `messages_tab.dart:49-93`
|
||||
|
||||
```dart
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _textController.text.trim();
|
||||
|
||||
// Always send to public channel (channel 0)
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: 0,
|
||||
text: text,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Status**: ✅ **WORKING**
|
||||
- Sends to public channel
|
||||
- Text limit enforced (160 chars)
|
||||
- User feedback via snackbar
|
||||
|
||||
#### ✅ SAR Markers to Rooms
|
||||
|
||||
**File**: `messages_tab.dart:109-221`
|
||||
|
||||
```dart
|
||||
Future<void> _sendSarMessage(...) async {
|
||||
// Format: S:<emoji>:<latitude>,<longitude>
|
||||
final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}';
|
||||
|
||||
if (sendToChannel) {
|
||||
// Send to public channel (ephemeral)
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: 0,
|
||||
text: fullMessage,
|
||||
);
|
||||
} else {
|
||||
// Send to room (persistent)
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: roomPublicKey!,
|
||||
text: fullMessage,
|
||||
messageId: messageId,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status**: ✅ **WORKING**
|
||||
- Sends SAR markers to rooms
|
||||
- Tracks delivery with message ID
|
||||
- Updates status (sending → sent → delivered)
|
||||
|
||||
### 1.2 Sending Messages - What's Missing
|
||||
|
||||
#### ❌ Direct Messages to Individual Contacts
|
||||
|
||||
**Current State**: No UI to send regular messages to individual contacts!
|
||||
|
||||
**Gap**: User can only:
|
||||
- Send to public channel
|
||||
- Send SAR markers to rooms
|
||||
|
||||
**Missing**: Send regular text messages to individual team members
|
||||
|
||||
**Example Use Case**:
|
||||
```
|
||||
User wants to send "Meet at checkpoint B" to John (a chat contact)
|
||||
Current: ❌ No way to do this
|
||||
Should: ✅ Send direct message via CMD_SEND_TXT_MSG
|
||||
```
|
||||
|
||||
#### ❌ Timeout Handling
|
||||
|
||||
**Current State**: Messages marked "Sent" wait forever for delivery confirmation
|
||||
|
||||
**File**: `messages_provider.dart:262-279`
|
||||
|
||||
```dart
|
||||
void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) {
|
||||
final updatedMessage = message.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
expectedAckTag: expectedAckTag,
|
||||
suggestedTimeoutMs: suggestedTimeoutMs, // ⚠️ Stored but not used!
|
||||
);
|
||||
|
||||
_pendingSentMessages[expectedAckTag] = updatedMessage;
|
||||
// ❌ No timeout timer started!
|
||||
}
|
||||
```
|
||||
|
||||
**Gap**: No timer to mark message as "Failed" if timeout expires
|
||||
|
||||
**Should Do**:
|
||||
```dart
|
||||
void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) {
|
||||
// ... existing code ...
|
||||
|
||||
// Start timeout timer
|
||||
Future.delayed(Duration(milliseconds: suggestedTimeoutMs), () {
|
||||
if (_pendingSentMessages.containsKey(expectedAckTag)) {
|
||||
// Message not delivered within timeout
|
||||
markMessageFailed(messageId);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ Message Retry Logic
|
||||
|
||||
**Current State**: Failed messages stay failed, no retry
|
||||
|
||||
**Gap**: MeshCore supports retries with `attempt` parameter (0-3)
|
||||
|
||||
**Protocol Spec** (MESSAGES.md):
|
||||
```
|
||||
CMD_SEND_TXT_MSG:
|
||||
- attempt (1 byte): 0-3 (retry attempt number)
|
||||
```
|
||||
|
||||
**Current Implementation** (meshcore_ble_service.dart:1183-1201):
|
||||
```dart
|
||||
Future<void> sendTextMessage({
|
||||
required Uint8List contactPublicKey,
|
||||
required String text,
|
||||
int textType = 0,
|
||||
int attempt = 0, // ✅ Parameter exists but never used!
|
||||
}) async {
|
||||
writer.writeByte(attempt); // Always 0
|
||||
}
|
||||
```
|
||||
|
||||
**Missing**: Retry logic that increments `attempt` on timeout
|
||||
|
||||
---
|
||||
|
||||
## 2. Detailed Gap Analysis
|
||||
|
||||
### Gap #1: No UI for Direct Messages to Contacts
|
||||
|
||||
#### Problem
|
||||
|
||||
**Current UI** (`messages_tab.dart`):
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Messages Tab │
|
||||
├─────────────────────────────┤
|
||||
│ │
|
||||
│ [Message List] │
|
||||
│ │
|
||||
│ │
|
||||
├─────────────────────────────┤
|
||||
│ [SAR] [Text Input] [Send] │ ← Always sends to public channel
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Missing**:
|
||||
- No recipient selector
|
||||
- No way to send DM to individual contact
|
||||
- Can only send to public channel OR rooms (via SAR dialog)
|
||||
|
||||
#### Solution
|
||||
|
||||
**Add Recipient Selector**:
|
||||
|
||||
```dart
|
||||
Contact? _selectedRecipient; // null = public channel
|
||||
|
||||
// In build():
|
||||
Row(
|
||||
children: [
|
||||
// Recipient dropdown
|
||||
DropdownButton<Contact?>(
|
||||
value: _selectedRecipient,
|
||||
hint: Text('Public Channel'),
|
||||
items: [
|
||||
DropdownMenuItem(value: null, child: Text('📢 Public')),
|
||||
...contactsProvider.chatContacts.map((contact) =>
|
||||
DropdownMenuItem(
|
||||
value: contact,
|
||||
child: Text('👤 ${contact.displayName}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (value) => setState(() => _selectedRecipient = value),
|
||||
),
|
||||
|
||||
// Message input
|
||||
Expanded(child: TextField(...)),
|
||||
|
||||
// Send button
|
||||
IconButton(
|
||||
onPressed: () => _selectedRecipient == null
|
||||
? _sendChannelMessage()
|
||||
: _sendDirectMessage(_selectedRecipient!),
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
**New Method**:
|
||||
```dart
|
||||
Future<void> _sendDirectMessage(Contact recipient) async {
|
||||
final text = _textController.text.trim();
|
||||
|
||||
final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent';
|
||||
|
||||
// Create sent message
|
||||
final sentMessage = Message(
|
||||
id: messageId,
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: devicePublicKey?.sublist(0, 6),
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
text: text,
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
);
|
||||
|
||||
// Add to messages list
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
|
||||
// Send via BLE
|
||||
final success = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: recipient.publicKey,
|
||||
text: text,
|
||||
messageId: messageId,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
messagesProvider.markMessageFailed(messageId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Gap #2: No Timeout Handling
|
||||
|
||||
#### Problem
|
||||
|
||||
**Current Flow**:
|
||||
```
|
||||
Send Message
|
||||
↓
|
||||
RESP_CODE_SENT (ACK tag: 12345, timeout: 30000ms)
|
||||
↓
|
||||
Mark as "Sent"
|
||||
↓
|
||||
⏳ Wait forever for PUSH_CODE_SEND_CONFIRMED...
|
||||
↓
|
||||
❌ If never arrives, message stays "Sent" indefinitely
|
||||
```
|
||||
|
||||
**Should Be**:
|
||||
```
|
||||
Send Message
|
||||
↓
|
||||
RESP_CODE_SENT (ACK tag: 12345, timeout: 30000ms)
|
||||
↓
|
||||
Mark as "Sent" + Start 30s timeout timer
|
||||
↓
|
||||
├─ PUSH_CODE_SEND_CONFIRMED arrives → ✅ Mark "Delivered"
|
||||
└─ Timeout expires → ❌ Mark "Failed"
|
||||
```
|
||||
|
||||
#### Solution
|
||||
|
||||
**Update MessagesProvider** (`messages_provider.dart`):
|
||||
|
||||
```dart
|
||||
// Track timeout timers by ACK tag
|
||||
final Map<int, Timer> _timeoutTimers = {};
|
||||
|
||||
void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1) {
|
||||
final message = _messages[index];
|
||||
final updatedMessage = message.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
expectedAckTag: expectedAckTag,
|
||||
suggestedTimeoutMs: suggestedTimeoutMs,
|
||||
);
|
||||
_messages[index] = updatedMessage;
|
||||
|
||||
// Track by ACK tag
|
||||
_pendingSentMessages[expectedAckTag] = updatedMessage;
|
||||
|
||||
// ✅ NEW: Start timeout timer
|
||||
_timeoutTimers[expectedAckTag] = Timer(
|
||||
Duration(milliseconds: suggestedTimeoutMs),
|
||||
() {
|
||||
// Timeout expired - mark as failed
|
||||
if (_pendingSentMessages.containsKey(expectedAckTag)) {
|
||||
print('⏱️ Message timeout: ACK $expectedAckTag not received within ${suggestedTimeoutMs}ms');
|
||||
markMessageFailed(messageId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
||||
// Find message by ACK code
|
||||
final message = _pendingSentMessages[ackCode];
|
||||
if (message != null) {
|
||||
final index = _messages.indexWhere((m) => m.id == message.id);
|
||||
if (index != -1) {
|
||||
final updatedMessage = message.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.delivered,
|
||||
roundTripTimeMs: roundTripTimeMs,
|
||||
deliveredAt: DateTime.now(),
|
||||
);
|
||||
_messages[index] = updatedMessage;
|
||||
|
||||
// ✅ NEW: Cancel timeout timer
|
||||
_timeoutTimers[ackCode]?.cancel();
|
||||
_timeoutTimers.remove(ackCode);
|
||||
|
||||
// Remove from pending
|
||||
_pendingSentMessages.remove(ackCode);
|
||||
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void markMessageFailed(String messageId) {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1) {
|
||||
final message = _messages[index];
|
||||
final updatedMessage = message.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.failed,
|
||||
);
|
||||
_messages[index] = updatedMessage;
|
||||
|
||||
// ✅ NEW: Cancel timeout timer if exists
|
||||
if (message.expectedAckTag != null) {
|
||||
_timeoutTimers[message.expectedAckTag]?.cancel();
|
||||
_timeoutTimers.remove(message.expectedAckTag);
|
||||
_pendingSentMessages.remove(message.expectedAckTag);
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ NEW: Cleanup on dispose
|
||||
@override
|
||||
void dispose() {
|
||||
// Cancel all pending timers
|
||||
for (final timer in _timeoutTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_timeoutTimers.clear();
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Gap #3: No Message Retry Logic
|
||||
|
||||
#### Problem
|
||||
|
||||
**Current**: Failed messages stay failed forever
|
||||
|
||||
**Protocol Supports**:
|
||||
```
|
||||
Attempt 0 → Timeout → ❌ Failed (no retry)
|
||||
```
|
||||
|
||||
**Should Support**:
|
||||
```
|
||||
Attempt 0 → Timeout → Retry
|
||||
Attempt 1 → Timeout → Retry
|
||||
Attempt 2 → Timeout → Retry
|
||||
Attempt 3 → Timeout → ❌ Failed (last attempt uses flood mode)
|
||||
```
|
||||
|
||||
#### Solution
|
||||
|
||||
**Option 1: Manual Retry (Simple)**
|
||||
|
||||
Add "Retry" button to failed messages:
|
||||
|
||||
```dart
|
||||
// In _MessageBubble:
|
||||
if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _retryMessage(message),
|
||||
icon: Icon(Icons.refresh),
|
||||
label: Text('Retry'),
|
||||
),
|
||||
],
|
||||
```
|
||||
|
||||
**Option 2: Automatic Retry (Advanced)**
|
||||
|
||||
Update timeout handler:
|
||||
|
||||
```dart
|
||||
void _handleMessageTimeout(String messageId, int attemptNumber) {
|
||||
if (attemptNumber < 3) {
|
||||
// Retry with next attempt number
|
||||
print('⏱️ Attempt $attemptNumber timeout - retrying...');
|
||||
_retryMessage(messageId, attemptNumber + 1);
|
||||
} else {
|
||||
// All attempts exhausted
|
||||
print('❌ All 4 attempts failed - marking as failed');
|
||||
markMessageFailed(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retryMessage(String messageId, int attempt) async {
|
||||
final message = _messages.firstWhere((m) => m.id == messageId);
|
||||
|
||||
// Update attempt count
|
||||
final updatedMessage = message.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
);
|
||||
// ... update in list ...
|
||||
|
||||
// Resend with incremented attempt number
|
||||
final success = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: message.recipientPublicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
attempt: attempt,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Priority Ranking
|
||||
|
||||
### Priority 1: CRITICAL (Blocks Core Functionality)
|
||||
|
||||
1. **❌ Gap #1: Direct Messages to Contacts**
|
||||
- **Impact**: Users can't send messages to individual team members
|
||||
- **Complexity**: Medium (UI + wire to existing BLE code)
|
||||
- **Effort**: 2-3 hours
|
||||
|
||||
### Priority 2: HIGH (Improves Reliability)
|
||||
|
||||
2. **❌ Gap #2: Timeout Handling**
|
||||
- **Impact**: Failed messages never show as failed
|
||||
- **Complexity**: Low (timer logic)
|
||||
- **Effort**: 1 hour
|
||||
|
||||
### Priority 3: MEDIUM (Nice to Have)
|
||||
|
||||
3. **❌ Gap #3: Automatic Retry**
|
||||
- **Impact**: Failed messages need manual intervention
|
||||
- **Complexity**: Medium (retry orchestration)
|
||||
- **Effort**: 2-3 hours
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Roadmap
|
||||
|
||||
### Phase 1: Basic DM Support (Priority 1)
|
||||
|
||||
**Goal**: Enable sending direct messages to contacts
|
||||
|
||||
**Tasks**:
|
||||
1. ✅ Add recipient selector dropdown to messages tab
|
||||
2. ✅ Add `_sendDirectMessage()` method
|
||||
3. ✅ Wire to existing `sendTextMessage()` BLE method
|
||||
4. ✅ Test with team members
|
||||
|
||||
**Files to Modify**:
|
||||
- `lib/screens/messages_tab.dart`
|
||||
- Add `Contact? _selectedRecipient` state
|
||||
- Add recipient dropdown above message input
|
||||
- Add `_sendDirectMessage()` method
|
||||
- Update `_sendMessage()` to route to channel vs contact
|
||||
|
||||
**Estimated Time**: 2-3 hours
|
||||
|
||||
### Phase 2: Timeout Handling (Priority 2)
|
||||
|
||||
**Goal**: Mark messages as failed when timeout expires
|
||||
|
||||
**Tasks**:
|
||||
1. ✅ Add `Map<int, Timer> _timeoutTimers` to MessagesProvider
|
||||
2. ✅ Start timer in `markMessageSent()`
|
||||
3. ✅ Cancel timer in `markMessageDelivered()`
|
||||
4. ✅ Call `markMessageFailed()` on timeout
|
||||
5. ✅ Add `dispose()` to cancel timers
|
||||
|
||||
**Files to Modify**:
|
||||
- `lib/providers/messages_provider.dart`
|
||||
- Add timeout timer tracking
|
||||
- Update `markMessageSent()`
|
||||
- Update `markMessageDelivered()`
|
||||
- Update `markMessageFailed()`
|
||||
- Add `dispose()`
|
||||
|
||||
**Estimated Time**: 1 hour
|
||||
|
||||
### Phase 3: Manual Retry (Priority 3a)
|
||||
|
||||
**Goal**: Let user manually retry failed messages
|
||||
|
||||
**Tasks**:
|
||||
1. ✅ Add "Retry" button to failed message bubbles
|
||||
2. ✅ Add `_retryMessage()` method
|
||||
3. ✅ Test retry flow
|
||||
|
||||
**Files to Modify**:
|
||||
- `lib/screens/messages_tab.dart`
|
||||
- Add retry button to `_MessageBubble` for failed messages
|
||||
- Add `_retryMessage()` callback
|
||||
|
||||
**Estimated Time**: 1 hour
|
||||
|
||||
### Phase 4: Automatic Retry (Priority 3b) - OPTIONAL
|
||||
|
||||
**Goal**: Automatically retry failed messages
|
||||
|
||||
**Tasks**:
|
||||
1. ✅ Update `_handleMessageTimeout()` to retry
|
||||
2. ✅ Pass `attempt` parameter through send chain
|
||||
3. ✅ Test 4-attempt retry cycle
|
||||
4. ✅ Verify attempt 3 uses flood mode (per protocol)
|
||||
|
||||
**Files to Modify**:
|
||||
- `lib/providers/messages_provider.dart`
|
||||
- `lib/providers/connection_provider.dart`
|
||||
- `lib/services/meshcore_ble_service.dart`
|
||||
|
||||
**Estimated Time**: 2-3 hours
|
||||
|
||||
---
|
||||
|
||||
## 5. Quick Fixes (Can Do Right Now)
|
||||
|
||||
### Quick Fix #1: Add "Reply" to Contact Messages
|
||||
|
||||
**File**: `lib/screens/messages_tab.dart`
|
||||
|
||||
Add long-press handler to contact messages:
|
||||
|
||||
```dart
|
||||
// In _MessageBubble:
|
||||
GestureDetector(
|
||||
onLongPress: message.isContactMessage
|
||||
? () => _showReplyOptions(context, message)
|
||||
: null,
|
||||
child: Container(...),
|
||||
)
|
||||
```
|
||||
|
||||
```dart
|
||||
void _showReplyOptions(BuildContext context, Message message) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.reply),
|
||||
title: Text('Reply to ${message.displaySender}'),
|
||||
onTap: () {
|
||||
// Set recipient and open keyboard
|
||||
Navigator.pop(context);
|
||||
// ... set _selectedRecipient ...
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing Checklist
|
||||
|
||||
### After Implementing Gap #1 (Direct Messages)
|
||||
|
||||
- [ ] Can send DM to chat contact
|
||||
- [ ] Message appears in recipient's messages list
|
||||
- [ ] Delivery status shows: Sending → Sent → Delivered
|
||||
- [ ] Failed messages show "Failed" status
|
||||
- [ ] Can send to public channel (existing feature still works)
|
||||
|
||||
### After Implementing Gap #2 (Timeout Handling)
|
||||
|
||||
- [ ] Turn off recipient device
|
||||
- [ ] Send message
|
||||
- [ ] Verify "Sent" status appears
|
||||
- [ ] Wait for timeout (30s)
|
||||
- [ ] Verify status changes to "Failed"
|
||||
- [ ] Turn on recipient device
|
||||
- [ ] Send message
|
||||
- [ ] Verify status changes to "Delivered" before timeout
|
||||
|
||||
### After Implementing Gap #3 (Retry)
|
||||
|
||||
- [ ] Manual retry: Click "Retry" on failed message
|
||||
- [ ] Verify message sends again
|
||||
- [ ] Auto retry: Turn off recipient device
|
||||
- [ ] Send message
|
||||
- [ ] Verify 4 retry attempts occur
|
||||
- [ ] Verify final status is "Failed" after all attempts
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary
|
||||
|
||||
### What's Already Great ✅
|
||||
|
||||
1. ✅ Protocol implementation is 100% correct
|
||||
2. ✅ Delivery tracking infrastructure exists
|
||||
3. ✅ SAR markers work perfectly
|
||||
4. ✅ Room messages work
|
||||
5. ✅ Channel messages work
|
||||
|
||||
### What Needs Adding ❌
|
||||
|
||||
1. ❌ **UI for direct messages to contacts** (2-3 hours)
|
||||
2. ❌ **Timeout timers** (1 hour)
|
||||
3. ❌ **Retry logic** (2-3 hours)
|
||||
|
||||
### Total Estimated Effort
|
||||
|
||||
**Minimum Viable** (Phase 1 + 2): **3-4 hours**
|
||||
**Full Featured** (All phases): **6-9 hours**
|
||||
|
||||
---
|
||||
|
||||
## 8. Recommended Next Steps
|
||||
|
||||
1. **Immediate** (Today): Implement Gap #1 (Direct Messages UI)
|
||||
- This unlocks the core messaging functionality
|
||||
- Users can finally message each other
|
||||
|
||||
2. **Short Term** (This Week): Implement Gap #2 (Timeout Handling)
|
||||
- Improves reliability
|
||||
- Users see when messages fail
|
||||
|
||||
3. **Optional** (Next Week): Implement Gap #3 (Retry Logic)
|
||||
- Automatic retries improve success rate
|
||||
- Manual retry button is simple fallback
|
||||
|
||||
Would you like me to implement Gap #1 (Direct Messages UI) first?
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,341 +0,0 @@
|
||||
# Messaging Improvements Implementation Summary
|
||||
|
||||
## Date: 2025-01-14
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the messaging improvements implemented based on the gap analysis in `MESSAGE_SEND_RECEIVE_GAP_ANALYSIS.md`.
|
||||
|
||||
## Gap Analysis Results
|
||||
|
||||
### Gap #1: Direct Messages UI
|
||||
**Status**: ✅ ALREADY IMPLEMENTED
|
||||
- **Location**: `lib/screens/contacts_tab.dart:351-937`
|
||||
- Direct message UI exists via Contacts tab
|
||||
- Users can tap message icon on chat contacts to open direct message sheet
|
||||
- `_DirectMessageSheet` widget provides full message composition UI
|
||||
- Messages are sent with delivery tracking via `sendTextMessage()`
|
||||
|
||||
### Gap #2: Timeout Handling
|
||||
**Status**: ✅ NEWLY IMPLEMENTED
|
||||
- **Files Modified**:
|
||||
- `lib/providers/messages_provider.dart`
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**1. Added Timer Infrastructure** (lines 1, 19):
|
||||
```dart
|
||||
import 'dart:async';
|
||||
|
||||
// Track timeout timers for pending messages
|
||||
final Map<int, Timer> _timeoutTimers = {};
|
||||
```
|
||||
|
||||
**2. Start Timeout on Message Sent** (lines 280-291):
|
||||
```dart
|
||||
// Start timeout timer
|
||||
_timeoutTimers[expectedAckTag] = Timer(
|
||||
Duration(milliseconds: suggestedTimeoutMs),
|
||||
() {
|
||||
print('⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)');
|
||||
if (_pendingSentMessages.containsKey(expectedAckTag)) {
|
||||
markMessageFailed(messageId);
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**3. Cancel Timeout on Delivery** (lines 312-314):
|
||||
```dart
|
||||
// Cancel timeout timer
|
||||
_timeoutTimers[ackCode]?.cancel();
|
||||
_timeoutTimers.remove(ackCode);
|
||||
```
|
||||
|
||||
**4. Cancel Timeout on Manual Failure** (lines 338-341):
|
||||
```dart
|
||||
// Cancel timeout timer if it exists
|
||||
if (message.expectedAckTag != null) {
|
||||
_timeoutTimers[message.expectedAckTag]?.cancel();
|
||||
_timeoutTimers.remove(message.expectedAckTag);
|
||||
_pendingSentMessages.remove(message.expectedAckTag);
|
||||
}
|
||||
```
|
||||
|
||||
**5. Clean Up on Dispose** (lines 351-359):
|
||||
```dart
|
||||
@override
|
||||
void dispose() {
|
||||
// Cancel all pending timeout timers
|
||||
for (final timer in _timeoutTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_timeoutTimers.clear();
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
### Gap #3: Retry Logic
|
||||
**Status**: ✅ NEWLY IMPLEMENTED
|
||||
- **Files Modified**:
|
||||
- `lib/screens/messages_tab.dart`
|
||||
- `lib/providers/connection_provider.dart`
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**1. Retry Button UI** (lines 570-597 in messages_tab.dart):
|
||||
```dart
|
||||
// Show retry button for failed messages
|
||||
if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () => _retryFailedMessage(context, message),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.orange, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.refresh, size: 12, color: Colors.orange),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Retry',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
```
|
||||
|
||||
**2. Retry Logic** (lines 400-479 in messages_tab.dart):
|
||||
```dart
|
||||
Future<void> _retryFailedMessage(BuildContext context, Message failedMessage) async {
|
||||
// Check connection
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
// Show error
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if max attempts reached (protocol supports 0-3, so 4 total attempts)
|
||||
final currentAttempt = failedMessage.attemptNumber ?? 0;
|
||||
if (currentAttempt >= 3) {
|
||||
// Show max attempts reached error
|
||||
return;
|
||||
}
|
||||
|
||||
final nextAttempt = currentAttempt + 1;
|
||||
final retryMessageId = '${failedMessage.id}_retry_$nextAttempt';
|
||||
|
||||
// Create retry message with updated attempt number
|
||||
final retryMessage = failedMessage.copyWith(
|
||||
id: retryMessageId,
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
attemptNumber: nextAttempt,
|
||||
sentAt: DateTime.now(),
|
||||
);
|
||||
|
||||
messagesProvider.addSentMessage(retryMessage);
|
||||
|
||||
// Resend the message
|
||||
if (failedMessage.messageType == MessageType.channel) {
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: failedMessage.channelIdx ?? 0,
|
||||
text: failedMessage.text,
|
||||
messageId: retryMessageId,
|
||||
attempt: nextAttempt,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Retrying message (attempt ${nextAttempt + 1}/4)...'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Added Attempt Parameter to Connection Provider** (lines 400-468 in connection_provider.dart):
|
||||
|
||||
Updated `sendTextMessage()`:
|
||||
```dart
|
||||
Future<bool> sendTextMessage({
|
||||
required Uint8List contactPublicKey,
|
||||
required String text,
|
||||
String? messageId,
|
||||
int attempt = 0, // NEW: retry attempt number (0-3)
|
||||
}) async {
|
||||
await _bleService.sendTextMessage(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
attempt: attempt, // NEW: pass to BLE service
|
||||
);
|
||||
|
||||
if (messageId != null) {
|
||||
_pendingSentMessageIds.add(messageId);
|
||||
print(' Added message ID to pending queue: $messageId (attempt $attempt)');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Updated `sendChannelMessage()`:
|
||||
```dart
|
||||
Future<void> sendChannelMessage({
|
||||
required int channelIdx,
|
||||
required String text,
|
||||
String? messageId, // NEW: track delivery
|
||||
int attempt = 0, // NEW: retry attempt number (0-3)
|
||||
}) async {
|
||||
await _bleService.sendChannelMessage(
|
||||
channelIdx: channelIdx,
|
||||
text: text,
|
||||
attempt: attempt, // NEW: pass to BLE service
|
||||
);
|
||||
|
||||
// NEW: Track message ID for delivery confirmation
|
||||
if (messageId != null) {
|
||||
_pendingSentMessageIds.add(messageId);
|
||||
print(' Added message ID to pending queue: $messageId (attempt $attempt)');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Timeout Flow
|
||||
|
||||
1. User sends message → `addSentMessage()` called with `sending` status
|
||||
2. BLE service sends message → receives `RESP_CODE_SENT` (code 6)
|
||||
3. `markMessageSent()` called with ACK tag and timeout value
|
||||
4. Timer started for specified timeout (e.g., 30000ms)
|
||||
5. Two possible outcomes:
|
||||
- **Success**: `PUSH_CODE_SEND_CONFIRMED` (0x82) arrives → `markMessageDelivered()` cancels timer → message marked `delivered`
|
||||
- **Timeout**: Timer expires → message automatically marked `failed`
|
||||
|
||||
### Retry Flow
|
||||
|
||||
1. Message times out or fails → UI shows red "Failed" status with orange "Retry" button
|
||||
2. User taps "Retry" button
|
||||
3. Check attempt number (must be < 3, since protocol supports 0-3 = 4 total attempts)
|
||||
4. Create new message with:
|
||||
- New message ID: `{original_id}_retry_{attempt}`
|
||||
- Status: `sending`
|
||||
- Attempt number: `currentAttempt + 1`
|
||||
5. Send message with new attempt number via BLE
|
||||
6. New timeout timer started automatically
|
||||
7. Process repeats until delivered or max attempts reached
|
||||
|
||||
## Protocol Compliance
|
||||
|
||||
All implementations follow the MeshCore BLE Companion Radio protocol:
|
||||
|
||||
- **Timeout values**: Use `suggestedTimeoutMs` from `RESP_CODE_SENT` (code 6)
|
||||
- **Attempt numbers**: Range 0-3 (4 total attempts) as specified in protocol
|
||||
- **Message tracking**: Use expected ACK tag from `RESP_CODE_SENT` to match with `PUSH_CODE_SEND_CONFIRMED` (0x82)
|
||||
- **Delivery confirmation**: Round-trip time (RTT) stored from delivery confirmation
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Timeout Handling
|
||||
- [ ] Send message to unreachable contact
|
||||
- [ ] Verify message shows "Sent" status initially
|
||||
- [ ] Wait for timeout period (e.g., 30 seconds)
|
||||
- [ ] Verify message automatically changes to "Failed" status
|
||||
- [ ] Check logs for timeout message: `⏱️ [MessagesProvider] Timeout for message...`
|
||||
|
||||
### Retry Logic
|
||||
- [ ] Cause a message to fail (send to non-existent contact or wait for timeout)
|
||||
- [ ] Verify "Failed" status shows with orange "Retry" button
|
||||
- [ ] Tap "Retry" button
|
||||
- [ ] Verify new message appears with "Sending" status
|
||||
- [ ] Verify snackbar shows "Retrying message (attempt 2/4)..."
|
||||
- [ ] Repeat retry up to 4 total attempts
|
||||
- [ ] On 4th attempt, verify "Retry" button disappears
|
||||
- [ ] Attempt to retry again, verify error: "Maximum retry attempts reached"
|
||||
|
||||
### Delivery Success
|
||||
- [ ] Send message to reachable contact
|
||||
- [ ] Verify message shows "Sent" status
|
||||
- [ ] Wait for delivery confirmation
|
||||
- [ ] Verify message changes to "Delivered" status with green checkmarks
|
||||
- [ ] Verify timeout timer was cancelled (no failure after timeout period)
|
||||
- [ ] Check logs for delivery message: `✅ [MessagesProvider] Message {id} delivered in {ms}ms`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Direct Message Retry**: Not yet implemented
|
||||
- Retry button works only for channel messages
|
||||
- Direct message retry would require looking up contact's full public key
|
||||
- Shows "Direct message retry not yet implemented" message
|
||||
|
||||
2. **Automatic Retry**: Not implemented
|
||||
- User must manually tap "Retry" button
|
||||
- Future enhancement could add automatic retry with exponential backoff
|
||||
|
||||
3. **Retry Deduplication**: Messages show as separate entries
|
||||
- Each retry creates a new message in the history
|
||||
- Future enhancement could group retries under original message
|
||||
|
||||
## Files Changed
|
||||
|
||||
1. **lib/providers/messages_provider.dart**
|
||||
- Added `dart:async` import
|
||||
- Added `_timeoutTimers` map
|
||||
- Modified `markMessageSent()` to start timers
|
||||
- Modified `markMessageDelivered()` to cancel timers
|
||||
- Modified `markMessageFailed()` to cancel timers
|
||||
- Added `dispose()` method to clean up timers
|
||||
|
||||
2. **lib/providers/connection_provider.dart**
|
||||
- Modified `sendTextMessage()` to accept `attempt` parameter
|
||||
- Modified `sendChannelMessage()` to accept `messageId` and `attempt` parameters
|
||||
- Both methods now track message IDs for delivery confirmation
|
||||
|
||||
3. **lib/screens/messages_tab.dart**
|
||||
- Added retry button UI to `_MessageBubble` widget
|
||||
- Added `_retryFailedMessage()` method
|
||||
- Retry UI appears only for failed messages
|
||||
- Shows attempt count (e.g., "attempt 2/4")
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Memory**: Minimal - one Timer object per pending message
|
||||
- **CPU**: Negligible - timers use OS-level scheduling
|
||||
- **Network**: No change - only affects local message state management
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Automatic Retry with Backoff**
|
||||
- Implement exponential backoff (e.g., 5s, 10s, 20s, 40s)
|
||||
- Configurable via settings
|
||||
|
||||
2. **Retry Grouping**
|
||||
- Group retry attempts under original message
|
||||
- Show retry history in message details
|
||||
|
||||
3. **Direct Message Retry**
|
||||
- Add contact lookup by public key prefix
|
||||
- Implement retry for direct messages
|
||||
|
||||
4. **Smart Timeout Adjustment**
|
||||
- Learn from network conditions
|
||||
- Adjust timeout based on historical RTT
|
||||
|
||||
5. **Batch Retry**
|
||||
- "Retry All Failed" button
|
||||
- Retry multiple failed messages at once
|
||||
|
||||
## Conclusion
|
||||
|
||||
The messaging system now has robust timeout handling and manual retry capabilities for channel messages. Messages automatically fail after the protocol-specified timeout period, and users can retry failed messages up to 4 times as allowed by the MeshCore protocol.
|
||||
|
||||
Direct messages can already be sent via the Contacts tab, so Gap #1 was already addressed. Gaps #2 and #3 are now fully implemented and ready for testing.
|
||||
Binary file not shown.
BIN
ios/Runner.ipa
BIN
ios/Runner.ipa
Binary file not shown.
@@ -489,7 +489,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -511,7 +511,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
|
||||
@@ -529,7 +529,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
|
||||
@@ -545,7 +545,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
|
||||
@@ -676,7 +676,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -699,7 +699,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>5</string>
|
||||
<string>7</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
@@ -5,24 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000202">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000208">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.672147">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.263442">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="92.816744">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="87.581069">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="14.725453">
|
||||
|
||||
<failure message="/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in 'Fastlane::Actions.execute_action' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:255:in 'block in Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in 'Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:157:in 'Fastlane::Runner#trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/fast_file.rb:159:in 'Fastlane::FastFile#method_missing' Fastfile:23:in 'block (2 levels) in Fastlane::FastFile#parsing_binding' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane.rb:41:in 'Fastlane::Lane#call' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:49:in 'block in Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in 'Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane_manager.rb:46:in 'Fastlane::LaneManager.cruise_lane' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/command_line_handler.rb:34:in 'Fastlane::CommandLineHandler.handle' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:110:in 'block (2 levels) in Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in 'Commander::Command#call' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in 'Commander::Command#run' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in 'Commander::Runner#run_active_command' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in 'Commander::Runner#run!' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in 'Commander::Delegates#run!' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:363:in 'Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:43:in 'Fastlane::CommandsGenerator.start' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in 'Fastlane::CLIToolsDistributor.take_off' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/bin/fastlane:23:in '<top (required)>' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in 'Kernel#load' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in '<main>' Error uploading ipa file: [Application Loader Error Output]: The call to the altool completed with a non-zero exit status: 1. This indicates a failure." />
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="255.604797">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -7,17 +7,29 @@ enum DrawingShapeType {
|
||||
rectangle,
|
||||
}
|
||||
|
||||
/// Drawing color enum for compact network transmission
|
||||
enum DrawingColor {
|
||||
red, // 0
|
||||
blue, // 1
|
||||
green, // 2
|
||||
yellow, // 3
|
||||
orange, // 4
|
||||
purple, // 5
|
||||
pink, // 6
|
||||
cyan, // 7
|
||||
}
|
||||
|
||||
/// Drawing colors available for user selection
|
||||
class DrawingColors {
|
||||
static const List<Color> palette = [
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.yellow,
|
||||
Colors.orange,
|
||||
Colors.purple,
|
||||
Colors.pink,
|
||||
Colors.cyan,
|
||||
Colors.red, // index 0
|
||||
Colors.blue, // index 1
|
||||
Colors.green, // index 2
|
||||
Colors.yellow, // index 3
|
||||
Colors.orange, // index 4
|
||||
Colors.purple, // index 5
|
||||
Colors.pink, // index 6
|
||||
Colors.cyan, // index 7
|
||||
];
|
||||
|
||||
static String colorToName(Color color) {
|
||||
@@ -31,6 +43,24 @@ class DrawingColors {
|
||||
if (color == Colors.cyan) return 'Cyan';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Convert Color to enum index for network transmission
|
||||
static int colorToIndex(Color color) {
|
||||
for (int i = 0; i < palette.length; i++) {
|
||||
if (palette[i].value == color.value) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0; // Default to red if not found
|
||||
}
|
||||
|
||||
/// Convert enum index to Color for network reception
|
||||
static Color indexToColor(int index) {
|
||||
if (index >= 0 && index < palette.length) {
|
||||
return palette[index];
|
||||
}
|
||||
return palette[0]; // Default to red if invalid index
|
||||
}
|
||||
}
|
||||
|
||||
/// Base class for map drawings
|
||||
@@ -56,23 +86,25 @@ abstract class MapDrawing {
|
||||
|
||||
/// Convert to JSON for network transmission (compact format)
|
||||
/// Uses short field names and excludes createdAt to minimize message size
|
||||
Map<String, dynamic> toNetworkJson(String senderName);
|
||||
/// Sender will be fetched from packet metadata
|
||||
Map<String, dynamic> toNetworkJson();
|
||||
|
||||
/// Parse network JSON (compact format)
|
||||
static MapDrawing? fromNetworkJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['t'] as String?;
|
||||
if (typeStr == null) return null;
|
||||
/// senderName will be populated from packet metadata
|
||||
static MapDrawing? fromNetworkJson(Map<String, dynamic> json, {String? senderName}) {
|
||||
final typeNum = json['t'] as int?;
|
||||
if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final type = DrawingShapeType.values.firstWhere(
|
||||
(e) => e.name == typeStr,
|
||||
);
|
||||
final type = DrawingShapeType.values[typeNum];
|
||||
|
||||
switch (type) {
|
||||
case DrawingShapeType.line:
|
||||
return LineDrawing.fromNetworkJson(json);
|
||||
return LineDrawing.fromNetworkJson(json, senderName: senderName);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromNetworkJson(json);
|
||||
return RectangleDrawing.fromNetworkJson(json, senderName: senderName);
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
@@ -126,13 +158,13 @@ class LineDrawing extends MapDrawing {
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson(String senderName) {
|
||||
// Compact format: t=type, c=color, s=sender, p=points
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points
|
||||
// Points are encoded as flat array [lat1,lon1,lat2,lon2,...]
|
||||
// Sender is fetched from packet metadata, not included in JSON
|
||||
return {
|
||||
't': type.name,
|
||||
'c': color.value,
|
||||
's': senderName,
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
'p': points.expand((p) => [p.latitude, p.longitude]).toList(),
|
||||
};
|
||||
}
|
||||
@@ -152,18 +184,17 @@ class LineDrawing extends MapDrawing {
|
||||
);
|
||||
}
|
||||
|
||||
static LineDrawing fromNetworkJson(Map<String, dynamic> json) {
|
||||
// Parse compact format
|
||||
static LineDrawing fromNetworkJson(Map<String, dynamic> json, {String? senderName}) {
|
||||
// Parse ultra-compact format
|
||||
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
|
||||
final points = <LatLng>[];
|
||||
for (int i = 0; i < pointsFlat.length; i += 2) {
|
||||
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1]));
|
||||
}
|
||||
final senderName = json['s'] as String?;
|
||||
|
||||
return LineDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
||||
color: Color(json['c'] as int),
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
points: points,
|
||||
senderName: senderName,
|
||||
@@ -219,12 +250,12 @@ class RectangleDrawing extends MapDrawing {
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson(String senderName) {
|
||||
// Compact format: t=type, c=color, s=sender, b=bounds [lat1,lon1,lat2,lon2]
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2]
|
||||
// Sender is fetched from packet metadata, not included in JSON
|
||||
return {
|
||||
't': type.name,
|
||||
'c': color.value,
|
||||
's': senderName,
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
'b': [topLeft.latitude, topLeft.longitude, bottomRight.latitude, bottomRight.longitude],
|
||||
};
|
||||
}
|
||||
@@ -245,14 +276,13 @@ class RectangleDrawing extends MapDrawing {
|
||||
);
|
||||
}
|
||||
|
||||
static RectangleDrawing fromNetworkJson(Map<String, dynamic> json) {
|
||||
// Parse compact format
|
||||
static RectangleDrawing fromNetworkJson(Map<String, dynamic> json, {String? senderName}) {
|
||||
// Parse ultra-compact format
|
||||
final bounds = (json['b'] as List<dynamic>).cast<double>();
|
||||
final senderName = json['s'] as String?;
|
||||
|
||||
return RectangleDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
||||
color: Color(json['c'] as int),
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: LatLng(bounds[0], bounds[1]),
|
||||
bottomRight: LatLng(bounds[2], bounds[3]),
|
||||
|
||||
@@ -50,6 +50,7 @@ class Message {
|
||||
final bool isSarMarker;
|
||||
final SarMarkerType? sarMarkerType;
|
||||
final LatLng? sarGpsCoordinates;
|
||||
final String? sarNotes; // Optional message/notes for SAR marker
|
||||
|
||||
// Display metadata
|
||||
final DateTime receivedAt;
|
||||
@@ -78,6 +79,7 @@ class Message {
|
||||
this.isSarMarker = false,
|
||||
this.sarMarkerType,
|
||||
this.sarGpsCoordinates,
|
||||
this.sarNotes,
|
||||
required this.receivedAt,
|
||||
this.senderName,
|
||||
this.deliveryStatus = MessageDeliveryStatus.received,
|
||||
@@ -162,7 +164,7 @@ class Message {
|
||||
timestamp: sentAt,
|
||||
senderPublicKey: senderPublicKeyPrefix,
|
||||
senderName: senderName,
|
||||
notes: text,
|
||||
notes: sarNotes, // Use dedicated notes field instead of full text
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,6 +220,7 @@ class Message {
|
||||
bool? isSarMarker,
|
||||
SarMarkerType? sarMarkerType,
|
||||
LatLng? sarGpsCoordinates,
|
||||
String? sarNotes,
|
||||
DateTime? receivedAt,
|
||||
String? senderName,
|
||||
MessageDeliveryStatus? deliveryStatus,
|
||||
@@ -240,6 +243,7 @@ class Message {
|
||||
isSarMarker: isSarMarker ?? this.isSarMarker,
|
||||
sarMarkerType: sarMarkerType ?? this.sarMarkerType,
|
||||
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
|
||||
sarNotes: sarNotes ?? this.sarNotes,
|
||||
receivedAt: receivedAt ?? this.receivedAt,
|
||||
senderName: senderName ?? this.senderName,
|
||||
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
|
||||
|
||||
@@ -68,7 +68,12 @@ class AppProvider with ChangeNotifier {
|
||||
// Check if message is a drawing broadcast
|
||||
if (DrawingMessageParser.isDrawingMessage(message.text)) {
|
||||
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
|
||||
final drawing = DrawingMessageParser.parseDrawingMessage(message.text);
|
||||
// Extract sender name from message packet metadata
|
||||
final senderName = message.senderName ?? 'unknown';
|
||||
final drawing = DrawingMessageParser.parseDrawingMessage(
|
||||
message.text,
|
||||
senderName: senderName,
|
||||
);
|
||||
if (drawing != null) {
|
||||
debugPrint('🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}');
|
||||
drawingProvider.addReceivedDrawing(drawing);
|
||||
@@ -177,6 +182,9 @@ class AppProvider with ChangeNotifier {
|
||||
// Sync device time
|
||||
await connectionProvider.syncDeviceTime();
|
||||
|
||||
// Get battery and storage information
|
||||
await connectionProvider.getBatteryAndStorage();
|
||||
|
||||
// Load contacts
|
||||
await connectionProvider.getContacts();
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@ class PingResult {
|
||||
});
|
||||
}
|
||||
|
||||
/// Scanned device with RSSI information
|
||||
class ScannedDevice {
|
||||
final BluetoothDevice device;
|
||||
final int rssi;
|
||||
|
||||
ScannedDevice({required this.device, required this.rssi});
|
||||
}
|
||||
|
||||
/// Connection Provider - manages MeshCore BLE connection
|
||||
class ConnectionProvider with ChangeNotifier {
|
||||
final MeshCoreBleService _bleService = MeshCoreBleService();
|
||||
@@ -36,8 +44,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
DeviceInfo _deviceInfo = DeviceInfo();
|
||||
DeviceInfo get deviceInfo => _deviceInfo;
|
||||
|
||||
List<BluetoothDevice> _scannedDevices = [];
|
||||
List<BluetoothDevice> get scannedDevices => _scannedDevices;
|
||||
List<ScannedDevice> _scannedDevices = [];
|
||||
List<ScannedDevice> get scannedDevices => _scannedDevices;
|
||||
|
||||
bool _isScanning = false;
|
||||
bool get isScanning => _isScanning;
|
||||
@@ -352,6 +360,15 @@ class ConnectionProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
});
|
||||
};
|
||||
|
||||
_bleService.onRssiUpdate = (rssi) {
|
||||
print('📡 [Provider] RSSI updated: $rssi dBm');
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
signalRssi: rssi,
|
||||
lastUpdate: DateTime.now(),
|
||||
);
|
||||
notifyListeners();
|
||||
};
|
||||
}
|
||||
|
||||
/// Start scanning for MeshCore devices
|
||||
@@ -364,15 +381,26 @@ class ConnectionProvider with ChangeNotifier {
|
||||
print('✅ [Provider] Scan state initialized, notifying listeners');
|
||||
|
||||
try {
|
||||
await for (final device
|
||||
await for (final scanResult
|
||||
in _bleService.scanForDevices(timeout: const Duration(seconds: 10))) {
|
||||
print('📱 [Provider] Device received from scan stream');
|
||||
if (!_scannedDevices.any((d) => d.remoteId == device.remoteId)) {
|
||||
_scannedDevices.add(device);
|
||||
print('✅ [Provider] Added device to list: ${device.platformName}, total: ${_scannedDevices.length}');
|
||||
print('📱 [Provider] Scan result received from scan stream');
|
||||
final device = scanResult.device;
|
||||
final rssi = scanResult.rssi;
|
||||
|
||||
if (!_scannedDevices.any((d) => d.device.remoteId == device.remoteId)) {
|
||||
_scannedDevices.add(ScannedDevice(device: device, rssi: rssi));
|
||||
print('✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}');
|
||||
notifyListeners();
|
||||
} else {
|
||||
print(' ⏭️ [Provider] Device already in list, skipping');
|
||||
// Update RSSI if device already exists
|
||||
final index = _scannedDevices.indexWhere((d) => d.device.remoteId == device.remoteId);
|
||||
if (index != -1 && _scannedDevices[index].rssi != rssi) {
|
||||
_scannedDevices[index] = ScannedDevice(device: device, rssi: rssi);
|
||||
print(' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm');
|
||||
notifyListeners();
|
||||
} else {
|
||||
print(' ⏭️ [Provider] Device already in list with same RSSI, skipping');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -273,7 +273,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
/// Broadcast a drawing to contacts
|
||||
/// Returns the formatted message string ready to send
|
||||
String createDrawingBroadcastMessage(MapDrawing drawing, String senderName) {
|
||||
return DrawingMessageParser.createDrawingMessage(drawing, senderName);
|
||||
/// Sender will be determined from packet metadata on receiving end
|
||||
String createDrawingBroadcastMessage(MapDrawing drawing) {
|
||||
return DrawingMessageParser.createDrawingMessage(drawing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ class HomeScreen extends StatefulWidget {
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin {
|
||||
class _HomeScreenState extends State<HomeScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
int _currentIndex = 0;
|
||||
bool _isMapFullscreen = false;
|
||||
@@ -70,7 +71,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(context, 'Location services are disabled. Please enable them in Settings.');
|
||||
ToastLogger.error(
|
||||
context,
|
||||
'Location services are disabled. Please enable them in Settings.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -89,7 +93,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(context, 'Location permission permanently denied. Please enable in Settings.');
|
||||
ToastLogger.error(
|
||||
context,
|
||||
'Location permission permanently denied. Please enable in Settings.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +131,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
await connectionProvider.sendSelfAdvert(floodMode: true);
|
||||
|
||||
if (context.mounted) {
|
||||
ToastLogger.success(context, 'Advertised at ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}');
|
||||
ToastLogger.success(
|
||||
context,
|
||||
'Advertised at ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ Failed to advertise device: $e');
|
||||
@@ -146,31 +156,40 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1E1E1E),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
icon: Icon(
|
||||
Icons.arrow_back,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
onPressed: () {
|
||||
connectionProvider.stopScan();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'MeshCore',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -178,7 +197,9 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
Text(
|
||||
'Scanning for devices...',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
@@ -186,8 +207,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
Icons.refresh,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: () {
|
||||
connectionProvider.stopScan();
|
||||
connectionProvider.startScan();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -203,12 +230,18 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings.',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer, fontSize: 13),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -222,16 +255,41 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
child: Consumer<ConnectionProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isScanning && provider.scannedDevices.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (provider.scannedDevices.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No devices found',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 16),
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bluetooth_searching,
|
||||
size: 64,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No devices found',
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
connectionProvider.stopScan();
|
||||
connectionProvider.startScan();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Scan Again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -239,42 +297,80 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
return ListView.builder(
|
||||
itemCount: provider.scannedDevices.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = provider.scannedDevices[index];
|
||||
final scannedDevice = provider.scannedDevices[index];
|
||||
final device = scannedDevice.device;
|
||||
final rssi = scannedDevice.rssi;
|
||||
final signalColor = _getSignalColor(rssi);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2D2D2D),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
leading: Icon(
|
||||
Icons.bluetooth,
|
||||
color: Colors.white,
|
||||
color: signalColor,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(
|
||||
device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
: 'Unknown Device',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Tap to connect',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Tap to connect',
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${rssi} dBm',
|
||||
style: TextStyle(
|
||||
color: signalColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.white,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onTap: () async {
|
||||
print('🔵 [UI] User tapped device: ${device.platformName}');
|
||||
print(
|
||||
'🔵 [UI] User tapped device: ${device.platformName}',
|
||||
);
|
||||
|
||||
// Get app provider reference before popping dialog
|
||||
final appProvider = context.read<AppProvider>();
|
||||
@@ -284,17 +380,25 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
|
||||
print('🔵 [UI] Calling provider.connect()...');
|
||||
final success = await provider.connect(device);
|
||||
print(success
|
||||
? '✅ [UI] provider.connect() returned success'
|
||||
: '❌ [UI] provider.connect() returned failure');
|
||||
print(
|
||||
success
|
||||
? '✅ [UI] provider.connect() returned success'
|
||||
: '❌ [UI] provider.connect() returned failure',
|
||||
);
|
||||
|
||||
if (success && provider.deviceInfo.isConnected) {
|
||||
print('✅ [UI] Device is connected, initializing app provider...');
|
||||
print(
|
||||
'✅ [UI] Device is connected, initializing app provider...',
|
||||
);
|
||||
await appProvider.initialize();
|
||||
print('✅ [UI] App provider initialized');
|
||||
} else {
|
||||
print('❌ [UI] Device not connected after connect() call');
|
||||
print(' Connection state: ${provider.deviceInfo.connectionState}');
|
||||
print(
|
||||
'❌ [UI] Device not connected after connect() call',
|
||||
);
|
||||
print(
|
||||
' Connection state: ${provider.deviceInfo.connectionState}',
|
||||
);
|
||||
print(' Error: ${provider.error}');
|
||||
}
|
||||
},
|
||||
@@ -317,60 +421,77 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
final shouldHideUI = _isMapFullscreen && _currentIndex == 2;
|
||||
|
||||
return Scaffold(
|
||||
appBar: shouldHideUI ? null : AppBar(
|
||||
title: _buildCompactStatusBar(),
|
||||
actions: [
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.map),
|
||||
SizedBox(width: 8),
|
||||
Text('Map Management'),
|
||||
appBar: shouldHideUI
|
||||
? null
|
||||
: AppBar(
|
||||
title: _buildCompactStatusBar(),
|
||||
actions: [
|
||||
Consumer<ConnectionProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.deviceInfo.isConnected) {
|
||||
return IconButton(
|
||||
onPressed: () async {
|
||||
await provider.disconnect();
|
||||
},
|
||||
icon: const Icon(Icons.power_settings_new),
|
||||
tooltip: 'Disconnect',
|
||||
color: Colors.red.shade700,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.map),
|
||||
SizedBox(width: 8),
|
||||
Text('Map Management'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Future.delayed(Duration.zero, () {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MapManagementScreen(
|
||||
tileCacheService: appProvider.tileCacheService,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.settings),
|
||||
SizedBox(width: 8),
|
||||
Text('Settings'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Future.delayed(Duration.zero, () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SettingsScreen(
|
||||
onThemeChanged: widget.onThemeChanged,
|
||||
currentTheme: widget.currentTheme,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Future.delayed(Duration.zero, () {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MapManagementScreen(
|
||||
tileCacheService: appProvider.tileCacheService,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.settings),
|
||||
SizedBox(width: 8),
|
||||
Text('Settings'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Future.delayed(Duration.zero, () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SettingsScreen(
|
||||
onThemeChanged: widget.onThemeChanged,
|
||||
currentTheme: widget.currentTheme,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
@@ -385,44 +506,46 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: shouldHideUI ? null : Consumer2<MessagesProvider, ContactsProvider>(
|
||||
builder: (context, messagesProvider, contactsProvider, child) {
|
||||
final unreadCount = messagesProvider.unreadCount;
|
||||
final newContactsCount = contactsProvider.newContactsCount;
|
||||
bottomNavigationBar: shouldHideUI
|
||||
? null
|
||||
: Consumer2<MessagesProvider, ContactsProvider>(
|
||||
builder: (context, messagesProvider, contactsProvider, child) {
|
||||
final unreadCount = messagesProvider.unreadCount;
|
||||
final newContactsCount = contactsProvider.newContactsCount;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
Tab(
|
||||
icon: _buildTabIconWithBadge(
|
||||
Icons.message,
|
||||
unreadCount,
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
text: 'Messages',
|
||||
),
|
||||
Tab(
|
||||
icon: _buildTabIconWithBadge(
|
||||
Icons.contacts,
|
||||
newContactsCount,
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
Tab(
|
||||
icon: _buildTabIconWithBadge(
|
||||
Icons.message,
|
||||
unreadCount,
|
||||
),
|
||||
text: 'Messages',
|
||||
),
|
||||
Tab(
|
||||
icon: _buildTabIconWithBadge(
|
||||
Icons.contacts,
|
||||
newContactsCount,
|
||||
),
|
||||
text: 'Contacts',
|
||||
),
|
||||
const Tab(icon: Icon(Icons.map), text: 'Map'),
|
||||
],
|
||||
),
|
||||
text: 'Contacts',
|
||||
),
|
||||
const Tab(icon: Icon(Icons.map), text: 'Map'),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -432,7 +555,9 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
final deviceInfo = provider.deviceInfo;
|
||||
final isConnected = deviceInfo.isConnected;
|
||||
|
||||
print('🎨 [UI] Building status bar - isConnected: $isConnected, state: ${deviceInfo.connectionState}');
|
||||
print(
|
||||
'🎨 [UI] Building status bar - isConnected: $isConnected, state: ${deviceInfo.connectionState}',
|
||||
);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
@@ -443,24 +568,46 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
children: [
|
||||
const Text(
|
||||
'MeshCore',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
isConnected
|
||||
? deviceInfo.displayName ?? 'Connected'
|
||||
: (provider.isReconnecting
|
||||
? 'Reconnecting... (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})'
|
||||
: 'Disconnected'),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: provider.isReconnecting
|
||||
? Colors.orange[600]
|
||||
: Colors.grey[600],
|
||||
if (isConnected)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// BLE connection strength indicator
|
||||
Icon(
|
||||
Icons.bluetooth_connected,
|
||||
color: deviceInfo.signalRssi != null
|
||||
? _getSignalColor(deviceInfo.signalRssi!)
|
||||
: Colors.grey,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Battery indicator
|
||||
if (deviceInfo.batteryPercent != null) ...[
|
||||
Icon(
|
||||
_getBatteryIcon(deviceInfo.batteryPercent!),
|
||||
color: _getBatteryColor(deviceInfo.batteryPercent!),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${deviceInfo.batteryPercent!.round()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: _getBatteryColor(
|
||||
deviceInfo.batteryPercent!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
else if (provider.isReconnecting)
|
||||
Text(
|
||||
'Reconnecting... (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})',
|
||||
style: TextStyle(fontSize: 14, color: Colors.orange[600]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -478,13 +625,17 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black54),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.black54,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.bluetooth, size: 18),
|
||||
label: Text(provider.isReconnecting
|
||||
? 'Reconnecting (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})'
|
||||
: 'Connect'),
|
||||
label: Text(
|
||||
provider.isReconnecting
|
||||
? 'Reconnecting (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})'
|
||||
: 'Connect',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
@@ -514,15 +665,27 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Advertise button (broadcast location)
|
||||
FilledButton(
|
||||
onPressed: () => _advertiseDevice(context),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.all(10),
|
||||
minimumSize: const Size(40, 40),
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.campaign, size: 20),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// RX/TX indicators with long press to open packet log
|
||||
GestureDetector(
|
||||
onLongPress: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PacketLogScreen(
|
||||
bleService: provider.bleService,
|
||||
),
|
||||
builder: (context) =>
|
||||
PacketLogScreen(bleService: provider.bleService),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -597,9 +760,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PacketLogScreen(
|
||||
bleService: provider.bleService,
|
||||
),
|
||||
builder: (context) =>
|
||||
PacketLogScreen(bleService: provider.bleService),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -610,34 +772,9 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
child: const Icon(Icons.settings, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Advertise button (broadcast location)
|
||||
FilledButton(
|
||||
onPressed: () => _advertiseDevice(context),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.all(10),
|
||||
minimumSize: const Size(40, 40),
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.campaign, size: 20),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Disconnect button (prominent, icon only)
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await provider.disconnect();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.all(10),
|
||||
minimumSize: const Size(40, 40),
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.power_settings_new, size: 20),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Disconnect button (prominent, icon only) - pushed to far right edge
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -661,7 +798,9 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
children: [
|
||||
// Connection status
|
||||
Icon(
|
||||
isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled,
|
||||
isConnected
|
||||
? Icons.bluetooth_connected
|
||||
: Icons.bluetooth_disabled,
|
||||
color: isConnected ? Colors.green : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -764,7 +903,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
Expanded(
|
||||
child: Text(
|
||||
provider.error!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
@@ -822,10 +964,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 18,
|
||||
minHeight: 18,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
||||
child: Text(
|
||||
count > 99 ? '99+' : count.toString(),
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -117,7 +117,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _showSarDialog() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -125,7 +124,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => SarUpdateSheet(
|
||||
onSend: (sarType, position, notes, roomPublicKey, sendToChannel) async {
|
||||
await _sendSarMessage(sarType, position, notes, roomPublicKey, sendToChannel);
|
||||
await _sendSarMessage(
|
||||
sarType,
|
||||
position,
|
||||
notes,
|
||||
roomPublicKey,
|
||||
sendToChannel,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -155,7 +160,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
try {
|
||||
// Format: S:<emoji>:<latitude>,<longitude>
|
||||
final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}';
|
||||
final sarMessage =
|
||||
'S:${sarType.emoji}:${position.latitude},${position.longitude}';
|
||||
|
||||
// Add notes if provided
|
||||
final fullMessage = notes != null && notes.isNotEmpty
|
||||
@@ -170,7 +176,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
ToastLogger.warning(context, '${sarType.displayName} marker broadcast to public channel');
|
||||
ToastLogger.warning(
|
||||
context,
|
||||
'${sarType.displayName} marker broadcast to public channel',
|
||||
);
|
||||
} else {
|
||||
// Create message ID
|
||||
final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent';
|
||||
@@ -202,7 +211,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final roomContact = contactsProvider.contacts.where((c) {
|
||||
return c.publicKey.length >= roomPublicKey!.length &&
|
||||
_publicKeysMatch(c.publicKey, roomPublicKey!);
|
||||
_publicKeysMatch(c.publicKey, roomPublicKey!);
|
||||
}).firstOrNull;
|
||||
|
||||
// Send SAR message to selected room (persisted and immutable)
|
||||
@@ -219,7 +228,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ToastLogger.success(context, '${sarType.displayName} marker sent to room');
|
||||
ToastLogger.success(
|
||||
context,
|
||||
'${sarType.displayName} marker sent to room',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -227,7 +239,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Handle pull-to-refresh for manual message sync
|
||||
/// This is a FALLBACK mechanism - messages are normally synced automatically via PUSH_CODE_MSG_WAITING
|
||||
Future<void> _handleRefresh() async {
|
||||
@@ -242,8 +253,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
try {
|
||||
print('🔄 [MessagesTab] Manual refresh triggered - syncing messages');
|
||||
final messageCount = await connectionProvider.syncAllMessages();
|
||||
print('✅ [MessagesTab] Synced $messageCount message(s)');
|
||||
|
||||
if (!mounted) return;
|
||||
if (messageCount > 0) {
|
||||
ToastLogger.success(context, 'Synced $messageCount message(s)');
|
||||
@@ -277,65 +286,73 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
onRefresh: _handleRefresh,
|
||||
child: messages.isEmpty
|
||||
? LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.message_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
builder: (context, constraints) =>
|
||||
SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.message_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Pull down to sync messages',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Pull down to sync messages',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
|
||||
// Display system messages with minimal styling
|
||||
if (message.isSystemMessage) {
|
||||
return _SystemMessageBubble(message: message);
|
||||
}
|
||||
// Display system messages with minimal styling
|
||||
if (message.isSystemMessage) {
|
||||
return _SystemMessageBubble(message: message);
|
||||
}
|
||||
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
onTap: message.isSarMarker &&
|
||||
message.sarGpsCoordinates != null
|
||||
? () {
|
||||
final mapProvider =
|
||||
context.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: message.sarGpsCoordinates!,
|
||||
zoom: 15.0,
|
||||
);
|
||||
widget.onNavigateToMap();
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
onTap:
|
||||
message.isSarMarker &&
|
||||
message.sarGpsCoordinates != null
|
||||
? () {
|
||||
final mapProvider = context
|
||||
.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: message.sarGpsCoordinates!,
|
||||
zoom: 15.0,
|
||||
);
|
||||
widget.onNavigateToMap();
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -352,68 +369,72 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// SAR quick action button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_location_alt),
|
||||
tooltip: 'Send SAR marker',
|
||||
onPressed: _showSarDialog,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Text field with embedded send button
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: _maxCharacters,
|
||||
maxLines: null,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
isDense: true,
|
||||
counterText: _characterCount >= 150
|
||||
? '$_characterCount/$_maxCharacters'
|
||||
: '',
|
||||
counterStyle: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
Icons.send_rounded,
|
||||
size: 22,
|
||||
color: _textController.text.trim().isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendMessage,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// SAR quick action button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_location_alt),
|
||||
tooltip: 'Send SAR marker',
|
||||
onPressed: _showSarDialog,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Text field with embedded send button
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: _maxCharacters,
|
||||
maxLines: null,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
isDense: true,
|
||||
counterText: _characterCount >= 150
|
||||
? '$_characterCount/$_maxCharacters'
|
||||
: '',
|
||||
counterStyle: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
Icons.send_rounded,
|
||||
size: 22,
|
||||
color: _textController.text.trim().isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendMessage,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -426,10 +447,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
this.onTap,
|
||||
});
|
||||
const _MessageBubble({required this.message, this.onTap});
|
||||
|
||||
/// Helper method to compare two public keys for equality
|
||||
bool _publicKeysMatch(Uint8List key1, Uint8List key2) {
|
||||
@@ -440,7 +458,10 @@ class _MessageBubble extends StatelessWidget {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _retryFailedMessage(BuildContext context, Message failedMessage) async {
|
||||
Future<void> _retryFailedMessage(
|
||||
BuildContext context,
|
||||
Message failedMessage,
|
||||
) async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
|
||||
@@ -467,15 +488,19 @@ class _MessageBubble extends StatelessWidget {
|
||||
// Direct message retry (for SAR markers sent to rooms)
|
||||
if (failedMessage.recipientPublicKey == null) {
|
||||
messagesProvider.markMessageFailed(retryMessageId);
|
||||
ToastLogger.error(context, 'Cannot retry: recipient information missing');
|
||||
ToastLogger.error(
|
||||
context,
|
||||
'Cannot retry: recipient information missing',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the room contact for path logging
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final roomContact = contactsProvider.contacts.where((c) {
|
||||
return c.publicKey.length >= failedMessage.recipientPublicKey!.length &&
|
||||
_publicKeysMatch(c.publicKey, failedMessage.recipientPublicKey!);
|
||||
return c.publicKey.length >=
|
||||
failedMessage.recipientPublicKey!.length &&
|
||||
_publicKeysMatch(c.publicKey, failedMessage.recipientPublicKey!);
|
||||
}).firstOrNull;
|
||||
|
||||
// Resend to the same room
|
||||
@@ -511,12 +536,14 @@ class _MessageBubble extends StatelessWidget {
|
||||
// Determine if this is own message
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final isOwnMessage = message.isSentMessage || message.isFromSelf(selfPublicKey);
|
||||
final isOwnMessage =
|
||||
message.isSentMessage || message.isFromSelf(selfPublicKey);
|
||||
|
||||
// Check if we can reply to this message (must be contact message from someone else)
|
||||
final canReply = message.isContactMessage &&
|
||||
!isOwnMessage &&
|
||||
message.senderPublicKeyPrefix != null;
|
||||
final canReply =
|
||||
message.isContactMessage &&
|
||||
!isOwnMessage &&
|
||||
message.senderPublicKeyPrefix != null;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -552,7 +579,10 @@ class _MessageBubble extends StatelessWidget {
|
||||
// Delete message option
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: Colors.red),
|
||||
title: const Text('Delete message', style: TextStyle(color: Colors.red)),
|
||||
title: const Text(
|
||||
'Delete message',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showDeleteConfirmation(context);
|
||||
@@ -575,7 +605,12 @@ class _MessageBubble extends StatelessWidget {
|
||||
|
||||
// Find contact by public key prefix (first 6 bytes)
|
||||
final senderKeyHex = message.senderPublicKeyPrefix!
|
||||
.sublist(0, message.senderPublicKeyPrefix!.length < 6 ? message.senderPublicKeyPrefix!.length : 6)
|
||||
.sublist(
|
||||
0,
|
||||
message.senderPublicKeyPrefix!.length < 6
|
||||
? message.senderPublicKeyPrefix!.length
|
||||
: 6,
|
||||
)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
|
||||
@@ -633,7 +668,8 @@ class _MessageBubble extends StatelessWidget {
|
||||
// after loading from storage
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final isOwnMessage = message.isSentMessage || message.isFromSelf(selfPublicKey);
|
||||
final isOwnMessage =
|
||||
message.isSentMessage || message.isFromSelf(selfPublicKey);
|
||||
|
||||
// Debug logging for sent messages
|
||||
if (message.isSentMessage) {
|
||||
@@ -642,9 +678,13 @@ class _MessageBubble extends StatelessWidget {
|
||||
debugPrint(' Delivery Status: ${message.deliveryStatus.name}');
|
||||
debugPrint(' isSentMessage: ${message.isSentMessage}');
|
||||
debugPrint(' isOwnMessage: $isOwnMessage');
|
||||
debugPrint(' Has recipientPublicKey: ${message.recipientPublicKey != null}');
|
||||
debugPrint(
|
||||
' Has recipientPublicKey: ${message.recipientPublicKey != null}',
|
||||
);
|
||||
if (message.recipientPublicKey != null) {
|
||||
debugPrint(' Recipient key (first 12 hex): ${message.recipientPublicKey!.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}');
|
||||
debugPrint(
|
||||
' Recipient key (first 12 hex): ${message.recipientPublicKey!.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,7 +694,12 @@ class _MessageBubble extends StatelessWidget {
|
||||
if (message.senderPublicKeyPrefix != null && !isOwnMessage) {
|
||||
// Find contact by public key prefix (first 6 bytes)
|
||||
final senderKeyHex = message.senderPublicKeyPrefix!
|
||||
.sublist(0, message.senderPublicKeyPrefix!.length < 6 ? message.senderPublicKeyPrefix!.length : 6)
|
||||
.sublist(
|
||||
0,
|
||||
message.senderPublicKeyPrefix!.length < 6
|
||||
? message.senderPublicKeyPrefix!.length
|
||||
: 6,
|
||||
)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
|
||||
@@ -671,10 +716,17 @@ class _MessageBubble extends StatelessWidget {
|
||||
// For sent direct messages, look up recipient contact
|
||||
dynamic recipientContact;
|
||||
String? recipientDisplayName;
|
||||
if (isOwnMessage && message.isContactMessage && message.recipientPublicKey != null) {
|
||||
if (isOwnMessage &&
|
||||
message.isContactMessage &&
|
||||
message.recipientPublicKey != null) {
|
||||
// Find recipient by public key
|
||||
final recipientKeyHex = message.recipientPublicKey!
|
||||
.sublist(0, message.recipientPublicKey!.length < 6 ? message.recipientPublicKey!.length : 6)
|
||||
.sublist(
|
||||
0,
|
||||
message.recipientPublicKey!.length < 6
|
||||
? message.recipientPublicKey!.length
|
||||
: 6,
|
||||
)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
|
||||
@@ -686,8 +738,12 @@ class _MessageBubble extends StatelessWidget {
|
||||
for (final c in contactsProvider.contacts) {
|
||||
debugPrint(' Contact: ${c.displayName ?? c.advName}');
|
||||
debugPrint(' Key: ${c.publicKeyHex}');
|
||||
debugPrint(' First 12 chars: ${c.publicKeyHex.substring(0, c.publicKeyHex.length >= 12 ? 12 : c.publicKeyHex.length)}');
|
||||
debugPrint(' Matches: ${c.publicKeyHex.startsWith(recipientKeyHex)}');
|
||||
debugPrint(
|
||||
' First 12 chars: ${c.publicKeyHex.substring(0, c.publicKeyHex.length >= 12 ? 12 : c.publicKeyHex.length)}',
|
||||
);
|
||||
debugPrint(
|
||||
' Matches: ${c.publicKeyHex.startsWith(recipientKeyHex)}',
|
||||
);
|
||||
}
|
||||
|
||||
recipientContact = contactsProvider.contacts.where((c) {
|
||||
@@ -704,7 +760,8 @@ class _MessageBubble extends StatelessWidget {
|
||||
if (roleEmoji != null && roleEmoji.isNotEmpty) {
|
||||
recipientDisplayName = '$roleEmoji ${recipientContact.displayName}';
|
||||
} else {
|
||||
recipientDisplayName = recipientContact.displayName ?? recipientContact.advName;
|
||||
recipientDisplayName =
|
||||
recipientContact.displayName ?? recipientContact.advName;
|
||||
}
|
||||
debugPrint(' Final recipient name: $recipientDisplayName');
|
||||
} else {
|
||||
@@ -737,20 +794,24 @@ class _MessageBubble extends StatelessWidget {
|
||||
width: 2,
|
||||
)
|
||||
: isOwnMessage
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||
width: 1.5,
|
||||
)
|
||||
: !message.isRead && !message.isSentMessage && !message.isSystemMessage
|
||||
? Border.all(
|
||||
color: Colors.blue,
|
||||
width: 1.5,
|
||||
)
|
||||
: null,
|
||||
? Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.3),
|
||||
width: 1.5,
|
||||
)
|
||||
: !message.isRead &&
|
||||
!message.isSentMessage &&
|
||||
!message.isSystemMessage
|
||||
? Border.all(color: Colors.blue, width: 1.5)
|
||||
: null,
|
||||
boxShadow: isSarMarker
|
||||
? [
|
||||
BoxShadow(
|
||||
color: _getSarMarkerBorderColor(context, isDarkMode).withValues(alpha: 0.3),
|
||||
color: _getSarMarkerBorderColor(
|
||||
context,
|
||||
isDarkMode,
|
||||
).withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -764,7 +825,10 @@ class _MessageBubble extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
// Unread indicator badge
|
||||
if (!message.isRead && !message.isSentMessage && !message.isSystemMessage && !isSarMarker)
|
||||
if (!message.isRead &&
|
||||
!message.isSentMessage &&
|
||||
!message.isSystemMessage &&
|
||||
!isSarMarker)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
@@ -795,7 +859,8 @@ class _MessageBubble extends StatelessWidget {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'SAR ALERT',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
@@ -806,7 +871,11 @@ class _MessageBubble extends StatelessWidget {
|
||||
)
|
||||
else ...[
|
||||
if (isOwnMessage)
|
||||
Icon(Icons.account_circle, size: 16, color: Theme.of(context).colorScheme.primary)
|
||||
Icon(
|
||||
Icons.account_circle,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
else if (message.isChannelMessage)
|
||||
const Icon(Icons.tag, size: 16)
|
||||
else
|
||||
@@ -815,25 +884,33 @@ class _MessageBubble extends StatelessWidget {
|
||||
Text(
|
||||
displayName,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOwnMessage ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOwnMessage
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
// Show recipient for sent direct messages
|
||||
if (isOwnMessage && message.isContactMessage && recipientDisplayName != null) ...[
|
||||
if (isOwnMessage &&
|
||||
message.isContactMessage &&
|
||||
recipientDisplayName != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 14,
|
||||
color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
recipientDisplayName,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -841,8 +918,10 @@ class _MessageBubble extends StatelessWidget {
|
||||
Text(
|
||||
message.timeAgo,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: isSarMarker ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
fontWeight: isSarMarker
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -863,16 +942,14 @@ class _MessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
message.sarMarkerType!.displayName,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (message.sarGpsCoordinates != null)
|
||||
Text(
|
||||
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -884,13 +961,25 @@ class _MessageBubble extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Display SAR notes/message if present
|
||||
if (message.sarNotes != null && message.sarNotes!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
message.sarNotes!,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
]
|
||||
// Regular message content
|
||||
else
|
||||
Text(
|
||||
message.text,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
|
||||
|
||||
// Delivery status for sent messages
|
||||
if (message.isSentMessage) ...[
|
||||
@@ -907,17 +996,21 @@ class _MessageBubble extends StatelessWidget {
|
||||
Text(
|
||||
message.deliveryStatusText,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: _getDeliveryStatusColor(message.deliveryStatus),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
color: _getDeliveryStatusColor(message.deliveryStatus),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
// Show retry button for failed messages
|
||||
if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[
|
||||
if (message.deliveryStatus ==
|
||||
MessageDeliveryStatus.failed) ...[
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: () => _retryFailedMessage(context, message),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -926,11 +1019,16 @@ class _MessageBubble extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.refresh, size: 12, color: Colors.orange),
|
||||
const Icon(
|
||||
Icons.refresh,
|
||||
size: 12,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Retry',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -979,12 +1077,20 @@ class _MessageBubble extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Color _getMessageBubbleColor(BuildContext context, bool isOwnMessage, bool isDarkMode) {
|
||||
Color _getMessageBubbleColor(
|
||||
BuildContext context,
|
||||
bool isOwnMessage,
|
||||
bool isDarkMode,
|
||||
) {
|
||||
if (isOwnMessage) {
|
||||
// Own messages: slightly highlighted with primary color tint
|
||||
return isDarkMode
|
||||
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.15);
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.15);
|
||||
} else {
|
||||
// Others' messages: default surface color
|
||||
return Theme.of(context).colorScheme.surfaceVariant;
|
||||
@@ -1000,24 +1106,24 @@ class _MessageBubble extends StatelessWidget {
|
||||
switch (message.sarMarkerType!) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return isDarkMode
|
||||
? const Color(0xFF1B5E20).withValues(alpha: 0.4) // Dark green
|
||||
: const Color(0xFFC8E6C9).withValues(alpha: 0.9); // Light green
|
||||
? const Color(0xFF1B5E20).withValues(alpha: 0.4) // Dark green
|
||||
: const Color(0xFFC8E6C9).withValues(alpha: 0.9); // Light green
|
||||
case SarMarkerType.fire:
|
||||
return isDarkMode
|
||||
? const Color(0xFFB71C1C).withValues(alpha: 0.4) // Dark red
|
||||
: const Color(0xFFFFCDD2).withValues(alpha: 0.9); // Light red
|
||||
? const Color(0xFFB71C1C).withValues(alpha: 0.4) // Dark red
|
||||
: const Color(0xFFFFCDD2).withValues(alpha: 0.9); // Light red
|
||||
case SarMarkerType.stagingArea:
|
||||
return isDarkMode
|
||||
? const Color(0xFF0D47A1).withValues(alpha: 0.4) // Dark blue
|
||||
: const Color(0xFFBBDEFB).withValues(alpha: 0.9); // Light blue
|
||||
? const Color(0xFF0D47A1).withValues(alpha: 0.4) // Dark blue
|
||||
: const Color(0xFFBBDEFB).withValues(alpha: 0.9); // Light blue
|
||||
case SarMarkerType.object:
|
||||
return isDarkMode
|
||||
? const Color(0xFF4A148C).withValues(alpha: 0.4) // Dark purple
|
||||
: const Color(0xFFE1BEE7).withValues(alpha: 0.9); // Light purple
|
||||
? const Color(0xFF4A148C).withValues(alpha: 0.4) // Dark purple
|
||||
: const Color(0xFFE1BEE7).withValues(alpha: 0.9); // Light purple
|
||||
case SarMarkerType.unknown:
|
||||
return isDarkMode
|
||||
? const Color(0xFF424242).withValues(alpha: 0.4) // Dark gray
|
||||
: const Color(0xFFEEEEEE).withValues(alpha: 0.9); // Light gray
|
||||
? const Color(0xFF424242).withValues(alpha: 0.4) // Dark gray
|
||||
: const Color(0xFFEEEEEE).withValues(alpha: 0.9); // Light gray
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1029,15 +1135,15 @@ class _MessageBubble extends StatelessWidget {
|
||||
// Use vibrant type-specific colors for borders
|
||||
switch (message.sarMarkerType!) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return const Color(0xFF4CAF50); // Green
|
||||
return const Color(0xFF4CAF50); // Green
|
||||
case SarMarkerType.fire:
|
||||
return const Color(0xFFF44336); // Red
|
||||
return const Color(0xFFF44336); // Red
|
||||
case SarMarkerType.stagingArea:
|
||||
return const Color(0xFF2196F3); // Blue
|
||||
return const Color(0xFF2196F3); // Blue
|
||||
case SarMarkerType.object:
|
||||
return const Color(0xFF9C27B0); // Purple
|
||||
return const Color(0xFF9C27B0); // Purple
|
||||
case SarMarkerType.unknown:
|
||||
return const Color(0xFF9E9E9E); // Gray
|
||||
return const Color(0xFF9E9E9E); // Gray
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,27 +1233,27 @@ class _SystemMessageBubble extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getLevelIcon(level),
|
||||
size: 14,
|
||||
color: levelColor,
|
||||
),
|
||||
Icon(_getLevelIcon(level), size: 14, color: levelColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
message.timeAgo,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message.text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
|
||||
),
|
||||
fontSize: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -1157,4 +1263,3 @@ class _SystemMessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../meshcore_constants.dart';
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
|
||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
||||
|
||||
/// Manages BLE connection lifecycle with automatic reconnection
|
||||
class BleConnectionManager {
|
||||
@@ -21,6 +22,10 @@ class BleConnectionManager {
|
||||
Timer? _reconnectionTimer;
|
||||
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
|
||||
|
||||
// RSSI monitoring
|
||||
Timer? _rssiTimer;
|
||||
int? _lastRssi;
|
||||
|
||||
// SAR-optimized reconnection: ~15 minutes total
|
||||
// Pattern: Fast retries first (for temporary issues), then slower retries (for extended disconnections)
|
||||
static const int _maxReconnectionAttempts = 30;
|
||||
@@ -38,6 +43,7 @@ class BleConnectionManager {
|
||||
OnConnectionStateCallback? onConnectionStateChanged;
|
||||
OnErrorCallback? onError;
|
||||
OnReconnectionAttemptCallback? onReconnectionAttempt;
|
||||
OnRssiUpdateCallback? onRssiUpdate;
|
||||
|
||||
// Getters
|
||||
bool get isConnected => _isConnected;
|
||||
@@ -49,7 +55,7 @@ class BleConnectionManager {
|
||||
BluetoothCharacteristic? get txCharacteristic => _txCharacteristic;
|
||||
|
||||
/// Scan for MeshCore devices
|
||||
Stream<BluetoothDevice> scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* {
|
||||
Stream<ScanResult> scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* {
|
||||
try {
|
||||
print('🔍 [BLE] Starting scan for MeshCore devices...');
|
||||
print(' Service UUID: ${MeshCoreConstants.bleServiceUuid}');
|
||||
@@ -73,7 +79,7 @@ class BleConnectionManager {
|
||||
.contains(Guid(MeshCoreConstants.bleServiceUuid))) {
|
||||
deviceCount++;
|
||||
print(' ✅ MeshCore device found! Total: $deviceCount');
|
||||
yield result.device;
|
||||
yield result;
|
||||
} else {
|
||||
print(' ❌ Not a MeshCore device (service UUID mismatch)');
|
||||
}
|
||||
@@ -170,6 +176,9 @@ class BleConnectionManager {
|
||||
// Monitor connection state for automatic reconnection
|
||||
_setupConnectionMonitoring();
|
||||
|
||||
// Start RSSI monitoring
|
||||
_startRssiMonitoring();
|
||||
|
||||
print('✅✅✅ [BLE] Connection completed successfully!');
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -189,6 +198,7 @@ class BleConnectionManager {
|
||||
// Disable reconnection before disconnecting
|
||||
_reconnectionEnabled = false;
|
||||
_cancelReconnection();
|
||||
_stopRssiMonitoring();
|
||||
|
||||
await _device?.disconnect();
|
||||
_isConnected = false;
|
||||
@@ -317,10 +327,40 @@ class BleConnectionManager {
|
||||
_reconnectionEnabled = true;
|
||||
}
|
||||
|
||||
/// Start monitoring RSSI in the background
|
||||
void _startRssiMonitoring() {
|
||||
print('📡 [BLE] Starting RSSI monitoring (every 5 seconds)');
|
||||
_stopRssiMonitoring(); // Cancel any existing timer
|
||||
|
||||
_rssiTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
|
||||
if (_device != null && _isConnected) {
|
||||
try {
|
||||
final rssi = await _device!.readRssi();
|
||||
if (_lastRssi != rssi) {
|
||||
_lastRssi = rssi;
|
||||
print('📡 [BLE] RSSI updated: $rssi dBm');
|
||||
onRssiUpdate?.call(rssi);
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ [BLE] Failed to read RSSI: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop RSSI monitoring
|
||||
void _stopRssiMonitoring() {
|
||||
_rssiTimer?.cancel();
|
||||
_rssiTimer = null;
|
||||
_lastRssi = null;
|
||||
print('📡 [BLE] RSSI monitoring stopped');
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
print('🔴 [BLE] Disposing BLE connection manager');
|
||||
_cancelReconnection();
|
||||
_stopRssiMonitoring();
|
||||
_device = null;
|
||||
_rxCharacteristic = null;
|
||||
_txCharacteristic = null;
|
||||
|
||||
@@ -31,6 +31,7 @@ typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb,
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
|
||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
||||
|
||||
/// MeshCore BLE Service - coordinates BLE communication components
|
||||
class MeshCoreBleService {
|
||||
@@ -42,6 +43,7 @@ class MeshCoreBleService {
|
||||
// Event callbacks
|
||||
OnConnectionStateCallback? onConnectionStateChanged;
|
||||
OnReconnectionAttemptCallback? onReconnectionAttempt;
|
||||
OnRssiUpdateCallback? onRssiUpdate;
|
||||
OnContactCallback? onContactReceived;
|
||||
OnContactsCompleteCallback? onContactsComplete;
|
||||
OnMessageCallback? onMessageReceived;
|
||||
@@ -83,6 +85,9 @@ class MeshCoreBleService {
|
||||
print('🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts');
|
||||
onReconnectionAttempt?.call(attemptNumber, maxAttempts);
|
||||
};
|
||||
_connectionManager.onRssiUpdate = (rssi) {
|
||||
onRssiUpdate?.call(rssi);
|
||||
};
|
||||
|
||||
// Command sender callbacks
|
||||
_commandSender.onError = (error) {
|
||||
@@ -167,7 +172,7 @@ class MeshCoreBleService {
|
||||
}
|
||||
|
||||
/// Scan for MeshCore devices
|
||||
Stream<BluetoothDevice> scanForDevices({Duration timeout = const Duration(seconds: 10)}) {
|
||||
Stream<ScanResult> scanForDevices({Duration timeout = const Duration(seconds: 10)}) {
|
||||
return _connectionManager.scanForDevices(timeout: timeout);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ class DrawingMessageParser {
|
||||
}
|
||||
|
||||
/// Parse drawing message text into MapDrawing object
|
||||
/// senderName should be extracted from packet metadata
|
||||
/// Returns null if parsing fails
|
||||
static MapDrawing? parseDrawingMessage(String text) {
|
||||
static MapDrawing? parseDrawingMessage(String text, {String? senderName}) {
|
||||
if (!isDrawingMessage(text)) {
|
||||
return null;
|
||||
}
|
||||
@@ -25,17 +26,18 @@ class DrawingMessageParser {
|
||||
// Parse JSON
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
|
||||
// Use compact network format parser
|
||||
return MapDrawing.fromNetworkJson(json);
|
||||
// Use ultra-compact network format parser
|
||||
// Sender name comes from packet metadata, not JSON
|
||||
return MapDrawing.fromNetworkJson(json, senderName: senderName);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create drawing message text from MapDrawing object
|
||||
/// Includes sender name in the message
|
||||
static String createDrawingMessage(MapDrawing drawing, String senderName) {
|
||||
final json = drawing.toNetworkJson(senderName);
|
||||
/// Sender will be determined from packet metadata on receiving end
|
||||
static String createDrawingMessage(MapDrawing drawing) {
|
||||
final json = drawing.toNetworkJson();
|
||||
final jsonStr = jsonEncode(json);
|
||||
return '$prefix$jsonStr';
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import '../models/sar_marker.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
/// Parser for SAR (Search & Rescue) special messages
|
||||
/// Format: S:<emoji>:<latitude>,<longitude>
|
||||
/// Format: S:<emoji>:<latitude>,<longitude>:<optional_message>
|
||||
/// Examples:
|
||||
/// S:🧑:37.7749,-122.4194
|
||||
/// S:🔥:40.7128,-74.0060
|
||||
/// S:🏕️:34.0522,-118.2437
|
||||
/// S:🔥:40.7128,-74.0060:Large wildfire spreading
|
||||
/// S:🏕️:34.0522,-118.2437:Base camp established
|
||||
class SarMessageParser {
|
||||
// Updated regex to allow optional notes after coordinates
|
||||
// Captures: emoji (one or more non-colon chars), latitude, longitude
|
||||
// Updated regex to capture optional message after coordinates
|
||||
// Captures: emoji (one or more non-colon chars), latitude, longitude, optional message
|
||||
// Note: Emojis are multi-byte characters, so we use [^:]+ instead of .
|
||||
static final RegExp _sarPattern = RegExp(
|
||||
r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*)',
|
||||
r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
|
||||
multiLine: false,
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ class SarMessageParser {
|
||||
final emoji = match.group(1)!;
|
||||
final latitude = double.parse(match.group(2)!);
|
||||
final longitude = double.parse(match.group(3)!);
|
||||
final inlineMessage = match.group(4)?.trim(); // Optional message after colon
|
||||
|
||||
// Validate coordinates
|
||||
if (latitude < -90 || latitude > 90) return null;
|
||||
@@ -47,14 +48,13 @@ class SarMessageParser {
|
||||
final markerType = SarMarkerType.fromEmoji(emoji);
|
||||
final location = LatLng(latitude, longitude);
|
||||
|
||||
// Extract notes if present (everything after coordinates on first line, or subsequent lines)
|
||||
// Combine inline message with multi-line notes
|
||||
String? notes;
|
||||
final coordsEnd = match.end;
|
||||
if (coordsEnd < firstLine.length) {
|
||||
// Notes on same line after coordinates
|
||||
notes = firstLine.substring(coordsEnd).trim();
|
||||
if (inlineMessage != null && inlineMessage.isNotEmpty) {
|
||||
notes = inlineMessage;
|
||||
}
|
||||
// Check for multi-line notes
|
||||
|
||||
// Check for multi-line notes (lines after the first line)
|
||||
final additionalNotes = extractNotes(text);
|
||||
if (additionalNotes != null) {
|
||||
notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes;
|
||||
@@ -80,6 +80,7 @@ class SarMessageParser {
|
||||
isSarMarker: true,
|
||||
sarMarkerType: sarInfo.type,
|
||||
sarGpsCoordinates: sarInfo.location,
|
||||
sarNotes: sarInfo.notes, // Extract and store notes
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +92,8 @@ class SarMessageParser {
|
||||
}) {
|
||||
final text = 'S:${type.emoji}:${location.latitude},${location.longitude}';
|
||||
if (notes != null && notes.isNotEmpty) {
|
||||
return '$text\n$notes';
|
||||
// Use colon-separated format for inline message
|
||||
return '$text:$notes';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -453,10 +453,8 @@ class DrawingToolbar extends StatelessWidget {
|
||||
for (final drawing in drawings) {
|
||||
try {
|
||||
debugPrint(' Creating message for drawing ${drawing.id}...');
|
||||
final message = drawingProvider.createDrawingBroadcastMessage(
|
||||
drawing,
|
||||
senderName,
|
||||
);
|
||||
// Sender name is no longer included in JSON - will be extracted from packet metadata
|
||||
final message = drawingProvider.createDrawingBroadcastMessage(drawing);
|
||||
debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...');
|
||||
debugPrint(' Sending to channel 0...');
|
||||
await connectionProvider.sendChannelMessage(
|
||||
@@ -525,10 +523,8 @@ class DrawingToolbar extends StatelessWidget {
|
||||
for (final drawing in drawings) {
|
||||
try {
|
||||
debugPrint(' Creating message for drawing ${drawing.id}...');
|
||||
final message = drawingProvider.createDrawingBroadcastMessage(
|
||||
drawing,
|
||||
senderName,
|
||||
);
|
||||
// Sender name is no longer included in JSON - will be extracted from packet metadata
|
||||
final message = drawingProvider.createDrawingBroadcastMessage(drawing);
|
||||
debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...');
|
||||
debugPrint(' Sending to room ${room.advName}...');
|
||||
await connectionProvider.sendTextMessage(
|
||||
|
||||
Reference in New Issue
Block a user