From 1329f3d3c5a87f80eb61bed4e61510c1ccfa91ca Mon Sep 17 00:00:00 2001 From: Strycher Date: Wed, 23 Sep 2026 01:08:28 -0400 Subject: [PATCH] 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.unreadable-, 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 --- lib/storage/contact_store.dart | 43 +++++++++++- test/storage/contact_store_test.dart | 97 ++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 test/storage/contact_store_test.dart diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index d907752b..9a647864 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -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> 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; - return jsonList + final contacts = jsonList .map((entry) => _fromJson(entry as Map)) .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)); } diff --git a/test/storage/contact_store_test.dart b/test/storage/contact_store_test.dart new file mode 100644 index 00000000..e892177e --- /dev/null +++ b/test/storage/contact_store_test.dart @@ -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.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']); + }); + }); +}