fix(#673): never save over contacts the app could not read

ContactStore.loadContacts returned an empty list when the stored JSON
failed to decode, and the next save wrote the in-memory list over the
stored contacts. A read failure was treated as "no contacts".

A decode failure is now logged as an error, the raw value is kept
under contacts<key>.unreadable-<timestamp>, and saves to that key are
refused (and logged) until a load succeeds. Other radios' keys are
unaffected.

Tests cover the round trip, the failure report, the raw copy, the
refused save, recovery after a good load, and another radio's key;
with the refusal disabled the save test fails.

Epic #668.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/750-contact-naming-plan
Strycher 2 months ago committed by Benjamin Wiechel
parent c314663623
commit 1329f3d3c5

@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import '../models/contact.dart';
import '../utils/app_logger.dart';
@ -15,6 +16,13 @@ class ContactStore {
String get keyFor => '$_keyPrefix$publicKeyHex';
// Set when the stored list for that key could not be decoded. A save there
// is refused until a load succeeds, so a partial in-memory list can never
// replace contacts the app merely failed to read (#660, #673).
String? _unreadableKey;
bool get lastLoadFailed => _unreadableKey == keyFor;
Future<List<Contact>> loadContacts() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load contacts.');
@ -47,10 +55,31 @@ class ContactStore {
try {
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
final contacts = jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
} catch (_) {
if (_unreadableKey == keyFor) _unreadableKey = null;
return contacts;
} catch (e) {
// SAFELANE 6: a decode failure is not "no contacts". Keep the raw value
// somewhere a later save cannot touch, then refuse saves to this key.
final key = keyFor;
final aside =
'$key.unreadable-${DateTime.now().toUtc().millisecondsSinceEpoch}';
_unreadableKey = key;
final message =
'Could not read saved contacts for $key: $e. Raw data kept under '
'$aside; saving contacts for this radio is disabled until they load.';
debugPrint(message);
appLogger.error(message, tag: 'Storage');
try {
await blobs.write(aside, jsonString);
} catch (writeError) {
appLogger.error(
'Could not keep unreadable contacts aside under $aside: $writeError',
tag: 'Storage',
);
}
return [];
}
}
@ -60,6 +89,14 @@ class ContactStore {
appLogger.warn('Public key hex is not set. Cannot save contacts.');
return;
}
if (lastLoadFailed) {
final message =
'Refusing to save ${contacts.length} contact(s) over $keyFor: the '
'stored list could not be read, so a save could replace it.';
debugPrint(message);
appLogger.error(message, tag: 'Storage');
return;
}
final jsonList = contacts.map(_toJson).toList();
await BlobStore.instance.write(keyFor, jsonEncode(jsonList));
}

@ -0,0 +1,97 @@
import 'dart:typed_data';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/storage/contact_store.dart';
import 'package:meshcore_open/storage/drift/blob_store.dart';
import 'package:meshcore_open/storage/drift/offband_database.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// #673: contacts the app could not read must never be replaced by a save.
/// A decode failure used to return an empty list silently, and the next save
/// wrote whatever was in memory over the stored contacts.
Contact _contact(int keyByte, String name) => Contact(
publicKey: Uint8List.fromList(List<int>.filled(32, keyByte)),
name: name,
type: 1,
pathLength: -1,
path: Uint8List(0),
lastSeen: DateTime.utc(2026, 7, 1),
);
void main() {
const radioKey = 'd549bbfbd003e580';
late OffbandDatabase db;
late BlobStore blobs;
late ContactStore store;
setUp(() async {
SharedPreferences.setMockInitialValues({});
PrefsManager.reset();
await PrefsManager.initialize();
db = OffbandDatabase(NativeDatabase.memory());
blobs = BlobStore(db);
BlobStore.overrideForTest(blobs);
store = ContactStore()..setPublicKeyHex = radioKey;
});
tearDown(() async {
BlobStore.clearTestOverride();
await db.close();
});
test('a readable store round-trips and saves normally', () async {
await store.saveContacts([_contact(0x11, 'Alpha')]);
final loaded = await store.loadContacts();
expect(loaded.map((c) => c.name), ['Alpha']);
expect(store.lastLoadFailed, isFalse);
});
group('an unreadable store', () {
const corrupt = '[{"publicKey": 12345, "name": "Alpha"';
setUp(() => blobs.write(store.keyFor, corrupt));
test('loads as empty but reports the failure', () async {
expect(await store.loadContacts(), isEmpty);
expect(store.lastLoadFailed, isTrue);
});
test('keeps the raw value aside, untouched', () async {
await store.loadContacts();
final aside = (await blobs.keysWithPrefix(
'${store.keyFor}.unreadable-',
)).single;
expect(await blobs.read(aside), corrupt);
});
test('refuses a save that would replace it', () async {
await store.loadContacts();
await store.saveContacts([_contact(0x22, 'Bravo')]);
expect(await blobs.read(store.keyFor), corrupt);
});
test('saving resumes once a load succeeds again', () async {
await store.loadContacts();
await blobs.write(store.keyFor, '[]');
await store.loadContacts();
await store.saveContacts([_contact(0x22, 'Bravo')]);
expect(store.lastLoadFailed, isFalse);
expect((await store.loadContacts()).map((c) => c.name), ['Bravo']);
});
test('does not block saves for a different radio', () async {
await store.loadContacts();
store.setPublicKeyHex = 'aa2dccf401e5aaaa';
await store.saveContacts([_contact(0x33, 'Charlie')]);
expect((await store.loadContacts()).map((c) => c.name), ['Charlie']);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.