mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
450 lines
14 KiB
Dart
450 lines
14 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import '../models/contact.dart';
|
||
import '../services/cayenne_lpp_parser.dart';
|
||
import '../services/contact_storage_service.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}) {
|
||
// Don't add contacts that match our device's public key
|
||
if (devicePublicKey != null &&
|
||
_publicKeysMatch(contact.publicKey, 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);
|
||
|
||
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;
|
||
_persistContacts();
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Compare two public keys for equality
|
||
bool _publicKeysMatch(Uint8List key1, Uint8List key2) {
|
||
if (key1.length != key2.length) return false;
|
||
for (int i = 0; i < key1.length; i++) {
|
||
if (key1[i] != key2[i]) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/// 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 &&
|
||
_publicKeysMatch(contact.publicKey, 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,
|
||
};
|
||
}
|
||
}
|