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:
Janez T
2025-10-16 13:32:58 +02:00
parent 5a096c048e
commit 23c439a92e
28 changed files with 957 additions and 4730 deletions

2
.gitignore vendored
View File

@@ -43,3 +43,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
*.zip
*.ipa

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -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! 🎉

View File

@@ -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

View File

@@ -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! 🎉

View File

@@ -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

View File

@@ -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.

Binary file not shown.

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 &apos;Fastlane::Actions.execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:255:in &apos;block in Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:157:in &apos;Fastlane::Runner#trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/fast_file.rb:159:in &apos;Fastlane::FastFile#method_missing&apos;&#10;Fastfile:23:in &apos;block (2 levels) in Fastlane::FastFile#parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane.rb:41:in &apos;Fastlane::Lane#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:49:in &apos;block in Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane_manager.rb:46:in &apos;Fastlane::LaneManager.cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/command_line_handler.rb:34:in &apos;Fastlane::CommandLineHandler.handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:110:in &apos;block (2 levels) in Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in &apos;Commander::Command#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in &apos;Commander::Command#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in &apos;Commander::Runner#run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in &apos;Commander::Runner#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in &apos;Commander::Delegates#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:363:in &apos;Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:43:in &apos;Fastlane::CommandsGenerator.start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in &apos;Fastlane::CLIToolsDistributor.take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/bin/fastlane:23:in &apos;&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in &apos;Kernel#load&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in &apos;&lt;main&gt;&apos;&#10;&#10;Error uploading ipa file: &#10; [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>

View File

@@ -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]),

View File

@@ -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,

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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(

View File

@@ -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 {
);
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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';
}

View File

@@ -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;
}

View File

@@ -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(