You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
143 lines
5.4 KiB
143 lines
5.4 KiB
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import '../models/contact.dart';
|
|
import '../utils/app_logger.dart';
|
|
import 'prefs_manager.dart';
|
|
|
|
class ContactStore {
|
|
static const String _keyPrefix = 'contacts';
|
|
|
|
String publicKeyHex = '';
|
|
set setPublicKeyHex(String value) =>
|
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
|
|
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
|
|
|
Future<List<Contact>> loadContacts() async {
|
|
if (publicKeyHex.isEmpty) {
|
|
appLogger.warn('Public key hex is not set. Cannot load contacts.');
|
|
return [];
|
|
}
|
|
final prefs = PrefsManager.instance;
|
|
String? jsonString = prefs.getString(keyFor);
|
|
if (jsonString == null || jsonString.isEmpty) {
|
|
// Attempt migration from legacy unscoped key on first load
|
|
// Only remove the legacy key when it actually exists: every prefs
|
|
// mutation rewrites the whole file on Windows.
|
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
|
appLogger.info(
|
|
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor',
|
|
);
|
|
await prefs.setString(keyFor, legacyJsonString);
|
|
await prefs.remove(_keyPrefix);
|
|
jsonString = legacyJsonString;
|
|
}
|
|
}
|
|
if (jsonString == null || jsonString.isEmpty) {
|
|
jsonString = prefs.getString(keyFor);
|
|
}
|
|
if (jsonString == null || jsonString.isEmpty) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
|
return jsonList
|
|
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
|
.toList();
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<void> saveContacts(List<Contact> contacts) async {
|
|
if (publicKeyHex.isEmpty) {
|
|
appLogger.warn('Public key hex is not set. Cannot save contacts.');
|
|
return;
|
|
}
|
|
final prefs = PrefsManager.instance;
|
|
final jsonList = contacts.map(_toJson).toList();
|
|
await prefs.setString(keyFor, jsonEncode(jsonList));
|
|
}
|
|
|
|
Map<String, dynamic> _toJson(Contact contact) {
|
|
return {
|
|
'publicKey': base64Encode(contact.publicKey),
|
|
'name': contact.name,
|
|
'type': contact.type,
|
|
'flags': contact.flags,
|
|
'pathLength': contact.pathLength,
|
|
'pathHashWidth': contact.pathHashWidth,
|
|
'path': base64Encode(contact.path),
|
|
'pathOverride': contact.pathOverride,
|
|
'pathOverrideBytes': contact.pathOverrideBytes != null
|
|
? base64Encode(contact.pathOverrideBytes!)
|
|
: null,
|
|
'latitude': contact.latitude,
|
|
'longitude': contact.longitude,
|
|
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
|
'lastModified': contact.lastModified?.millisecondsSinceEpoch,
|
|
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
|
'isActive': contact.isActive,
|
|
'rawPacket': contact.rawPacket != null
|
|
? base64Encode(contact.rawPacket!)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
Contact _fromJson(Map<String, dynamic> json) {
|
|
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
|
final lastMessageMs = json['lastMessageAt'] as int?;
|
|
final lastModifiedMs = json['lastModified'] as int?;
|
|
final isLegacyPathRecord =
|
|
!json.containsKey('pathHashWidth') &&
|
|
((json['pathLength'] as int? ?? -1) > 0);
|
|
if (isLegacyPathRecord) {
|
|
appLogger.info(
|
|
'Dropping pre-#309 path for contact ${json['name']} '
|
|
'(decoded with the old count-as-bytes rule, so truncated); '
|
|
'reverting to flood until the device re-supplies it',
|
|
);
|
|
}
|
|
return Contact(
|
|
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
|
name: json['name'] as String? ?? 'Unknown',
|
|
type: json['type'] as int? ?? 0,
|
|
flags: json['flags'] as int? ?? 0,
|
|
// Records written before #309 have no 'pathHashWidth' and their path was
|
|
// decoded with the old count-as-bytes rule, so at any width above 1 the
|
|
// stored bytes are a truncated fragment. Truncated bytes cannot be
|
|
// recovered by reinterpreting them, so a legacy record's path is dropped
|
|
// and the contact reverts to flood until the radio re-supplies it (the
|
|
// device refreshes contacts routinely, so this self-heals). Ben's call:
|
|
// re-fetch rather than keep a path we know is wrong. (#309)
|
|
pathLength: isLegacyPathRecord ? -1 : (json['pathLength'] as int? ?? -1),
|
|
pathHashWidth: json['pathHashWidth'] as int? ?? 1,
|
|
path: (!isLegacyPathRecord && json['path'] != null)
|
|
? Uint8List.fromList(base64Decode(json['path'] as String))
|
|
: Uint8List(0),
|
|
pathOverride: json['pathOverride'] as int?,
|
|
pathOverrideBytes: json['pathOverrideBytes'] != null
|
|
? Uint8List.fromList(
|
|
base64Decode(json['pathOverrideBytes'] as String),
|
|
)
|
|
: null,
|
|
latitude: (json['latitude'] as num?)?.toDouble(),
|
|
longitude: (json['longitude'] as num?)?.toDouble(),
|
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
|
lastModified: lastModifiedMs == null
|
|
? null
|
|
: DateTime.fromMillisecondsSinceEpoch(lastModifiedMs),
|
|
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
|
lastMessageMs ?? lastSeenMs,
|
|
),
|
|
isActive: json['isActive'] as bool? ?? true,
|
|
rawPacket: json['rawPacket'] != null
|
|
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
|
|
: null,
|
|
);
|
|
}
|
|
}
|