feat: MeshCore SAR - Flutter BLE mesh radio companion app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janez T
2026-02-28 10:11:33 +01:00
commit baf49d27d8
313 changed files with 101770 additions and 0 deletions

View File

@@ -0,0 +1,446 @@
import 'package:flutter/foundation.dart';
import '../models/contact.dart';
import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart';
import '../utils/key_comparison.dart';
/// Contacts Provider - manages contact list and telemetry
class ContactsProvider with ChangeNotifier {
final Map<String, Contact> _contacts = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
// Add default public channel on initialization
ContactsProvider() {
_ensurePublicChannelExists();
}
bool get isInitialized => _isInitialized;
/// Initialize and load persisted contacts at app startup
/// This loads contacts without filtering, allowing offline viewing
Future<void> initializeEarly() async {
if (_isInitialized) return;
try {
debugPrint(
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
);
final storedContacts = await _storageService.loadContacts();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
for (final contact in storedContacts) {
// Skip any contacts with all-zeros public key (shouldn't happen, but safety check)
if (contact.publicKeyHex == publicChannelKey) {
continue;
}
_contacts[contact.publicKeyHex] = contact;
}
_isInitialized = true;
debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts',
);
// Ensure public channel exists after loading
_ensurePublicChannelExists();
notifyListeners();
} catch (e) {
debugPrint('❌ [ContactsProvider] Error in early initialization: $e');
_isInitialized = true; // Mark as initialized even on error
_ensurePublicChannelExists();
}
}
/// Initialize and load persisted contacts
/// [devicePublicKey] - device's own public key to exclude from loaded contacts
Future<void> initialize({Uint8List? devicePublicKey}) async {
if (_isInitialized) {
// If already initialized (from early load), just filter out self-contact
if (devicePublicKey != null) {
_removeSelfContact(devicePublicKey);
}
return;
}
try {
debugPrint('📦 [ContactsProvider] Loading persisted contacts...');
final storedContacts = await _storageService.loadContacts(
excludePublicKey: devicePublicKey,
);
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
for (final contact in storedContacts) {
// Skip any contacts with all-zeros public key (shouldn't happen, but safety check)
if (contact.publicKeyHex == publicChannelKey) {
continue;
}
_contacts[contact.publicKeyHex] = contact;
}
_isInitialized = true;
debugPrint(
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts',
);
// Ensure public channel exists after loading
_ensurePublicChannelExists();
notifyListeners();
} catch (e) {
debugPrint('❌ [ContactsProvider] Error initializing: $e');
_isInitialized = true; // Mark as initialized even on error
_ensurePublicChannelExists();
}
}
/// Remove self-contact from loaded contacts (called after BLE connection established)
void _removeSelfContact(Uint8List devicePublicKey) {
final selfKeyHex = devicePublicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (_contacts.containsKey(selfKeyHex)) {
final selfContact = _contacts[selfKeyHex]!;
debugPrint(
'🗑️ [ContactsProvider] Removing self-contact: ${selfContact.advName}',
);
_contacts.remove(selfKeyHex);
_persistContacts();
notifyListeners();
}
}
/// Ensure public channel always exists in the list
void _ensurePublicChannelExists() {
// Public channel has all-zeros public key (32 bytes = 64 hex chars)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
if (!_contacts.containsKey(publicChannelKey)) {
// Create a pseudo-contact for the public channel (ephemeral broadcast)
_contacts[publicChannelKey] = Contact(
publicKey: Uint8List.fromList(
List.filled(32, 0),
), // Zero key for public
type: ContactType.channel, // Channel type (not room!)
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Public Channel',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
}
/// Persist contacts to storage (async, non-blocking)
Future<void> _persistContacts() async {
try {
// Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
final contactsToSave = _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
await _storageService.saveContacts(contactsToSave);
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
}
}
List<Contact> get contacts => _contacts.values.toList();
List<Contact> get chatContacts =>
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
List<Contact> get repeaters =>
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
List<Contact> get rooms =>
contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
List<Contact> get channels {
// Always ensure public channel exists when getting channels
_ensurePublicChannelExists();
return contacts.where((c) => c.isChannel).toList()..sort(_sortByLastSeen);
}
/// Get both rooms and channels (destinations for SAR markers)
List<Contact> get roomsAndChannels {
_ensurePublicChannelExists();
return contacts.where((c) => c.isRoom || c.isChannel).toList()
..sort(_sortByLastSeen);
}
/// Get contacts with location (for map display)
List<Contact> get contactsWithLocation =>
contacts.where((c) => c.displayLocation != null).toList();
/// Get chat contacts with location (team members on map)
List<Contact> get chatContactsWithLocation =>
chatContacts.where((c) => c.displayLocation != null).toList();
/// Sort contacts by last seen (most recent first)
int _sortByLastSeen(Contact a, Contact b) {
return b.lastSeenTime.compareTo(a.lastSeenTime);
}
/// Add or update a contact
/// Excludes contacts that match the device's own public key
void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) {
debugPrint('📝 [ContactsProvider] addOrUpdateContact called: ${contact.advName} (type: ${contact.type.displayName}, key: ${contact.publicKeyHex.substring(0, 8)}...)');
// Don't add contacts that match our device's public key
if (devicePublicKey != null &&
contact.publicKey.matches(devicePublicKey)) {
debugPrint(
' [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}',
);
return;
}
// Check if this is a new contact
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
debugPrint(' isNew: $isNewContact, total contacts before: ${_contacts.length}');
Contact updatedContact;
if (isNewContact) {
// New contact - add initial location to history if available
updatedContact = contact.copyWith(isNew: true);
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
contact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
contact.advertLocation!,
timestamp,
);
}
} else {
// Existing contact - preserve history and isNew status
final existingContact = _contacts[contact.publicKeyHex]!;
// Start with existing contact
updatedContact = contact.copyWith(
isNew: existingContact.isNew,
advertHistory: existingContact.advertHistory,
);
// Add new location to history if location has changed
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
contact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
contact.advertLocation!,
timestamp,
);
}
}
_contacts[contact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}');
_persistContacts();
notifyListeners();
debugPrint(' 🔔 notifyListeners() called');
}
/// Add multiple contacts
/// Excludes contacts that match the device's own public key
void addContacts(List<Contact> contacts, {Uint8List? devicePublicKey}) {
int excluded = 0;
for (final contact in contacts) {
// Don't add contacts that match our device's public key
if (devicePublicKey != null &&
contact.publicKey.matches(devicePublicKey)) {
debugPrint(
' [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}',
);
excluded++;
continue;
}
_contacts[contact.publicKeyHex] = contact;
}
if (excluded > 0) {
debugPrint(
' [ContactsProvider] Excluded $excluded contact(s) matching device public key',
);
}
_persistContacts();
notifyListeners();
}
/// Update contact telemetry
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
debugPrint(
' Public key prefix (hex): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(' LPP data size: ${lppData.length} bytes');
// Find contact by public key prefix
final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) {
debugPrint(' ❌ Contact not found for this prefix');
return;
}
debugPrint(' ✅ Found contact: ${contact.advName}');
debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}');
try {
// Parse Cayenne LPP data
final telemetry = CayenneLppParser.parse(lppData);
debugPrint(' ✅ Parsed new telemetry');
debugPrint(' New telemetry timestamp: ${telemetry.timestamp}');
// Update contact with new telemetry AND last seen time
// lastAdvert is Unix timestamp in seconds
final currentTimestamp =
(DateTime.now().millisecondsSinceEpoch / 1000).round();
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
debugPrint(' New lastAdvert: $currentTimestamp');
final updatedContact = contact.copyWith(
telemetry: telemetry,
lastAdvert: currentTimestamp, // Update last seen time
);
_contacts[contact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
_persistContacts();
debugPrint(' ✅ Persisted contacts to storage');
notifyListeners();
debugPrint(' ✅ Notified listeners - UI should update');
} catch (e) {
debugPrint(' ❌ Failed to parse telemetry: $e');
debugPrint('Failed to parse telemetry: $e');
}
}
/// Find contact by public key prefix (6 bytes)
Contact? _findContactByPrefix(Uint8List prefix) {
if (prefix.length < 6) return null;
final prefixHex = prefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
for (final contact in contacts) {
if (contact.publicKeyHex.startsWith(prefixHex)) {
return contact;
}
}
return null;
}
/// Find contact by public key
Contact? findContactByKey(Uint8List publicKey) {
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
return _contacts[keyHex];
}
/// Find contact by name
Contact? findContactByName(String name) {
return contacts.firstWhere(
(c) => c.advName == name,
orElse: () => contacts.first,
);
}
/// Get contacts with low battery
List<Contact> get lowBatteryContacts {
return contacts.where((c) {
final battery = c.displayBattery;
return battery != null && battery < 20.0;
}).toList();
}
/// Get recently seen contacts (within last 10 minutes)
List<Contact> get recentlySeenContacts {
return contacts.where((c) => c.isRecentlySeen).toList();
}
/// Get count of new contacts (not yet viewed)
int get newContactsCount =>
contacts.where((c) => c.isNew && !c.isChannel).length;
/// Mark all contacts as viewed (not new)
void markAllAsViewed() {
bool hasChanges = false;
_contacts.forEach((key, contact) {
if (contact.isNew && !contact.isChannel) {
_contacts[key] = contact.copyWith(isNew: false);
hasChanges = true;
}
});
if (hasChanges) {
_persistContacts();
notifyListeners();
}
}
/// Mark a specific contact as viewed (not new)
void markAsViewed(String publicKeyHex) {
final contact = _contacts[publicKeyHex];
if (contact != null && contact.isNew) {
_contacts[publicKeyHex] = contact.copyWith(isNew: false);
_persistContacts();
notifyListeners();
}
}
/// Clear all contacts
void clearContacts() {
_contacts.clear();
_persistContacts();
notifyListeners();
}
/// Remove a contact
/// [onRemoveFromDevice] - Optional callback to remove contact from BLE device
Future<void> removeContact(
String publicKeyHex, {
Future<void> Function(Uint8List)? onRemoveFromDevice,
}) async {
// Get the contact before removing
final contact = _contacts[publicKeyHex];
if (contact == null) return;
// Remove from device first if callback provided
if (onRemoveFromDevice != null) {
await onRemoveFromDevice(contact.publicKey);
}
// Then remove from local storage
_contacts.remove(publicKeyHex);
_persistContacts();
notifyListeners();
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
return await _storageService.getStorageStats();
}
/// Get contact count by type
Map<String, int> get contactCounts {
return {
'chat': chatContacts.length,
'repeater': repeaters.length,
'room': rooms.length,
'total': contacts.length,
};
}
}