mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
- i18n: translate connection screen + chat tab keys into 13 locales; add "Send my contact" (advert) strings in all locales - feat: move self-advert from home header into composer "+" action menu (new lib/utils/advert_helper.dart, always shows Flood/Direct sheet) - fix: hide-repeaters map toggle was bypassed in simple mode - fix: simple mode map now shows only favourite chat contacts - fix: wrap ListTiles in Material (contact_tile secondary actions, inferred contact group card) to satisfy Flutter ink assertions - device-authoritative contact/channel sync and UX review fixes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1913 lines
61 KiB
Dart
1913 lines
61 KiB
Dart
import 'dart:convert';
|
||
import 'dart:math';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:latlong2/latlong.dart';
|
||
import '../models/contact.dart';
|
||
import '../models/contact_group.dart';
|
||
import '../models/message_contact_location.dart';
|
||
import '../services/cayenne_lpp_parser.dart';
|
||
import '../services/contact_storage_service.dart';
|
||
import '../services/profiles_feature_service.dart';
|
||
import '../utils/fast_gps_packet.dart';
|
||
import '../utils/rssi_location_estimator.dart';
|
||
import '../utils/key_comparison.dart';
|
||
|
||
class PendingAdvert {
|
||
final Uint8List publicKey;
|
||
final DateTime receivedAt;
|
||
final String? advName;
|
||
final int? typeValue;
|
||
final int? flags;
|
||
final int? lastAdvert;
|
||
final int? advLat;
|
||
final int? advLon;
|
||
final int? signedEncodedPathLen;
|
||
final Uint8List? paddedPathBytes;
|
||
final int? rxRssiDbm;
|
||
final int? rxSnrRaw;
|
||
final int? repeaterBatteryMv;
|
||
final int? repeaterQueueLen;
|
||
final int? repeaterLastRssi;
|
||
final int? repeaterLastSnrRaw;
|
||
final int? repeaterUptimeSecs;
|
||
|
||
const PendingAdvert({
|
||
required this.publicKey,
|
||
required this.receivedAt,
|
||
this.advName,
|
||
this.typeValue,
|
||
this.flags,
|
||
this.lastAdvert,
|
||
this.advLat,
|
||
this.advLon,
|
||
this.signedEncodedPathLen,
|
||
this.paddedPathBytes,
|
||
this.rxRssiDbm,
|
||
this.rxSnrRaw,
|
||
this.repeaterBatteryMv,
|
||
this.repeaterQueueLen,
|
||
this.repeaterLastRssi,
|
||
this.repeaterLastSnrRaw,
|
||
this.repeaterUptimeSecs,
|
||
});
|
||
|
||
String get publicKeyHex =>
|
||
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||
|
||
String get shortDisplayKey {
|
||
final prefix = publicKey.length >= 6 ? publicKey.sublist(0, 6) : publicKey;
|
||
return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||
}
|
||
|
||
PendingAdvert copyWith({
|
||
Uint8List? publicKey,
|
||
DateTime? receivedAt,
|
||
String? advName,
|
||
int? typeValue,
|
||
int? flags,
|
||
int? lastAdvert,
|
||
int? advLat,
|
||
int? advLon,
|
||
int? signedEncodedPathLen,
|
||
Uint8List? paddedPathBytes,
|
||
int? rxRssiDbm,
|
||
int? rxSnrRaw,
|
||
int? repeaterBatteryMv,
|
||
int? repeaterQueueLen,
|
||
int? repeaterLastRssi,
|
||
int? repeaterLastSnrRaw,
|
||
int? repeaterUptimeSecs,
|
||
}) {
|
||
return PendingAdvert(
|
||
publicKey: publicKey ?? this.publicKey,
|
||
receivedAt: receivedAt ?? this.receivedAt,
|
||
advName: advName ?? this.advName,
|
||
typeValue: typeValue ?? this.typeValue,
|
||
flags: flags ?? this.flags,
|
||
lastAdvert: lastAdvert ?? this.lastAdvert,
|
||
advLat: advLat ?? this.advLat,
|
||
advLon: advLon ?? this.advLon,
|
||
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
|
||
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
|
||
rxRssiDbm: rxRssiDbm ?? this.rxRssiDbm,
|
||
rxSnrRaw: rxSnrRaw ?? this.rxSnrRaw,
|
||
repeaterBatteryMv: repeaterBatteryMv ?? this.repeaterBatteryMv,
|
||
repeaterQueueLen: repeaterQueueLen ?? this.repeaterQueueLen,
|
||
repeaterLastRssi: repeaterLastRssi ?? this.repeaterLastRssi,
|
||
repeaterLastSnrRaw: repeaterLastSnrRaw ?? this.repeaterLastSnrRaw,
|
||
repeaterUptimeSecs: repeaterUptimeSecs ?? this.repeaterUptimeSecs,
|
||
);
|
||
}
|
||
|
||
double? get repeaterBatteryPercent {
|
||
if (repeaterBatteryMv == null) return null;
|
||
final voltage = repeaterBatteryMv! / 1000.0;
|
||
if (voltage <= 3.0) return 0.0;
|
||
if (voltage >= 4.2) return 100.0;
|
||
return ((voltage - 3.0) / 1.2) * 100.0;
|
||
}
|
||
|
||
double? get repeaterLastSnr =>
|
||
repeaterLastSnrRaw == null ? null : repeaterLastSnrRaw! / 4.0;
|
||
|
||
double? get rxSnr => rxSnrRaw == null ? null : rxSnrRaw! / 4.0;
|
||
}
|
||
|
||
class _RetainedRoute {
|
||
final int signedEncodedPathLen;
|
||
final Uint8List paddedPathBytes;
|
||
|
||
const _RetainedRoute({
|
||
required this.signedEncodedPathLen,
|
||
required this.paddedPathBytes,
|
||
});
|
||
}
|
||
|
||
/// Contacts Provider - manages contact list and telemetry
|
||
class ContactsProvider with ChangeNotifier {
|
||
static const double _firstHopFallbackOffsetMeters = 100.0;
|
||
static const String autoGroupIdPrefix = 'auto_group_';
|
||
static const String _rawTelemetryHexKey = '__raw_lpp_hex';
|
||
final Map<String, Contact> _contacts = {};
|
||
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||
final Map<String, LatLng> _estimatedLocations = {};
|
||
final Map<String, List<RssiObservation>> _rssiObservations = {};
|
||
final Map<String, Contact> _retainedContactsForSync = {};
|
||
final ContactStorageService _storageService = ContactStorageService();
|
||
bool _isInitialized = false;
|
||
bool _isPersisting = false;
|
||
bool _persistRequested = false;
|
||
bool _isPersistingPendingAdverts = false;
|
||
bool _persistPendingAdvertsRequested = false;
|
||
String? _storageNamespace;
|
||
String? _selfPublicKeyHex;
|
||
ContactTelemetry? _selfTelemetry;
|
||
|
||
// Add default public channel on initialization
|
||
ContactsProvider()
|
||
: _storageNamespace = ProfileStorageScope.effectiveNamespace {
|
||
_ensurePublicChannelExists();
|
||
}
|
||
|
||
bool get isInitialized => _isInitialized;
|
||
String? get storageNamespace => _storageNamespace;
|
||
|
||
/// Initialize and load persisted contacts at app startup
|
||
/// This loads contacts without filtering, allowing offline viewing
|
||
Future<void> initializeEarly() async {
|
||
if (_isInitialized) return;
|
||
|
||
await _loadFromStorage();
|
||
}
|
||
|
||
Future<void> reloadFromStorage({
|
||
String? namespace,
|
||
Uint8List? devicePublicKey,
|
||
}) async {
|
||
_storageNamespace = namespace;
|
||
_setSelfDevicePublicKey(devicePublicKey);
|
||
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
|
||
}
|
||
|
||
Future<void> persistNow() async {
|
||
await _storageService.saveContacts(
|
||
_contactsForStorage(),
|
||
namespace: _storageNamespace,
|
||
);
|
||
await _storageService.saveContactGroups(
|
||
_savedContactGroups,
|
||
namespace: _storageNamespace,
|
||
);
|
||
await _storageService.savePendingAdverts(
|
||
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
|
||
namespace: _storageNamespace,
|
||
);
|
||
}
|
||
|
||
Future<void> cloneCurrentStorageTo(String? namespace) async {
|
||
await _storageService.saveContacts(
|
||
_contactsForStorage(),
|
||
namespace: namespace,
|
||
);
|
||
await _storageService.saveContactGroups(
|
||
_savedContactGroups,
|
||
namespace: namespace,
|
||
);
|
||
await _storageService.savePendingAdverts(
|
||
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
|
||
namespace: namespace,
|
||
);
|
||
}
|
||
|
||
Future<void> _loadFromStorage({
|
||
bool force = false,
|
||
Uint8List? devicePublicKey,
|
||
}) async {
|
||
if (_isInitialized && !force) return;
|
||
|
||
try {
|
||
_resetInMemoryState();
|
||
debugPrint(
|
||
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
|
||
);
|
||
final storedContacts = await _storageService.loadContacts(
|
||
namespace: _storageNamespace,
|
||
);
|
||
final storedGroups = await _storageService.loadContactGroups(
|
||
namespace: _storageNamespace,
|
||
);
|
||
final storedPendingAdverts = await _storageService.loadPendingAdverts(
|
||
namespace: _storageNamespace,
|
||
);
|
||
|
||
// 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;
|
||
_savedContactGroups
|
||
..clear()
|
||
..addAll(storedGroups);
|
||
_restorePendingAdverts(
|
||
storedPendingAdverts,
|
||
devicePublicKey: devicePublicKey,
|
||
);
|
||
debugPrint(
|
||
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||
);
|
||
|
||
// 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 {
|
||
_setSelfDevicePublicKey(devicePublicKey);
|
||
if (_isInitialized) {
|
||
// If already initialized (from early load), just filter out self-contact
|
||
if (devicePublicKey != null) {
|
||
_removeSelfContact(devicePublicKey);
|
||
}
|
||
return;
|
||
}
|
||
|
||
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
|
||
if (devicePublicKey != null) {
|
||
_removeSelfContact(devicePublicKey);
|
||
}
|
||
}
|
||
|
||
/// Clear runtime contact state before a live device contact sync begins.
|
||
///
|
||
/// This intentionally does not touch persisted storage. It snapshots the
|
||
/// current in-memory contacts so sync updates can still merge against the
|
||
/// latest local state, but keeps the visible list intact so reconnects do
|
||
/// not blank the UI while the device is resyncing.
|
||
Future<void> prepareForDeviceContactSync({Uint8List? devicePublicKey}) async {
|
||
_setSelfDevicePublicKey(devicePublicKey);
|
||
if (!_isInitialized) {
|
||
final storedGroups = await _storageService.loadContactGroups(
|
||
namespace: _storageNamespace,
|
||
);
|
||
_savedContactGroups
|
||
..clear()
|
||
..addAll(storedGroups);
|
||
_isInitialized = true;
|
||
}
|
||
|
||
debugPrint(
|
||
'🧹 [ContactsProvider] Preparing retained contact state for device sync',
|
||
);
|
||
_retainedContactsForSync
|
||
..clear()
|
||
..addEntries(
|
||
_contactsForStorage().map(
|
||
(contact) => MapEntry(contact.publicKeyHex, contact),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Finish a device contact sync started with [prepareForDeviceContactSync].
|
||
///
|
||
/// The device contact list is authoritative: when [deviceListComplete] is
|
||
/// true, any contact still left in the retained snapshot (i.e. not sent by
|
||
/// the device during this sync) is deleted locally. Channel pseudo-contacts
|
||
/// are excluded — they mirror device channel slots and are reconciled by the
|
||
/// channel sync instead. When the sync was partial (timeout/disconnect),
|
||
/// nothing is deleted so a flaky link can't wipe valid contacts.
|
||
void finalizeDeviceContactSync({required bool deviceListComplete}) {
|
||
if (!deviceListComplete) {
|
||
if (_retainedContactsForSync.isNotEmpty) {
|
||
debugPrint(
|
||
'ℹ️ [ContactsProvider] Partial contact sync - keeping '
|
||
'${_retainedContactsForSync.length} unconfirmed contact(s)',
|
||
);
|
||
}
|
||
_retainedContactsForSync.clear();
|
||
return;
|
||
}
|
||
|
||
final staleKeys = _retainedContactsForSync.keys
|
||
.where((key) => _contacts[key]?.type != ContactType.channel)
|
||
.where(_contacts.containsKey)
|
||
.toList();
|
||
for (final key in staleKeys) {
|
||
final removed = _contacts.remove(key);
|
||
debugPrint(
|
||
'🗑️ [ContactsProvider] Removing contact no longer on device: '
|
||
'${removed?.advName} (${key.substring(0, 8)}...)',
|
||
);
|
||
}
|
||
_retainedContactsForSync.clear();
|
||
|
||
if (staleKeys.isNotEmpty) {
|
||
_persistContacts();
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// 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, coalescing).
|
||
Future<void> _persistContacts() async {
|
||
_persistRequested = true;
|
||
if (_isPersisting) return;
|
||
_isPersisting = true;
|
||
try {
|
||
while (_persistRequested) {
|
||
_persistRequested = false;
|
||
await _storageService.saveContacts(
|
||
_contactsForStorage(),
|
||
namespace: _storageNamespace,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
|
||
} finally {
|
||
_isPersisting = false;
|
||
}
|
||
}
|
||
|
||
Future<void> _persistPendingAdverts() async {
|
||
_persistPendingAdvertsRequested = true;
|
||
if (_isPersistingPendingAdverts) return;
|
||
_isPersistingPendingAdverts = true;
|
||
try {
|
||
while (_persistPendingAdvertsRequested) {
|
||
_persistPendingAdvertsRequested = false;
|
||
await _storageService.savePendingAdverts(
|
||
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
|
||
namespace: _storageNamespace,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e');
|
||
} finally {
|
||
_isPersistingPendingAdverts = false;
|
||
}
|
||
}
|
||
|
||
List<Contact> get contacts => _contacts.values.toList();
|
||
ContactTelemetry? get selfTelemetry => _selfTelemetry;
|
||
List<Contact> get favouriteContacts =>
|
||
_contacts.values.where((c) => c.isFavourite).toList();
|
||
List<Contact> get favouriteChatContacts =>
|
||
chatContacts.where((c) => c.isFavourite).toList();
|
||
List<Contact> get favouriteContactsWithLocation =>
|
||
favouriteContacts.where((c) => effectiveLocationFor(c) != null).toList();
|
||
List<SavedContactGroup> get savedContactGroups =>
|
||
List<SavedContactGroup>.from(_savedContactGroups)
|
||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||
List<PendingAdvert> get pendingAdverts =>
|
||
_pendingAdverts.values.toList()
|
||
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||
|
||
PendingAdvert? pendingAdvertByKey(Uint8List publicKey) {
|
||
final keyHex = publicKey
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join('');
|
||
return _pendingAdverts[keyHex];
|
||
}
|
||
|
||
bool shouldEnrichPendingAdvert(Uint8List publicKey) {
|
||
final advert = pendingAdvertByKey(publicKey);
|
||
if (advert == null) {
|
||
return false;
|
||
}
|
||
|
||
final hasName = advert.advName?.trim().isNotEmpty ?? false;
|
||
final hasType = advert.typeValue != null && advert.typeValue != 0;
|
||
final hasLocation =
|
||
advert.advLat != null &&
|
||
advert.advLon != null &&
|
||
(advert.advLat != 0 || advert.advLon != 0);
|
||
return !(hasName && hasType && hasLocation);
|
||
}
|
||
|
||
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
|
||
return savedContactGroups
|
||
.where((group) => group.sectionKey == sectionKey)
|
||
.toList();
|
||
}
|
||
|
||
bool hasSavedGroupForFilter(String sectionKey, String query) {
|
||
final normalizedQuery = _normalizeGroupQuery(query);
|
||
if (normalizedQuery.isEmpty) {
|
||
return false;
|
||
}
|
||
|
||
return _savedContactGroups.any(
|
||
(group) =>
|
||
group.sectionKey == sectionKey &&
|
||
_normalizeGroupQuery(group.query) == normalizedQuery,
|
||
);
|
||
}
|
||
|
||
Future<void> addSavedGroupForFilter(
|
||
String sectionKey,
|
||
String query, {
|
||
String? label,
|
||
List<String>? matchPrefixes,
|
||
bool isAutoGroup = false,
|
||
}) async {
|
||
final normalizedQuery = _normalizeGroupQuery(query);
|
||
if (normalizedQuery.isEmpty ||
|
||
hasSavedGroupForFilter(sectionKey, normalizedQuery)) {
|
||
return;
|
||
}
|
||
|
||
_savedContactGroups.add(
|
||
SavedContactGroup(
|
||
id: '${sectionKey}_${DateTime.now().microsecondsSinceEpoch}',
|
||
sectionKey: sectionKey,
|
||
label: (label ?? query).trim(),
|
||
query: query.trim(),
|
||
createdAt: DateTime.now(),
|
||
matchPrefixes: matchPrefixes,
|
||
isAutoGroup: isAutoGroup,
|
||
),
|
||
);
|
||
|
||
await _persistSavedGroups();
|
||
notifyListeners();
|
||
}
|
||
|
||
Future<void> removeSavedGroupById(String id) async {
|
||
final beforeCount = _savedContactGroups.length;
|
||
_savedContactGroups.removeWhere((group) => group.id == id);
|
||
if (_savedContactGroups.length == beforeCount) {
|
||
return;
|
||
}
|
||
|
||
await _persistSavedGroups();
|
||
notifyListeners();
|
||
}
|
||
|
||
Future<void> removeSavedGroupForFilter(
|
||
String sectionKey,
|
||
String query,
|
||
) async {
|
||
final normalizedQuery = _normalizeGroupQuery(query);
|
||
final beforeCount = _savedContactGroups.length;
|
||
_savedContactGroups.removeWhere(
|
||
(group) =>
|
||
group.sectionKey == sectionKey &&
|
||
_normalizeGroupQuery(group.query) == normalizedQuery,
|
||
);
|
||
if (_savedContactGroups.length == beforeCount) {
|
||
return;
|
||
}
|
||
|
||
await _persistSavedGroups();
|
||
notifyListeners();
|
||
}
|
||
|
||
Future<void> replaceAutoGroupsForSection(
|
||
String sectionKey,
|
||
List<SavedContactGroup> groups,
|
||
) async {
|
||
_savedContactGroups.removeWhere(
|
||
(group) => group.sectionKey == sectionKey && group.isAutoGroup,
|
||
);
|
||
_savedContactGroups.addAll(groups);
|
||
await _persistSavedGroups();
|
||
notifyListeners();
|
||
}
|
||
|
||
Future<void> _persistSavedGroups() async {
|
||
try {
|
||
await _storageService.saveContactGroups(
|
||
_savedContactGroups,
|
||
namespace: _storageNamespace,
|
||
);
|
||
} catch (e) {
|
||
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
|
||
}
|
||
}
|
||
|
||
String _normalizeGroupQuery(String query) => query.trim().toLowerCase();
|
||
|
||
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 sensorContacts =>
|
||
contacts.where((c) => c.isSensor).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);
|
||
}
|
||
|
||
/// All estimated locations (for passing to map marker service).
|
||
Map<String, LatLng> get estimatedLocations =>
|
||
Map.unmodifiable(_estimatedLocations);
|
||
|
||
/// Get estimated location for a contact (RSSI-based, near last-hop repeater).
|
||
LatLng? estimatedLocationFor(String publicKeyHex) =>
|
||
_estimatedLocations[publicKeyHex];
|
||
|
||
/// Set an estimated location for a contact that has no GPS.
|
||
void setEstimatedLocation(String publicKeyHex, LatLng location) {
|
||
_estimatedLocations[publicKeyHex] = location;
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Record an RSSI observation and re-trilaterate the contact's position.
|
||
///
|
||
/// Keeps up to 5 observations per unique repeater (latest wins).
|
||
/// With multiple repeaters, trilateration produces a better estimate.
|
||
void addRssiObservation({
|
||
required String contactPublicKeyHex,
|
||
required List<int> contactPublicKey,
|
||
required RssiObservation observation,
|
||
}) {
|
||
final observations = _rssiObservations.putIfAbsent(
|
||
contactPublicKeyHex,
|
||
() => [],
|
||
);
|
||
|
||
// Replace existing observation from the same repeater, or add new
|
||
final repeaterKey =
|
||
'${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}';
|
||
observations.removeWhere(
|
||
(o) =>
|
||
'${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' ==
|
||
repeaterKey,
|
||
);
|
||
observations.add(observation);
|
||
|
||
// Keep at most 8 observations (most recent per repeater)
|
||
if (observations.length > 8) {
|
||
observations.sort((a, b) => b.observedAt.compareTo(a.observedAt));
|
||
observations.removeRange(8, observations.length);
|
||
}
|
||
|
||
final estimated = RssiLocationEstimator.trilaterate(
|
||
observations: observations,
|
||
contactPublicKey: contactPublicKey,
|
||
);
|
||
if (estimated != null) {
|
||
_estimatedLocations[contactPublicKeyHex] = estimated;
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Effective location: own GPS/advert > estimated from RSSI.
|
||
LatLng? effectiveLocationFor(Contact contact) {
|
||
return contact.displayLocation ?? _estimatedLocations[contact.publicKeyHex];
|
||
}
|
||
|
||
/// Get contacts with location (for map display)
|
||
/// Includes contacts with estimated locations from RSSI.
|
||
List<Contact> get contactsWithLocation =>
|
||
contacts.where((c) => effectiveLocationFor(c) != null).toList();
|
||
|
||
/// Get chat contacts with location (team members on map)
|
||
List<Contact> get chatContactsWithLocation =>
|
||
chatContacts.where((c) => effectiveLocationFor(c) != null).toList();
|
||
|
||
MessageContactLocation? buildMessageContactLocationSnapshot(
|
||
Contact contact, {
|
||
DateTime? capturedAt,
|
||
}) {
|
||
final snapshotTime = capturedAt ?? DateTime.now();
|
||
final telemetryGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
|
||
final telemetryTimestamp = contact.telemetry?.timestamp;
|
||
|
||
AdvertLocation? advertLocation;
|
||
for (final point in contact.advertHistory) {
|
||
if (!point.timestamp.isAfter(snapshotTime)) {
|
||
advertLocation = point;
|
||
break;
|
||
}
|
||
}
|
||
advertLocation ??= contact.advertHistory.isNotEmpty
|
||
? contact.advertHistory.first
|
||
: null;
|
||
|
||
if (telemetryGps != null) {
|
||
final shouldUseTelemetry =
|
||
telemetryTimestamp == null ||
|
||
advertLocation == null ||
|
||
!telemetryTimestamp.isBefore(advertLocation.timestamp);
|
||
if (shouldUseTelemetry) {
|
||
return MessageContactLocation(
|
||
location: telemetryGps,
|
||
source: 'telemetry',
|
||
capturedAt: snapshotTime,
|
||
sourceTimestamp: telemetryTimestamp,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (advertLocation != null) {
|
||
return MessageContactLocation(
|
||
location: advertLocation.location,
|
||
source: 'advert',
|
||
capturedAt: snapshotTime,
|
||
sourceTimestamp: advertLocation.timestamp,
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// 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 existingContact =
|
||
_contacts[contact.publicKeyHex] ??
|
||
_retainedContactsForSync[contact.publicKeyHex];
|
||
final isNewContact = existingContact == null;
|
||
debugPrint(
|
||
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
||
);
|
||
|
||
final updatedContact = _mergeIncomingContact(
|
||
incomingContact: contact,
|
||
existingContact: existingContact,
|
||
);
|
||
|
||
_contacts[contact.publicKeyHex] = updatedContact;
|
||
_retainedContactsForSync.remove(contact.publicKeyHex);
|
||
// Keep repeaters and sensors in pending adverts so they remain visible
|
||
// in the discovery list (with a checkmark). Remove others.
|
||
if (contact.type != ContactType.repeater &&
|
||
contact.type != ContactType.sensor) {
|
||
_pendingAdverts.remove(contact.publicKeyHex);
|
||
}
|
||
debugPrint(
|
||
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
||
);
|
||
_persistContacts();
|
||
_persistPendingAdverts();
|
||
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;
|
||
}
|
||
final existingContact =
|
||
_contacts[contact.publicKeyHex] ??
|
||
_retainedContactsForSync[contact.publicKeyHex];
|
||
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
|
||
incomingContact: contact,
|
||
existingContact: existingContact,
|
||
);
|
||
_retainedContactsForSync.remove(contact.publicKeyHex);
|
||
if (contact.type != ContactType.repeater &&
|
||
contact.type != ContactType.sensor) {
|
||
_pendingAdverts.remove(contact.publicKeyHex);
|
||
}
|
||
}
|
||
if (excluded > 0) {
|
||
debugPrint(
|
||
'ℹ️ [ContactsProvider] Excluded $excluded contact(s) matching device public key',
|
||
);
|
||
}
|
||
_persistContacts();
|
||
_persistPendingAdverts();
|
||
notifyListeners();
|
||
}
|
||
|
||
Contact _mergeIncomingContact({
|
||
required Contact incomingContact,
|
||
Contact? existingContact,
|
||
}) {
|
||
final retainedRoute = _retainedRouteForContact(
|
||
keyHex: incomingContact.publicKeyHex,
|
||
incomingContact: incomingContact,
|
||
existingContact: existingContact,
|
||
);
|
||
final mergedTelemetry = _mergeTelemetryForContact(
|
||
existingTelemetry: existingContact?.telemetry,
|
||
incomingTelemetry: incomingContact.telemetry,
|
||
);
|
||
final inferredFallbackLocation = _inferFirstHopFallbackLocation(
|
||
incomingContact: incomingContact,
|
||
existingContact: existingContact,
|
||
retainedRoute: retainedRoute,
|
||
mergedTelemetry: mergedTelemetry,
|
||
);
|
||
final inferredFallbackAdvLat = inferredFallbackLocation != null
|
||
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.latitude)
|
||
: null;
|
||
final inferredFallbackAdvLon = inferredFallbackLocation != null
|
||
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.longitude)
|
||
: null;
|
||
|
||
if (existingContact == null) {
|
||
var newContact = incomingContact.copyWith(
|
||
isNew: true,
|
||
telemetry: mergedTelemetry,
|
||
outPathLen:
|
||
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
||
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
|
||
advLat: incomingContact.advertLocation != null
|
||
? incomingContact.advLat
|
||
: inferredFallbackAdvLat ?? incomingContact.advLat,
|
||
advLon: incomingContact.advertLocation != null
|
||
? incomingContact.advLon
|
||
: inferredFallbackAdvLon ?? incomingContact.advLon,
|
||
);
|
||
if (incomingContact.advertLocation != null) {
|
||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||
incomingContact.lastAdvert * 1000,
|
||
);
|
||
newContact = newContact.addAdvertLocation(
|
||
incomingContact.advertLocation!,
|
||
timestamp,
|
||
);
|
||
}
|
||
return newContact;
|
||
}
|
||
|
||
final incomingAdvertLocation = incomingContact.advertLocation;
|
||
final existingAdvertLocation = existingContact.advertLocation;
|
||
|
||
var updatedContact = incomingContact.copyWith(
|
||
isNew: false,
|
||
nameOverride: existingContact.nameOverride,
|
||
advertHistory: existingContact.advertHistory,
|
||
telemetry: mergedTelemetry,
|
||
outPathLen:
|
||
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
||
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
|
||
advLat: incomingAdvertLocation != null
|
||
? incomingContact.advLat
|
||
: existingAdvertLocation != null
|
||
? existingContact.advLat
|
||
: inferredFallbackAdvLat ?? incomingContact.advLat,
|
||
advLon: incomingAdvertLocation != null
|
||
? incomingContact.advLon
|
||
: existingAdvertLocation != null
|
||
? existingContact.advLon
|
||
: inferredFallbackAdvLon ?? incomingContact.advLon,
|
||
);
|
||
|
||
if (incomingAdvertLocation != null) {
|
||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||
incomingContact.lastAdvert * 1000,
|
||
);
|
||
updatedContact = updatedContact.addAdvertLocation(
|
||
incomingAdvertLocation,
|
||
timestamp,
|
||
);
|
||
}
|
||
|
||
return updatedContact;
|
||
}
|
||
|
||
_RetainedRoute? _retainedRouteForContact({
|
||
required String keyHex,
|
||
required Contact incomingContact,
|
||
required Contact? existingContact,
|
||
}) {
|
||
if (incomingContact.routeHasPath) {
|
||
return null;
|
||
}
|
||
|
||
final pendingAdvert = _pendingAdverts[keyHex];
|
||
final pendingPathBytes = pendingAdvert?.paddedPathBytes;
|
||
final pendingPathLen = pendingAdvert?.signedEncodedPathLen;
|
||
if (pendingPathLen != null &&
|
||
pendingPathBytes != null &&
|
||
pendingPathBytes.isNotEmpty) {
|
||
return _RetainedRoute(
|
||
signedEncodedPathLen: pendingPathLen,
|
||
paddedPathBytes: Uint8List.fromList(pendingPathBytes),
|
||
);
|
||
}
|
||
|
||
if (existingContact != null && existingContact.routeHasPath) {
|
||
return _RetainedRoute(
|
||
signedEncodedPathLen: existingContact.outPathLen,
|
||
paddedPathBytes: Uint8List.fromList(existingContact.outPath),
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
LatLng? _inferFirstHopFallbackLocation({
|
||
required Contact incomingContact,
|
||
required Contact? existingContact,
|
||
required _RetainedRoute? retainedRoute,
|
||
required ContactTelemetry? mergedTelemetry,
|
||
}) {
|
||
if (_getValidGpsOrNull(mergedTelemetry?.gpsLocation) != null) {
|
||
return null;
|
||
}
|
||
if (incomingContact.advertLocation != null ||
|
||
existingContact?.advertLocation != null) {
|
||
return null;
|
||
}
|
||
|
||
final routeBytes =
|
||
retainedRoute?.paddedPathBytes ??
|
||
(incomingContact.routeHasPath
|
||
? incomingContact.routePathBytes
|
||
: existingContact?.routeHasPath == true
|
||
? existingContact!.routePathBytes
|
||
: null);
|
||
final routeHashSize = retainedRoute != null
|
||
? ((ContactRouteCodec.toUnsignedDescriptor(
|
||
retainedRoute.signedEncodedPathLen,
|
||
) >>
|
||
6) +
|
||
1)
|
||
: incomingContact.routeHasPath
|
||
? incomingContact.routeHashSize
|
||
: existingContact?.routeHasPath == true
|
||
? existingContact!.routeHashSize
|
||
: 0;
|
||
if (routeBytes == null ||
|
||
routeHashSize <= 0 ||
|
||
routeBytes.length < routeHashSize) {
|
||
return null;
|
||
}
|
||
|
||
final lastHopHex = routeBytes
|
||
.sublist(routeBytes.length - routeHashSize)
|
||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||
.join();
|
||
final repeaterCandidates = _contacts.values.where((candidate) {
|
||
if (!candidate.isRepeater ||
|
||
candidate.publicKeyHex == incomingContact.publicKeyHex) {
|
||
return false;
|
||
}
|
||
final location = candidate.displayLocation;
|
||
return location != null && candidate.publicKeyHex.startsWith(lastHopHex);
|
||
}).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert));
|
||
if (repeaterCandidates.isEmpty) {
|
||
return null;
|
||
}
|
||
|
||
final repeaterLocation = repeaterCandidates.first.displayLocation;
|
||
if (repeaterLocation == null) {
|
||
return null;
|
||
}
|
||
|
||
final bearingDegrees = _stableFallbackBearingDegrees(incomingContact);
|
||
return _offsetFromLocation(
|
||
repeaterLocation,
|
||
distanceMeters: _firstHopFallbackOffsetMeters,
|
||
bearingDegrees: bearingDegrees,
|
||
);
|
||
}
|
||
|
||
double _stableFallbackBearingDegrees(Contact contact) {
|
||
if (contact.publicKey.length < 2) {
|
||
return 90.0;
|
||
}
|
||
final seed = (contact.publicKey[0] << 8) | contact.publicKey[1];
|
||
return (seed % 360).toDouble();
|
||
}
|
||
|
||
LatLng _offsetFromLocation(
|
||
LatLng origin, {
|
||
required double distanceMeters,
|
||
required double bearingDegrees,
|
||
}) {
|
||
const earthRadiusMeters = 6371000.0;
|
||
final angularDistance = distanceMeters / earthRadiusMeters;
|
||
final bearingRadians = bearingDegrees * 3.1415926535897932 / 180.0;
|
||
final lat1 = origin.latitude * 3.1415926535897932 / 180.0;
|
||
final lon1 = origin.longitude * 3.1415926535897932 / 180.0;
|
||
|
||
final sinLat1 = sin(lat1);
|
||
final cosLat1 = cos(lat1);
|
||
final sinAngularDistance = sin(angularDistance);
|
||
final cosAngularDistance = cos(angularDistance);
|
||
|
||
final lat2 = asin(
|
||
sinLat1 * cosAngularDistance +
|
||
cosLat1 * sinAngularDistance * cos(bearingRadians),
|
||
);
|
||
final lon2 =
|
||
lon1 +
|
||
atan2(
|
||
sin(bearingRadians) * sinAngularDistance * cosLat1,
|
||
cosAngularDistance - sinLat1 * sin(lat2),
|
||
);
|
||
|
||
return LatLng(
|
||
lat2 * 180.0 / 3.1415926535897932,
|
||
((lon2 * 180.0 / 3.1415926535897932 + 540.0) % 360.0) - 180.0,
|
||
);
|
||
}
|
||
|
||
/// Update contact telemetry
|
||
// Dedup: track last telemetry data hash per contact to avoid processing
|
||
// the same payload twice (0x8B + 0x8C can both fire for the same data).
|
||
final Map<String, (int, DateTime)> _lastTelemetryHash = {};
|
||
|
||
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');
|
||
|
||
// Deduplicate: same data for same contact within 2 seconds = skip
|
||
final prefixHex = publicKeyPrefix
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join();
|
||
final dataHash = lppData.fold<int>(0, (h, b) => h * 31 + b);
|
||
final now = DateTime.now();
|
||
final last = _lastTelemetryHash[prefixHex];
|
||
if (last != null &&
|
||
last.$1 == dataHash &&
|
||
now.difference(last.$2).inSeconds < 2) {
|
||
debugPrint(' ⏭️ Duplicate telemetry payload, skipping');
|
||
return;
|
||
}
|
||
_lastTelemetryHash[prefixHex] = (dataHash, now);
|
||
|
||
// Find contact by public key prefix
|
||
final contact = _findContactByPrefix(publicKeyPrefix);
|
||
final isSelfTelemetry =
|
||
contact == null && _matchesSelfPrefix(publicKeyPrefix);
|
||
if (contact == null && !isSelfTelemetry) {
|
||
debugPrint(' ❌ Contact not found for this prefix');
|
||
return;
|
||
}
|
||
|
||
if (isSelfTelemetry) {
|
||
debugPrint(' ✅ Matched self telemetry response');
|
||
debugPrint(
|
||
' Old self telemetry timestamp: ${_selfTelemetry?.timestamp}',
|
||
);
|
||
} else {
|
||
debugPrint(' ✅ Found contact: ${contact!.advName}');
|
||
debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}');
|
||
}
|
||
|
||
try {
|
||
// Parse Cayenne LPP data
|
||
var telemetry = _withRawTelemetryHex(
|
||
CayenneLppParser.parse(lppData),
|
||
lppData,
|
||
);
|
||
debugPrint(' ✅ Parsed new telemetry');
|
||
debugPrint(' New telemetry timestamp: ${telemetry.timestamp}');
|
||
|
||
final incomingGps = telemetry.gpsLocation;
|
||
if (_isInvalidTelemetryGps(incomingGps)) {
|
||
// Always sanitize invalid placeholder GPS to null first.
|
||
telemetry = ContactTelemetry(
|
||
gpsLocation: null,
|
||
batteryPercentage: telemetry.batteryPercentage,
|
||
batteryMilliVolts: telemetry.batteryMilliVolts,
|
||
temperature: telemetry.temperature,
|
||
timestamp: telemetry.timestamp,
|
||
humidity: telemetry.humidity,
|
||
pressure: telemetry.pressure,
|
||
extraSensorData: telemetry.extraSensorData,
|
||
);
|
||
}
|
||
|
||
final previousTelemetry = isSelfTelemetry
|
||
? _selfTelemetry
|
||
: contact!.telemetry;
|
||
|
||
final mergedTelemetry = _mergeTelemetryForContact(
|
||
existingTelemetry: previousTelemetry,
|
||
incomingTelemetry: telemetry,
|
||
);
|
||
if (mergedTelemetry != null) {
|
||
if (_shouldRetainLastValidGps(
|
||
previousTelemetry,
|
||
telemetry.gpsLocation,
|
||
)) {
|
||
debugPrint(
|
||
' ⚠️ Incoming telemetry GPS is invalid/missing; keeping last known contact location as fallback: $incomingGps',
|
||
);
|
||
}
|
||
telemetry = ContactTelemetry(
|
||
gpsLocation: telemetry.gpsLocation,
|
||
batteryPercentage: mergedTelemetry.batteryPercentage,
|
||
batteryMilliVolts: mergedTelemetry.batteryMilliVolts,
|
||
temperature: mergedTelemetry.temperature,
|
||
timestamp: mergedTelemetry.timestamp,
|
||
humidity: mergedTelemetry.humidity,
|
||
pressure: mergedTelemetry.pressure,
|
||
extraSensorData: mergedTelemetry.extraSensorData,
|
||
);
|
||
}
|
||
|
||
if (isSelfTelemetry) {
|
||
_selfTelemetry = telemetry;
|
||
notifyListeners();
|
||
debugPrint(' ✅ Updated self telemetry');
|
||
return;
|
||
}
|
||
|
||
final resolvedContact = contact!;
|
||
|
||
// 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: ${resolvedContact.lastAdvert}');
|
||
debugPrint(' New lastAdvert: $currentTimestamp');
|
||
|
||
final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation);
|
||
final updatedContact = resolvedContact.copyWith(
|
||
telemetry: telemetry,
|
||
lastAdvert: currentTimestamp, // Update last seen time
|
||
advLat: persistedGps != null
|
||
? _coordinateToAdvertMicrodegrees(persistedGps.latitude)
|
||
: resolvedContact.advLat,
|
||
advLon: persistedGps != null
|
||
? _coordinateToAdvertMicrodegrees(persistedGps.longitude)
|
||
: resolvedContact.advLon,
|
||
);
|
||
_contacts[resolvedContact.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');
|
||
}
|
||
}
|
||
|
||
void updateFastGps(Uint8List publicKeyPrefix, FastGpsPacket packet) {
|
||
final contact = _findContactByPrefix(publicKeyPrefix);
|
||
if (contact == null) {
|
||
debugPrint(
|
||
'⚠️ [ContactsProvider] Fast GPS sender not found: ${packet.senderKey6}',
|
||
);
|
||
return;
|
||
}
|
||
|
||
final updatedTelemetry = _mergeTelemetryForContact(
|
||
existingTelemetry: contact.telemetry,
|
||
incomingTelemetry: ContactTelemetry(
|
||
gpsLocation: LatLng(packet.latitude, packet.longitude),
|
||
batteryPercentage: null,
|
||
batteryMilliVolts: null,
|
||
temperature: null,
|
||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||
packet.timestampSeconds * 1000,
|
||
),
|
||
humidity: null,
|
||
pressure: null,
|
||
extraSensorData: null,
|
||
),
|
||
);
|
||
|
||
debugPrint(
|
||
'📥 [ContactsProvider] Fast GPS received '
|
||
'sender=${packet.senderKey6} '
|
||
'contact=${contact.displayName} '
|
||
'contactKey=${contact.publicKeyHex} '
|
||
'lat=${packet.latitude} '
|
||
'lon=${packet.longitude} '
|
||
'ts=${packet.timestampSeconds}',
|
||
);
|
||
|
||
final updatedContact = contact.copyWith(
|
||
telemetry: updatedTelemetry,
|
||
lastAdvert: packet.timestampSeconds,
|
||
lastMod: packet.timestampSeconds,
|
||
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
|
||
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
|
||
);
|
||
_contacts[contact.publicKeyHex] = updatedContact;
|
||
_estimatedLocations[contact.publicKeyHex] = LatLng(
|
||
packet.latitude,
|
||
packet.longitude,
|
||
);
|
||
debugPrint(
|
||
'✅ [ContactsProvider] Fast GPS applied '
|
||
'contact=${updatedContact.displayName} '
|
||
'contactKey=${updatedContact.publicKeyHex} '
|
||
'lastAdvert=${updatedContact.lastAdvert}',
|
||
);
|
||
_persistContacts();
|
||
notifyListeners();
|
||
}
|
||
|
||
bool _isInvalidTelemetryGps(LatLng? location) {
|
||
if (location == null) return false;
|
||
final lat = location.latitude;
|
||
final lon = location.longitude;
|
||
|
||
if (!lat.isFinite || !lon.isFinite) return true;
|
||
|
||
// Many devices report "0000" placeholder GPS as (0.0, 0.0).
|
||
const epsilon = 1e-7;
|
||
return lat.abs() < epsilon && lon.abs() < epsilon;
|
||
}
|
||
|
||
LatLng? _getValidGpsOrNull(LatLng? location) {
|
||
if (location == null || _isInvalidTelemetryGps(location)) {
|
||
return null;
|
||
}
|
||
return location;
|
||
}
|
||
|
||
bool _shouldRetainLastValidGps(
|
||
ContactTelemetry? existingTelemetry,
|
||
LatLng? incomingGps,
|
||
) {
|
||
final hasPreviousValidGps =
|
||
_getValidGpsOrNull(existingTelemetry?.gpsLocation) != null;
|
||
if (!hasPreviousValidGps) {
|
||
return false;
|
||
}
|
||
|
||
return incomingGps == null;
|
||
}
|
||
|
||
ContactTelemetry? _mergeTelemetryForContact({
|
||
ContactTelemetry? existingTelemetry,
|
||
ContactTelemetry? incomingTelemetry,
|
||
}) {
|
||
if (incomingTelemetry == null) {
|
||
return existingTelemetry;
|
||
}
|
||
|
||
// Telemetry packets and contact refreshes are often sparse. Preserve the
|
||
// last known reading for any field that is omitted in the incoming update.
|
||
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
|
||
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
|
||
final mergedExtraSensorData = _mergeExtraSensorData(
|
||
existingTelemetry?.extraSensorData,
|
||
incomingTelemetry.extraSensorData,
|
||
);
|
||
|
||
return ContactTelemetry(
|
||
gpsLocation: incomingGps ?? previousGps,
|
||
batteryPercentage:
|
||
incomingTelemetry.batteryPercentage ??
|
||
existingTelemetry?.batteryPercentage,
|
||
batteryMilliVolts:
|
||
incomingTelemetry.batteryMilliVolts ??
|
||
existingTelemetry?.batteryMilliVolts,
|
||
temperature:
|
||
incomingTelemetry.temperature ?? existingTelemetry?.temperature,
|
||
timestamp: incomingTelemetry.timestamp,
|
||
humidity: incomingTelemetry.humidity ?? existingTelemetry?.humidity,
|
||
pressure: incomingTelemetry.pressure ?? existingTelemetry?.pressure,
|
||
extraSensorData: mergedExtraSensorData.isEmpty
|
||
? null
|
||
: mergedExtraSensorData,
|
||
);
|
||
}
|
||
|
||
ContactTelemetry _withRawTelemetryHex(
|
||
ContactTelemetry telemetry,
|
||
Uint8List lppData,
|
||
) {
|
||
final extraSensorData = <String, dynamic>{...?telemetry.extraSensorData};
|
||
extraSensorData[_rawTelemetryHexKey] = _bytesToHex(lppData);
|
||
return ContactTelemetry(
|
||
gpsLocation: telemetry.gpsLocation,
|
||
batteryPercentage: telemetry.batteryPercentage,
|
||
batteryMilliVolts: telemetry.batteryMilliVolts,
|
||
temperature: telemetry.temperature,
|
||
timestamp: telemetry.timestamp,
|
||
humidity: telemetry.humidity,
|
||
pressure: telemetry.pressure,
|
||
extraSensorData: extraSensorData,
|
||
);
|
||
}
|
||
|
||
String _bytesToHex(Uint8List bytes) {
|
||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||
}
|
||
|
||
Map<String, dynamic> _mergeExtraSensorData(
|
||
Map<String, dynamic>? existingExtraSensorData,
|
||
Map<String, dynamic>? incomingExtraSensorData,
|
||
) {
|
||
if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) {
|
||
return <String, dynamic>{...?existingExtraSensorData};
|
||
}
|
||
|
||
final isFullTelemetryRefresh = incomingExtraSensorData.containsKey(
|
||
_rawTelemetryHexKey,
|
||
);
|
||
final merged = <String, dynamic>{
|
||
if (!isFullTelemetryRefresh) ...?existingExtraSensorData,
|
||
};
|
||
|
||
final incomingMetricFamilies = incomingExtraSensorData.keys
|
||
.map(_telemetryMetricFamilyForKey)
|
||
.whereType<String>()
|
||
.toSet();
|
||
if (incomingMetricFamilies.isNotEmpty) {
|
||
merged.removeWhere((key, _) {
|
||
final family = _telemetryMetricFamilyForKey(key);
|
||
return family != null && incomingMetricFamilies.contains(family);
|
||
});
|
||
}
|
||
|
||
merged.addAll(incomingExtraSensorData);
|
||
return merged;
|
||
}
|
||
|
||
String? _telemetryMetricFamilyForKey(String key) {
|
||
const sourcePrefix = '__source_channel:';
|
||
if (key.startsWith(sourcePrefix)) {
|
||
return key.substring(sourcePrefix.length);
|
||
}
|
||
|
||
final separatorIndex = key.lastIndexOf('_');
|
||
if (separatorIndex <= 0 || separatorIndex == key.length - 1) {
|
||
return key;
|
||
}
|
||
|
||
final suffix = key.substring(separatorIndex + 1);
|
||
if (int.tryParse(suffix) == null) {
|
||
return key;
|
||
}
|
||
|
||
return key.substring(0, separatorIndex);
|
||
}
|
||
|
||
int _coordinateToAdvertMicrodegrees(double coordinate) {
|
||
return (coordinate * 1e6).round();
|
||
}
|
||
|
||
/// Find contact by public key prefix (6 bytes)
|
||
Contact? _findContactByPrefix(Uint8List prefix) {
|
||
if (prefix.isEmpty) return null;
|
||
|
||
final takeLen = prefix.length < 6 ? prefix.length : 6;
|
||
final prefixHex = prefix
|
||
.sublist(0, takeLen)
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join('');
|
||
|
||
for (final contact in _contacts.values) {
|
||
if (contact.publicKeyHex.startsWith(prefixHex)) {
|
||
return contact;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Find contact by first 6-byte public key prefix.
|
||
Contact? findContactByPrefix(Uint8List prefix) {
|
||
return _findContactByPrefix(prefix);
|
||
}
|
||
|
||
/// Find contact by 12-hex-char public key prefix.
|
||
Contact? findContactByPrefixHex(String prefixHex) {
|
||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(prefixHex)) return null;
|
||
final bytes = Uint8List(6);
|
||
for (var i = 0; i < 6; i++) {
|
||
final start = i * 2;
|
||
bytes[i] = int.parse(prefixHex.substring(start, start + 2), radix: 16);
|
||
}
|
||
return _findContactByPrefix(bytes);
|
||
}
|
||
|
||
/// Find contact by public key
|
||
Contact? findContactByKey(Uint8List publicKey) {
|
||
final keyHex = publicKey
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join('');
|
||
return _contacts[keyHex];
|
||
}
|
||
|
||
void _setSelfDevicePublicKey(Uint8List? devicePublicKey) {
|
||
final nextKeyHex = _publicKeyHexOrNull(devicePublicKey);
|
||
if (_selfPublicKeyHex != nextKeyHex) {
|
||
_selfTelemetry = null;
|
||
}
|
||
_selfPublicKeyHex = nextKeyHex;
|
||
}
|
||
|
||
String? _publicKeyHexOrNull(Uint8List? publicKey) {
|
||
if (publicKey == null || publicKey.isEmpty) {
|
||
return null;
|
||
}
|
||
|
||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||
}
|
||
|
||
bool _matchesSelfPrefix(Uint8List prefix) {
|
||
final selfKeyHex = _selfPublicKeyHex;
|
||
if (selfKeyHex == null || prefix.isEmpty) {
|
||
return false;
|
||
}
|
||
|
||
final takeLen = prefix.length < 6 ? prefix.length : 6;
|
||
final prefixHex = prefix
|
||
.sublist(0, takeLen)
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join('');
|
||
return selfKeyHex.startsWith(prefixHex);
|
||
}
|
||
|
||
/// Clear a contact's learned path locally so the UI and next send both
|
||
/// prefer flood routing until the radio reports a fresh route.
|
||
void markPathUnhealthy(Uint8List publicKey) {
|
||
final contact = findContactByKey(publicKey);
|
||
if (contact == null || !contact.routeHasPath) {
|
||
return;
|
||
}
|
||
|
||
_contacts[contact.publicKeyHex] = contact.copyWith(
|
||
outPathLen: -1,
|
||
outPath: Uint8List(0),
|
||
);
|
||
_persistContacts();
|
||
notifyListeners();
|
||
}
|
||
|
||
void setContactRouteLocal(
|
||
Uint8List publicKey, {
|
||
required int signedEncodedPathLen,
|
||
required Uint8List paddedPathBytes,
|
||
LatLng? inferredFallbackLocation,
|
||
}) {
|
||
final contact = findContactByKey(publicKey);
|
||
if (contact == null) {
|
||
return;
|
||
}
|
||
|
||
var updatedContact = contact.copyWith(
|
||
outPathLen: signedEncodedPathLen,
|
||
outPath: Uint8List.fromList(paddedPathBytes),
|
||
);
|
||
if (inferredFallbackLocation != null) {
|
||
updatedContact = updatedContact
|
||
.copyWith(
|
||
advLat: _coordinateToAdvertMicrodegrees(
|
||
inferredFallbackLocation.latitude,
|
||
),
|
||
advLon: _coordinateToAdvertMicrodegrees(
|
||
inferredFallbackLocation.longitude,
|
||
),
|
||
)
|
||
.addAdvertLocation(inferredFallbackLocation, DateTime.now());
|
||
}
|
||
_contacts[contact.publicKeyHex] = updatedContact;
|
||
_persistContacts();
|
||
notifyListeners();
|
||
}
|
||
|
||
void retainReceivedRoute(
|
||
Uint8List publicKey, {
|
||
required int signedEncodedPathLen,
|
||
required Uint8List paddedPathBytes,
|
||
Uint8List? devicePublicKey,
|
||
}) {
|
||
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||
return;
|
||
}
|
||
|
||
final keyHex = publicKey
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join('');
|
||
final contact = _contacts[keyHex];
|
||
if (contact != null) {
|
||
_contacts[keyHex] = contact.copyWith(
|
||
outPathLen: signedEncodedPathLen,
|
||
outPath: Uint8List.fromList(paddedPathBytes),
|
||
);
|
||
_persistContacts();
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
final now = DateTime.now();
|
||
final existing = _pendingAdverts[keyHex];
|
||
_pendingAdverts[keyHex] =
|
||
(existing ??
|
||
PendingAdvert(
|
||
publicKey: Uint8List.fromList(publicKey),
|
||
receivedAt: now,
|
||
))
|
||
.copyWith(
|
||
receivedAt: now,
|
||
signedEncodedPathLen: signedEncodedPathLen,
|
||
paddedPathBytes: Uint8List.fromList(paddedPathBytes),
|
||
);
|
||
notifyListeners();
|
||
}
|
||
|
||
void resetContactRouteLocal(Uint8List publicKey) {
|
||
final contact = findContactByKey(publicKey);
|
||
if (contact == null) {
|
||
return;
|
||
}
|
||
|
||
_contacts[contact.publicKeyHex] = contact.copyWith(
|
||
outPathLen: -1,
|
||
outPath: Uint8List(0),
|
||
);
|
||
_persistContacts();
|
||
notifyListeners();
|
||
}
|
||
|
||
void setContactNameOverride(String publicKeyHex, String? overrideName) {
|
||
final contact = _contacts[publicKeyHex];
|
||
if (contact == null) {
|
||
return;
|
||
}
|
||
|
||
final normalizedOverride = overrideName?.trim();
|
||
final nextOverride =
|
||
(normalizedOverride == null || normalizedOverride.isEmpty)
|
||
? null
|
||
: normalizedOverride;
|
||
if (contact.nameOverride == nextOverride) {
|
||
return;
|
||
}
|
||
|
||
_contacts[publicKeyHex] = contact.copyWith(nameOverride: nextOverride);
|
||
_persistContacts();
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
||
/// Excludes only self key; known contacts still keep a discovery entry.
|
||
bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||
return false;
|
||
}
|
||
|
||
final keyHex = publicKey
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join('');
|
||
final existing = _pendingAdverts[keyHex];
|
||
final now = DateTime.now();
|
||
if (existing != null) {
|
||
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
|
||
_persistPendingAdverts();
|
||
notifyListeners();
|
||
return false;
|
||
} else {
|
||
_pendingAdverts[keyHex] = PendingAdvert(
|
||
publicKey: Uint8List.fromList(publicKey),
|
||
receivedAt: now,
|
||
);
|
||
_persistPendingAdverts();
|
||
notifyListeners();
|
||
return true;
|
||
}
|
||
}
|
||
|
||
bool addOrUpdatePendingAdvertContact(
|
||
Contact contact, {
|
||
Uint8List? devicePublicKey,
|
||
}) {
|
||
if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) {
|
||
return false;
|
||
}
|
||
|
||
final keyHex = contact.publicKeyHex;
|
||
final route = ContactRouteCodec.fromContact(contact);
|
||
final existing = _pendingAdverts[keyHex];
|
||
final now = DateTime.now();
|
||
final updated =
|
||
(existing ??
|
||
PendingAdvert(
|
||
publicKey: Uint8List.fromList(contact.publicKey),
|
||
receivedAt: now,
|
||
))
|
||
.copyWith(
|
||
receivedAt: now,
|
||
advName: contact.advName.trim().isEmpty ? null : contact.advName,
|
||
typeValue: contact.type.value,
|
||
flags: contact.flags,
|
||
lastAdvert: contact.lastAdvert,
|
||
advLat: contact.advLat,
|
||
advLon: contact.advLon,
|
||
signedEncodedPathLen:
|
||
route?.signedEncodedPathLen ?? existing?.signedEncodedPathLen,
|
||
paddedPathBytes: route?.paddedPathBytes == null
|
||
? existing?.paddedPathBytes
|
||
: Uint8List.fromList(route!.paddedPathBytes),
|
||
);
|
||
|
||
_pendingAdverts[keyHex] = updated;
|
||
_persistPendingAdverts();
|
||
notifyListeners();
|
||
return existing == null;
|
||
}
|
||
|
||
bool addOrUpdatePendingAdvertMetadata({
|
||
required Uint8List publicKey,
|
||
required int typeValue,
|
||
Uint8List? devicePublicKey,
|
||
int? flags,
|
||
String? advName,
|
||
int? lastAdvert,
|
||
int? advLat,
|
||
int? advLon,
|
||
int? signedEncodedPathLen,
|
||
Uint8List? paddedPathBytes,
|
||
int? rxRssiDbm,
|
||
int? rxSnrRaw,
|
||
DateTime? receivedAt,
|
||
}) {
|
||
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||
return false;
|
||
}
|
||
|
||
final keyHex = publicKey
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join('');
|
||
final existing = _pendingAdverts[keyHex];
|
||
final nextReceivedAt = receivedAt ?? DateTime.now();
|
||
_pendingAdverts[keyHex] =
|
||
(existing ??
|
||
PendingAdvert(
|
||
publicKey: Uint8List.fromList(publicKey),
|
||
receivedAt: nextReceivedAt,
|
||
))
|
||
.copyWith(
|
||
receivedAt: nextReceivedAt,
|
||
typeValue: typeValue,
|
||
advName: advName?.trim().isNotEmpty == true
|
||
? advName!.trim()
|
||
: existing?.advName,
|
||
flags: flags ?? existing?.flags,
|
||
lastAdvert: lastAdvert ?? existing?.lastAdvert,
|
||
advLat: advLat ?? existing?.advLat,
|
||
advLon: advLon ?? existing?.advLon,
|
||
signedEncodedPathLen:
|
||
signedEncodedPathLen ?? existing?.signedEncodedPathLen,
|
||
paddedPathBytes: paddedPathBytes == null
|
||
? existing?.paddedPathBytes
|
||
: Uint8List.fromList(paddedPathBytes),
|
||
rxRssiDbm: rxRssiDbm ?? existing?.rxRssiDbm,
|
||
rxSnrRaw: rxSnrRaw ?? existing?.rxSnrRaw,
|
||
);
|
||
|
||
_persistPendingAdverts();
|
||
notifyListeners();
|
||
return existing == null;
|
||
}
|
||
|
||
/// Find contact by name
|
||
Contact? findContactByName(String name) {
|
||
for (final contact in _contacts.values) {
|
||
if (contact.advName == name) return contact;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// 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();
|
||
_pendingAdverts.clear();
|
||
_retainedContactsForSync.clear();
|
||
_ensurePublicChannelExists();
|
||
_persistContacts();
|
||
_persistPendingAdverts();
|
||
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);
|
||
_pendingAdverts.remove(publicKeyHex);
|
||
_retainedContactsForSync.remove(publicKeyHex);
|
||
_persistContacts();
|
||
_persistPendingAdverts();
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Get storage statistics
|
||
Future<Map<String, dynamic>> getStorageStats() async {
|
||
return await _storageService.getStorageStats(namespace: _storageNamespace);
|
||
}
|
||
|
||
Future<void> clearPendingAdverts() async {
|
||
if (_pendingAdverts.isEmpty) {
|
||
return;
|
||
}
|
||
_pendingAdverts.clear();
|
||
await _storageService.clearPendingAdverts(namespace: _storageNamespace);
|
||
notifyListeners();
|
||
}
|
||
|
||
void updatePendingAdvertStatusByPrefix(
|
||
Uint8List publicKeyPrefix, {
|
||
int? batteryMv,
|
||
int? queueLen,
|
||
int? lastRssi,
|
||
int? lastSnrRaw,
|
||
int? uptimeSecs,
|
||
}) {
|
||
final prefixHex = publicKeyPrefix
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join('');
|
||
final match = _pendingAdverts.entries.where(
|
||
(entry) => entry.key.startsWith(prefixHex),
|
||
);
|
||
if (match.isEmpty) {
|
||
return;
|
||
}
|
||
final key = match.first.key;
|
||
final existing = _pendingAdverts[key]!;
|
||
_pendingAdverts[key] = existing.copyWith(
|
||
repeaterBatteryMv: batteryMv,
|
||
repeaterQueueLen: queueLen,
|
||
repeaterLastRssi: lastRssi,
|
||
repeaterLastSnrRaw: lastSnrRaw,
|
||
repeaterUptimeSecs: uptimeSecs,
|
||
);
|
||
_persistPendingAdverts();
|
||
notifyListeners();
|
||
}
|
||
|
||
List<Contact> _contactsForStorage() {
|
||
// Don't persist the public channel pseudo-contact (all zeros key)
|
||
const publicChannelKey =
|
||
'0000000000000000000000000000000000000000000000000000000000000000';
|
||
return _contacts.entries
|
||
.where((entry) => entry.key != publicChannelKey)
|
||
.map((entry) => entry.value)
|
||
.toList();
|
||
}
|
||
|
||
void _restorePendingAdverts(
|
||
List<Map<String, dynamic>> storedPendingAdverts, {
|
||
Uint8List? devicePublicKey,
|
||
}) {
|
||
_pendingAdverts.clear();
|
||
for (final json in storedPendingAdverts) {
|
||
final advert = _pendingAdvertFromJson(json);
|
||
if (advert == null) continue;
|
||
if (devicePublicKey != null &&
|
||
advert.publicKey.matches(devicePublicKey)) {
|
||
continue;
|
||
}
|
||
if (_contacts.containsKey(advert.publicKeyHex)) {
|
||
continue;
|
||
}
|
||
_pendingAdverts[advert.publicKeyHex] = advert;
|
||
}
|
||
}
|
||
|
||
void _resetInMemoryState() {
|
||
_contacts.clear();
|
||
_savedContactGroups.clear();
|
||
_pendingAdverts.clear();
|
||
_estimatedLocations.clear();
|
||
_rssiObservations.clear();
|
||
_retainedContactsForSync.clear();
|
||
_selfTelemetry = null;
|
||
}
|
||
|
||
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
|
||
return {
|
||
'publicKey': base64Encode(advert.publicKey),
|
||
'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch,
|
||
'advName': advert.advName,
|
||
'typeValue': advert.typeValue,
|
||
'flags': advert.flags,
|
||
'lastAdvert': advert.lastAdvert,
|
||
'advLat': advert.advLat,
|
||
'advLon': advert.advLon,
|
||
'signedEncodedPathLen': advert.signedEncodedPathLen,
|
||
'paddedPathBytes': advert.paddedPathBytes == null
|
||
? null
|
||
: base64Encode(advert.paddedPathBytes!),
|
||
'rxRssiDbm': advert.rxRssiDbm,
|
||
'rxSnrRaw': advert.rxSnrRaw,
|
||
'repeaterBatteryMv': advert.repeaterBatteryMv,
|
||
'repeaterQueueLen': advert.repeaterQueueLen,
|
||
'repeaterLastRssi': advert.repeaterLastRssi,
|
||
'repeaterLastSnrRaw': advert.repeaterLastSnrRaw,
|
||
'repeaterUptimeSecs': advert.repeaterUptimeSecs,
|
||
};
|
||
}
|
||
|
||
PendingAdvert? _pendingAdvertFromJson(Map<String, dynamic> json) {
|
||
try {
|
||
return PendingAdvert(
|
||
publicKey: Uint8List.fromList(
|
||
base64Decode(json['publicKey'] as String),
|
||
),
|
||
receivedAt: DateTime.fromMillisecondsSinceEpoch(
|
||
json['receivedAtMillis'] as int,
|
||
),
|
||
advName: json['advName'] as String?,
|
||
typeValue: json['typeValue'] as int?,
|
||
flags: json['flags'] as int?,
|
||
lastAdvert: json['lastAdvert'] as int?,
|
||
advLat: json['advLat'] as int?,
|
||
advLon: json['advLon'] as int?,
|
||
signedEncodedPathLen: json['signedEncodedPathLen'] as int?,
|
||
paddedPathBytes: json['paddedPathBytes'] == null
|
||
? null
|
||
: Uint8List.fromList(
|
||
base64Decode(json['paddedPathBytes'] as String),
|
||
),
|
||
rxRssiDbm: json['rxRssiDbm'] as int?,
|
||
rxSnrRaw: json['rxSnrRaw'] as int?,
|
||
repeaterBatteryMv: json['repeaterBatteryMv'] as int?,
|
||
repeaterQueueLen: json['repeaterQueueLen'] as int?,
|
||
repeaterLastRssi: json['repeaterLastRssi'] as int?,
|
||
repeaterLastSnrRaw: json['repeaterLastSnrRaw'] as int?,
|
||
repeaterUptimeSecs: json['repeaterUptimeSecs'] as int?,
|
||
);
|
||
} catch (e) {
|
||
debugPrint(
|
||
'❌ [ContactsProvider] Error parsing pending advert from JSON: $e',
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Get contact count by type
|
||
Map<String, int> get contactCounts {
|
||
return {
|
||
'chat': chatContacts.length,
|
||
'repeater': repeaters.length,
|
||
'sensor': sensorContacts.length,
|
||
'room': rooms.length,
|
||
'total': contacts.length,
|
||
};
|
||
}
|
||
}
|