From 5a2a38c86277856e0150887f403d5ecd55cda3bf Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Fri, 6 Nov 2026 18:15:41 -0700 Subject: [PATCH 01/15] test(backup): settings travel in a full export and come back on import --- test/backup/backup_support.dart | 150 ++++++++++++++++++++ test/backup/prefs_roundtrip_test.dart | 153 +++++++++++++++++++++ test/backup/settings_audit_guard_test.dart | 95 +++++++++++++ test/backup/settings_complete_test.dart | 94 +++++++++++++ test/backup/settings_paths_test.dart | 95 +++++++++++++ 5 files changed, 587 insertions(+) create mode 100644 test/backup/backup_support.dart create mode 100644 test/backup/prefs_roundtrip_test.dart create mode 100644 test/backup/settings_audit_guard_test.dart create mode 100644 test/backup/settings_complete_test.dart create mode 100644 test/backup/settings_paths_test.dart diff --git a/test/backup/backup_support.dart b/test/backup/backup_support.dart new file mode 100644 index 000000000..f5e43286e --- /dev/null +++ b/test/backup/backup_support.dart @@ -0,0 +1,150 @@ +// Shared setup for the full-export tests: a throwaway live DB, fake +// path_provider dirs, and helpers to read what an export carries. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/db.dart'; + +class _FakePaths extends PathProviderPlatform { + _FakePaths(this.tmp, this.docs); + final String tmp, docs; + @override + Future getTemporaryPath() async => tmp; + @override + Future getApplicationDocumentsPath() async => docs; + @override + Future getApplicationSupportPath() async => docs; + @override + Future getApplicationCachePath() async => tmp; +} + +class BackupEnv { + BackupEnv._(this.root, this.dbDir); + final Directory root; + final String dbDir; + + Directory get docs => Directory(p.join(root.path, 'docs')); + static Future create(String dbName) async { + TestWidgetsFlutterBinding.ensureInitialized(); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + final root = await Directory.systemTemp.createTemp('openstrap_bkx_'); + await Directory(p.join(root.path, 'tmp')).create(); + await Directory(p.join(root.path, 'docs')).create(); + PathProviderPlatform.instance = + _FakePaths(p.join(root.path, 'tmp'), p.join(root.path, 'docs')); + LocalDb.dbName = dbName; + final env = BackupEnv._(root, await databaseFactory.getDatabasesPath()); + await env.wipe(); + return env; + } + + /// A brand-new install: empty DB and empty prefs. + Future wipe() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dbDir, LocalDb.dbName)); + SharedPreferences.setMockInitialValues({}); + } + + Future dispose() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dbDir, LocalDb.dbName)); + if (await root.exists()) await root.delete(recursive: true); + } + + /// Thirty 1 Hz rows and a derived day, so the install is "not fresh". + Future seedData({int t0 = 1790000000}) async { + final db = await LocalDb.instance; + for (var i = 0; i < 30; i++) { + await db.insert('decoded_onehz', { + 'device_id': '', 'ts_ms': (t0 + i) * 1000, 'rec_ts': t0 + i, + 'counter': i + 1, 'hr': 60, + }); + } + await db.insert('day_result', { + 'day_id': '2026-09-21', 'algo_version': 100, 'payload_json': '{}', + 'computed_at': 1, 'finalized': 1, + }); + } +} + +/// The tables of the database at [path]. +Future> tablesOf(String path) async { + final db = await databaseFactoryFfi.openDatabase(path, options: + OpenDatabaseOptions(readOnly: true, singleInstance: false)); + try { + return { + for (final r in await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type = 'table'")) + r['name'] as String, + }; + } finally { + await db.close(); + } +} + +/// `backup_prefs` of an export as key -> (type, value); null when the table +/// does not exist. +Future?> exportedPrefs(String path) async { + if (!(await tablesOf(path)).contains('backup_prefs')) return null; + final db = await databaseFactoryFfi.openDatabase(path, options: + OpenDatabaseOptions(readOnly: true, singleInstance: false)); + try { + return { + for (final r in await db.query('backup_prefs')) + r['key'] as String: ('${r['type']}', '${r['value']}'), + }; + } finally { + await db.close(); + } +} + +/// Every pref as a plain map (keys sorted). +Future> allPrefs() async { + final sp = await SharedPreferences.getInstance(); + await sp.reload(); + return {for (final k in sp.getKeys().toList()..sort()) k: sp.get(k)!}; +} + +/// Sets [values] as this install's prefs. +Future setPrefs(Map values) async { + SharedPreferences.setMockInitialValues(values); + await SharedPreferences.getInstance().then((sp) => sp.reload()); +} + +void expectNoSecretIn(String path, Iterable secrets) { + final bytes = File(path).readAsBytesSync(); + final text = String.fromCharCodes(bytes); + for (final s in secrets) { + expect(text.contains(s), isFalse, reason: 'secret "$s" leaked into export'); + } +} + +// Read the policy as source so the red tests compile before it exists. +Map policyMap(String name) { + final f = File('lib/data/backup_extras.dart'); + expect(f.existsSync(), isTrue, reason: 'the backup needs a settings policy'); + final block = RegExp(name + r' = \{([\s\S]*?)\n\};') + .firstMatch(f.readAsStringSync()); + expect(block, isNotNull, reason: name); + final entries = RegExp(r"'([^']+)': ([\s\S]*?)(?=\n '[^']+':|$)"); + return {for (final m in entries.allMatches(block!.group(1)!)) + m.group(1)!: m.group(2)!.trim()}; +} + +Future isFreshInstall() async { + final db = await LocalDb.instance; + final rows = await db.rawQuery('SELECT EXISTS(SELECT 1 FROM decoded_onehz) a, ' + 'EXISTS(SELECT 1 FROM day_result) b'); + return rows.first['a'] == 0 && rows.first['b'] == 0; +} + +Matcher needsReentry(List expected) => predicate((dynamic result) { + try { return equals(expected).matches(result.needsReentry, {}); } + on NoSuchMethodError { return false; } +}, 'import result with needsReentry $expected'); diff --git a/test/backup/prefs_roundtrip_test.dart b/test/backup/prefs_roundtrip_test.dart new file mode 100644 index 000000000..1b0381ddc --- /dev/null +++ b/test/backup/prefs_roundtrip_test.dart @@ -0,0 +1,153 @@ +// A full export carries the owner's settings (SharedPreferences), and an import +// restores them: into a fresh install the backup wins; into an install that +// already has data the device keeps what it has and only fills what is absent. +// +// RED: the export writes no backup_prefs table and the import restores none. +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/data/db.dart'; + +import 'backup_support.dart'; + +/// Upstream settings plus future keys and every supported preference type. +const _settings = { + 'gesture_double_tap': 'mark_moment', + 'notif_quiet_enabled': true, + 'notif_water_interval': 90, + 'local_profile_json': '{"name":"Owner","weight_kg":72.5}', + 'notif_battery_pct': 15, + 'notif_fired:2026-09-21:low_read': true, + 'theme_choice': 'dark', + 'profile_weight': 72.5, + 'selected_sensors': ['one', 'two'], + 'future.setting': 'also travels', +}; + +const _denied = { + 'install_device_id': 'install-AAA', + 'paired_remote_id': 'AA:BB:CC:DD:EE:FF', + 'consent_telemetry': true, + 'backup.folder': 'content://tree/primary%3ABackups', + 'ble.ask_add_pending': true, +}; + +const _secrets = { + 'coach_api_key': 'sk-SECRET-COACH-12345', + 'oura_token': 'OURA-SECRET-67890', +}; + +void main() { + late BackupEnv env; + setUpAll(() async => env = await BackupEnv.create('bkx_prefs_test.db')); + tearDownAll(() async => env.dispose()); + + test('the denylist names a reason for every entry', () { + final denied = policyMap('kPrefsNotBackedUp'); + for (final e in denied.entries) { + expect(e.value, isNotEmpty, reason: e.key); + } + for (final k in _denied.keys) { + expect(!denied.containsKey(k), isFalse, reason: k); + } + for (final k in _settings.keys) { + expect(!denied.containsKey(k), isTrue, reason: k); + } + }); + + test('export carries every settings key, no denylisted key, no secret', + () async { + await env.wipe(); + FlutterSecureStorage.setMockInitialValues(Map.of(_secrets)); + await setPrefs({..._settings, ..._denied}); + await env.seedData(); + final path = await LocalDb.exportCopy(); + + final got = await exportedPrefs(path); + expect(got, isNotNull, reason: 'export has a backup_prefs table'); + expect(got!.keys.toSet(), _settings.keys.toSet()); + for (final k in _denied.keys) { + expect(got.containsKey(k), isFalse, reason: '$k stays on the device'); + } + expectNoSecretIn(path, _secrets.values); + expect(got['notif_water_interval']!.$1, 'int'); + expect(got['notif_quiet_enabled']!.$1, 'bool'); + expect(got['gesture_double_tap']!.$1, 'string'); + }); + + test('the live database never gains backup_* tables', () async { + await env.wipe(); + await setPrefs(Map.of(_settings)); + await LocalDb.exportCopy(); + final db = await LocalDb.instance; + final names = { + for (final r in await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type = 'table'")) + r['name'] as String, + }; + expect(names.where((n) => n.startsWith('backup_')), isEmpty); + }); + + test('fresh install: every backed-up key comes back equal', () async { + await env.wipe(); + await setPrefs({..._settings, ..._denied}); + await env.seedData(); + final path = await LocalDb.exportCopy(); + + await env.wipe(); // a new phone + await LocalDb.instance; + await setPrefs({'install_device_id': 'install-BBB', 'theme_choice': 'light'}); + expect(await isFreshInstall(), isTrue); + await LocalDb.importFromDbFile(path); + + final now = await allPrefs(); + for (final e in _settings.entries) { + expect(now[e.key], e.value, reason: e.key); + } + expect(now['install_device_id'], 'install-BBB', + reason: 'per-install ids are never restored'); + expect(now.containsKey('paired_remote_id'), isFalse); + expect(now.containsKey('backup.folder'), isFalse); + }); + + test('install with data: device values win, absent keys are restored', + () async { + await env.wipe(); + await setPrefs(Map.of(_settings)); + await env.seedData(); + final path = await LocalDb.exportCopy(); + + await env.wipe(); + await env.seedData(t0: 1790100000); // not fresh + await setPrefs({ + 'notif_water_interval': 60, // differs from the backup's 90 + 'notif_battery_pct': 30, // differs + }); + expect(await isFreshInstall(), isFalse); + await LocalDb.importFromDbFile(path); + + final now = await allPrefs(); + expect(now['notif_water_interval'], 60, reason: 'device value kept'); + expect(now['notif_battery_pct'], 30, reason: 'device value kept'); + expect(now['local_profile_json'], _settings['local_profile_json'], + reason: 'absent here, so restored'); + expect(now['gesture_double_tap'], 'mark_moment'); + expect(now['notif_fired:2026-09-21:low_read'], true); + }); + + test('importing the same backup twice changes nothing', () async { + await env.wipe(); + await setPrefs(Map.of(_settings)); + await env.seedData(); + final path = await LocalDb.exportCopy(); + await env.wipe(); + await LocalDb.importFromDbFile(path); + final once = await allPrefs(); + await LocalDb.importFromDbFile(path); + expect(await allPrefs(), once); + expect(once.keys.toSet(), _settings.keys.toSet()); + final sp = await SharedPreferences.getInstance(); + expect(sp.getInt('notif_water_interval'), 90); + }); +} diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart new file mode 100644 index 000000000..b396dad2d --- /dev/null +++ b/test/backup/settings_audit_guard_test.dart @@ -0,0 +1,95 @@ +// Source guards back up the export/import behavior tests. New preference keys +// travel by default; exclusions and secure stores must name a reason. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; + +import 'backup_support.dart'; + +Map _constants(String text) => { + for (final m in RegExp(r'''(?:const|final)\s+(?:String\s+)?(\w+)\s*=\s*['"]([^'"\n]+)['"]''').allMatches(text)) + m.group(1)!: m.group(2)!, +}; + +String? _key(String expr, String text) { + final literal = RegExp(r'''^['"]([^'"]+)['"]$''').firstMatch(expr); + if (literal != null) return literal.group(1); + if (expr.startsWith('Prefs.')) { + return _constants(File('lib/state/prefs.dart').readAsStringSync())[ + expr.substring('Prefs.'.length)]; + } + return _constants(text)[expr]; +} + +void main() { + final lib = Directory('lib').listSync(recursive: true).whereType() + .where((f) => f.path.endsWith('.dart')).toList(); + final writes = RegExp(r'''\.(setString|setStringList|setBool|setBoolAcked|setInt|setDouble)\(\s*('[^']*'|"[^"]*"|[\w.]+)\s*,'''); + late BackupEnv env; + setUpAll(() async => env = await BackupEnv.create('bkx_audit_test.db')); + tearDownAll(() async => env.dispose()); + + test('every written preference is carried or excluded with a reason', () async { + final keys = {}; + for (final f in lib) { + final text = f.readAsStringSync(); + for (final m in writes.allMatches(text)) { + final key = _key(m.group(2)!, text); + if (key != null && !key.contains(r'$')) keys.add(key); + } + } + expect(keys, containsAll(['theme_choice', 'backup.cadence', 'coach_model', + 'local_profile_json', 'consent_chosen']), reason: 'scanner positive control'); + final denied = policyMap('kPrefsNotBackedUp'); + await env.wipe(); + await setPrefs({for (final key in keys) key: 'audit value'}); + final path = await LocalDb.exportCopy(); + final prefs = await exportedPrefs(path); + expect(prefs, isNotNull); + expect(prefs!.keys.toSet(), keys.difference(denied.keys.toSet())); + }); + + test('denylisted keys are real and each has a reason', () { + final all = lib.where((f) => f.path != 'lib/data/backup_extras.dart') + .map((f) => f.readAsStringSync()).join('\n'); + final denied = policyMap('kPrefsNotBackedUp'); + expect(denied.keys, containsAll(['install_device_id', 'consent_chosen', + 'consent_telemetry', 'consent_health_data', 'coach_api_key_present'])); + for (final e in denied.entries) { + expect(e.value.length, greaterThan(10), reason: e.key); + expect(all, contains("'${e.key}'"), reason: '${e.key} is a stale exclusion'); + } + }); + + test('a credential never sits in plain preferences', () { + final credential = RegExp(r'api_key|secret|password|auth_token'); + expect(credential.hasMatch('coach_api_key'), isTrue); + final bad = []; + for (final f in lib) { + final text = f.readAsStringSync(); + for (final m in writes.allMatches(text)) { + final key = _key(m.group(2)!, text); + if (key != null && credential.hasMatch(key) && + !key.endsWith('_present')) { + bad.add('${f.path}: $key'); + } + } + } + expect(bad, isEmpty); + }); + + test('every secure-storage owner is listed with a reason', () { + final owners = { + for (final f in lib) + if (f.path != 'lib/data/backup_extras.dart' && + f.readAsStringSync().contains('FlutterSecureStorage')) f.path, + }; + expect(owners, contains('lib/coach/coach_config.dart')); + final secrets = policyMap('kSecretsNotBackedUp'); + expect(secrets.keys.toSet(), owners); + for (final e in secrets.entries) { + expect(e.value.length, greaterThan(10), reason: e.key); + } + }); +} diff --git a/test/backup/settings_complete_test.dart b/test/backup/settings_complete_test.dart new file mode 100644 index 000000000..c0f4ad274 --- /dev/null +++ b/test/backup/settings_complete_test.dart @@ -0,0 +1,94 @@ +// After a wipe and an import the owner has every setting back. These pin the +// gaps the audit found: backup.cadence swept up by a prefix, consent state half +// restored, and a coach key lost without a word. + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/data/db.dart'; + +import 'backup_support.dart'; + +void main() { + late BackupEnv env; + setUpAll(() async => env = await BackupEnv.create('bkx_complete_test.db')); + tearDownAll(() async => env.dispose()); + + Future wipeAll() async { + await env.wipe(); + FlutterSecureStorage.setMockInitialValues({}); + } + + test('the auto-backup cadence the owner chose comes back; device state stays', + () async { + await wipeAll(); + await setPrefs({ + 'backup.cadence': 'weekly', + 'backup.folder': 'content://x', + 'backup.last_run_ms': 5, + 'backup.last_error': 'boom', + }); + await env.seedData(); + final path = await LocalDb.exportCopy(); + await wipeAll(); + await LocalDb.importFromDbFile(path); + final now = await allPrefs(); + expect(now['backup.cadence'], 'weekly'); + expect(now.containsKey('backup.folder'), isFalse); + expect(now.containsKey('backup.last_run_ms'), isFalse); + expect(now.containsKey('backup.last_error'), isFalse); + }); + + test('consent is asked again as a whole: no half-restored consent state', + () async { + await wipeAll(); + await setPrefs({ + 'consent_telemetry': true, + 'consent_health_data': true, + 'consent_chosen': true, + }); + await env.seedData(); + final path = await LocalDb.exportCopy(); + await wipeAll(); + await LocalDb.importFromDbFile(path); + final now = await allPrefs(); + expect(now.keys.where((k) => k.startsWith('consent_')), isEmpty); + }); + + test('a coach key that cannot travel is reported for re-entry, never hidden', + () async { + await wipeAll(); + FlutterSecureStorage.setMockInitialValues({'coach_api_key': 'sk-SECRET'}); + await setPrefs({'coach_api_key_present': true, 'coach_model': 'm'}); + await env.seedData(); + final path = await LocalDb.exportCopy(); + expectNoSecretIn(path, ['sk-SECRET']); + + await wipeAll(); // the new phone has no keychain payload + final result = await LocalDb.importFromDbFile(path); + final now = await allPrefs(); + expect(now['coach_model'], 'm'); + expect(now.containsKey('coach_api_key_present'), isFalse, + reason: 'the marker would claim a key this phone does not hold'); + expect(result, needsReentry(['coach_api_key'])); + }); + + test('no re-entry is reported when the key is already on this phone or never set', + () async { + await wipeAll(); + await setPrefs({'coach_model': 'm'}); + await env.seedData(); + final never = await LocalDb.exportCopy(); + await wipeAll(); + expect(await LocalDb.importFromDbFile(never), needsReentry([])); + + await wipeAll(); + FlutterSecureStorage.setMockInitialValues({'coach_api_key': 'sk-1'}); + await setPrefs({'coach_api_key_present': true}); + await env.seedData(); + final had = await LocalDb.exportCopy(); + await wipeAll(); + FlutterSecureStorage.setMockInitialValues({'coach_api_key': 'sk-1'}); + expect(await LocalDb.importFromDbFile(had), needsReentry([])); + }); +} diff --git a/test/backup/settings_paths_test.dart b/test/backup/settings_paths_test.dart new file mode 100644 index 000000000..5b5eed330 --- /dev/null +++ b/test/backup/settings_paths_test.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/auto_backup.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/prefs.dart'; +import 'package:openstrap_edge/theme/theme_controller.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'backup_support.dart'; + +void main() { + late BackupEnv env; + setUpAll(() async => env = await BackupEnv.create('bkx_paths_test.db')); + tearDownAll(() async => env.dispose()); + + test('automatic backups carry settings and gzip import restores them', () async { + await env.wipe(); + await setPrefs({'theme_choice': 'dark', 'backup.cadence': 'weekly'}); + final backup = await runBackup(now: DateTime(2026, 9, 21)); + expect(backup.succeeded, isTrue, reason: backup.error); + await env.wipe(); + await LocalDb.importFromDbFile(backup.path!); + expect((await allPrefs())['theme_choice'], 'dark'); + expect((await allPrefs())['backup.cadence'], 'weekly'); + }); + + test('an export for telemetry or support carries no settings or key signals', () async { + await env.wipe(); + await setPrefs({'theme_choice': 'dark', 'coach_api_key_present': true}); + final String path; + try { + path = await Function.apply(LocalDb.exportCopy, [], {#includePrefs: false}); + } on NoSuchMethodError { + fail('exportCopy must allow telemetry/support to omit preferences'); + } + final tables = await tablesOf(path); + expect(tables.where((t) => t.startsWith('backup_')), isEmpty); + for (final file in ['lib/telemetry/health_uploader.dart', 'lib/support/issue_report.dart']) { + expect(File(file).readAsStringSync(), contains('includePrefs: false'), + reason: '$file must use the private export mode'); + } + }); + + test('older backups with no extras still import', () async { + await env.wipe(); + await env.seedData(); + final path = await LocalDb.exportCopy(); + final copy = await databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions(singleInstance: false)); + for (final name in (await tablesOf(path)).where((t) => t.startsWith('backup_'))) { + await copy.execute('DROP TABLE $name'); + } + await copy.close(); + await env.wipe(); + await setPrefs({'theme_choice': 'light'}); + final counts = await LocalDb.importFromDbFile(path); + expect(counts['decoded_onehz'], 30); + expect((await allPrefs())['theme_choice'], 'light'); + }); + + test('import reloads the live profile and synchronous preferences', () async { + await env.wipe(); + await setPrefs({'local_profile_json': '{"name":"Backup Name"}', + 'ui.recap_range': 2}); + final path = await LocalDb.exportCopy(); + await env.wipe(); + await Prefs.ensureLoaded(); + final app = AppState.forTesting(); + addTearDown(app.dispose); + await app.importEdgeBackup(path); + expect(app.user?['name'], 'Backup Name'); + expect(Prefs.getInt('ui.recap_range', 0), 2); + }); + + test('a live theme reload adopts the imported choice without rewriting it', () async { + await env.wipe(); + await setPrefs({'theme_choice': 'dark'}); + final path = await LocalDb.exportCopy(); + await env.wipe(); + final theme = ThemeController.seed(AppThemeChoice.light, Brightness.light); + addTearDown(theme.dispose); + await LocalDb.importFromDbFile(path); + try { + await (theme as dynamic).reloadFromPrefs(); + } on NoSuchMethodError { + fail('the live theme must be reloadable after importing settings'); + } + expect(theme.choice, AppThemeChoice.dark); + expect(theme.effective, Brightness.dark); + expect((await allPrefs())['theme_choice'], 'dark'); + }); +} From a51e335a423c9e9a9d8a631fc47b9c0b15d7ef88 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Fri, 6 Nov 2026 18:35:32 -0700 Subject: [PATCH 02/15] feat(backup): a full export carries your settings, and an import brings them back Export writes every setting into a backup_prefs table in the copy only; keys that belong to this phone are listed in kPrefsNotBackedUp with a reason. Auto-backup and manual export carry settings; telemetry and support exports do not. Import restores all settings on a fresh install and only absent keys otherwise, then reloads theme, language, units and clock format. Consent is asked again as a whole. A coach key left in secure storage on the old phone is reported for re-entry instead of being claimed present. --- lib/app.dart | 12 ++ lib/data/backup_extras.dart | 163 ++++++++++++++++++++ lib/data/db.dart | 39 ++++- lib/l10n/app_de.arb | 6 + lib/l10n/app_en.arb | 6 + lib/l10n/app_es.arb | 6 + lib/l10n/app_fr.arb | 6 + lib/l10n/app_hi.arb | 6 + lib/l10n/app_ru.arb | 6 + lib/l10n/app_th.arb | 6 + lib/l10n/app_zh.arb | 6 + lib/notify/notification_relay.dart | 9 +- lib/state/app_state.dart | 32 +++- lib/state/clock_format.dart | 9 ++ lib/state/locale_controller.dart | 9 ++ lib/state/prefs.dart | 6 + lib/state/units_controller.dart | 9 ++ lib/support/issue_report.dart | 3 +- lib/telemetry/health_uploader.dart | 3 +- lib/theme/theme_controller.dart | 11 ++ lib/ui2/onboarding/welcome.dart | 41 ++++- test/auto_backup_test.dart | 3 + test/backup/backup_support.dart | 23 +-- test/backup/prefs_roundtrip_test.dart | 15 ++ test/backup/settings_complete_test.dart | 47 ++++++ test/backup/settings_paths_test.dart | 101 ++++++++++-- test/backup/settings_presentation_test.dart | 89 +++++++++++ test/export_retention_test.dart | 3 + test/observation_isolation_test.dart | 2 + test/substrate_archive_db_test.dart | 5 +- 30 files changed, 636 insertions(+), 46 deletions(-) create mode 100644 lib/data/backup_extras.dart create mode 100644 test/backup/settings_presentation_test.dart diff --git a/lib/app.dart b/lib/app.dart index 3e58c40e0..c2cfef1a0 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'ai/briefing.dart' show BriefingPeriod; import 'coach/coach_config.dart'; import 'data/db.dart'; +import 'data/backup_extras.dart' show settingsRestoredSignal; import 'l10n/app_localizations.dart'; import 'notify/notification_service.dart'; import 'notify/tap_router.dart'; @@ -14,6 +15,7 @@ import 'state/app_state.dart'; import 'state/clock_format.dart'; import 'state/locale_controller.dart'; import 'state/prefs.dart'; +import 'state/units_controller.dart'; import 'telemetry/telemetry_service.dart'; import 'theme/theme_controller.dart'; import 'theme/theme_switcher.dart'; @@ -61,6 +63,7 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + settingsRestoredSignal.addListener(_onSettingsRestored); unawaited(LocalDb.setForeground( screenReadsForeground(WidgetsBinding.instance.lifecycleState))); // Hand the BYOK provider config to AppState (briefing generation + the @@ -75,8 +78,17 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver }); } + void _onSettingsRestored() { + if (!mounted) return; + unawaited(context.read().reloadFromPrefs()); + unawaited(context.read().reloadFromPrefs()); + unawaited(context.read().reloadFromPrefs()); + unawaited(context.read().reloadFromPrefs()); + } + @override void dispose() { + settingsRestoredSignal.removeListener(_onSettingsRestored); WidgetsBinding.instance.removeObserver(this); super.dispose(); } diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart new file mode 100644 index 000000000..898c253bf --- /dev/null +++ b/lib/data/backup_extras.dart @@ -0,0 +1,163 @@ +// Settings carried by full exports. These tables exist only in the copy; +// the live schema and database restore table list never include them. +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show ValueNotifier; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; + +const Map kPrefsNotBackedUp = { + 'install_device_id': 'identifies this install; a restore must not clone it', + 'paired_remote_id': 'BLE address bonded to this phone; pair again', + 'consent_telemetry': 'consent is asked again on each install', + 'consent_health_data': 'consent is asked again on each install', + 'consent_chosen': 'the consent question is asked again as a whole', + 'coach_api_key_present': 'the keychain payload does not travel; this marker ' + 'would claim a key the new phone does not hold', + 'coach_chatgpt_present': 'the secure sign-in session does not travel; ' + 'sign in to ChatGPT again on this phone', + 'coach_chatgpt_client_id': 'OAuth client registered for this installation; ' + 'a new sign-in registers its own client', + 'coach_chatgpt_host_id': 'identifies this phone to the sign-in service', + 'coach_chatgpt_welcomed': 'the new sign-in must show its welcome on this phone', + 'backup.folder': 'storage-access URI permission belongs to this phone', + 'backup.last_run_ms': 'status of this install\'s automatic backup', + 'backup.last_error': 'status of this install\'s automatic backup', + 'ble.ask_add_pending': 'transient pairing prompt pending on this phone', + 'health_upload_last_ts': 'upload cursor for this installation', +}; + +const Map kSecretsNotBackedUp = { + 'lib/coach/coach_config.dart': 'coach API key is a credential in secure ' + 'storage; a missing key is reported for re-entry after import', + 'lib/coach/coach_chatgpt.dart': 'ChatGPT access and refresh tokens stay in ' + 'secure storage; sign in again on the new phone', + 'lib/ble/miband_link.dart': 'Mi Band pairing key is a credential; ' + 'pair the band again on this phone', + 'lib/ble/oura_link.dart': 'Oura pairing key is a credential; ' + 'pair the ring again on this phone', +}; + +/// The app root reloads presentation controllers after AppState adopts prefs. +final settingsRestoredSignal = ValueNotifier(0); + +/// Keeps the existing row-count API while reporting settings and lost secrets. +class ImportSummary extends MapView { + ImportSummary(super.map, { + this.keysWritten = const {}, + this.needsReentry = const [], + }); + + final Set keysWritten; + final List needsReentry; +} + +bool _backedUp(String key) => !kPrefsNotBackedUp.containsKey(key); + +(String, String)? _encode(Object? value) => switch (value) { + bool b => ('bool', '$b'), + int i => ('int', '$i'), + double d => ('double', '$d'), + String s => ('string', s), + List l => ('stringList', jsonEncode(l.cast())), + _ => null, +}; + +Future writeBackupPrefs(String path) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + final copy = await openDatabase(path, singleInstance: false); + try { + await copy.transaction((tx) async { + await tx.execute('CREATE TABLE backup_prefs ' + '(key TEXT PRIMARY KEY, type TEXT NOT NULL, value TEXT NOT NULL)'); + for (final key in prefs.getKeys()) { + if (!_backedUp(key)) continue; + final value = _encode(prefs.get(key)); + if (value == null) continue; + await tx.insert('backup_prefs', { + 'key': key, 'type': value.$1, 'value': value.$2, + }); + } + // Presence only, never the credential or its denylisted preference. + // Do not access the keychain during export (it may be locked). + await tx.execute('CREATE TABLE backup_secrets (key TEXT PRIMARY KEY)'); + if (prefs.get('coach_api_key_present') == true) { + await tx.insert('backup_secrets', {'key': 'coach_api_key'}); + } + }); + } finally { + await copy.close(); + } +} + +Future _hasTable(Database db, String table) async => + (await db.rawQuery('SELECT 1 FROM sqlite_master ' + "WHERE type = 'table' AND name = ?", [table])).isNotEmpty; + +Future _write(SharedPreferences prefs, String key, + String type, String value) async { + switch (type) { + case 'bool': + if (value != 'true' && value != 'false') { + throw const FormatException('invalid preference boolean'); + } + return prefs.setBool(key, value == 'true'); + case 'int': + return prefs.setInt(key, int.parse(value)); + case 'double': + return prefs.setDouble(key, double.parse(value)); + case 'string': + return prefs.setString(key, value); + case 'stringList': + final list = jsonDecode(value); + if (list is! List || list.any((v) => v is! String)) { + throw const FormatException('invalid preference list'); + } + return prefs.setStringList(key, list.cast()); + default: + throw FormatException('unknown preference type $type'); + } +} + +Future restoreBackupPrefs(String path, Map counts, { + required bool freshInstall, +}) async { + final src = await openDatabase(path, readOnly: true, singleInstance: false); + try { + final written = {}; + if (await _hasTable(src, 'backup_prefs')) { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + for (final row in await src.query('backup_prefs')) { + final key = row['key'] as String; + if (!_backedUp(key) || (!freshInstall && prefs.containsKey(key))) { + continue; + } + // Bad rows fail visibly; storage refusing a write must not be + // reported as a successful settings restore. + if (!await _write(prefs, key, '${row['type']}', '${row['value']}')) { + throw StateError('Could not restore preference $key'); + } + written.add(key); + } + } + final needs = []; + if (await _hasTable(src, 'backup_secrets') && + (await src.query('backup_secrets', where: 'key = ?', + whereArgs: ['coach_api_key'])).isNotEmpty) { + String? key; + try { + key = await const FlutterSecureStorage().read(key: 'coach_api_key') + .timeout(const Duration(seconds: 6)); + } catch (_) {/* unreadable also needs the owner's attention */} + if (key == null || key.isEmpty) needs.add('coach_api_key'); + } + return ImportSummary(counts, + keysWritten: Set.unmodifiable(written), needsReentry: List.unmodifiable(needs)); + } finally { + await src.close(); + } +} diff --git a/lib/data/db.dart b/lib/data/db.dart index c3ab9f1f6..0c077b86f 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -48,6 +48,7 @@ import 'coverage_resolver.dart' show CoverageInterval; import 'day_label.dart'; import 'activity_store.dart'; import 'attach_copy.dart'; +import 'backup_extras.dart'; import '../models/activity_suggestion.dart'; import '../compute/accepted_naps.dart'; import '../compute/nap_edits.dart' show NapEditKind; @@ -10727,11 +10728,15 @@ class LocalDb { /// shrink it — a year of history would cost its full size five times over. /// A manual export keeps it, which is the lossless copy the user asked for. /// + /// [includePrefs] carries settings in export-only tables. Telemetry and + /// support exports pass false to keep settings and key signals private. + /// /// [into] puts the copy somewhere other than the temp root; the manual /// export uses its own folder so it can prune its old copies without /// touching snapshots other features are still working on. static Future exportCopy({ bool includeSubstrateArchive = true, + bool includePrefs = true, Directory? into, }) async { final db = await instance; @@ -10766,6 +10771,7 @@ class LocalDb { await copy.close(); copy = null; } + if (includePrefs) await writeBackupPrefs(dest); } catch (_) { // The caller never learns the path of a failed copy, so nobody else // could ever delete it: a full copy of the database left in temp. @@ -11601,12 +11607,12 @@ class LocalDb { /// renames on the way through is exactly the situation a restore has to /// survive. Plain `.db` files (older backups, and `exportCopy` output) take /// the same path they always did. - static Future> importFromDbFile(String path) async { + static Future importFromDbFile(String path) async { if (!await File(path).exists()) { throw const FileSystemException('Backup file not found'); } if (await sniffFile(path) != ImportContainer.gzip) { - return _mergeFromDbFile(path); + return _restoreFromDbFile(path); } final work = await Directory.systemTemp.createTemp('openstrap_restore_'); try { @@ -11617,7 +11623,7 @@ class LocalDb { 'That file unpacked to something that is not an OpenStrap database.', ); } - return await _mergeFromDbFile(inflated); + return await _restoreFromDbFile(inflated); } finally { // The inflated copy is a full second copy of the database, so it goes // whether the import worked or not. @@ -11629,6 +11635,33 @@ class LocalDb { } } + /// An empty ledger may still hold onboarding defaults. Existing history or + /// hand-entered data makes this an established install, even without a band. + static Future _backupPrefsFreshInstall(Database db) async { + const candidates = [ + 'decoded_onehz', 'decoded_rr', 'samples', 'day_result', 'observation', + 'sessions', 'journal', 'substrate_archive', 'raw_archive', + 'imported_workout', 'imported_measurement', 'lab_result', 'food_entry', + 'med_dose', 'strength_set', 'cycle_log', 'breathing_session', 'sleep_nap', + ]; + // Some stores, including raw_archive, are created lazily. + final tables = {for (final row in await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type = 'table'")) row['name']}; + final probes = [for (final table in candidates) + if (tables.contains(table)) 'EXISTS(SELECT 1 FROM $table)']; + if (probes.isEmpty) return true; + return (await db.rawQuery('SELECT ${probes.join(' OR ')} AS populated')) + .single['populated'] == 0; + } + + static Future _restoreFromDbFile(String path) async { + final db = await instance; + // Decide before imported rows make a new phone look established. + final fresh = await _backupPrefsFreshInstall(db); + final counts = await _mergeFromDbFile(path); + return restoreBackupPrefs(path, counts, freshInstall: fresh); + } + /// Merge every table [tables] names from the database file at [path] into /// this one. /// diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fe1fa7f57..72f545998 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3472,5 +3472,11 @@ "type": "int" } } + }, + "welcomeCoachKeyReentry": "Dein API-Schlüssel für den Coach bleibt im sicheren Speicher und wurde nicht gesichert. Gib ihn in den KI-Einstellungen erneut ein, um den Coach auf diesem Telefon zu nutzen.", + "welcomeSettingsRestored": "{n} Einstellungen wiederhergestellt", + "@welcomeSettingsRestored": { + "description": "Import report: number of restored app preferences", + "placeholders": {"n": {"type": "int"}} } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ea800171..4e559e147 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16331,5 +16331,11 @@ "type": "int" } } + }, + "welcomeCoachKeyReentry": "Your coach API key stays in secure storage and was not backed up. Enter it again in AI settings to use the coach on this phone.", + "welcomeSettingsRestored": "{n} settings restored", + "@welcomeSettingsRestored": { + "description": "Import report: number of restored app preferences", + "placeholders": {"n": {"type": "int"}} } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..f65c93663 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2890,5 +2890,11 @@ "type": "int" } } + }, + "welcomeCoachKeyReentry": "Tu clave de API del asistente permanece en el almacenamiento seguro y no se incluyó en la copia. Vuelve a introducirla en los ajustes de IA para usar el asistente en este teléfono.", + "welcomeSettingsRestored": "{n} ajustes restaurados", + "@welcomeSettingsRestored": { + "description": "Import report: number of restored app preferences", + "placeholders": {"n": {"type": "int"}} } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7c26d7e89..d269d287e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2861,5 +2861,11 @@ "type": "int" } } + }, + "welcomeCoachKeyReentry": "Votre clé API du coach reste dans le stockage sécurisé et n’a pas été sauvegardée. Saisissez-la à nouveau dans les réglages de l’IA pour utiliser le coach sur ce téléphone.", + "welcomeSettingsRestored": "{n} réglages restaurés", + "@welcomeSettingsRestored": { + "description": "Import report: number of restored app preferences", + "placeholders": {"n": {"type": "int"}} } } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 990d67975..1e3c59691 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2861,5 +2861,11 @@ "type": "int" } } + }, + "welcomeCoachKeyReentry": "आपके कोच की API कुंजी सुरक्षित स्टोरेज में रहती है और बैकअप में शामिल नहीं की गई। इस फ़ोन पर कोच इस्तेमाल करने के लिए इसे AI सेटिंग में फिर से दर्ज करें।", + "welcomeSettingsRestored": "{n} सेटिंग बहाल हुईं", + "@welcomeSettingsRestored": { + "description": "Import report: number of restored app preferences", + "placeholders": {"n": {"type": "int"}} } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c86544a36..5efccbf04 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7904,5 +7904,11 @@ "type": "int" } } + }, + "welcomeCoachKeyReentry": "API-ключ тренера остаётся в защищённом хранилище и не включён в резервную копию. Введите его снова в настройках ИИ, чтобы пользоваться тренером на этом телефоне.", + "welcomeSettingsRestored": "Восстановлено настроек: {n}", + "@welcomeSettingsRestored": { + "description": "Import report: number of restored app preferences", + "placeholders": {"n": {"type": "int"}} } } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 924d3dbf9..452a350ed 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16325,5 +16325,11 @@ "type": "int" } } + }, + "welcomeCoachKeyReentry": "คีย์ API ของโค้ชยังอยู่ในพื้นที่จัดเก็บที่ปลอดภัยและไม่ได้รวมอยู่ในข้อมูลสำรอง โปรดป้อนคีย์อีกครั้งในการตั้งค่า AI เพื่อใช้โค้ชบนโทรศัพท์เครื่องนี้", + "welcomeSettingsRestored": "กู้คืนการตั้งค่าแล้ว {n} รายการ", + "@welcomeSettingsRestored": { + "description": "Import report: number of restored app preferences", + "placeholders": {"n": {"type": "int"}} } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1cf2c9848..c9384cd30 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2861,5 +2861,11 @@ "type": "int" } } + }, + "welcomeCoachKeyReentry": "教练的 API 密钥保留在安全存储中,未包含在备份内。请在 AI 设置中重新输入,以便在此手机上使用教练。", + "welcomeSettingsRestored": "已恢复 {n} 项设置", + "@welcomeSettingsRestored": { + "description": "Import report: number of restored app preferences", + "placeholders": {"n": {"type": "int"}} } } diff --git a/lib/notify/notification_relay.dart b/lib/notify/notification_relay.dart index 0992ac049..cfdb6bd98 100644 --- a/lib/notify/notification_relay.dart +++ b/lib/notify/notification_relay.dart @@ -126,6 +126,14 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { /// once at startup. No-op on iOS. Future bootstrap() async { if (!supported) return; + WidgetsBinding.instance.addObserver(this); + await reloadFromPrefs(); + } + + /// Adopt imported relay settings without registering another observer. + Future reloadFromPrefs() async { + if (!supported) return; + _offGeneration++; final prefs = await SharedPreferences.getInstance(); _enabled = prefs.getBool(_kEnabled) ?? false; _packages @@ -140,7 +148,6 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { for (final p in _packages) { if (!_seen.contains(p)) _seen.add(p); } - WidgetsBinding.instance.addObserver(this); await refreshPermission(); _resync(); notifyListeners(); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 0d6267716..aaca3ddf6 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -72,6 +72,7 @@ import '../compute/inputs/measurement_inputs.dart' profileWithWeighing, profileWithoutStaleWeighing; import '../compute/profile.dart'; +import '../data/backup_extras.dart'; import '../data/day_label.dart'; import '../data/journal_fields.dart' show JournalMetricValue, kJournalFieldsByKey; @@ -555,7 +556,7 @@ class AppState extends ChangeNotifier { String? _onboardChoice; String? get onboardChoice => _onboardChoice; - Future _loadProfile() async { + Future _loadProfile({bool loadOnboardChoice = true}) async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kProfile); if (raw != null) { @@ -565,7 +566,7 @@ class AppState extends ChangeNotifier { /* ignore corrupt blob */ } } - _onboardChoice = prefs.getString(_kOnboard); + if (loadOnboardChoice) _onboardChoice = prefs.getString(_kOnboard); // The companion-URL override is loaded in _initCompanion (single source of // truth for every network call — announcements, OTA, telemetry, import). healthSyncEnabled = prefs.getBool(_kHealthSync) ?? false; @@ -730,7 +731,12 @@ class AppState extends ChangeNotifier { /// Days of stored raw history the last backup import added or filled in. int lastImportArchiveRestored = 0; + List lastImportNeedsReentry = const []; + int lastImportPrefsRestored = 0; + Future importEdgeBackup(String path) async { + lastImportNeedsReentry = const []; + lastImportPrefsRestored = 0; importRollupError = null; lastImportArchiveSkipped = 0; lastImportArchiveRestored = 0; @@ -741,9 +747,29 @@ class AppState extends ChangeNotifier { // Invalidated however the import ends: it commits page by page, so a later // page that throws can still leave merged calibration runs behind, and the // refit can fail after a clean import. The cache holds runs, not profiles. - final Map counts; + final ImportSummary counts; try { counts = await LocalDb.importFromDbFile(path); + lastImportNeedsReentry = counts.needsReentry; + lastImportPrefsRestored = counts.keysWritten.length; + if (counts.keysWritten.isNotEmpty) { + await Prefs.reload(); + // Keep the active import on its report until Continue is chosen. + await _loadProfile(loadOnboardChoice: false); + final prefs = await SharedPreferences.getInstance(); + CompanionClient.overrideUrl = prefs.getString(_kCompanionUrl); + await gestureSettings.bootstrap(); + await notificationRelay.reloadFromPrefs(); + _deviceAlerts.refreshThreshold(); + await armWaterReminder(); + try { + await coachConfig?.load(trusted: true) + .timeout(const Duration(seconds: 6)); + } catch (e) { + _log('[import] coach settings reload failed: $e'); + } + settingsRestoredSignal.value++; + } await LocalDb.refitAllLiveStepGains(); } finally { invalidateLiveStepCalibration(); diff --git a/lib/state/clock_format.dart b/lib/state/clock_format.dart index 7e4dabae4..fe742638b 100644 --- a/lib/state/clock_format.dart +++ b/lib/state/clock_format.dart @@ -54,6 +54,15 @@ class ClockFormatController extends ChangeNotifier { ClockFormat.h12 => false, }; + /// Adopt restored settings without persisting another write. + Future reloadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + final next = _parse(prefs.getString(_kClockFormat)); + if (next == _format) return; + _format = next; + notifyListeners(); + } + Future setFormat(ClockFormat f) async { if (_format == f) return; _format = f; diff --git a/lib/state/locale_controller.dart b/lib/state/locale_controller.dart index 4f0c74378..a59772841 100644 --- a/lib/state/locale_controller.dart +++ b/lib/state/locale_controller.dart @@ -79,6 +79,15 @@ class LocaleController extends ChangeNotifier { String? get code => _code; Locale? get locale => _code == null ? null : Locale(_code!); + /// Adopt restored settings without persisting another write. + Future reloadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + final next = _validCode(prefs.getString(_kLocale)); + if (next == _code) return; + _code = next; + notifyListeners(); + } + Future setCode(String? code) async { if (_code == code) return; _code = code; diff --git a/lib/state/prefs.dart b/lib/state/prefs.dart index d83147674..5843873ed 100644 --- a/lib/state/prefs.dart +++ b/lib/state/prefs.dart @@ -24,6 +24,12 @@ class Prefs { } catch (_) {/* reads fall back to defaults */} } + /// Refresh the synchronous cache after a backup restores settings. + static Future reload() async { + _sp = await SharedPreferences.getInstance(); + await _sp!.reload(); + } + /// Whether storage is actually available, i.e. whether a `getX` default is /// "the key is unset" or "we cannot see what you chose". /// diff --git a/lib/state/units_controller.dart b/lib/state/units_controller.dart index f8e88c9f0..7cb7ccd0c 100644 --- a/lib/state/units_controller.dart +++ b/lib/state/units_controller.dart @@ -38,6 +38,15 @@ class UnitsController extends ChangeNotifier { UnitSystem get system => _system; bool get isImperial => _system == UnitSystem.imperial; + /// Adopt restored settings without persisting another write. + Future reloadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + final next = _parse(prefs.getString(_kUnits)); + if (next == _system) return; + _system = next; + notifyListeners(); + } + Future setSystem(UnitSystem s) async { if (_system == s) return; _system = s; diff --git a/lib/support/issue_report.dart b/lib/support/issue_report.dart index ad213331f..219a64931 100644 --- a/lib/support/issue_report.dart +++ b/lib/support/issue_report.dart @@ -366,7 +366,8 @@ Future<({File zip, Map meta})> collectReport( if (draft.fullDatabase) { onStage?.call(ReportStage.database); // The existing export path: a VACUUM INTO snapshot, not a file copy. - path = await LocalDb.exportCopy(includeSubstrateArchive: false); + path = await LocalDb.exportCopy( + includeSubstrateArchive: false, includePrefs: false); } else { onStage?.call(ReportStage.slice); path = await exportRecentSlice(rawDays: days); diff --git a/lib/telemetry/health_uploader.dart b/lib/telemetry/health_uploader.dart index d66f886fe..c8cae0f2d 100644 --- a/lib/telemetry/health_uploader.dart +++ b/lib/telemetry/health_uploader.dart @@ -105,7 +105,8 @@ class HealthUploader { // year of already-compressed raw history read into memory and re-gzipped // here would be both a bigger upload than anyone consented to and a // memory spike on the phone. - final path = await LocalDb.exportCopy(includeSubstrateArchive: false); + final path = await LocalDb.exportCopy( + includeSubstrateArchive: false, includePrefs: false); final file = File(path); try { final bytes = await file.readAsBytes(); diff --git a/lib/theme/theme_controller.dart b/lib/theme/theme_controller.dart index fdc20f5d6..eb54c1b15 100644 --- a/lib/theme/theme_controller.dart +++ b/lib/theme/theme_controller.dart @@ -90,6 +90,17 @@ class ThemeController extends ChangeNotifier { await prefs.setString(_kChoice, choice.name); } + /// Adopt an imported choice without writing it back. + Future reloadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + final next = _parse(prefs.getString(_kChoice)); + if (next == _choice) return; + _choice = next; + _applyActive(); + _applySystemChrome(); + notifyListeners(); + } + /// Called when the OS brightness changes (only matters under `system`). void updatePlatformBrightness(Brightness b) { if (_platform == b) return; diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index e290466d8..1159e11ca 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -77,6 +77,9 @@ class ImportOutcome { /// re-importing corrects the (marker, date) rows it names. final int labRows; + final int prefsRestored; + final List needsReentry; + /// Journal or lab CSV lines that were REJECTED, never clamped — "line 12: /// note is 40122 characters". Shown up to a cap, because a validation the /// user cannot see is a silent drop. @@ -103,6 +106,8 @@ class ImportOutcome { this.archiveBucketsRestored = 0, this.journalRows = 0, this.labRows = 0, + this.prefsRestored = 0, + this.needsReentry = const [], this.rejectedRows = const [], this.error, this.rollupError, @@ -120,6 +125,7 @@ class ImportOutcome { /// the gate waits for them instead of moving on to pairing. bool get needsReading => lostSomething || + needsReentry.isNotEmpty || rejectedRows.isNotEmpty || readError != null || rollupError != null; @@ -132,7 +138,8 @@ class ImportOutcome { skippedDays == 0 && journalRows == 0 && labRows == 0 && - archiveBucketsRestored == 0; + archiveBucketsRestored == 0 && + prefsRestored == 0; } /// Raised when an encrypted backup was picked and the user closed the @@ -365,7 +372,8 @@ Future runImport( final sources = []; var days = 0, workouts = 0, skipped = 0, late = 0, stranded = 0; var journalRows = 0; - var labRows = 0; + var labRows = 0, prefsRestored = 0; + final needsReentry = {}; final corruptTables = {}; var archiveSkipped = 0, archiveRestored = 0; final rejected = []; @@ -434,6 +442,8 @@ Future runImport( rollupError ??= app.importRollupError; archiveSkipped += app.lastImportArchiveSkipped; archiveRestored += app.lastImportArchiveRestored; + prefsRestored += app.lastImportPrefsRestored; + needsReentry.addAll(app.lastImportNeedsReentry); } } finally { // The decrypted copy is the whole health record in plaintext. It exists @@ -557,6 +567,8 @@ Future runImport( archiveBucketsRestored: archiveRestored, journalRows: journalRows, labRows: labRows, + prefsRestored: prefsRestored, + needsReentry: needsReentry.toList(), rejectedRows: rejected, rollupError: rollupError, // A file that would not decrypt is reported the same way a file that would @@ -744,6 +756,14 @@ class ImportReport extends StatelessWidget { final ImportOutcome o; const ImportReport(this.o, {super.key}); + Widget _keyReentry(AppLocalizations? l) => StatusCard( + l?.coachApiKeyLabel ?? 'API key', + l?.welcomeCoachKeyReentry ?? + 'Your coach API key stays in secure storage and was not backed up. ' + 'Enter it again in AI settings to use the coach on this phone.', + icon: LucideIcons.keyRound, + ); + @override Widget build(BuildContext c) { final p = P.of(c); @@ -778,11 +798,12 @@ class ImportReport extends StatelessWidget { // can still have skipped archive buckets it could not read. That is the // one part of the answer the user can act on, so it is not swallowed // by the empty case. - if (o.archiveBucketsSkipped == 0) return nothing; + if (o.archiveBucketsSkipped == 0 && o.needsReentry.isEmpty) return nothing; return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ nothing, const SizedBox(height: S.x3), - StatusCard( + if (o.needsReentry.isNotEmpty) _keyReentry(l), + if (o.archiveBucketsSkipped > 0) StatusCard( l?.welcomePartOfFileNotUsedTitle ?? 'Part of that file could not be used', _archiveSkipped(o, l), @@ -825,11 +846,15 @@ class ImportReport extends StatelessWidget { '${o.archiveBucketsRestored} day' '${o.archiveBucketsRestored == 1 ? '' : 's'}' : null; - final headline = [days, journal, labs, workouts, skipped, archive].firstWhere((c) => c != null)!; + final settings = o.prefsRestored > 0 + ? l?.welcomeSettingsRestored(o.prefsRestored) ?? + '${o.prefsRestored} settings restored' + : null; + final headline = [days, journal, labs, workouts, skipped, archive, settings].firstWhere((c) => c != null)!; // journal is NEVER folded in here — when it isn't the headline, days > 0 // means it gets its own "N journal days REPLACED" line below instead (a // different fact: a journal CSV always replaces, on any day it names). - final also = [labs, workouts, skipped, archive].whereType().where((c) => c != headline).toList(); + final also = [labs, workouts, skipped, archive, settings].whereType().where((c) => c != headline).toList(); return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Surface( child: Row(children: [ @@ -850,6 +875,10 @@ class ImportReport extends StatelessWidget { ), ]), ), + if (o.needsReentry.isNotEmpty) ...[ + const SizedBox(height: S.x3), + _keyReentry(l), + ], // REJECTED, never clamped. A row outside its declared range is not // salvageable by trimming it — that would store a value the user never // wrote — so it is refused by line number and the other 300 land. diff --git a/test/auto_backup_test.dart b/test/auto_backup_test.dart index 6d2a3e6bd..48bc24cb3 100644 --- a/test/auto_backup_test.dart +++ b/test/auto_backup_test.dart @@ -9,6 +9,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:openstrap_edge/data/auto_backup.dart'; import 'package:openstrap_edge/data/db.dart'; import 'package:path/path.dart' as p; @@ -37,6 +38,8 @@ class _FakePathProvider extends PathProviderPlatform { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); group('backupIsDue', () { final now = DateTime(2026, 8, 9, 12); diff --git a/test/backup/backup_support.dart b/test/backup/backup_support.dart index f5e43286e..d7a112eb8 100644 --- a/test/backup/backup_support.dart +++ b/test/backup/backup_support.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/backup_extras.dart'; class _FakePaths extends PathProviderPlatform { _FakePaths(this.tmp, this.docs); @@ -125,17 +126,11 @@ void expectNoSecretIn(String path, Iterable secrets) { } } -// Read the policy as source so the red tests compile before it exists. -Map policyMap(String name) { - final f = File('lib/data/backup_extras.dart'); - expect(f.existsSync(), isTrue, reason: 'the backup needs a settings policy'); - final block = RegExp(name + r' = \{([\s\S]*?)\n\};') - .firstMatch(f.readAsStringSync()); - expect(block, isNotNull, reason: name); - final entries = RegExp(r"'([^']+)': ([\s\S]*?)(?=\n '[^']+':|$)"); - return {for (final m in entries.allMatches(block!.group(1)!)) - m.group(1)!: m.group(2)!.trim()}; -} +Map policyMap(String name) => switch (name) { + 'kPrefsNotBackedUp' => kPrefsNotBackedUp, + 'kSecretsNotBackedUp' => kSecretsNotBackedUp, + _ => throw ArgumentError.value(name), +}; Future isFreshInstall() async { final db = await LocalDb.instance; @@ -144,7 +139,5 @@ Future isFreshInstall() async { return rows.first['a'] == 0 && rows.first['b'] == 0; } -Matcher needsReentry(List expected) => predicate((dynamic result) { - try { return equals(expected).matches(result.needsReentry, {}); } - on NoSuchMethodError { return false; } -}, 'import result with needsReentry $expected'); +Matcher needsReentry(List expected) => isA() + .having((r) => r.needsReentry, 'needsReentry', expected); diff --git a/test/backup/prefs_roundtrip_test.dart b/test/backup/prefs_roundtrip_test.dart index 1b0381ddc..6a3881e7a 100644 --- a/test/backup/prefs_roundtrip_test.dart +++ b/test/backup/prefs_roundtrip_test.dart @@ -76,6 +76,21 @@ void main() { expect(got['gesture_double_tap']!.$1, 'string'); }); + test('native string lists survive export and restore', () async { + await env.wipe(); + // Method-channel decoding returns List, even though every item + // is a string. SharedPreferences.getStringList accepts this native shape. + await setPrefs({'selected_sensors': ['one', 'two']}); + final sp = await SharedPreferences.getInstance(); + expect(sp.getStringList('selected_sensors'), ['one', 'two']); + final path = await LocalDb.exportCopy(); + expect((await exportedPrefs(path))!['selected_sensors'], + ('stringList', '["one","two"]')); + await env.wipe(); + await LocalDb.importFromDbFile(path); + expect((await allPrefs())['selected_sensors'], ['one', 'two']); + }); + test('the live database never gains backup_* tables', () async { await env.wipe(); await setPrefs(Map.of(_settings)); diff --git a/test/backup/settings_complete_test.dart b/test/backup/settings_complete_test.dart index c0f4ad274..48081af01 100644 --- a/test/backup/settings_complete_test.dart +++ b/test/backup/settings_complete_test.dart @@ -2,6 +2,7 @@ // gaps the audit found: backup.cadence swept up by a prefix, consent state half // restored, and a coach key lost without a word. +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -39,6 +40,18 @@ void main() { expect(now.containsKey('backup.last_error'), isFalse); }); + test('hand-entered history also preserves the device settings', () async { + await wipeAll(); + await setPrefs({'theme_choice': 'dark', 'backup.cadence': 'weekly'}); + final path = await LocalDb.exportCopy(); + await wipeAll(); + final db = await LocalDb.instance; + await db.insert('journal', {'date': '2026-09-21', 'updated_at': 1}); + await setPrefs({'theme_choice': 'light'}); + await LocalDb.importFromDbFile(path); + expect(await allPrefs(), {'theme_choice': 'light', 'backup.cadence': 'weekly'}); + }); + test('consent is asked again as a whole: no half-restored consent state', () async { await wipeAll(); @@ -73,6 +86,40 @@ void main() { expect(result, needsReentry(['coach_api_key'])); }); + test('ChatGPT credentials and phone registration stay on the phone', () async { + await wipeAll(); + FlutterSecureStorage.setMockInitialValues({'coach_chatgpt_session': 'SECRET-SESSION'}); + await setPrefs({ + 'coach_use_chatgpt': true, + 'coach_chatgpt_present': true, + 'coach_chatgpt_client_id': 'client-old', + 'coach_chatgpt_host_id': 'host-old', + 'coach_chatgpt_welcomed': true, + }); + final path = await LocalDb.exportCopy(); + expect((await exportedPrefs(path))!.keys, ['coach_use_chatgpt']); + expectNoSecretIn(path, ['SECRET-SESSION', 'client-old', 'host-old']); + await wipeAll(); + await LocalDb.importFromDbFile(path); + expect(await allPrefs(), {'coach_use_chatgpt': true}); + }); + + test('a foreign backup cannot restore consent or a keychain marker', () async { + await wipeAll(); + await setPrefs({'theme_choice': 'dark'}); + final path = await LocalDb.exportCopy(); + final copy = await databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions(singleInstance: false)); + for (final key in ['consent_chosen', 'consent_telemetry', + 'consent_health_data', 'coach_api_key_present']) { + await copy.insert('backup_prefs', {'key': key, 'type': 'bool', 'value': 'true'}); + } + await copy.close(); + await wipeAll(); + await LocalDb.importFromDbFile(path); + expect(await allPrefs(), {'theme_choice': 'dark'}); + }); + test('no re-entry is reported when the key is already on this phone or never set', () async { await wipeAll(); diff --git a/test/backup/settings_paths_test.dart b/test/backup/settings_paths_test.dart index 5b5eed330..8101c87fc 100644 --- a/test/backup/settings_paths_test.dart +++ b/test/backup/settings_paths_test.dart @@ -1,16 +1,33 @@ +import 'dart:async'; import 'dart:io'; +import 'package:archive/archive.dart'; +import 'package:notification_listener_service/notification_event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/auto_backup.dart'; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/state/prefs.dart'; +import 'package:openstrap_edge/notify/notification_relay.dart'; import 'package:openstrap_edge/theme/theme_controller.dart'; +import 'package:openstrap_edge/support/issue_report.dart'; +import 'package:openstrap_edge/ui2/onboarding/welcome.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'backup_support.dart'; +// Only the platform availability/permission is faked; settings and the pending +// notification run through the real relay. +class _AndroidRelay extends NotificationRelay { + _AndroidRelay({required super.buzz, required super.quietNow}) + : super(isConnected: () => true); + @override + bool get supported => true; + @override + Future refreshPermission() async => false; +} + void main() { late BackupEnv env; setUpAll(() async => env = await BackupEnv.create('bkx_paths_test.db')); @@ -30,18 +47,30 @@ void main() { test('an export for telemetry or support carries no settings or key signals', () async { await env.wipe(); await setPrefs({'theme_choice': 'dark', 'coach_api_key_present': true}); - final String path; - try { - path = await Function.apply(LocalDb.exportCopy, [], {#includePrefs: false}); - } on NoSuchMethodError { - fail('exportCopy must allow telemetry/support to omit preferences'); - } + final path = await LocalDb.exportCopy(includePrefs: false); final tables = await tablesOf(path); expect(tables.where((t) => t.startsWith('backup_')), isEmpty); - for (final file in ['lib/telemetry/health_uploader.dart', 'lib/support/issue_report.dart']) { - expect(File(file).readAsStringSync(), contains('includePrefs: false'), - reason: '$file must use the private export mode'); - } + // The uploader is compile-time gated and requires battery/Wi-Fi plugins. + expect(File('lib/telemetry/health_uploader.dart').readAsStringSync(), + contains('includePrefs: false')); + }); + + test('a full support report contains no backup settings or key signals', () async { + await env.wipe(); + await setPrefs({'theme_choice': 'dark', 'coach_api_key_present': true}); + final personal = await LocalDb.exportCopy(); + expect((await exportedPrefs(personal))!['theme_choice'], ('string', 'dark')); + final app = AppState.forTesting(); + addTearDown(app.dispose); + final report = await collectReport(app, const ReportDraft( + category: ReportCategory.sync, title: 'Sync', description: 'Details', + fullDatabase: true, + )); + final zip = ZipDecoder().decodeBytes(await report.zip.readAsBytes()); + final payload = zip.files.singleWhere((f) => f.name == 'database.db.gz'); + final db = File('${env.root.path}/support.db'); + await db.writeAsBytes(gzip.decode(payload.content as List)); + expect((await tablesOf(db.path)).where((t) => t.startsWith('backup_')), isEmpty); }); test('older backups with no extras still import', () async { @@ -75,6 +104,52 @@ void main() { expect(Prefs.getInt('ui.recap_range', 0), 2); }); + test('the import report receives settings counts and coach re-entry', () async { + await env.wipe(); + await setPrefs({'coach_model': 'm', 'coach_api_key_present': true, + 'onboarding_choice': 'existing'}); + final path = await LocalDb.exportCopy(); + await env.wipe(); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.initialized = true; + final result = await runImport(app, [path]); + expect(app.route, AppRoute.welcome, + reason: 'restored onboarding prefs must not hide the import report'); + expect(await settleOnboardImport(app, result), isTrue); + expect(result.needsReentry, ['coach_api_key']); + expect(result.prefsRestored, 2); + expect(result.nothingLanded, isFalse); + expect(result.needsReading, isTrue); + await app.completeImportOnboard(); + expect(app.route, AppRoute.pairing); + }); + + test('relay reload adopts imported apps and cancels a pending buzz', () async { + await env.wipe(); + var buzzes = 0; + final quiet = Completer(); + final relay = _AndroidRelay(buzz: () async { buzzes++; }, + quietNow: () => quiet.future); + addTearDown(relay.dispose); + relay.packages.add('com.chat'); + relay.debugOnNotification(ServiceNotificationEvent( + id: 1, title: '', content: '', packageName: 'com.chat', + canReply: false, haveExtraPicture: false, hasRemoved: false, + onGoing: false, timestamp: 0, + appIcon: null, extrasPicture: null, largeIcon: null, + )); + await setPrefs({'notif_relay_enabled': false, + 'notif_relay_packages': ['com.chat', 'com.mail']}); + await relay.reloadFromPrefs(); + expect(relay.enabled, isFalse); + expect(relay.packages, {'com.chat', 'com.mail'}); + expect(relay.seenPackages, ['com.chat', 'com.mail']); + quiet.complete(false); + await Future.value(); + expect(buzzes, 0); + }); + test('a live theme reload adopts the imported choice without rewriting it', () async { await env.wipe(); await setPrefs({'theme_choice': 'dark'}); @@ -83,11 +158,7 @@ void main() { final theme = ThemeController.seed(AppThemeChoice.light, Brightness.light); addTearDown(theme.dispose); await LocalDb.importFromDbFile(path); - try { - await (theme as dynamic).reloadFromPrefs(); - } on NoSuchMethodError { - fail('the live theme must be reloadable after importing settings'); - } + await theme.reloadFromPrefs(); expect(theme.choice, AppThemeChoice.dark); expect(theme.effective, Brightness.dark); expect((await allPrefs())['theme_choice'], 'dark'); diff --git a/test/backup/settings_presentation_test.dart b/test/backup/settings_presentation_test.dart new file mode 100644 index 000000000..444fcb28b --- /dev/null +++ b/test/backup/settings_presentation_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:openstrap_edge/app.dart'; +import 'package:openstrap_edge/coach/coach_config.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/clock_format.dart'; +import 'package:openstrap_edge/state/locale_controller.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:openstrap_edge/theme/theme_controller.dart'; +import 'package:openstrap_edge/theme/theme_switcher.dart'; +import 'package:openstrap_edge/ui2/onboarding/welcome.dart'; + +import 'backup_support.dart'; + +void main() { + late BackupEnv env; + setUpAll(() async => env = await BackupEnv.create('bkx_presentation_test.db')); + tearDownAll(() async => env.dispose()); + + testWidgets('an import updates the app root and live presentation controllers', + (tester) async { + late String path; + await tester.runAsync(() async { + await env.wipe(); + FlutterSecureStorage.setMockInitialValues({}); + await setPrefs({'theme_choice': 'dark', 'locale_override': 'es', + 'units_system': 'imperial', 'clock_format': 'h12', + 'local_profile_json': '{"name":"Restored"}'}); + path = await LocalDb.exportCopy(); + await env.wipe(); + }); + final app = AppState.forTesting(); + final theme = ThemeController.seed(AppThemeChoice.light, Brightness.light); + final locale = LocaleController.seed(null); + final units = UnitsController.seed(UnitSystem.metric); + final clock = ClockFormatController.seed(ClockFormat.h24); + final coach = CoachConfig(); + addTearDown(() { + app.dispose(); + coach.dispose(); + theme.dispose(); + locale.dispose(); + units.dispose(); + clock.dispose(); + }); + await tester.pumpWidget(MultiProvider(providers: [ + ChangeNotifierProvider.value(value: app), + ChangeNotifierProvider.value(value: coach), + ChangeNotifierProvider.value(value: theme), + ChangeNotifierProvider.value(value: locale), + ChangeNotifierProvider.value(value: units), + ChangeNotifierProvider.value(value: clock), + ], child: const OpenStrapApp())); + // The existing overlay creates its fade controller lazily in run(). + // Initialise it while mounted so the app root can be disposed in this test. + themeSwitchKey.currentState!.run(() {}); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.runAsync(() => app.importEdgeBackup(path)); + await tester.pump(); + expect(app.user?['name'], 'Restored'); + expect(theme.choice, AppThemeChoice.dark); + expect(locale.code, 'es'); + expect(units.system, UnitSystem.imperial); + expect(clock.format, ClockFormat.h12); + final material = tester.widget(find.byType(MaterialApp)); + expect(material.locale, const Locale('es')); + expect(material.themeMode, ThemeMode.dark); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + for (final prefs in [0, 2]) { + testWidgets('coach re-entry is visible with $prefs restored settings', (tester) async { + final result = ImportOutcome(source: 'OpenStrap backup', + prefsRestored: prefs, needsReentry: const ['coach_api_key']); + expect(result.needsReading, isTrue); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: ImportReport(result)))); + expect(find.textContaining('Enter it again in AI settings'), findsOneWidget); + if (prefs > 0) { + expect(find.text('2 settings restored'), findsOneWidget); + expect(find.text('Nothing was imported'), findsNothing); + } + }); + } +} diff --git a/test/export_retention_test.dart b/test/export_retention_test.dart index b0756b7e7..bff2d1d99 100644 --- a/test/export_retention_test.dart +++ b/test/export_retention_test.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/data/export_retention.dart'; import 'package:path/path.dart' as p; @@ -33,6 +34,8 @@ List _names(Directory dir) => dir.listSync().map((e) => p.basename(e.path)).toList()..sort(); void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); group('pruneSharedExports', () { late Directory dir; setUp(() async => dir = await Directory.systemTemp.createTemp('osx_')); diff --git a/test/observation_isolation_test.dart b/test/observation_isolation_test.dart index 03ba779f6..400bb262a 100644 --- a/test/observation_isolation_test.dart +++ b/test/observation_isolation_test.dart @@ -29,6 +29,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:openstrap_edge/coach/coach_db.dart'; import 'package:openstrap_edge/compute/derivation_engine.dart'; import 'package:openstrap_edge/data/db.dart'; @@ -140,6 +141,7 @@ Future _everyDerivedRead() async { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); // ── LAYER 1 — structural ──────────────────────────────────────────────────── test('only lib/data/db.dart may reach the observation table', () { diff --git a/test/substrate_archive_db_test.dart b/test/substrate_archive_db_test.dart index aeddd85ce..0fc3b18fc 100644 --- a/test/substrate_archive_db_test.dart +++ b/test/substrate_archive_db_test.dart @@ -10,6 +10,7 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:openstrap_edge/data/auto_backup.dart'; import 'package:openstrap_edge/data/day_label.dart'; import 'package:openstrap_edge/data/db.dart'; @@ -241,6 +242,8 @@ void _expectSameRows( } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); late Directory tmp; final policy = const SubstrateArchivePolicy(365); const cutoff = _d0 + 2 * _day; // start of day 3 @@ -1416,7 +1419,7 @@ void main() { ]) { expect( src(auto), - contains('exportCopy(includeSubstrateArchive: false)'), + matches(RegExp(r'exportCopy\([^)]*includeSubstrateArchive:\s*false')), reason: auto, ); } From 65f9ad045d91c83bf4fa501255a01c2b9b48f9a6 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Fri, 6 Nov 2026 18:55:49 -0700 Subject: [PATCH 03/15] fix(backup): device-bound alarm, workout and pairing state stays on the phone Alarm arm/confirm state, the unfinished workout draft (private sessions included), the step-recovery journal, the telemetry outbox and the paired band's identity mirrors no longer travel; the alarm schedule itself is restored and armed through the normal path. An install that is set up but has no history is no longer treated as fresh, so an import fills only absent settings there. A coach key that cannot be read is reported as unreadable, not missing. The audit test now names the keys that must and must not travel itself instead of reading the production list. --- lib/data/backup_extras.dart | 29 ++- lib/data/db.dart | 37 +++- test/backup/settings_audit_guard_test.dart | 173 ++++++++++++++- test/backup/settings_complete_test.dart | 238 +++++++++++++++++++++ test/backup/settings_paths_test.dart | 2 + 5 files changed, 459 insertions(+), 20 deletions(-) diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index 898c253bf..8b3dfaf1f 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -11,6 +11,9 @@ import 'package:sqflite/sqflite.dart'; const Map kPrefsNotBackedUp = { 'install_device_id': 'identifies this install; a restore must not clone it', 'paired_remote_id': 'BLE address bonded to this phone; pair again', + 'paired_serial': 'identity mirror of the band paired to this phone', + 'paired_generation': 'hardware generation of the band paired to this phone', + 'paired_worn_on': 'wearing mirror includes a BLE handle; the device-row merge restores the choice', 'consent_telemetry': 'consent is asked again on each install', 'consent_health_data': 'consent is asked again on each install', 'consent_chosen': 'the consent question is asked again as a whole', @@ -26,6 +29,16 @@ const Map kPrefsNotBackedUp = { 'backup.last_run_ms': 'status of this install\'s automatic backup', 'backup.last_error': 'status of this install\'s automatic backup', 'ble.ask_add_pending': 'transient pairing prompt pending on this phone', + 'live.session_draft': 'unfinished workout entries, including private sessions, stay local', + 'live_session_checkpoint': 'write-ahead step recovery for this phone; replay could duplicate steps', + 'telemetry_outbox': 'queued diagnostics belong to this install and its consent', + 'alarm_epoch': 'arm belongs to the old band; the restored schedule must arm again', + 'alarm_epoch_confirmed': 'confirmation belongs to an arm on the old band', + 'alarm_set_at_ms': 'confirmation replay floor for the old band arm', + 'alarm_pending_until': 'pending confirmation window for the old band arm', + 'alarm_latch_alert': 'pending latch alert for the old band arm', + 'alarm_probe_pending': 'legacy pending hardware probe; never resume on another band', + 'alarm_fired_at': 'last fire event from the old band, not the chosen schedule', 'health_upload_last_ts': 'upload cursor for this installation', }; @@ -48,10 +61,13 @@ class ImportSummary extends MapView { ImportSummary(super.map, { this.keysWritten = const {}, this.needsReentry = const [], + this.unreadableSecrets = const [], }); final Set keysWritten; final List needsReentry; + /// Read failed or timed out; absence is unknown. A later import retries it. + final List unreadableSecrets; } bool _backedUp(String key) => !kPrefsNotBackedUp.containsKey(key); @@ -145,18 +161,21 @@ Future restoreBackupPrefs(String path, Map counts, { } } final needs = []; + final unreadable = []; if (await _hasTable(src, 'backup_secrets') && (await src.query('backup_secrets', where: 'key = ?', whereArgs: ['coach_api_key'])).isNotEmpty) { - String? key; try { - key = await const FlutterSecureStorage().read(key: 'coach_api_key') + final key = await const FlutterSecureStorage().read(key: 'coach_api_key') .timeout(const Duration(seconds: 6)); - } catch (_) {/* unreadable also needs the owner's attention */} - if (key == null || key.isEmpty) needs.add('coach_api_key'); + if (key == null || key.isEmpty) needs.add('coach_api_key'); + } catch (_) { + unreadable.add('coach_api_key'); + } } return ImportSummary(counts, - keysWritten: Set.unmodifiable(written), needsReentry: List.unmodifiable(needs)); + keysWritten: Set.unmodifiable(written), needsReentry: List.unmodifiable(needs), + unreadableSecrets: List.unmodifiable(unreadable)); } finally { await src.close(); } diff --git a/lib/data/db.dart b/lib/data/db.dart index 0c077b86f..4673e4cd7 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11635,9 +11635,19 @@ class LocalDb { } } - /// An empty ledger may still hold onboarding defaults. Existing history or - /// hand-entered data makes this an established install, even without a band. + /// Startup defaults alone do not establish an install. Setup, a successful + /// prior restore, or hand-entered/history rows do, even without a band. + static const _kSettingsImported = 'backup_settings_imported'; + static Future _backupPrefsFreshInstall(Database db) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + if (prefs.getBool('onboard.completed') == true || + prefs.containsKey('onboarding_choice') || + prefs.containsKey('local_profile_json') || + await getCursor(_kSettingsImported) != null) { + return false; + } const candidates = [ 'decoded_onehz', 'decoded_rr', 'samples', 'day_result', 'observation', 'sessions', 'journal', 'substrate_archive', 'raw_archive', @@ -11656,10 +11666,22 @@ class LocalDb { static Future _restoreFromDbFile(String path) async { final db = await instance; - // Decide before imported rows make a new phone look established. - final fresh = await _backupPrefsFreshInstall(db); + // Old database-only backups need no preference store. Inspect the copy + // before merge, which can make a new phone look established. + final src = await openDatabase(path, readOnly: true, singleInstance: false); + final bool hasSettings; + try { + hasSettings = await _objectType(src, 'backup_prefs') == 'table'; + } finally { + await src.close(); + } + final fresh = hasSettings && await _backupPrefsFreshInstall(db); final counts = await _mergeFromDbFile(path); - return restoreBackupPrefs(path, counts, freshInstall: fresh); + final summary = await restoreBackupPrefs(path, counts, freshInstall: fresh); + // Persist only after settings restore succeeds, including a settings-only + // backup without an onboarding choice. A later restore keeps local edits. + if (hasSettings) await setCursor(_kSettingsImported, '1'); + return summary; } /// Merge every table [tables] names from the database file at [path] into @@ -11679,6 +11701,7 @@ class LocalDb { // banked. They were also simply MISSING here until now — nutrition, // medication, strength sets, symptoms and routes did not survive a // backup/restore round trip at all, the same omission `wipeAll` documents. + 'alarm_schedule', 'journal', 'journal_metric', 'journal_field_def', @@ -11781,6 +11804,8 @@ class LocalDb { /// (one day's fields, one session's sets), because the app rewrites each /// as a whole and a row-by-row merge splices two versions together. static const Map, String?)> _restoreEditedKeys = { + // Keep configured weekdays; a restore fills only absent schedule slots. + 'alarm_schedule': (['weekday'], null), 'journal': (['date'], 'updated_at'), 'journal_metric': (['date'], 'updated_at'), 'lab_result': (['marker', 'taken_on'], 'updated_at'), @@ -12655,6 +12680,8 @@ class LocalDb { } if (t == 'sync_cursor') { final name = '${row['name']}'; + // Only a successful restore on THIS install establishes it. + if (name == _kSettingsImported) continue; if (localCursors.containsKey(name)) { if (name != kDecodedPrunedBeforeCursor) continue; final mine = int.tryParse('${localCursors[name]}'); diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart index b396dad2d..488a6e090 100644 --- a/test/backup/settings_audit_guard_test.dart +++ b/test/backup/settings_audit_guard_test.dart @@ -4,9 +4,113 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'backup_support.dart'; +// Deliberately independent of kPrefsNotBackedUp. Changing production policy +// must not silently change the expected export. Add new settings here by hand. +const _mustTravel = { + 'theme_choice', 'locale_override', 'units_system', 'clock_format', + 'local_profile_json', 'onboarding_choice', 'onboard.completed', + 'onboard.skipped_pairing', 'onboard.saw_profile_setup', + 'backup.cadence', 'gesture_double_tap', 'band.strap_name', + 'health_sync', 'phone_steps', 'cycle_tracking_enabled', 'update_checks_enabled', + 'companion_url', 'nutrition.barcode_lookup', 'share.map_tiles', 'dev.mode', + 'coach_api', 'coach_base_url', 'coach_model', 'coach_timeout_seconds', + 'coach_use_chatgpt', + 'ai_morning_enabled', 'ai_morning_min', 'ai_evening_enabled', 'ai_evening_min', + 'ai_journal_enabled', 'ai_journal_min', + 'workout.zone_alert_enabled', 'workout.zone_alert_target_zone', 'workout.rpe_skips', + 'notif_health', 'notif_recovery', 'notif_reminders', 'notif_device', + 'notif_quiet_enabled', 'notif_quiet_start', 'notif_quiet_end', + 'notif_critical_override', 'notif_water', 'notif_water_interval', + 'notif_auto_detect', 'notif_nap_detect', 'notif_movement', 'notif_meds', + 'notif_checkin', 'notif_battery_pct', 'notif_stepgoal', 'notif_winddown', + 'notif_alarm_latch_failed', 'notif_alarm_night_check', + 'notif_relay_enabled', 'notif_relay_packages', + 'ui.shell_tab.v2', 'ui.recap_range', 'ui.workouts_range', + 'ui.wearable_show_estimates', 'ui.wearable_side_by_side', + 'rough_night.ask', 'rough_night.dismissed', + // Existing history/dedupe state travels to avoid repeated alerts/imports. + 'ai.journal_done_day', 'last_recovery_notif_day', 'last_stepgoal_day', + 'last_inactivity_ms', 'last_staleness_notified_ms', 'notif_relay_seen', + 'health_auto_import_last_ms', 'health_auto_import_workouts', + 'health_import_profile_at', 'health_import_workouts_at', + 'health_workout_import_deleted', 'phone_steps_healthstore_purged', + 'whoop_import_deleted_workouts', 'issue_reports_sent', + 'device_alerts.last_charge_event_ts', 'device_alerts.last_charge_wall_sec', + 'device_alerts.charge_shown_day', 'device_alerts.low_armed', + // One concrete member of each dynamic family below. + 'notif_fired:2026-09-21:low_read', + 'notif_osid:health:2026-09-21:low_read', 'notif_osslot:health:0', + 'notif_osnext:health', 'ui.metric_tab.sleep', + 'nudge.discord.dismissed', 'nudge.discord.last_shown_ms', + 'ai.briefing.morning', + // Serial-scoped ECG safety guards must survive so reconnect still cleans up + // a possibly active publisher. These are separate from alarm arm receipts. + 'ecg.guard.MG123', 'ecg.wrist.MG123', 'ecg.maverick.MG123', + 'future.setting', +}; + +const _mustStay = { + 'install_device_id', 'paired_remote_id', 'paired_serial', 'paired_generation', + 'paired_worn_on', 'ble.ask_add_pending', + 'consent_telemetry', 'consent_health_data', 'consent_chosen', + 'coach_api_key_present', 'coach_chatgpt_present', 'coach_chatgpt_client_id', + 'coach_chatgpt_host_id', 'coach_chatgpt_welcomed', + 'backup.folder', 'backup.last_run_ms', 'backup.last_error', 'health_upload_last_ts', + 'alarm_epoch', 'alarm_epoch_confirmed', 'alarm_set_at_ms', + 'alarm_pending_until', 'alarm_latch_alert', 'alarm_probe_pending', 'alarm_fired_at', + 'live.session_draft', 'live_session_checkpoint', 'telemetry_outbox', +}; + +// Interpolated keys, including ones hidden behind key-building helpers. +// Notification dedupe/allocation survives to avoid replaying alerts; UI choices +// and community-ask dismissal/cooldown are user preferences. metricTab has no +// current writer, but its public key builder is covered for future callers. +const _dynamicExamples = { + r'ai.briefing.${p.id}': 'ai.briefing.morning', + r'ecg.guard.$serial': 'ecg.guard.MG123', + r'ecg.wrist.$serial': 'ecg.wrist.MG123', + r'ecg.maverick.$serial': 'ecg.maverick.MG123', + r'$_prefix$dedupeKey': 'notif_fired:2026-09-21:low_read', + r'$_prefix$k': 'notif_fired:2026-09-21:low_read', + r'$_kSlot$cat:${e.dedupeKey}': 'notif_osid:health:2026-09-21:low_read', + r'$_kOwner$cat:$slot': 'notif_osslot:health:0', + r'$_kNext$cat': 'notif_osnext:health', + r'ui.metric_tab.$metric': 'ui.metric_tab.sleep', + r'nudge.${a.name}.dismissed': 'nudge.discord.dismissed', + r'nudge.${a.name}.last_shown_ms': 'nudge.discord.last_shown_ms', +}; + +// Runtime arguments and aliased constants the literal scanner cannot resolve. +// Empty lists are delegating writers, audited at their owning call sites. +const _indirectKeys = >{ + 'lib/ai/briefing.dart:_slotKey(b.period)': ['ai.briefing.morning'], + 'lib/ecg/ecg_guard_store.dart:guardKey(serial)': ['ecg.guard.MG123'], + 'lib/ecg/ecg_guard_store.dart:wristKey(serial)': ['ecg.wrist.MG123'], + 'lib/ecg/ecg_guard_store.dart:maverickKey(serial)': ['ecg.maverick.MG123'], + 'lib/data/backup_extras.dart:key': [], // generic restore of backup_prefs + 'lib/state/prefs.dart:key': [], // generic SharedPreferences facade + 'lib/notify/device_alerts.dart:key': [ + 'device_alerts.last_charge_event_ts', 'device_alerts.last_charge_wall_sec', + 'device_alerts.charge_shown_day', 'device_alerts.low_armed'], + 'lib/notify/notification_center.dart:prefsKey': [ + 'last_recovery_notif_day', 'last_stepgoal_day'], + 'lib/health/health_import_state.dart:what.key': [ + 'health_import_profile_at', 'health_import_workouts_at'], + 'lib/notify/notification_prefs.dart:_kBatteryPct': ['notif_battery_pct'], + 'lib/state/app_state.dart:_kHealthSync': ['health_sync'], + 'lib/sync/paired_device.dart:_kWornOn': ['paired_worn_on'], + 'lib/sync/paired_device.dart:key': [ + 'paired_remote_id', 'paired_serial', 'paired_generation'], + 'lib/ui2/onboarding/pairing.dart:key': [ + 'onboard.skipped_pairing', 'onboard.saw_profile_setup'], + 'lib/ui2/nudges.dart:_lastShownKey(a)': ['nudge.discord.last_shown_ms'], + 'lib/ui2/nudges.dart:_dismissedKey(a)': ['nudge.discord.dismissed'], +}; + Map _constants(String text) => { for (final m in RegExp(r'''(?:const|final)\s+(?:String\s+)?(\w+)\s*=\s*['"]([^'"\n]+)['"]''').allMatches(text)) m.group(1)!: m.group(2)!, @@ -25,40 +129,89 @@ String? _key(String expr, String text) { void main() { final lib = Directory('lib').listSync(recursive: true).whereType() .where((f) => f.path.endsWith('.dart')).toList(); - final writes = RegExp(r'''\.(setString|setStringList|setBool|setBoolAcked|setInt|setDouble)\(\s*('[^']*'|"[^"]*"|[\w.]+)\s*,'''); + final writes = RegExp(r'''\.(setString|setStringList|setBool|setBoolAcked|setInt|setDouble)\(\s*('[^']*'|"[^"]*"|[\w.]+(?:\([^()]*\))?)\s*,'''); late BackupEnv env; setUpAll(() async => env = await BackupEnv.create('bkx_audit_test.db')); tearDownAll(() async => env.dispose()); - test('every written preference is carried or excluded with a reason', () async { + test('every written key has an independent policy, including dynamic families', () { final keys = {}; + final indirect = {}; for (final f in lib) { final text = f.readAsStringSync(); for (final m in writes.allMatches(text)) { final key = _key(m.group(2)!, text); - if (key != null && !key.contains(r'$')) keys.add(key); + if (key == null) { + final owner = '${f.path}:${m.group(2)}'; + expect(_indirectKeys, contains(owner), reason: 'unresolved writer $owner'); + indirect.add(owner); + keys.addAll(_indirectKeys[owner]!); + } else if (key.contains(r'$')) { + expect(_dynamicExamples, contains(key), reason: 'uncovered key family $key'); + keys.add(_dynamicExamples[key]!); + } else { + keys.add(key); + } } } + expect(indirect, _indirectKeys.keys.toSet(), reason: 'stale indirect-key audit'); expect(keys, containsAll(['theme_choice', 'backup.cadence', 'coach_model', - 'local_profile_json', 'consent_chosen']), reason: 'scanner positive control'); - final denied = policyMap('kPrefsNotBackedUp'); + 'local_profile_json', 'consent_chosen', 'nudge.discord.dismissed']), + reason: 'scanner positive control'); + expect(keys.difference({..._mustTravel, ..._mustStay}), isEmpty, + reason: 'new preference needs a policy decision in this test'); + final all = lib.map((f) => f.readAsStringSync()).join('\n'); + for (final e in _dynamicExamples.entries) { + expect(all, contains(e.key), reason: 'stale dynamic family ${e.key}'); + expect(_mustTravel, contains(e.value), reason: e.key); + } + }); + + test('required user settings and dynamic keys actually travel', () async { + await env.wipe(); + await setPrefs({for (final key in {..._mustTravel, ..._mustStay}) key: 'audit value'}); + final prefs = (await exportedPrefs(await LocalDb.exportCopy()))!; + for (final key in _mustTravel) { + expect(prefs[key], ('string', 'audit value'), reason: '$key must travel'); + } + }); + + test('device and transient keys never travel, including foreign backups', () async { await env.wipe(); - await setPrefs({for (final key in keys) key: 'audit value'}); + await setPrefs({for (final key in {..._mustTravel, ..._mustStay}) key: 'audit value'}); final path = await LocalDb.exportCopy(); - final prefs = await exportedPrefs(path); - expect(prefs, isNotNull); - expect(prefs!.keys.toSet(), keys.difference(denied.keys.toSet())); + final prefs = (await exportedPrefs(path))!; + for (final key in _mustStay) { + expect(prefs, isNot(contains(key)), reason: '$key stays on the phone'); + } + // Even an old or foreign export that carries blocked state cannot inject it. + final copy = await databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions(singleInstance: false)); + for (final key in _mustStay) { + await copy.insert('backup_prefs', + {'key': key, 'type': 'string', 'value': 'foreign state'}, + conflictAlgorithm: ConflictAlgorithm.replace); + } + await copy.close(); + await env.wipe(); + final restored = await LocalDb.importFromDbFile(path); + expect(restored.keysWritten, _mustTravel); + expect((await allPrefs()).keys.toSet(), _mustTravel); }); test('denylisted keys are real and each has a reason', () { final all = lib.where((f) => f.path != 'lib/data/backup_extras.dart') .map((f) => f.readAsStringSync()).join('\n'); final denied = policyMap('kPrefsNotBackedUp'); + expect(denied.keys.toSet(), _mustStay, reason: 'exclusions need an explicit test policy'); expect(denied.keys, containsAll(['install_device_id', 'consent_chosen', 'consent_telemetry', 'consent_health_data', 'coach_api_key_present'])); for (final e in denied.entries) { expect(e.value.length, greaterThan(10), reason: e.key); - expect(all, contains("'${e.key}'"), reason: '${e.key} is a stale exclusion'); + // This legacy probe is explicitly blocked on foreign/older backups too. + if (e.key != 'alarm_probe_pending') { + expect(all, contains("'${e.key}'"), reason: '${e.key} is a stale exclusion'); + } } }); diff --git a/test/backup/settings_complete_test.dart b/test/backup/settings_complete_test.dart index 48081af01..3521e3505 100644 --- a/test/backup/settings_complete_test.dart +++ b/test/backup/settings_complete_test.dart @@ -2,14 +2,37 @@ // gaps the audit found: backup.cadence swept up by a prefix, consent state half // restored, and a coach key lost without a word. +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/test/test_flutter_secure_storage_platform.dart'; +// The secure-storage plugin exposes its platform test seam in this dependency. +// ignore: depend_on_referenced_packages +import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/state/prefs.dart'; +import 'package:openstrap_edge/ui2/activity/live.dart'; +import 'package:openstrap_edge/ui2/activity/catalogue.dart'; +import 'package:openstrap_edge/state/alarm_schedule.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; import 'backup_support.dart'; +class _ReadKeychain extends TestFlutterSecureStoragePlatform { + _ReadKeychain(this.readKey) : super({}); + final Future Function() readKey; + @override + Future read({required String key, + required Map options}) => readKey(); +} + void main() { late BackupEnv env; setUpAll(() async => env = await BackupEnv.create('bkx_complete_test.db')); @@ -52,6 +75,170 @@ void main() { expect(await allPrefs(), {'theme_choice': 'light', 'backup.cadence': 'weekly'}); }); + test('set up without history: device values win and absent keys restore', + () async { + await wipeAll(); + await setPrefs({'theme_choice': 'dark', 'backup.cadence': 'weekly'}); + final path = await LocalDb.exportCopy(); + for (final setup in >[ + {'onboard.completed': true}, + {'onboarding_choice': 'new'}, + {'local_profile_json': '{"name":"Owner"}'}, + ]) { + await wipeAll(); + await setPrefs({...setup, 'theme_choice': 'light'}); + final result = await LocalDb.importFromDbFile(path); + expect((await allPrefs())['theme_choice'], 'light', reason: '$setup'); + expect((await allPrefs())['backup.cadence'], 'weekly'); + expect(result.keysWritten, {'backup.cadence'}); + } + }); + + test('a successful settings-only import protects subsequent device edits', + () async { + await wipeAll(); + await setPrefs({'theme_choice': 'dark', 'backup.cadence': 'weekly'}); + final path = await LocalDb.exportCopy(); + await wipeAll(); + // True first run, including startup defaults: the backup wins all keys. + await setPrefs({'theme_choice': 'system', 'backup.cadence': 'daily'}); + final first = await LocalDb.importFromDbFile(path); + expect(first.keysWritten, {'theme_choice', 'backup.cadence'}); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('theme_choice', 'light'); + await prefs.remove('backup.cadence'); + await LocalDb.close(); // the import marker must outlive a process/DB reopen + final second = await LocalDb.importFromDbFile(path); + expect((await allPrefs())['theme_choice'], 'light'); + expect((await allPrefs())['backup.cadence'], 'weekly'); + expect(second.keysWritten, {'backup.cadence'}); + }); + + test('a failed restore cannot inherit another install successful-import marker', + () async { + await wipeAll(); + await setPrefs({'theme_choice': 'dark'}); + final original = await LocalDb.exportCopy(); + await wipeAll(); + await LocalDb.importFromDbFile(original); + final path = await LocalDb.exportCopy(); // contains the source's import marker + final copy = await databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions(singleInstance: false)); + await copy.delete('backup_prefs'); + await copy.insert('backup_prefs', + {'key': 'theme_choice', 'type': 'bool', 'value': 'invalid'}); + await copy.close(); + await wipeAll(); + await setPrefs({'theme_choice': 'system'}); + await expectLater(LocalDb.importFromDbFile(path), throwsFormatException); + expect(await LocalDb.getCursor('backup_settings_imported'), isNull, + reason: 'this phone has not successfully restored settings'); + }); + + test('a restored schedule writes its next alarm to the new band', () async { + await wipeAll(); + final now = DateTime(2026, 10, 20, 22); + final next = DateTime(2026, 10, 21, 7); + final epoch = next.millisecondsSinceEpoch ~/ 1000; + await LocalDb.setAlarmScheduleDay(weekday: 2, hour: 7, minute: 0, + enabled: true, smartWindowMinutes: 20); + await setPrefs({ + 'alarm_epoch': epoch, + 'alarm_epoch_confirmed': true, + 'alarm_set_at_ms': now.millisecondsSinceEpoch, + 'alarm_pending_until': ['$epoch', '${next.millisecondsSinceEpoch}'], + 'alarm_latch_alert': ['$epoch', '1'], + 'alarm_probe_pending': true, + 'alarm_fired_at': epoch - 86400, + }); + final path = await LocalDb.exportCopy(); + // Keep the restore-merge assertion separate from the arm dedupe regression. + final backedUp = await databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions(readOnly: true, singleInstance: false)); + final schedule = [for (final r in await backedUp.query('alarm_schedule')) + AlarmScheduleEntry.fromRow(r)]; + await backedUp.close(); + await wipeAll(); + await LocalDb.importFromDbFile(path); + final restoredSchedule = [for (final r in await LocalDb.alarmScheduleRows()) + AlarmScheduleEntry.fromRow(r)]; + expect(schedule, [const AlarmScheduleEntry(weekday: 2, hour: 7, minute: 0, + smartWindowMinutes: 20)]); + final prefs = await SharedPreferences.getInstance(); + final writes = []; + final engine = BleEngine(onRecord: (_, _) async {}, onState: (_) {}); + engine.debugInstallFakeLink(band: proto.BandProfile.gen4, + onWrite: (bytes) async { + final inner = proto.parseFrame(bytes, profile: proto.BandProfile.gen4)!.inner; + if (inner[2] == proto.Cmd.setAlarmTime) { + writes.add(inner[2]); + final reply = proto.buildFrame([ + 0x24, 0x94, proto.Cmd.setAlarmTime, inner[1], 0x01, + proto.AlarmStatus.validInputPattern, + ], profile: proto.BandProfile.gen4); + engine.debugProcessImmediateFrame( + proto.parseFrame(reply, profile: proto.BandProfile.gen4)!); + } + return true; + }); + final armed = await armNextScheduledOccurrence(engine: engine, + schedule: restoredSchedule, currentArmedEpoch: prefs.getInt('alarm_epoch'), now: now); + expect(writes, [proto.Cmd.setAlarmTime], reason: 'the new band needs an arm'); + expect(armed.epoch, epoch); + expect(armed.failed, isFalse); + expect(restoredSchedule, schedule, reason: 'the schedule must be restored too'); + expect((await allPrefs()).keys.where((k) => k.startsWith('alarm_')), isEmpty); + }); + + test('restored alarm weekdays fill gaps and keep this phone configuration', + () async { + await wipeAll(); + await LocalDb.setAlarmScheduleDay(weekday: 0, hour: 7, minute: 0, enabled: true); + await LocalDb.setAlarmScheduleDay(weekday: 1, hour: 8, minute: 15, enabled: true); + final path = await LocalDb.exportCopy(); + await wipeAll(); + await LocalDb.setAlarmScheduleDay(weekday: 0, hour: 9, minute: 30, enabled: false); + await LocalDb.importFromDbFile(path); + final restored = [for (final r in await LocalDb.alarmScheduleRows()) + AlarmScheduleEntry.fromRow(r)]; + expect(restored, [ + const AlarmScheduleEntry(weekday: 0, hour: 9, minute: 30, enabled: false), + const AlarmScheduleEntry(weekday: 1, hour: 8, minute: 15), + ]); + await LocalDb.importFromDbFile(path); + expect([for (final r in await LocalDb.alarmScheduleRows()) + AlarmScheduleEntry.fromRow(r)], restored); + }); + + test('export during a private workout leaves drafts and recovery state local', + () async { + await wipeAll(); + await setPrefs({'theme_choice': 'dark', + 'live_session_checkpoint': '{"runs":[[1790000000,1790000060,123]]}', + 'telemetry_outbox': '[{"message":"pending private diagnostics"}]', + }); + await Prefs.reload(); + final draft = LiveDraft.begin(activityByName('running')!, private: true); + addTearDown(LiveDraft.debugForget); + draft.put('private_note', 'PRIVATE-WORKOUT-ENTRY'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('live.session_draft'), contains('PRIVATE-WORKOUT-ENTRY')); + final path = await LocalDb.exportCopy(); + expect((await exportedPrefs(path))!.keys, ['theme_choice']); + expectNoSecretIn(path, ['PRIVATE-WORKOUT-ENTRY', 'pending private diagnostics']); + // Taking a backup must leave the running session intact on this phone. + expect(LiveDraft.current!.private, isTrue); + expect(LiveDraft.current!.data['private_note'], 'PRIVATE-WORKOUT-ENTRY'); + LiveDraft.debugForget(); + await wipeAll(); + await Prefs.reload(); + await LocalDb.importFromDbFile(path); + await Prefs.reload(); + expect(LiveDraft.current, isNull); + expect(await allPrefs(), {'theme_choice': 'dark'}); + LiveDraft.debugForget(); + }); + test('consent is asked again as a whole: no half-restored consent state', () async { await wipeAll(); @@ -86,6 +273,57 @@ void main() { expect(result, needsReentry(['coach_api_key'])); }); + test('an unreadable coach key is not reported as missing, and can be retried', + () async { + await wipeAll(); + await setPrefs({'coach_api_key_present': true}); + final path = await LocalDb.exportCopy(); + await wipeAll(); + FlutterSecureStoragePlatform.instance = _ReadKeychain(() async { + throw PlatformException(code: 'keychain_locked'); + }); + addTearDown(() => FlutterSecureStorage.setMockInitialValues({})); + final failed = await LocalDb.importFromDbFile(path); + expect(failed.needsReentry, isEmpty, reason: 'a failed read proves no absence'); + expect(failed.unreadableSecrets, ['coach_api_key']); + FlutterSecureStorage.setMockInitialValues({'coach_api_key': ''}); + final empty = await LocalDb.importFromDbFile(path); + expect(empty.needsReentry, ['coach_api_key']); + expect(empty.unreadableSecrets, isEmpty); + FlutterSecureStorage.setMockInitialValues({'coach_api_key': 'sk-on-this-phone'}); + final retried = await LocalDb.importFromDbFile(path); + expect(retried.needsReentry, isEmpty); + expect(retried.unreadableSecrets, isEmpty); + }); + + test('a timed-out coach key read is not reported as missing', () async { + await wipeAll(); + await setPrefs({'coach_api_key_present': true}); + final path = await LocalDb.exportCopy(); + await wipeAll(); + final read = Completer(); + FlutterSecureStoragePlatform.instance = _ReadKeychain(() => read.future); + addTearDown(() => FlutterSecureStorage.setMockInitialValues({})); + final time = FakeAsync(); + final timeoutReady = Completer(); + final result = runZoned(() => LocalDb.importFromDbFile(path), + zoneSpecification: ZoneSpecification(createTimer: (self, parent, zone, + duration, callback) { + if (duration == const Duration(seconds: 6)) { + final timer = time.run((_) => Timer(duration, callback)); + timeoutReady.complete(); + return timer; + } + return parent.createTimer(zone, duration, callback); + })); + await timeoutReady.future; + time.elapse(const Duration(seconds: 6)); + final timedOut = await result; + expect(timedOut.needsReentry, isEmpty, reason: 'timeout is not an empty key'); + expect(timedOut.unreadableSecrets, ['coach_api_key']); + read.complete('sk-still-on-this-phone'); + }); + test('ChatGPT credentials and phone registration stay on the phone', () async { await wipeAll(); FlutterSecureStorage.setMockInitialValues({'coach_chatgpt_session': 'SECRET-SESSION'}); diff --git a/test/backup/settings_paths_test.dart b/test/backup/settings_paths_test.dart index 8101c87fc..5fa46f27c 100644 --- a/test/backup/settings_paths_test.dart +++ b/test/backup/settings_paths_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:notification_listener_service/notification_event.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/auto_backup.dart'; import 'package:openstrap_edge/data/db.dart'; @@ -32,6 +33,7 @@ void main() { late BackupEnv env; setUpAll(() async => env = await BackupEnv.create('bkx_paths_test.db')); tearDownAll(() async => env.dispose()); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); test('automatic backups carry settings and gzip import restores them', () async { await env.wipe(); From 5d9960b94b4b5c96b1aae7ae6cd5fdea7aa1bd20 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Fri, 6 Nov 2026 18:57:44 -0700 Subject: [PATCH 04/15] fix(backup): a custom coach server address stays on the phone A custom address can carry an access key, so coach_base_url is no longer exported. A non-default address on the old phone is reported after import so the user can enter it again; the default OpenAI address asks for nothing. --- lib/data/backup_extras.dart | 14 ++++++++ lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/onboarding/welcome.dart | 29 +++++++++++------ test/backup/settings_audit_guard_test.dart | 4 +-- test/backup/settings_complete_test.dart | 37 ++++++++++++++++++++++ 12 files changed, 81 insertions(+), 11 deletions(-) diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index 8b3dfaf1f..cf5762751 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -8,6 +8,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; +import '../coach/coach_config.dart' show CoachConfig; + const Map kPrefsNotBackedUp = { 'install_device_id': 'identifies this install; a restore must not clone it', 'paired_remote_id': 'BLE address bonded to this phone; pair again', @@ -25,6 +27,8 @@ const Map kPrefsNotBackedUp = { 'a new sign-in registers its own client', 'coach_chatgpt_host_id': 'identifies this phone to the sign-in service', 'coach_chatgpt_welcomed': 'the new sign-in must show its welcome on this phone', + 'coach_base_url': 'a custom server address can carry an access key; ' + 'a non-default one is reported for re-entry', 'backup.folder': 'storage-access URI permission belongs to this phone', 'backup.last_run_ms': 'status of this install\'s automatic backup', 'backup.last_error': 'status of this install\'s automatic backup', @@ -103,6 +107,10 @@ Future writeBackupPrefs(String path) async { if (prefs.get('coach_api_key_present') == true) { await tx.insert('backup_secrets', {'key': 'coach_api_key'}); } + final server = prefs.get('coach_base_url'); + if (server is String && server.isNotEmpty && server != CoachConfig.defaultBaseUrl) { + await tx.insert('backup_secrets', {'key': 'coach_base_url'}); + } }); } finally { await copy.close(); @@ -173,6 +181,12 @@ Future restoreBackupPrefs(String path, Map counts, { unreadable.add('coach_api_key'); } } + if (await _hasTable(src, 'backup_secrets') && + (await src.query('backup_secrets', where: 'key = ?', + whereArgs: ['coach_base_url'])).isNotEmpty && + !(await SharedPreferences.getInstance()).containsKey('coach_base_url')) { + needs.add('coach_base_url'); + } return ImportSummary(counts, keysWritten: Set.unmodifiable(written), needsReentry: List.unmodifiable(needs), unreadableSecrets: List.unmodifiable(unreadable)); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 72f545998..f20189db6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3474,6 +3474,7 @@ } }, "welcomeCoachKeyReentry": "Dein API-Schlüssel für den Coach bleibt im sicheren Speicher und wurde nicht gesichert. Gib ihn in den KI-Einstellungen erneut ein, um den Coach auf diesem Telefon zu nutzen.", + "welcomeCoachServerReentry": "Deine eigene Coach-Serveradresse wurde nicht gesichert, weil eine Adresse einen Schlüssel enthalten kann. Gib sie in den KI-Einstellungen erneut ein.", "welcomeSettingsRestored": "{n} Einstellungen wiederhergestellt", "@welcomeSettingsRestored": { "description": "Import report: number of restored app preferences", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4e559e147..b5c5dc343 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16333,6 +16333,7 @@ } }, "welcomeCoachKeyReentry": "Your coach API key stays in secure storage and was not backed up. Enter it again in AI settings to use the coach on this phone.", + "welcomeCoachServerReentry": "Your custom coach server address was not backed up, because an address can hold a key. Enter it again in AI settings.", "welcomeSettingsRestored": "{n} settings restored", "@welcomeSettingsRestored": { "description": "Import report: number of restored app preferences", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f65c93663..df1555a4e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2892,6 +2892,7 @@ } }, "welcomeCoachKeyReentry": "Tu clave de API del asistente permanece en el almacenamiento seguro y no se incluyó en la copia. Vuelve a introducirla en los ajustes de IA para usar el asistente en este teléfono.", + "welcomeCoachServerReentry": "La dirección de tu servidor personalizado del asistente no se incluyó en la copia, porque una dirección puede contener una clave. Vuelve a introducirla en los ajustes de IA.", "welcomeSettingsRestored": "{n} ajustes restaurados", "@welcomeSettingsRestored": { "description": "Import report: number of restored app preferences", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d269d287e..9f148c371 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2863,6 +2863,7 @@ } }, "welcomeCoachKeyReentry": "Votre clé API du coach reste dans le stockage sécurisé et n’a pas été sauvegardée. Saisissez-la à nouveau dans les réglages de l’IA pour utiliser le coach sur ce téléphone.", + "welcomeCoachServerReentry": "L’adresse de votre serveur de coach personnalisé n’a pas été sauvegardée, car une adresse peut contenir une clé. Saisissez-la à nouveau dans les réglages de l’IA.", "welcomeSettingsRestored": "{n} réglages restaurés", "@welcomeSettingsRestored": { "description": "Import report: number of restored app preferences", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 1e3c59691..6139c610f 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2863,6 +2863,7 @@ } }, "welcomeCoachKeyReentry": "आपके कोच की API कुंजी सुरक्षित स्टोरेज में रहती है और बैकअप में शामिल नहीं की गई। इस फ़ोन पर कोच इस्तेमाल करने के लिए इसे AI सेटिंग में फिर से दर्ज करें।", + "welcomeCoachServerReentry": "आपका कस्टम कोच सर्वर पता बैकअप नहीं हुआ, क्योंकि पते में कुंजी हो सकती है। इसे AI सेटिंग्स में फिर से दर्ज करें।", "welcomeSettingsRestored": "{n} सेटिंग बहाल हुईं", "@welcomeSettingsRestored": { "description": "Import report: number of restored app preferences", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5efccbf04..ed03fab8c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7906,6 +7906,7 @@ } }, "welcomeCoachKeyReentry": "API-ключ тренера остаётся в защищённом хранилище и не включён в резервную копию. Введите его снова в настройках ИИ, чтобы пользоваться тренером на этом телефоне.", + "welcomeCoachServerReentry": "Адрес вашего собственного сервера коуча не сохранён в резервной копии, потому что адрес может содержать ключ. Введите его снова в настройках ИИ.", "welcomeSettingsRestored": "Восстановлено настроек: {n}", "@welcomeSettingsRestored": { "description": "Import report: number of restored app preferences", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 452a350ed..8b4eb956e 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16327,6 +16327,7 @@ } }, "welcomeCoachKeyReentry": "คีย์ API ของโค้ชยังอยู่ในพื้นที่จัดเก็บที่ปลอดภัยและไม่ได้รวมอยู่ในข้อมูลสำรอง โปรดป้อนคีย์อีกครั้งในการตั้งค่า AI เพื่อใช้โค้ชบนโทรศัพท์เครื่องนี้", + "welcomeCoachServerReentry": "ที่อยู่เซิร์ฟเวอร์โค้ชที่คุณกำหนดเองไม่ได้ถูกสำรองไว้ เพราะที่อยู่อาจมีคีย์อยู่ด้วย ป้อนอีกครั้งในการตั้งค่า AI", "welcomeSettingsRestored": "กู้คืนการตั้งค่าแล้ว {n} รายการ", "@welcomeSettingsRestored": { "description": "Import report: number of restored app preferences", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c9384cd30..44312a9bf 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2863,6 +2863,7 @@ } }, "welcomeCoachKeyReentry": "教练的 API 密钥保留在安全存储中,未包含在备份内。请在 AI 设置中重新输入,以便在此手机上使用教练。", + "welcomeCoachServerReentry": "你的自定义教练服务器地址没有备份,因为地址中可能包含密钥。请在 AI 设置中重新输入。", "welcomeSettingsRestored": "已恢复 {n} 项设置", "@welcomeSettingsRestored": { "description": "Import report: number of restored app preferences", diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index 1159e11ca..fce877af0 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -756,13 +756,24 @@ class ImportReport extends StatelessWidget { final ImportOutcome o; const ImportReport(this.o, {super.key}); - Widget _keyReentry(AppLocalizations? l) => StatusCard( - l?.coachApiKeyLabel ?? 'API key', - l?.welcomeCoachKeyReentry ?? - 'Your coach API key stays in secure storage and was not backed up. ' - 'Enter it again in AI settings to use the coach on this phone.', - icon: LucideIcons.keyRound, - ); + Widget _keyReentry(AppLocalizations? l, List keys) => + Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (keys.contains('coach_api_key')) StatusCard( + l?.coachApiKeyLabel ?? 'API key', + l?.welcomeCoachKeyReentry ?? + 'Your coach API key stays in secure storage and was not backed up. ' + 'Enter it again in AI settings to use the coach on this phone.', + icon: LucideIcons.keyRound, + ), + if (keys.length > 1) const SizedBox(height: S.x3), + if (keys.contains('coach_base_url')) StatusCard( + l?.coachBaseUrlLabel ?? 'Base URL', + l?.welcomeCoachServerReentry ?? + 'Your custom coach server address was not backed up, because an ' + 'address can hold a key. Enter it again in AI settings.', + icon: LucideIcons.keyRound, + ), + ]); @override Widget build(BuildContext c) { @@ -802,7 +813,7 @@ class ImportReport extends StatelessWidget { return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ nothing, const SizedBox(height: S.x3), - if (o.needsReentry.isNotEmpty) _keyReentry(l), + if (o.needsReentry.isNotEmpty) _keyReentry(l, o.needsReentry), if (o.archiveBucketsSkipped > 0) StatusCard( l?.welcomePartOfFileNotUsedTitle ?? 'Part of that file could not be used', @@ -877,7 +888,7 @@ class ImportReport extends StatelessWidget { ), if (o.needsReentry.isNotEmpty) ...[ const SizedBox(height: S.x3), - _keyReentry(l), + _keyReentry(l, o.needsReentry), ], // REJECTED, never clamped. A row outside its declared range is not // salvageable by trimming it — that would store a value the user never diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart index 488a6e090..d70cf6a67 100644 --- a/test/backup/settings_audit_guard_test.dart +++ b/test/backup/settings_audit_guard_test.dart @@ -17,7 +17,7 @@ const _mustTravel = { 'backup.cadence', 'gesture_double_tap', 'band.strap_name', 'health_sync', 'phone_steps', 'cycle_tracking_enabled', 'update_checks_enabled', 'companion_url', 'nutrition.barcode_lookup', 'share.map_tiles', 'dev.mode', - 'coach_api', 'coach_base_url', 'coach_model', 'coach_timeout_seconds', + 'coach_api', 'coach_model', 'coach_timeout_seconds', 'coach_use_chatgpt', 'ai_morning_enabled', 'ai_morning_min', 'ai_evening_enabled', 'ai_evening_min', 'ai_journal_enabled', 'ai_journal_min', @@ -58,7 +58,7 @@ const _mustStay = { 'paired_worn_on', 'ble.ask_add_pending', 'consent_telemetry', 'consent_health_data', 'consent_chosen', 'coach_api_key_present', 'coach_chatgpt_present', 'coach_chatgpt_client_id', - 'coach_chatgpt_host_id', 'coach_chatgpt_welcomed', + 'coach_chatgpt_host_id', 'coach_chatgpt_welcomed', 'coach_base_url', 'backup.folder', 'backup.last_run_ms', 'backup.last_error', 'health_upload_last_ts', 'alarm_epoch', 'alarm_epoch_confirmed', 'alarm_set_at_ms', 'alarm_pending_until', 'alarm_latch_alert', 'alarm_probe_pending', 'alarm_fired_at', diff --git a/test/backup/settings_complete_test.dart b/test/backup/settings_complete_test.dart index 3521e3505..92a6e47a4 100644 --- a/test/backup/settings_complete_test.dart +++ b/test/backup/settings_complete_test.dart @@ -273,6 +273,43 @@ void main() { expect(result, needsReentry(['coach_api_key'])); }); + test('a custom coach server does not travel and is reported for re-entry', + () async { + await wipeAll(); + const server = 'https://llm.example.net/v1?key=sk-IN-THE-URL'; + await setPrefs({'coach_base_url': server, 'coach_model': 'm'}); + await env.seedData(); + final path = await LocalDb.exportCopy(); + expectNoSecretIn(path, ['sk-IN-THE-URL', 'llm.example.net']); + + await wipeAll(); + final result = await LocalDb.importFromDbFile(path); + final now = await allPrefs(); + expect(now['coach_model'], 'm'); + expect(now.containsKey('coach_base_url'), isFalse); + expect(result.needsReentry, ['coach_base_url']); + }); + + test('the default coach server asks for nothing after import', () async { + await wipeAll(); + await setPrefs({'coach_base_url': 'https://api.openai.com/v1'}); + final path = await LocalDb.exportCopy(); + await wipeAll(); + final result = await LocalDb.importFromDbFile(path); + expect(result.needsReentry, isEmpty); + }); + + test('a server already set on this phone is not asked for again', () async { + await wipeAll(); + await setPrefs({'coach_base_url': 'https://old.example.net/v1'}); + final path = await LocalDb.exportCopy(); + await wipeAll(); + await setPrefs({'coach_base_url': 'https://new.example.net/v1'}); + final result = await LocalDb.importFromDbFile(path); + expect((await allPrefs())['coach_base_url'], 'https://new.example.net/v1'); + expect(result.needsReentry, isEmpty); + }); + test('an unreadable coach key is not reported as missing, and can be retried', () async { await wipeAll(); From 39adde96379199d7d80d5fd75e690562fa34b830 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Fri, 6 Nov 2026 19:28:50 -0700 Subject: [PATCH 05/15] fix(backup): a rebuild keeps the alarm schedule; settings adoption is its own step The alarm schedule now travels in a restore, so the corrupt-database salvage saves it too. The settings reload after an import moves into _adoptRestoredSettings, keeping importEdgeBackup short. --- lib/data/db.dart | 2 ++ lib/state/app_state.dart | 44 ++++++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 4673e4cd7..b888953df 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -293,6 +293,8 @@ class LocalDb { 'cycle_symptom', 'sleep_override', 'sleep_nap', + // The alarm schedule the user chose; a rebuild re-arms it like a restore. + 'alarm_schedule', // A ring's own hypnogram. Nothing here can re-derive it. 'vendor_sleep_epoch', 'workout_suggestions', diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index aaca3ddf6..e87d41160 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -734,6 +734,29 @@ class AppState extends ChangeNotifier { List lastImportNeedsReentry = const []; int lastImportPrefsRestored = 0; + /// Settings an import wrote take effect now, without a restart. + Future _adoptRestoredSettings(ImportSummary counts) async { + lastImportNeedsReentry = counts.needsReentry; + lastImportPrefsRestored = counts.keysWritten.length; + if (counts.keysWritten.isEmpty) return; + await Prefs.reload(); + // Keep the active import on its report until Continue is chosen. + await _loadProfile(loadOnboardChoice: false); + final prefs = await SharedPreferences.getInstance(); + CompanionClient.overrideUrl = prefs.getString(_kCompanionUrl); + await gestureSettings.bootstrap(); + await notificationRelay.reloadFromPrefs(); + _deviceAlerts.refreshThreshold(); + await armWaterReminder(); + try { + await coachConfig?.load(trusted: true) + .timeout(const Duration(seconds: 6)); + } catch (e) { + _log('[import] coach settings reload failed: $e'); + } + settingsRestoredSignal.value++; + } + Future importEdgeBackup(String path) async { lastImportNeedsReentry = const []; lastImportPrefsRestored = 0; @@ -750,26 +773,7 @@ class AppState extends ChangeNotifier { final ImportSummary counts; try { counts = await LocalDb.importFromDbFile(path); - lastImportNeedsReentry = counts.needsReentry; - lastImportPrefsRestored = counts.keysWritten.length; - if (counts.keysWritten.isNotEmpty) { - await Prefs.reload(); - // Keep the active import on its report until Continue is chosen. - await _loadProfile(loadOnboardChoice: false); - final prefs = await SharedPreferences.getInstance(); - CompanionClient.overrideUrl = prefs.getString(_kCompanionUrl); - await gestureSettings.bootstrap(); - await notificationRelay.reloadFromPrefs(); - _deviceAlerts.refreshThreshold(); - await armWaterReminder(); - try { - await coachConfig?.load(trusted: true) - .timeout(const Duration(seconds: 6)); - } catch (e) { - _log('[import] coach settings reload failed: $e'); - } - settingsRestoredSignal.value++; - } + await _adoptRestoredSettings(counts); await LocalDb.refitAllLiveStepGains(); } finally { invalidateLiveStepCalibration(); From 3d083966b580934046fc3a55c9cec619506f490c Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Fri, 6 Nov 2026 21:13:03 -0700 Subject: [PATCH 06/15] =?UTF-8?q?fix(backup):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20alarm=20reload,=20ChatGPT=20notice,=20skipped=20row?= =?UTF-8?q?s,=20reminders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Any import that brings alarm schedule rows reloads the schedule and re-arms when connected, with or without settings. - The old band's low-battery latch stays on the phone. - A blank custom coach server counts as missing for the re-entry notice. - The established-install check also counts user-edited tables. - A ChatGPT sign-in on the old phone is reported for sign-in on the new one (presence only; no tokens leave secure storage). - A malformed settings row is skipped and reported instead of stopping the restore; the rest of the settings still come back. - Restored reminder choices are rescheduled right away. - A restored value a controller cannot parse is logged and leaves the current value in place. --- lib/app.dart | 25 ++- lib/data/backup_extras.dart | 35 ++++- lib/data/db.dart | 5 +- lib/l10n/app_de.arb | 7 + lib/l10n/app_en.arb | 7 + lib/l10n/app_es.arb | 7 + lib/l10n/app_fr.arb | 7 + lib/l10n/app_hi.arb | 7 + lib/l10n/app_ru.arb | 7 + lib/l10n/app_th.arb | 7 + lib/l10n/app_zh.arb | 7 + lib/state/app_state.dart | 10 ++ lib/ui2/onboarding/welcome.dart | 30 +++- test/backup/settings_audit_guard_test.dart | 3 +- test/backup/settings_complete_test.dart | 164 +++++++++++++++++++- test/backup/settings_presentation_test.dart | 97 ++++++++++++ 16 files changed, 403 insertions(+), 22 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index c2cfef1a0..645789558 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -80,10 +80,27 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver void _onSettingsRestored() { if (!mounted) return; - unawaited(context.read().reloadFromPrefs()); - unawaited(context.read().reloadFromPrefs()); - unawaited(context.read().reloadFromPrefs()); - unawaited(context.read().reloadFromPrefs()); + unawaited(_reloadRestoredSetting('theme_choice', + context.read().reloadFromPrefs)); + unawaited(_reloadRestoredSetting('locale_override', + context.read().reloadFromPrefs)); + unawaited(_reloadRestoredSetting('units_system', + context.read().reloadFromPrefs)); + unawaited(_reloadRestoredSetting('clock_format', + context.read().reloadFromPrefs)); + } + + Future _reloadRestoredSetting( + String key, Future Function() reload) async { + try { + await reload(); + } catch (error, stack) { + // Controllers parse before replacing their value, so a malformed pref + // leaves the current/default choice active while other reloads continue. + debugPrint('[import] $key reload failed: $error'); + TelemetryService.instance.recordNonFatal(error, stack, + reason: 'restored $key reload failed'); + } } @override diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index cf5762751..9d3b3fe96 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -36,6 +36,8 @@ const Map kPrefsNotBackedUp = { 'live.session_draft': 'unfinished workout entries, including private sessions, stay local', 'live_session_checkpoint': 'write-ahead step recovery for this phone; replay could duplicate steps', 'telemetry_outbox': 'queued diagnostics belong to this install and its consent', + 'device_alerts.low_armed': 'low-battery latch belongs to the old band; ' + 'the new band must establish its own battery state', 'alarm_epoch': 'arm belongs to the old band; the restored schedule must arm again', 'alarm_epoch_confirmed': 'confirmation belongs to an arm on the old band', 'alarm_set_at_ms': 'confirmation replay floor for the old band arm', @@ -66,12 +68,15 @@ class ImportSummary extends MapView { this.keysWritten = const {}, this.needsReentry = const [], this.unreadableSecrets = const [], + this.refusedPrefs = const [], }); final Set keysWritten; final List needsReentry; /// Read failed or timed out; absence is unknown. A later import retries it. final List unreadableSecrets; + /// Rows in the backup that could not be read; skipped, the rest restored. + final List refusedPrefs; } bool _backedUp(String key) => !kPrefsNotBackedUp.containsKey(key); @@ -107,6 +112,9 @@ Future writeBackupPrefs(String path) async { if (prefs.get('coach_api_key_present') == true) { await tx.insert('backup_secrets', {'key': 'coach_api_key'}); } + if (prefs.get('coach_chatgpt_present') == true) { + await tx.insert('backup_secrets', {'key': 'coach_chatgpt'}); + } final server = prefs.get('coach_base_url'); if (server is String && server.isNotEmpty && server != CoachConfig.defaultBaseUrl) { await tx.insert('backup_secrets', {'key': 'coach_base_url'}); @@ -152,6 +160,7 @@ Future restoreBackupPrefs(String path, Map counts, { final src = await openDatabase(path, readOnly: true, singleInstance: false); try { final written = {}; + final refused = []; if (await _hasTable(src, 'backup_prefs')) { final prefs = await SharedPreferences.getInstance(); await prefs.reload(); @@ -160,11 +169,17 @@ Future restoreBackupPrefs(String path, Map counts, { if (!_backedUp(key) || (!freshInstall && prefs.containsKey(key))) { continue; } - // Bad rows fail visibly; storage refusing a write must not be - // reported as a successful settings restore. - if (!await _write(prefs, key, '${row['type']}', '${row['value']}')) { - throw StateError('Could not restore preference $key'); + // A malformed row is skipped and reported, so one bad value cannot + // block every setting after it. Storage refusing a write is a real + // failure and must not be reported as a successful restore. + final bool ok; + try { + ok = await _write(prefs, key, '${row['type']}', '${row['value']}'); + } on FormatException { + refused.add(key); + continue; } + if (!ok) throw StateError('Could not restore preference $key'); written.add(key); } } @@ -184,12 +199,20 @@ Future restoreBackupPrefs(String path, Map counts, { if (await _hasTable(src, 'backup_secrets') && (await src.query('backup_secrets', where: 'key = ?', whereArgs: ['coach_base_url'])).isNotEmpty && - !(await SharedPreferences.getInstance()).containsKey('coach_base_url')) { + ((await SharedPreferences.getInstance()).getString('coach_base_url') + ?.trim().isEmpty ?? true)) { needs.add('coach_base_url'); } + if (await _hasTable(src, 'backup_secrets') && + (await src.query('backup_secrets', where: 'key = ?', + whereArgs: ['coach_chatgpt'])).isNotEmpty && + (await SharedPreferences.getInstance()).get('coach_chatgpt_present') != true) { + needs.add('coach_chatgpt'); + } return ImportSummary(counts, keysWritten: Set.unmodifiable(written), needsReentry: List.unmodifiable(needs), - unreadableSecrets: List.unmodifiable(unreadable)); + unreadableSecrets: List.unmodifiable(unreadable), + refusedPrefs: List.unmodifiable(refused)); } finally { await src.close(); } diff --git a/lib/data/db.dart b/lib/data/db.dart index b888953df..a99eb2ca2 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11650,12 +11650,13 @@ class LocalDb { await getCursor(_kSettingsImported) != null) { return false; } - const candidates = [ + final candidates = { + ..._restoreEditedKeys.keys, 'decoded_onehz', 'decoded_rr', 'samples', 'day_result', 'observation', 'sessions', 'journal', 'substrate_archive', 'raw_archive', 'imported_workout', 'imported_measurement', 'lab_result', 'food_entry', 'med_dose', 'strength_set', 'cycle_log', 'breathing_session', 'sleep_nap', - ]; + }; // Some stores, including raw_archive, are created lazily. final tables = {for (final row in await db.rawQuery( "SELECT name FROM sqlite_master WHERE type = 'table'")) row['name']}; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f20189db6..efb3a86e8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3474,6 +3474,13 @@ } }, "welcomeCoachKeyReentry": "Dein API-Schlüssel für den Coach bleibt im sicheren Speicher und wurde nicht gesichert. Gib ihn in den KI-Einstellungen erneut ein, um den Coach auf diesem Telefon zu nutzen.", + "welcomeSettingsSkipped": "{n, plural, =1{1 Einstellung in der Sicherung war nicht lesbar und wurde übersprungen} other{{n} Einstellungen in der Sicherung waren nicht lesbar und wurden übersprungen}}", + "@welcomeSettingsSkipped": { + "description": "Import report: settings rows in the backup that could not be read", + "placeholders": {"n": {"type": "int"}} + }, + "welcomeSettingsSkippedBody": "Dafür bleiben die Werte dieses Telefons. Alles andere aus der Sicherung wurde wiederhergestellt.", + "welcomeCoachChatGptReentry": "Deine ChatGPT-Anmeldung bleibt im sicheren Speicher und wurde nicht gesichert. Melde dich in den KI-Einstellungen erneut an, um den Coach auf diesem Telefon zu nutzen.", "welcomeCoachServerReentry": "Deine eigene Coach-Serveradresse wurde nicht gesichert, weil eine Adresse einen Schlüssel enthalten kann. Gib sie in den KI-Einstellungen erneut ein.", "welcomeSettingsRestored": "{n} Einstellungen wiederhergestellt", "@welcomeSettingsRestored": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b5c5dc343..b36c6ebca 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16333,6 +16333,13 @@ } }, "welcomeCoachKeyReentry": "Your coach API key stays in secure storage and was not backed up. Enter it again in AI settings to use the coach on this phone.", + "welcomeSettingsSkipped": "{n, plural, =1{1 setting in the backup could not be read and was skipped} other{{n} settings in the backup could not be read and were skipped}}", + "@welcomeSettingsSkipped": { + "description": "Import report: settings rows in the backup that could not be read", + "placeholders": {"n": {"type": "int"}} + }, + "welcomeSettingsSkippedBody": "Those keep this phone's values. Everything else in the backup was restored.", + "welcomeCoachChatGptReentry": "Your ChatGPT sign-in stays in secure storage and was not backed up. Sign in again in AI settings to use the coach on this phone.", "welcomeCoachServerReentry": "Your custom coach server address was not backed up, because an address can hold a key. Enter it again in AI settings.", "welcomeSettingsRestored": "{n} settings restored", "@welcomeSettingsRestored": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index df1555a4e..5b40ec0bc 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2892,6 +2892,13 @@ } }, "welcomeCoachKeyReentry": "Tu clave de API del asistente permanece en el almacenamiento seguro y no se incluyó en la copia. Vuelve a introducirla en los ajustes de IA para usar el asistente en este teléfono.", + "welcomeSettingsSkipped": "{n, plural, =1{1 ajuste de la copia no se pudo leer y se omitió} other{{n} ajustes de la copia no se pudieron leer y se omitieron}}", + "@welcomeSettingsSkipped": { + "description": "Import report: settings rows in the backup that could not be read", + "placeholders": {"n": {"type": "int"}} + }, + "welcomeSettingsSkippedBody": "Esos conservan los valores de este teléfono. Todo lo demás de la copia se restauró.", + "welcomeCoachChatGptReentry": "Tu sesión de ChatGPT permanece en el almacenamiento seguro y no se incluyó en la copia. Vuelve a iniciar sesión en los ajustes de IA para usar el asistente en este teléfono.", "welcomeCoachServerReentry": "La dirección de tu servidor personalizado del asistente no se incluyó en la copia, porque una dirección puede contener una clave. Vuelve a introducirla en los ajustes de IA.", "welcomeSettingsRestored": "{n} ajustes restaurados", "@welcomeSettingsRestored": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9f148c371..aefabece6 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2863,6 +2863,13 @@ } }, "welcomeCoachKeyReentry": "Votre clé API du coach reste dans le stockage sécurisé et n’a pas été sauvegardée. Saisissez-la à nouveau dans les réglages de l’IA pour utiliser le coach sur ce téléphone.", + "welcomeSettingsSkipped": "{n, plural, =1{1 réglage de la sauvegarde était illisible et a été ignoré} other{{n} réglages de la sauvegarde étaient illisibles et ont été ignorés}}", + "@welcomeSettingsSkipped": { + "description": "Import report: settings rows in the backup that could not be read", + "placeholders": {"n": {"type": "int"}} + }, + "welcomeSettingsSkippedBody": "Ils gardent les valeurs de ce téléphone. Tout le reste de la sauvegarde a été restauré.", + "welcomeCoachChatGptReentry": "Votre connexion à ChatGPT reste dans le stockage sécurisé et n’a pas été sauvegardée. Connectez-vous à nouveau dans les réglages de l’IA pour utiliser le coach sur ce téléphone.", "welcomeCoachServerReentry": "L’adresse de votre serveur de coach personnalisé n’a pas été sauvegardée, car une adresse peut contenir une clé. Saisissez-la à nouveau dans les réglages de l’IA.", "welcomeSettingsRestored": "{n} réglages restaurés", "@welcomeSettingsRestored": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6139c610f..a7f5ff03f 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2863,6 +2863,13 @@ } }, "welcomeCoachKeyReentry": "आपके कोच की API कुंजी सुरक्षित स्टोरेज में रहती है और बैकअप में शामिल नहीं की गई। इस फ़ोन पर कोच इस्तेमाल करने के लिए इसे AI सेटिंग में फिर से दर्ज करें।", + "welcomeSettingsSkipped": "{n, plural, =1{बैकअप की 1 सेटिंग पढ़ी नहीं जा सकी और छोड़ दी गई} other{बैकअप की {n} सेटिंग्स पढ़ी नहीं जा सकीं और छोड़ दी गईं}}", + "@welcomeSettingsSkipped": { + "description": "Import report: settings rows in the backup that could not be read", + "placeholders": {"n": {"type": "int"}} + }, + "welcomeSettingsSkippedBody": "उनके लिए इस फ़ोन के मान बने रहेंगे। बैकअप का बाकी सब बहाल हो गया।", + "welcomeCoachChatGptReentry": "आपका ChatGPT साइन-इन सुरक्षित स्टोरेज में रहता है और बैकअप में शामिल नहीं किया गया। इस फ़ोन पर कोच इस्तेमाल करने के लिए AI सेटिंग में फिर से साइन इन करें।", "welcomeCoachServerReentry": "आपका कस्टम कोच सर्वर पता बैकअप नहीं हुआ, क्योंकि पते में कुंजी हो सकती है। इसे AI सेटिंग्स में फिर से दर्ज करें।", "welcomeSettingsRestored": "{n} सेटिंग बहाल हुईं", "@welcomeSettingsRestored": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ed03fab8c..bfe8caba0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7906,6 +7906,13 @@ } }, "welcomeCoachKeyReentry": "API-ключ тренера остаётся в защищённом хранилище и не включён в резервную копию. Введите его снова в настройках ИИ, чтобы пользоваться тренером на этом телефоне.", + "welcomeSettingsSkipped": "{n, plural, =1{1 настройку из резервной копии не удалось прочитать, она пропущена} one{{n} настройку из резервной копии не удалось прочитать, она пропущена} few{{n} настройки из резервной копии не удалось прочитать, они пропущены} many{{n} настроек из резервной копии не удалось прочитать, они пропущены} other{{n} настройки из резервной копии не удалось прочитать, они пропущены}}", + "@welcomeSettingsSkipped": { + "description": "Import report: settings rows in the backup that could not be read", + "placeholders": {"n": {"type": "int"}} + }, + "welcomeSettingsSkippedBody": "Для них остаются значения этого телефона. Всё остальное из резервной копии восстановлено.", + "welcomeCoachChatGptReentry": "Данные входа в ChatGPT остаются в защищённом хранилище и не включены в резервную копию. Войдите снова в настройках ИИ, чтобы пользоваться тренером на этом телефоне.", "welcomeCoachServerReentry": "Адрес вашего собственного сервера коуча не сохранён в резервной копии, потому что адрес может содержать ключ. Введите его снова в настройках ИИ.", "welcomeSettingsRestored": "Восстановлено настроек: {n}", "@welcomeSettingsRestored": { diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 8b4eb956e..2a5e071d6 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16327,6 +16327,13 @@ } }, "welcomeCoachKeyReentry": "คีย์ API ของโค้ชยังอยู่ในพื้นที่จัดเก็บที่ปลอดภัยและไม่ได้รวมอยู่ในข้อมูลสำรอง โปรดป้อนคีย์อีกครั้งในการตั้งค่า AI เพื่อใช้โค้ชบนโทรศัพท์เครื่องนี้", + "welcomeSettingsSkipped": "{n, plural, other{อ่านการตั้งค่า {n} รายการในข้อมูลสำรองไม่ได้และข้ามไป}}", + "@welcomeSettingsSkipped": { + "description": "Import report: settings rows in the backup that could not be read", + "placeholders": {"n": {"type": "int"}} + }, + "welcomeSettingsSkippedBody": "รายการเหล่านั้นใช้ค่าของโทรศัพท์เครื่องนี้ต่อ ส่วนอื่นทั้งหมดในข้อมูลสำรองกู้คืนแล้ว", + "welcomeCoachChatGptReentry": "ข้อมูลการลงชื่อเข้าใช้ ChatGPT ยังอยู่ในพื้นที่จัดเก็บที่ปลอดภัยและไม่ได้รวมอยู่ในข้อมูลสำรอง โปรดลงชื่อเข้าใช้อีกครั้งในการตั้งค่า AI เพื่อใช้โค้ชบนโทรศัพท์เครื่องนี้", "welcomeCoachServerReentry": "ที่อยู่เซิร์ฟเวอร์โค้ชที่คุณกำหนดเองไม่ได้ถูกสำรองไว้ เพราะที่อยู่อาจมีคีย์อยู่ด้วย ป้อนอีกครั้งในการตั้งค่า AI", "welcomeSettingsRestored": "กู้คืนการตั้งค่าแล้ว {n} รายการ", "@welcomeSettingsRestored": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 44312a9bf..66c91b5c7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2863,6 +2863,13 @@ } }, "welcomeCoachKeyReentry": "教练的 API 密钥保留在安全存储中,未包含在备份内。请在 AI 设置中重新输入,以便在此手机上使用教练。", + "welcomeSettingsSkipped": "{n, plural, other{备份中有 {n} 项设置无法读取,已跳过}}", + "@welcomeSettingsSkipped": { + "description": "Import report: settings rows in the backup that could not be read", + "placeholders": {"n": {"type": "int"}} + }, + "welcomeSettingsSkippedBody": "这些设置保留本机的值。备份中的其他内容都已恢复。", + "welcomeCoachChatGptReentry": "你的 ChatGPT 登录信息保留在安全存储中,未包含在备份内。请在 AI 设置中重新登录,以便在此手机上使用教练。", "welcomeCoachServerReentry": "你的自定义教练服务器地址没有备份,因为地址中可能包含密钥。请在 AI 设置中重新输入。", "welcomeSettingsRestored": "已恢复 {n} 项设置", "@welcomeSettingsRestored": { diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index e87d41160..a502615d8 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -733,11 +733,13 @@ class AppState extends ChangeNotifier { List lastImportNeedsReentry = const []; int lastImportPrefsRestored = 0; + int lastImportPrefsRefused = 0; /// Settings an import wrote take effect now, without a restart. Future _adoptRestoredSettings(ImportSummary counts) async { lastImportNeedsReentry = counts.needsReentry; lastImportPrefsRestored = counts.keysWritten.length; + lastImportPrefsRefused = counts.refusedPrefs.length; if (counts.keysWritten.isEmpty) return; await Prefs.reload(); // Keep the active import on its report until Continue is chosen. @@ -748,6 +750,9 @@ class AppState extends ChangeNotifier { await notificationRelay.reloadFromPrefs(); _deviceAlerts.refreshThreshold(); await armWaterReminder(); + // Restored reminder choices take effect now; the coach and locale + // listeners only fire when those change. + unawaited(_ensureRemindersScheduled()); try { await coachConfig?.load(trusted: true) .timeout(const Duration(seconds: 6)); @@ -760,6 +765,7 @@ class AppState extends ChangeNotifier { Future importEdgeBackup(String path) async { lastImportNeedsReentry = const []; lastImportPrefsRestored = 0; + lastImportPrefsRefused = 0; importRollupError = null; lastImportArchiveSkipped = 0; lastImportArchiveRestored = 0; @@ -777,6 +783,10 @@ class AppState extends ChangeNotifier { await LocalDb.refitAllLiveStepGains(); } finally { invalidateLiveStepCalibration(); + // Table rows can arrive without settings, including from older backups. + // Reload even after a partial merge, then use normal arm/dedupe handling. + await _loadAlarmSchedule(); + if (isConnected) await _armNextAlarmOccurrence(); } // An import can MERGE calibration rows (`live_step_calibration`) in from the // backup without this process knowing, and the cached fit is the only thing diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index fce877af0..b0a287f12 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -78,6 +78,8 @@ class ImportOutcome { final int labRows; final int prefsRestored; + /// Settings rows the backup held but could not be read; skipped. + final int prefsRefused; final List needsReentry; /// Journal or lab CSV lines that were REJECTED, never clamped — "line 12: @@ -107,6 +109,7 @@ class ImportOutcome { this.journalRows = 0, this.labRows = 0, this.prefsRestored = 0, + this.prefsRefused = 0, this.needsReentry = const [], this.rejectedRows = const [], this.error, @@ -126,6 +129,7 @@ class ImportOutcome { bool get needsReading => lostSomething || needsReentry.isNotEmpty || + prefsRefused > 0 || rejectedRows.isNotEmpty || readError != null || rollupError != null; @@ -372,7 +376,7 @@ Future runImport( final sources = []; var days = 0, workouts = 0, skipped = 0, late = 0, stranded = 0; var journalRows = 0; - var labRows = 0, prefsRestored = 0; + var labRows = 0, prefsRestored = 0, prefsRefused = 0; final needsReentry = {}; final corruptTables = {}; var archiveSkipped = 0, archiveRestored = 0; @@ -443,6 +447,7 @@ Future runImport( archiveSkipped += app.lastImportArchiveSkipped; archiveRestored += app.lastImportArchiveRestored; prefsRestored += app.lastImportPrefsRestored; + prefsRefused += app.lastImportPrefsRefused; needsReentry.addAll(app.lastImportNeedsReentry); } } finally { @@ -568,6 +573,7 @@ Future runImport( journalRows: journalRows, labRows: labRows, prefsRestored: prefsRestored, + prefsRefused: prefsRefused, needsReentry: needsReentry.toList(), rejectedRows: rejected, rollupError: rollupError, @@ -756,6 +762,14 @@ class ImportReport extends StatelessWidget { final ImportOutcome o; const ImportReport(this.o, {super.key}); + Widget _prefsSkipped(ImportOutcome o, AppLocalizations? l) => StatusCard( + l?.welcomeSettingsSkipped(o.prefsRefused) ?? + '${o.prefsRefused} setting${o.prefsRefused == 1 ? ' in the backup could not be read and was' : 's in the backup could not be read and were'} skipped', + l?.welcomeSettingsSkippedBody ?? + 'Those keep this phone\'s values. Everything else in the backup was restored.', + icon: LucideIcons.fileWarning, + ); + Widget _keyReentry(AppLocalizations? l, List keys) => Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (keys.contains('coach_api_key')) StatusCard( @@ -773,6 +787,13 @@ class ImportReport extends StatelessWidget { 'address can hold a key. Enter it again in AI settings.', icon: LucideIcons.keyRound, ), + if (keys.contains('coach_chatgpt')) StatusCard( + 'ChatGPT', + l?.welcomeCoachChatGptReentry ?? + 'Your ChatGPT sign-in stays in secure storage and was not backed up. ' + 'Sign in again in AI settings to use the coach on this phone.', + icon: LucideIcons.keyRound, + ), ]); @override @@ -809,11 +830,12 @@ class ImportReport extends StatelessWidget { // can still have skipped archive buckets it could not read. That is the // one part of the answer the user can act on, so it is not swallowed // by the empty case. - if (o.archiveBucketsSkipped == 0 && o.needsReentry.isEmpty) return nothing; + if (o.archiveBucketsSkipped == 0 && o.needsReentry.isEmpty && o.prefsRefused == 0) return nothing; return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ nothing, const SizedBox(height: S.x3), if (o.needsReentry.isNotEmpty) _keyReentry(l, o.needsReentry), + if (o.prefsRefused > 0) _prefsSkipped(o, l), if (o.archiveBucketsSkipped > 0) StatusCard( l?.welcomePartOfFileNotUsedTitle ?? 'Part of that file could not be used', @@ -890,6 +912,10 @@ class ImportReport extends StatelessWidget { const SizedBox(height: S.x3), _keyReentry(l, o.needsReentry), ], + if (o.prefsRefused > 0) ...[ + const SizedBox(height: S.x3), + _prefsSkipped(o, l), + ], // REJECTED, never clamped. A row outside its declared range is not // salvageable by trimming it — that would store a value the user never // wrote — so it is refused by line number and the other 300 land. diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart index d70cf6a67..8e6184e2c 100644 --- a/test/backup/settings_audit_guard_test.dart +++ b/test/backup/settings_audit_guard_test.dart @@ -40,7 +40,7 @@ const _mustTravel = { 'health_workout_import_deleted', 'phone_steps_healthstore_purged', 'whoop_import_deleted_workouts', 'issue_reports_sent', 'device_alerts.last_charge_event_ts', 'device_alerts.last_charge_wall_sec', - 'device_alerts.charge_shown_day', 'device_alerts.low_armed', + 'device_alerts.charge_shown_day', // One concrete member of each dynamic family below. 'notif_fired:2026-09-21:low_read', 'notif_osid:health:2026-09-21:low_read', 'notif_osslot:health:0', @@ -54,6 +54,7 @@ const _mustTravel = { }; const _mustStay = { + 'device_alerts.low_armed', 'install_device_id', 'paired_remote_id', 'paired_serial', 'paired_generation', 'paired_worn_on', 'ble.ask_add_pending', 'consent_telemetry', 'consent_health_data', 'consent_chosen', diff --git a/test/backup/settings_complete_test.dart b/test/backup/settings_complete_test.dart index 92a6e47a4..5b8d86b44 100644 --- a/test/backup/settings_complete_test.dart +++ b/test/backup/settings_complete_test.dart @@ -18,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/ble/ble_engine.dart'; import 'package:openstrap_edge/state/prefs.dart'; +import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/ui2/activity/live.dart'; import 'package:openstrap_edge/ui2/activity/catalogue.dart'; import 'package:openstrap_edge/state/alarm_schedule.dart'; @@ -43,6 +44,139 @@ void main() { FlutterSecureStorage.setMockInitialValues({}); } + for (final withPrefs in [false, true]) { + test('AppState reloads and arms an imported weekday with prefs=$withPrefs', + () async { + await wipeAll(); + final now = DateTime.now(); + final tomorrow = DateTime(now.year, now.month, now.day + 1, 7, 15); + final weekday = tomorrow.weekday - 1; + await LocalDb.setAlarmScheduleDay(weekday: weekday, hour: 7, minute: 15, + enabled: true, smartWindowMinutes: 20); + if (withPrefs) await setPrefs({'theme_choice': 'dark'}); + final path = await LocalDb.exportCopy(); + if (!withPrefs) { + final copy = await databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions(singleInstance: false)); + await copy.execute('DROP TABLE backup_prefs'); + await copy.execute('DROP TABLE backup_secrets'); + await copy.close(); + } + await wipeAll(); + final engine = BleEngine(onRecord: (_, _) async {}, onState: (_) {}); + final writes = []; + engine.debugInstallFakeLink(band: proto.BandProfile.gen4, + onWrite: (bytes) async { + final inner = proto.parseFrame(bytes, profile: proto.BandProfile.gen4)!.inner; + if (inner[2] == proto.Cmd.setAlarmTime) { + writes.add(inner[2]); + final reply = proto.buildFrame([ + 0x24, 0x94, proto.Cmd.setAlarmTime, inner[1], 0x01, + proto.AlarmStatus.validInputPattern, + ], profile: proto.BandProfile.gen4); + engine.debugProcessImmediateFrame( + proto.parseFrame(reply, profile: proto.BandProfile.gen4)!); + } + return true; + }); + final app = AppState.forTesting(engine: engine); + addTearDown(app.dispose); + app.device.connection = 'connected'; + expect(app.alarmSchedule[weekday].enabled, isFalse); + await app.importEdgeBackup(path); + expect(app.alarmSchedule[weekday], AlarmScheduleEntry(weekday: weekday, + hour: 7, minute: 15, smartWindowMinutes: 20)); + expect(writes, [proto.Cmd.setAlarmTime]); + expect(app.alarmEpoch, tomorrow.millisecondsSinceEpoch ~/ 1000); + expect((await allPrefs())['alarm_epoch'], tomorrow.millisecondsSinceEpoch ~/ 1000); + // A repeated import goes through normal arm deduplication. + await app.importEdgeBackup(path); + expect(writes, [proto.Cmd.setAlarmTime]); + }); + } + + test('a DB-only alarm restore establishes settings before a later import', + () async { + await wipeAll(); + await setPrefs({'theme_choice': 'dark', 'backup.cadence': 'weekly'}); + final settings = await LocalDb.exportCopy(); + await wipeAll(); + await LocalDb.setAlarmScheduleDay(weekday: 2, hour: 7, minute: 15, enabled: true); + final old = await LocalDb.exportCopy(); + final copy = await databaseFactoryFfi.openDatabase(old, + options: OpenDatabaseOptions(singleInstance: false)); + await copy.execute('DROP TABLE backup_prefs'); + await copy.execute('DROP TABLE backup_secrets'); + await copy.close(); + await wipeAll(); + await LocalDb.importFromDbFile(old); + expect(await LocalDb.alarmScheduleRows(), hasLength(1)); + await setPrefs({'theme_choice': 'light'}); + final result = await LocalDb.importFromDbFile(settings); + expect((await allPrefs())['theme_choice'], 'light'); + expect((await allPrefs())['backup.cadence'], 'weekly'); + expect(result.keysWritten, {'backup.cadence'}); + }); + + for (final entry in >{ + 'journal_metric': {'date': '2026-09-21', 'field': 'water', 'value': 250, 'updated_at': 1}, + 'cycle_symptom': {'date': '2026-09-21', 'symptoms_json': '["cramps"]', 'updated_at': 1}, + 'sleep_override': {'day_id': '2026-09-21', 'onset_ts': 1790000000, + 'offset_ts': 1790028800, 'source': 'manual', 'created_at': 1}, + 'step_calibration': {'device_family': 'whoop', 'wearing': 0, 'factor': 1.1, + 'n_days': 3, 'version': 1, 'updated_ts': 1}, + 'med_def': {'key': 'vitamin', 'label': 'Vitamin', 'schedule_json': '[480]', + 'created_at': 1}, + }.entries) { + test('${entry.key} alone protects existing settings from import', () async { + await wipeAll(); + await setPrefs({'theme_choice': 'dark', 'backup.cadence': 'weekly'}); + final path = await LocalDb.exportCopy(); + await wipeAll(); + await (await LocalDb.instance).insert(entry.key, entry.value); + await setPrefs({'theme_choice': 'light'}); + final result = await LocalDb.importFromDbFile(path); + expect((await allPrefs())['theme_choice'], 'light'); + expect(result.keysWritten, {'backup.cadence'}); + }); + } + + for (final blank in ['', ' ']) { + test('a blank coach server needs re-entry: "$blank"', () async { + await wipeAll(); + await setPrefs({'coach_base_url': 'https://old.example.net/v1'}); + final path = await LocalDb.exportCopy(); + await wipeAll(); + await setPrefs({'coach_base_url': blank}); + final result = await LocalDb.importFromDbFile(path); + expect(result.needsReentry, ['coach_base_url']); + expect((await allPrefs())['coach_base_url'], blank); + }); + } + + test('ChatGPT exports only presence and requests sign-in on a new phone', + () async { + await wipeAll(); + FlutterSecureStorage.setMockInitialValues({'coach_chatgpt_session': 'SECRET-SESSION'}); + await setPrefs({'coach_chatgpt_present': true, 'coach_use_chatgpt': true}); + final path = await LocalDb.exportCopy(); + final copy = await databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions(readOnly: true, singleInstance: false)); + final markers = await copy.query('backup_secrets'); + await copy.close(); + expect(markers, [{'key': 'coach_chatgpt'}]); + expect((await exportedPrefs(path))!.keys, ['coach_use_chatgpt']); + expectNoSecretIn(path, ['SECRET-SESSION']); + await wipeAll(); + expect((await LocalDb.importFromDbFile(path)).needsReentry, ['coach_chatgpt']); + expect(await allPrefs(), {'coach_use_chatgpt': true}); + await setPrefs({'coach_chatgpt_present': true}); + expect((await LocalDb.importFromDbFile(path)).needsReentry, isEmpty); + await wipeAll(); + final never = await LocalDb.exportCopy(); + expect((await LocalDb.importFromDbFile(never)).needsReentry, isEmpty); + }); + test('the auto-backup cadence the owner chose comes back; device state stays', () async { await wipeAll(); @@ -114,7 +248,7 @@ void main() { expect(second.keysWritten, {'backup.cadence'}); }); - test('a failed restore cannot inherit another install successful-import marker', + test('a backup without settings cannot hand this phone its import marker', () async { await wipeAll(); await setPrefs({'theme_choice': 'dark'}); @@ -124,15 +258,31 @@ void main() { final path = await LocalDb.exportCopy(); // contains the source's import marker final copy = await databaseFactoryFfi.openDatabase(path, options: OpenDatabaseOptions(singleInstance: false)); - await copy.delete('backup_prefs'); - await copy.insert('backup_prefs', - {'key': 'theme_choice', 'type': 'bool', 'value': 'invalid'}); + await copy.execute('DROP TABLE backup_prefs'); await copy.close(); await wipeAll(); - await setPrefs({'theme_choice': 'system'}); - await expectLater(LocalDb.importFromDbFile(path), throwsFormatException); + await LocalDb.importFromDbFile(path); expect(await LocalDb.getCursor('backup_settings_imported'), isNull, - reason: 'this phone has not successfully restored settings'); + reason: 'this phone has not restored settings'); + }); + + test('a malformed setting is skipped and counted; the rest still come back', + () async { + await wipeAll(); + await setPrefs({'theme_choice': 'dark', 'backup.cadence': 'weekly'}); + final path = await LocalDb.exportCopy(); + final copy = await databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions(singleInstance: false)); + await copy.update('backup_prefs', {'type': 'bool', 'value': 'invalid'}, + where: 'key = ?', whereArgs: ['theme_choice']); + await copy.close(); + await wipeAll(); + final result = await LocalDb.importFromDbFile(path); + final now = await allPrefs(); + expect(now.containsKey('theme_choice'), isFalse); + expect(now['backup.cadence'], 'weekly'); + expect(result.refusedPrefs, ['theme_choice']); + expect(result.keysWritten, contains('backup.cadence')); }); test('a restored schedule writes its next alarm to the new band', () async { diff --git a/test/backup/settings_presentation_test.dart b/test/backup/settings_presentation_test.dart index 444fcb28b..3aef73dda 100644 --- a/test/backup/settings_presentation_test.dart +++ b/test/backup/settings_presentation_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -72,6 +74,91 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); + testWidgets('a wrong-typed clock setting is logged without an unhandled error', + (tester) async { + late String path; + await tester.runAsync(() async { + await env.wipe(); + FlutterSecureStorage.setMockInitialValues({}); + await setPrefs({'theme_choice': 'dark', 'locale_override': 'es', + 'units_system': 'imperial', 'clock_format': true, + 'local_profile_json': '{"name":"Restored"}'}); + path = await LocalDb.exportCopy(); + await env.wipe(); + }); + final app = AppState.forTesting(); + final theme = ThemeController.seed(AppThemeChoice.light, Brightness.light); + final locale = LocaleController.seed(null); + final units = UnitsController.seed(UnitSystem.metric); + final clock = ClockFormatController.seed(ClockFormat.h24); + final coach = CoachConfig(); + addTearDown(() { + app.dispose(); + coach.dispose(); + theme.dispose(); + locale.dispose(); + units.dispose(); + clock.dispose(); + }); + await tester.pumpWidget(MultiProvider(providers: [ + ChangeNotifierProvider.value(value: app), + ChangeNotifierProvider.value(value: coach), + ChangeNotifierProvider.value(value: theme), + ChangeNotifierProvider.value(value: locale), + ChangeNotifierProvider.value(value: units), + ChangeNotifierProvider.value(value: clock), + ], child: const OpenStrapApp())); + // The existing overlay creates its fade controller lazily in run(). + // Initialise it while mounted so the app root can be disposed in this test. + themeSwitchKey.currentState!.run(() {}); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + final errors = []; + final logs = []; + final previousPrint = debugPrint; + debugPrint = (message, {wrapWidth}) { + if (message != null) logs.add(message); + }; + addTearDown(() => debugPrint = previousPrint); + await tester.runAsync(() async { + final completed = Completer(); + runZonedGuarded(() async { + try { + await app.importEdgeBackup(path); + } finally { + completed.complete(); + } + }, (error, stack) => errors.add(error)); + await completed.future; + }); + debugPrint = previousPrint; + await tester.pump(); + expect(errors, isEmpty, reason: 'controller reload failures must be caught'); + expect(logs, contains(contains('clock_format reload failed'))); + expect(app.user?['name'], 'Restored'); + expect(theme.choice, AppThemeChoice.dark); + expect(locale.code, 'es'); + expect(units.system, UnitSystem.imperial); + expect(clock.format, ClockFormat.h24); + final material = tester.widget(find.byType(MaterialApp)); + expect(material.locale, const Locale('es')); + expect(material.themeMode, ThemeMode.dark); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + for (final prefs in [0, 2]) { + testWidgets('ChatGPT sign-in notice is visible with $prefs restored settings', + (tester) async { + final result = ImportOutcome(source: 'OpenStrap backup', + prefsRestored: prefs, needsReentry: const ['coach_chatgpt']); + expect(result.needsReading, isTrue); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: ImportReport(result)))); + expect(find.textContaining('Sign in again in AI settings'), findsOneWidget); + expect(find.textContaining('Enter it again in AI settings'), findsNothing); + }); + } + for (final prefs in [0, 2]) { testWidgets('coach re-entry is visible with $prefs restored settings', (tester) async { final result = ImportOutcome(source: 'OpenStrap backup', @@ -86,4 +173,14 @@ void main() { } }); } + + testWidgets('skipped settings are reported, not hidden', (tester) async { + final result = ImportOutcome(source: 'OpenStrap backup', + prefsRestored: 5, prefsRefused: 1); + expect(result.needsReading, isTrue); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: ImportReport(result)))); + expect(find.textContaining('1 setting in the backup could not be read'), + findsOneWidget); + }); } From 370f2b8643575da9e320564a9a905061179c489a Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Fri, 6 Nov 2026 21:17:12 -0700 Subject: [PATCH 07/15] fix(backup): a live setting of the wrong type is refused and reported Theme, language, units and clock format are reloaded right after an import. A backup row for one of them with a type the controller cannot read is no longer written and counted as restored; it is skipped and shown in the import report's skipped count, and the controller keeps the stored state. --- lib/data/backup_extras.dart | 15 +++++++++++++++ test/backup/settings_complete_test.dart | 14 ++++++++++++++ test/backup/settings_presentation_test.dart | 9 ++++++--- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index 9d3b3fe96..2d25507b0 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -79,6 +79,16 @@ class ImportSummary extends MapView { final List refusedPrefs; } +/// Settings the app reloads live right after an import, with the stored type +/// each controller reads. A row of another type is refused, not written: the +/// controller could not use it, so counting it as restored would be false. +const Map _kLivePrefTypes = { + 'theme_choice': 'string', + 'locale_override': 'string', + 'units_system': 'string', + 'clock_format': 'string', +}; + bool _backedUp(String key) => !kPrefsNotBackedUp.containsKey(key); (String, String)? _encode(Object? value) => switch (value) { @@ -172,6 +182,11 @@ Future restoreBackupPrefs(String path, Map counts, { // A malformed row is skipped and reported, so one bad value cannot // block every setting after it. Storage refusing a write is a real // failure and must not be reported as a successful restore. + final want = _kLivePrefTypes[key]; + if (want != null && row['type'] != want) { + refused.add(key); + continue; + } final bool ok; try { ok = await _write(prefs, key, '${row['type']}', '${row['value']}'); diff --git a/test/backup/settings_complete_test.dart b/test/backup/settings_complete_test.dart index 5b8d86b44..a521f6e1e 100644 --- a/test/backup/settings_complete_test.dart +++ b/test/backup/settings_complete_test.dart @@ -285,6 +285,20 @@ void main() { expect(result.keysWritten, contains('backup.cadence')); }); + test('a live setting of the wrong type is refused and reported, not counted', + () async { + await wipeAll(); + await setPrefs({'clock_format': true, 'backup.cadence': 'weekly'}); + final path = await LocalDb.exportCopy(); + await wipeAll(); + final result = await LocalDb.importFromDbFile(path); + final now = await allPrefs(); + expect(now.containsKey('clock_format'), isFalse); + expect(result.refusedPrefs, ['clock_format']); + expect(result.keysWritten, isNot(contains('clock_format'))); + expect(now['backup.cadence'], 'weekly'); + }); + test('a restored schedule writes its next alarm to the new band', () async { await wipeAll(); final now = DateTime(2026, 10, 20, 22); diff --git a/test/backup/settings_presentation_test.dart b/test/backup/settings_presentation_test.dart index 3aef73dda..c775f3d5b 100644 --- a/test/backup/settings_presentation_test.dart +++ b/test/backup/settings_presentation_test.dart @@ -74,7 +74,7 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); - testWidgets('a wrong-typed clock setting is logged without an unhandled error', + testWidgets('a wrong-typed clock setting is refused; the other settings still apply', (tester) async { late String path; await tester.runAsync(() async { @@ -134,12 +134,15 @@ void main() { debugPrint = previousPrint; await tester.pump(); expect(errors, isEmpty, reason: 'controller reload failures must be caught'); - expect(logs, contains(contains('clock_format reload failed'))); + expect(logs, isNot(contains(contains('clock_format reload failed'))), + reason: 'the wrong-typed row is refused before it is written'); + expect(app.lastImportPrefsRefused, 1); expect(app.user?['name'], 'Restored'); expect(theme.choice, AppThemeChoice.dark); expect(locale.code, 'es'); expect(units.system, UnitSystem.imperial); - expect(clock.format, ClockFormat.h24); + // Nothing was written for the clock, so it reads the stored state: unset. + expect(clock.format, ClockFormat.system); final material = tester.widget(find.byType(MaterialApp)); expect(material.locale, const Locale('es')); expect(material.themeMode, ThemeMode.dark); From 75ec46a2f499530a41296a8107484bcfc1a46fc0 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sat, 7 Nov 2026 00:06:11 -0700 Subject: [PATCH 08/15] l10n(ru): drop the =1 branch gen-l10n reports as overridden by one --- lib/l10n/app_ru.arb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index bfe8caba0..1db98c9c9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7906,7 +7906,7 @@ } }, "welcomeCoachKeyReentry": "API-ключ тренера остаётся в защищённом хранилище и не включён в резервную копию. Введите его снова в настройках ИИ, чтобы пользоваться тренером на этом телефоне.", - "welcomeSettingsSkipped": "{n, plural, =1{1 настройку из резервной копии не удалось прочитать, она пропущена} one{{n} настройку из резервной копии не удалось прочитать, она пропущена} few{{n} настройки из резервной копии не удалось прочитать, они пропущены} many{{n} настроек из резервной копии не удалось прочитать, они пропущены} other{{n} настройки из резервной копии не удалось прочитать, они пропущены}}", + "welcomeSettingsSkipped": "{n, plural, one{{n} настройку из резервной копии не удалось прочитать, она пропущена} few{{n} настройки из резервной копии не удалось прочитать, они пропущены} many{{n} настроек из резервной копии не удалось прочитать, они пропущены} other{{n} настройки из резервной копии не удалось прочитать, они пропущены}}", "@welcomeSettingsSkipped": { "description": "Import report: settings rows in the backup that could not be read", "placeholders": {"n": {"type": "int"}} From 2dddb3b1ae3f8fffbc6d9cd4a269046e51cccde8 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sat, 7 Nov 2026 06:05:00 -0700 Subject: [PATCH 09/15] fix(backup): the skipped-settings card no longer claims everything else was restored On a phone that is already set up, settings it already has keep their values, and the card can also show when nothing was imported; it now describes only the skipped settings, in all 8 locales and the fallback. --- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_ru.arb | 2 +- lib/l10n/app_th.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/ui2/onboarding/welcome.dart | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index efb3a86e8..616f99098 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3479,7 +3479,7 @@ "description": "Import report: settings rows in the backup that could not be read", "placeholders": {"n": {"type": "int"}} }, - "welcomeSettingsSkippedBody": "Dafür bleiben die Werte dieses Telefons. Alles andere aus der Sicherung wurde wiederhergestellt.", + "welcomeSettingsSkippedBody": "Dafür bleiben die Werte dieses Telefons.", "welcomeCoachChatGptReentry": "Deine ChatGPT-Anmeldung bleibt im sicheren Speicher und wurde nicht gesichert. Melde dich in den KI-Einstellungen erneut an, um den Coach auf diesem Telefon zu nutzen.", "welcomeCoachServerReentry": "Deine eigene Coach-Serveradresse wurde nicht gesichert, weil eine Adresse einen Schlüssel enthalten kann. Gib sie in den KI-Einstellungen erneut ein.", "welcomeSettingsRestored": "{n} Einstellungen wiederhergestellt", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b36c6ebca..d40903899 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16338,7 +16338,7 @@ "description": "Import report: settings rows in the backup that could not be read", "placeholders": {"n": {"type": "int"}} }, - "welcomeSettingsSkippedBody": "Those keep this phone's values. Everything else in the backup was restored.", + "welcomeSettingsSkippedBody": "Those keep this phone's values.", "welcomeCoachChatGptReentry": "Your ChatGPT sign-in stays in secure storage and was not backed up. Sign in again in AI settings to use the coach on this phone.", "welcomeCoachServerReentry": "Your custom coach server address was not backed up, because an address can hold a key. Enter it again in AI settings.", "welcomeSettingsRestored": "{n} settings restored", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5b40ec0bc..a2c3c76a6 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2897,7 +2897,7 @@ "description": "Import report: settings rows in the backup that could not be read", "placeholders": {"n": {"type": "int"}} }, - "welcomeSettingsSkippedBody": "Esos conservan los valores de este teléfono. Todo lo demás de la copia se restauró.", + "welcomeSettingsSkippedBody": "Esos conservan los valores de este teléfono.", "welcomeCoachChatGptReentry": "Tu sesión de ChatGPT permanece en el almacenamiento seguro y no se incluyó en la copia. Vuelve a iniciar sesión en los ajustes de IA para usar el asistente en este teléfono.", "welcomeCoachServerReentry": "La dirección de tu servidor personalizado del asistente no se incluyó en la copia, porque una dirección puede contener una clave. Vuelve a introducirla en los ajustes de IA.", "welcomeSettingsRestored": "{n} ajustes restaurados", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index aefabece6..cd106dc01 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2868,7 +2868,7 @@ "description": "Import report: settings rows in the backup that could not be read", "placeholders": {"n": {"type": "int"}} }, - "welcomeSettingsSkippedBody": "Ils gardent les valeurs de ce téléphone. Tout le reste de la sauvegarde a été restauré.", + "welcomeSettingsSkippedBody": "Ils gardent les valeurs de ce téléphone.", "welcomeCoachChatGptReentry": "Votre connexion à ChatGPT reste dans le stockage sécurisé et n’a pas été sauvegardée. Connectez-vous à nouveau dans les réglages de l’IA pour utiliser le coach sur ce téléphone.", "welcomeCoachServerReentry": "L’adresse de votre serveur de coach personnalisé n’a pas été sauvegardée, car une adresse peut contenir une clé. Saisissez-la à nouveau dans les réglages de l’IA.", "welcomeSettingsRestored": "{n} réglages restaurés", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a7f5ff03f..9e879ed1a 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2868,7 +2868,7 @@ "description": "Import report: settings rows in the backup that could not be read", "placeholders": {"n": {"type": "int"}} }, - "welcomeSettingsSkippedBody": "उनके लिए इस फ़ोन के मान बने रहेंगे। बैकअप का बाकी सब बहाल हो गया।", + "welcomeSettingsSkippedBody": "उनके लिए इस फ़ोन के मान बने रहेंगे।", "welcomeCoachChatGptReentry": "आपका ChatGPT साइन-इन सुरक्षित स्टोरेज में रहता है और बैकअप में शामिल नहीं किया गया। इस फ़ोन पर कोच इस्तेमाल करने के लिए AI सेटिंग में फिर से साइन इन करें।", "welcomeCoachServerReentry": "आपका कस्टम कोच सर्वर पता बैकअप नहीं हुआ, क्योंकि पते में कुंजी हो सकती है। इसे AI सेटिंग्स में फिर से दर्ज करें।", "welcomeSettingsRestored": "{n} सेटिंग बहाल हुईं", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1db98c9c9..53fb2ed76 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7911,7 +7911,7 @@ "description": "Import report: settings rows in the backup that could not be read", "placeholders": {"n": {"type": "int"}} }, - "welcomeSettingsSkippedBody": "Для них остаются значения этого телефона. Всё остальное из резервной копии восстановлено.", + "welcomeSettingsSkippedBody": "Для них остаются значения этого телефона.", "welcomeCoachChatGptReentry": "Данные входа в ChatGPT остаются в защищённом хранилище и не включены в резервную копию. Войдите снова в настройках ИИ, чтобы пользоваться тренером на этом телефоне.", "welcomeCoachServerReentry": "Адрес вашего собственного сервера коуча не сохранён в резервной копии, потому что адрес может содержать ключ. Введите его снова в настройках ИИ.", "welcomeSettingsRestored": "Восстановлено настроек: {n}", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 2a5e071d6..e6b99d95a 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16332,7 +16332,7 @@ "description": "Import report: settings rows in the backup that could not be read", "placeholders": {"n": {"type": "int"}} }, - "welcomeSettingsSkippedBody": "รายการเหล่านั้นใช้ค่าของโทรศัพท์เครื่องนี้ต่อ ส่วนอื่นทั้งหมดในข้อมูลสำรองกู้คืนแล้ว", + "welcomeSettingsSkippedBody": "รายการเหล่านั้นใช้ค่าของโทรศัพท์เครื่องนี้ต่อ", "welcomeCoachChatGptReentry": "ข้อมูลการลงชื่อเข้าใช้ ChatGPT ยังอยู่ในพื้นที่จัดเก็บที่ปลอดภัยและไม่ได้รวมอยู่ในข้อมูลสำรอง โปรดลงชื่อเข้าใช้อีกครั้งในการตั้งค่า AI เพื่อใช้โค้ชบนโทรศัพท์เครื่องนี้", "welcomeCoachServerReentry": "ที่อยู่เซิร์ฟเวอร์โค้ชที่คุณกำหนดเองไม่ได้ถูกสำรองไว้ เพราะที่อยู่อาจมีคีย์อยู่ด้วย ป้อนอีกครั้งในการตั้งค่า AI", "welcomeSettingsRestored": "กู้คืนการตั้งค่าแล้ว {n} รายการ", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 66c91b5c7..18fef6f1f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2868,7 +2868,7 @@ "description": "Import report: settings rows in the backup that could not be read", "placeholders": {"n": {"type": "int"}} }, - "welcomeSettingsSkippedBody": "这些设置保留本机的值。备份中的其他内容都已恢复。", + "welcomeSettingsSkippedBody": "这些设置保留本机的值。", "welcomeCoachChatGptReentry": "你的 ChatGPT 登录信息保留在安全存储中,未包含在备份内。请在 AI 设置中重新登录,以便在此手机上使用教练。", "welcomeCoachServerReentry": "你的自定义教练服务器地址没有备份,因为地址中可能包含密钥。请在 AI 设置中重新输入。", "welcomeSettingsRestored": "已恢复 {n} 项设置", diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index b0a287f12..aa89cb08e 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -766,7 +766,7 @@ class ImportReport extends StatelessWidget { l?.welcomeSettingsSkipped(o.prefsRefused) ?? '${o.prefsRefused} setting${o.prefsRefused == 1 ? ' in the backup could not be read and was' : 's in the backup could not be read and were'} skipped', l?.welcomeSettingsSkippedBody ?? - 'Those keep this phone\'s values. Everything else in the backup was restored.', + 'Those keep this phone\'s values.', icon: LucideIcons.fileWarning, ); From 84ef43dad954ba1b5a0cb3301ce6cff847043008 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 17:57:25 -0700 Subject: [PATCH 10/15] fix(backup): refuse a restored setting whose type differs from how the app reads it The type check covered four keys. A row stored as another type (a bool for local_profile_json) was written, and the typed getter then threw on every launch. kPrefTypes now holds the stored type of every backed-up key the app reads with a typed getter, plus kPrefTypeFamilies for dated and per-item keys. A guard test scans lib/ and fails when a backed-up key is read but missing from the table, or read with a different type. A custom coach server is also reported for re-entry when this phone saved the default address explicitly, not only when it has none. --- lib/data/backup_extras.dart | 126 +++++++++++++- test/backup/pref_type_refusal_test.dart | 116 +++++++++++++ test/backup/pref_types_guard_test.dart | 190 +++++++++++++++++++++ test/backup/settings_audit_guard_test.dart | 12 +- 4 files changed, 434 insertions(+), 10 deletions(-) create mode 100644 test/backup/pref_type_refusal_test.dart create mode 100644 test/backup/pref_types_guard_test.dart diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index 2d25507b0..34027ee6e 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -79,16 +79,117 @@ class ImportSummary extends MapView { final List refusedPrefs; } -/// Settings the app reloads live right after an import, with the stored type -/// each controller reads. A row of another type is refused, not written: the -/// controller could not use it, so counting it as restored would be false. -const Map _kLivePrefTypes = { - 'theme_choice': 'string', +/// The stored type of each backed-up key the app reads with a typed getter. +/// A row of another type is refused, not written: the getter throws on a cast +/// mismatch (int vs double included), on every launch after the restore. A +/// backed-up key missing here is written as-is; the guard test keeps this +/// table complete. Keys in [kPrefsNotBackedUp] need no entry. +const Map kPrefTypes = { + 'ai.journal_done_day': 'string', + 'ai_evening_enabled': 'bool', + 'ai_evening_min': 'int', + 'ai_journal_enabled': 'bool', + 'ai_journal_min': 'int', + 'ai_morning_enabled': 'bool', + 'ai_morning_min': 'int', + 'backup.cadence': 'string', + 'band.strap_name': 'string', + 'clock_format': 'string', + 'coach_api': 'string', + 'coach_model': 'string', + 'coach_timeout_seconds': 'int', + 'coach_use_chatgpt': 'bool', + 'companion_url': 'string', + 'cycle_tracking_enabled': 'bool', + 'dev.mode': 'bool', + 'device_alerts.charge_shown_day': 'int', + 'device_alerts.last_charge_event_ts': 'int', + 'device_alerts.last_charge_wall_sec': 'int', + 'gesture_double_tap': 'string', + 'health_auto_import_last_ms': 'int', + 'health_auto_import_workouts': 'bool', + 'health_import_profile_at': 'int', + 'health_import_workouts_at': 'int', + 'health_sync': 'bool', + 'health_workout_import_deleted': 'stringList', + 'issue_reports_sent': 'string', + 'last_inactivity_ms': 'int', + 'last_recovery_notif_day': 'string', + 'last_staleness_notified_ms': 'int', + 'last_stepgoal_day': 'string', + 'local_profile_json': 'string', 'locale_override': 'string', + 'notif_alarm_latch_failed': 'bool', + 'notif_alarm_night_check': 'bool', + 'notif_auto_detect': 'bool', + 'notif_battery_pct': 'int', + 'notif_checkin': 'bool', + 'notif_critical_override': 'bool', + 'notif_device': 'bool', + 'notif_fired_keys': 'stringList', + 'notif_health': 'bool', + 'notif_meds': 'bool', + 'notif_movement': 'bool', + 'notif_nap_detect': 'bool', + 'notif_quiet_enabled': 'bool', + 'notif_quiet_end': 'int', + 'notif_quiet_start': 'int', + 'notif_recovery': 'bool', + 'notif_relay_enabled': 'bool', + 'notif_relay_packages': 'stringList', + 'notif_relay_seen': 'stringList', + 'notif_reminders': 'bool', + 'notif_stepgoal': 'bool', + 'notif_water': 'bool', + 'notif_water_interval': 'int', + 'notif_winddown': 'bool', + 'nutrition.barcode_lookup': 'bool', + 'onboard.completed': 'bool', + 'onboard.saw_profile_setup': 'bool', + 'onboard.skipped_pairing': 'bool', + 'onboarding_choice': 'string', + 'phone_steps': 'bool', + 'phone_steps_healthstore_purged': 'bool', + 'rough_night.ask': 'string', + 'rough_night.dismissed': 'string', + 'share.map_tiles': 'bool', + 'theme_choice': 'string', + 'ui.shell_tab': 'int', + 'ui.shell_tab.v2': 'int', + 'ui.wearable_show_estimates': 'bool', + 'ui.wearable_side_by_side': 'bool', 'units_system': 'string', - 'clock_format': 'string', + 'update_checks_enabled': 'bool', + 'whoop_import_deleted_workouts': 'stringList', + 'workout.rpe_skips': 'int', + 'workout.zone_alert_enabled': 'bool', + 'workout.zone_alert_target_zone': 'int', }; +/// Dated or per-item key families: (prefix, suffix, stored type). +const List<(String, String, String)> kPrefTypeFamilies = [ + ('ai.briefing.', '', 'string'), + ('ecg.guard.', '', 'bool'), + ('ecg.wrist.', '', 'string'), + ('ecg.maverick.', '', 'bool'), + ('notif_fired:', '', 'bool'), + ('notif_osid:', '', 'int'), + ('notif_osslot:', '', 'string'), + ('notif_osnext:', '', 'int'), + ('nudge.', '.dismissed', 'bool'), + ('nudge.', '.last_shown_ms', 'int'), +]; + +/// The stored type the app reads [key] with, or null when it is not known. +String? expectedPrefType(String key) { + final exact = kPrefTypes[key]; + if (exact != null) return exact; + for (final (prefix, suffix, type) in kPrefTypeFamilies) { + if (key.startsWith(prefix) && key.endsWith(suffix)) return type; + } + return null; +} + bool _backedUp(String key) => !kPrefsNotBackedUp.containsKey(key); (String, String)? _encode(Object? value) => switch (value) { @@ -164,6 +265,13 @@ Future _write(SharedPreferences prefs, String key, } } +/// This phone has no server of its own: unset, blank, or the default saved +/// explicitly. Either way the user would silently stay on the default. +bool _needsServerReentry(String? saved) { + final v = saved?.trim() ?? ''; + return v.isEmpty || v == CoachConfig.defaultBaseUrl; +} + Future restoreBackupPrefs(String path, Map counts, { required bool freshInstall, }) async { @@ -182,7 +290,7 @@ Future restoreBackupPrefs(String path, Map counts, { // A malformed row is skipped and reported, so one bad value cannot // block every setting after it. Storage refusing a write is a real // failure and must not be reported as a successful restore. - final want = _kLivePrefTypes[key]; + final want = expectedPrefType(key); if (want != null && row['type'] != want) { refused.add(key); continue; @@ -214,8 +322,8 @@ Future restoreBackupPrefs(String path, Map counts, { if (await _hasTable(src, 'backup_secrets') && (await src.query('backup_secrets', where: 'key = ?', whereArgs: ['coach_base_url'])).isNotEmpty && - ((await SharedPreferences.getInstance()).getString('coach_base_url') - ?.trim().isEmpty ?? true)) { + _needsServerReentry((await SharedPreferences.getInstance()) + .getString('coach_base_url'))) { needs.add('coach_base_url'); } if (await _hasTable(src, 'backup_secrets') && diff --git a/test/backup/pref_type_refusal_test.dart b/test/backup/pref_type_refusal_test.dart new file mode 100644 index 000000000..b4c05682c --- /dev/null +++ b/test/backup/pref_type_refusal_test.dart @@ -0,0 +1,116 @@ +// A backup row whose stored type differs from how the app reads that key must +// be refused and reported, never written: the typed getter would throw on every +// later launch. Also: a custom coach server is reported when this phone saved +// the default address explicitly. +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/db.dart'; + +import 'backup_support.dart'; + +void main() { + late BackupEnv env; + setUpAll(() async => env = await BackupEnv.create('bkx_pref_types_test.db')); + tearDownAll(() async => env.dispose()); + + Future wipeAll() async { + await env.wipe(); + FlutterSecureStorage.setMockInitialValues({}); + } + + /// Exports [prefs], then rewrites [retype] rows to another stored type. + Future exportRetyped(Map prefs, + Map retype) async { + await wipeAll(); + await setPrefs(prefs); + final path = await LocalDb.exportCopy(); + final copy = await databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions(singleInstance: false)); + for (final e in retype.entries) { + await copy.update('backup_prefs', {'type': e.value.$1, 'value': e.value.$2}, + where: 'key = ?', whereArgs: [e.key]); + } + await copy.close(); + return path; + } + + test('a profile stored as a bool is refused, not written, and does not throw', + () async { + final path = await exportRetyped( + {'local_profile_json': '{"name":"Owner"}', 'backup.cadence': 'weekly'}, + {'local_profile_json': ('bool', 'true')}); + await wipeAll(); + final result = await LocalDb.importFromDbFile(path); + final now = await allPrefs(); + expect(now.containsKey('local_profile_json'), isFalse); + expect(result.refusedPrefs, ['local_profile_json']); + expect(result.keysWritten, isNot(contains('local_profile_json'))); + expect(now['backup.cadence'], 'weekly', reason: 'the rest still comes back'); + }); + + test('an int setting stored as a double is refused', () async { + final path = await exportRetyped({'notif_water_interval': 90}, + {'notif_water_interval': ('double', '90.0')}); + await wipeAll(); + final result = await LocalDb.importFromDbFile(path); + expect((await allPrefs()).containsKey('notif_water_interval'), isFalse); + expect(result.refusedPrefs, ['notif_water_interval']); + }); + + test('a string list stored as a string is refused', () async { + final path = await exportRetyped({'notif_relay_packages': ['a.b']}, + {'notif_relay_packages': ('string', 'a.b')}); + await wipeAll(); + final result = await LocalDb.importFromDbFile(path); + expect((await allPrefs()).containsKey('notif_relay_packages'), isFalse); + expect(result.refusedPrefs, ['notif_relay_packages']); + }); + + test('a dated flag family member of the wrong type is refused', () async { + final path = await exportRetyped({'notif_fired:2026-09-21:low_read': true}, + {'notif_fired:2026-09-21:low_read': ('string', 'yes')}); + await wipeAll(); + final result = await LocalDb.importFromDbFile(path); + expect(result.refusedPrefs, ['notif_fired:2026-09-21:low_read']); + }); + + test('rows of the right type, and keys the app never reads, restore as-is', + () async { + await wipeAll(); + await setPrefs({ + 'local_profile_json': '{"name":"Owner"}', 'notif_water_interval': 90, + 'notif_relay_packages': ['a.b'], 'future.setting': 'also travels', + }); + final path = await LocalDb.exportCopy(); + await wipeAll(); + final result = await LocalDb.importFromDbFile(path); + expect(result.refusedPrefs, isEmpty); + expect((await allPrefs())['notif_water_interval'], 90); + expect((await allPrefs())['future.setting'], 'also travels'); + }); + + Future exportCustomServer() async { + await wipeAll(); + await setPrefs({'coach_base_url': 'https://llm.example.net/v1'}); + return LocalDb.exportCopy(); + } + + test('a destination that saved the default server still needs re-entry', + () async { + final path = await exportCustomServer(); + await wipeAll(); + await setPrefs({'coach_base_url': ' https://api.openai.com/v1 '}); + final result = await LocalDb.importFromDbFile(path); + expect(result, needsReentry(['coach_base_url'])); + }); + + test('a destination with its own custom server is not asked again', () async { + final path = await exportCustomServer(); + await wipeAll(); + await setPrefs({'coach_base_url': 'https://mine.example.net/v1'}); + final result = await LocalDb.importFromDbFile(path); + expect(result.needsReentry, isEmpty); + }); +} diff --git a/test/backup/pref_types_guard_test.dart b/test/backup/pref_types_guard_test.dart new file mode 100644 index 000000000..450206010 --- /dev/null +++ b/test/backup/pref_types_guard_test.dart @@ -0,0 +1,190 @@ +// Every backed-up preference the app reads with a typed getter has its stored +// type in kPrefTypes (or a family in kPrefTypeFamilies). Restore refuses a row +// of another type; without an entry here that check silently does not apply +// and a mistyped row would make the getter throw on every later launch. +// +// Scans lib/ for `.getString/getBool/getInt/getDouble/getStringList()`. +// A key is a literal or a `const` name (aliases followed). A key built at run +// time must be listed in _dynamicReads with sample keys it can take. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/data/backup_extras.dart'; + +/// "file|expression|type" -> keys the call can read. The table must give each +/// one that exact type. `*` as the file skips the file check. +const _dynamicReads = >{ + 'briefing.dart|_slotKey(period|string': ['ai.briefing.morning'], + 'ecg_guard_store.dart|guardKey(serial|bool': ['ecg.guard.S1'], + 'ecg_guard_store.dart|wristKey(serial|string': ['ecg.wrist.S1'], + 'ecg_guard_store.dart|maverickKey(serial|bool': ['ecg.maverick.S1'], + 'health_import_state.dart|what.key|int': [ + 'health_import_profile_at', 'health_import_workouts_at', + ], + 'device_alerts.dart|key|int': [ + 'device_alerts.last_charge_event_ts', 'device_alerts.last_charge_wall_sec', + 'device_alerts.charge_shown_day', 'notif_battery_pct', + ], + "fired_keys.dart|'\$_prefix\$dedupeKey'|bool": ['notif_fired:2026-09-21:x'], + 'notification_center.dart|prefsKey|string': [ + 'last_recovery_notif_day', 'last_stepgoal_day', + ], + 'notification_ids.dart|nextKey|int': ['notif_osnext:health'], + 'notification_ids.dart|slotKey|int': ['notif_osid:health:2026-09-21:x'], + 'notification_ids.dart|ownerKey|string': ['notif_osslot:health:3'], + 'notification_ids.dart|k|int': ['notif_osid:health:2026-09-21:x'], + 'nudges.dart|_dismissedKey(a|bool': ['nudge.pairing.dismissed'], + 'nudges.dart|_lastShownKey(a|int': ['nudge.pairing.last_shown_ms'], + // The typed wrappers; each caller passes a literal or a const that is + // scanned like any other read. + 'prefs.dart|key|int': [], + 'prefs.dart|key|string': [], + 'prefs.dart|key|bool': [], +}; + +/// A key read with two types is a bug in the app; none is excused today. +const _readWithTwoTypes = {}; + +final _constRe = RegExp( + r'''(?:static\s+)?const\s+(?:String\s+)?(\w+)\s*=\s*(?:'([^'$]*)'|"([^"$]*)")\s*;'''); +final _aliasRe = RegExp( + r'(?:static\s+)?const\s+(?:String\s+)?(\w+)\s*=\s*([A-Za-z_]\w*(?:\.\w+)?)\s*;'); +final _readRe = RegExp( + r'\.(getString|getBool|getInt|getDouble|getStringList)\(\s*([^,)]+?)\s*[,)]'); + +const _typeOf = { + 'getString': 'string', 'getBool': 'bool', 'getInt': 'int', + 'getDouble': 'double', 'getStringList': 'stringList', +}; + +String _base(String path) => path.split('/').last; + +void main() { + final files = Directory('lib').listSync(recursive: true) + .whereType().where((f) => f.path.endsWith('.dart')).toList(); + final text = {for (final f in files) f.path: f.readAsStringSync()}; + + // name -> value, per file and across lib (the latter only when unambiguous). + final local = >{}; + final global = >{}; + void add(String file, String name, String value) { + (local[file] ??= {})[name] = value; + (global[name] ??= {}).add(value); + } + + for (final e in text.entries) { + for (final m in _constRe.allMatches(e.value)) { + add(e.key, m.group(1)!, m.group(2) ?? m.group(3)!); + } + } + // Aliases (`const _k = otherConst;`), a few rounds deep. + for (var round = 0; round < 3; round++) { + for (final e in text.entries) { + for (final m in _aliasRe.allMatches(e.value)) { + final name = m.group(1)!; + if (local[e.key]?.containsKey(name) ?? false) continue; + final target = m.group(2)!.split('.').last; + final v = local[e.key]?[target] ?? + (global[target]?.length == 1 ? global[target]!.single : null); + if (v != null) add(e.key, name, v); + } + } + } + + String? resolve(String file, String expr) { + if ((expr.startsWith("'") && expr.endsWith("'")) || + (expr.startsWith('"') && expr.endsWith('"'))) { + return expr.contains(r'$') ? null : expr.substring(1, expr.length - 1); + } + final name = expr.split('.').last; + return local[file]?[name] ?? + (global[name]?.length == 1 ? global[name]!.single : null); + } + + final reads = >>{}; // key -> type -> sites + final dynamicSeen = {}; + final unlisted = []; + for (final e in text.entries) { + for (final m in _readRe.allMatches(e.value)) { + final type = _typeOf[m.group(1)]!; + final expr = m.group(2)!; + final line = e.value.substring(0, m.start).split('\n').length; + final key = resolve(e.key, expr); + if (key != null) { + ((reads[key] ??= {})[type] ??= {}).add('${e.key}:$line'); + continue; + } + final id = '${_base(e.key)}|$expr|$type'; + if (_dynamicReads.containsKey(id)) { + dynamicSeen.add(id); + } else { + unlisted.add('${e.key}:$line $expr ($type)'); + } + } + } + + test('the scan finds the reads it is meant to guard', () { + expect(reads.length, greaterThan(70)); + expect(reads['local_profile_json']!.keys, {'string'}); + expect(reads['notif_water_interval']!.keys, {'int'}); + expect(reads['notif_relay_packages']!.keys, {'stringList'}); + }); + + test('a key read at run time is listed with the keys it can take', () { + expect(unlisted, isEmpty, + reason: 'add each to _dynamicReads with sample keys, or use a const'); + expect(_dynamicReads.keys.toSet().difference(dynamicSeen), isEmpty, + reason: 'stale _dynamicReads entries'); + }); + + test('no key is read with two different types', () { + final two = { + for (final e in reads.entries) + if (e.value.length > 1) e.key: e.value, + }; + expect(two.keys.toSet().difference(_readWithTwoTypes.keys.toSet()), isEmpty, + reason: '$two'); + }); + + test('every backed-up key that is read has its type in the table', () { + final wrong = []; + void check(String key, String type, String where) { + if (kPrefsNotBackedUp.containsKey(key)) return; + final want = expectedPrefType(key); + if (want != type) wrong.add('$key read as $type at $where; table: $want'); + } + + for (final e in reads.entries) { + for (final t in e.value.entries) { + check(e.key, t.key, t.value.first); + } + } + for (final e in _dynamicReads.entries) { + final type = e.key.split('|').last; + for (final k in e.value) { + check(k, type, e.key); + } + } + expect(wrong, isEmpty); + }); + + test('the table has no entry nothing reads, and none that is denylisted', () { + final read = { + ...reads.keys, + for (final v in _dynamicReads.values) ...v, + }; + expect(kPrefTypes.keys.where((k) => !read.contains(k)), isEmpty, + reason: 'stale kPrefTypes entries'); + expect(kPrefTypes.keys.where(kPrefsNotBackedUp.containsKey), isEmpty, + reason: 'a denylisted key never reaches restore'); + for (final t in kPrefTypes.values) { + expect(const {'string', 'bool', 'int', 'double', 'stringList'}, contains(t)); + } + for (final (prefix, suffix, type) in kPrefTypeFamilies) { + expect(prefix, isNotEmpty); + expect(suffix, isA()); + expect(const {'string', 'bool', 'int', 'double', 'stringList'}, contains(type)); + } + }); +} diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart index 8e6184e2c..4187d1c97 100644 --- a/test/backup/settings_audit_guard_test.dart +++ b/test/backup/settings_audit_guard_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/backup_extras.dart' show expectedPrefType; import 'package:openstrap_edge/data/db.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -179,7 +180,16 @@ void main() { test('device and transient keys never travel, including foreign backups', () async { await env.wipe(); - await setPrefs({for (final key in {..._mustTravel, ..._mustStay}) key: 'audit value'}); + // Typed as the app reads them: restore refuses a row of another type. + await setPrefs({ + for (final key in {..._mustTravel, ..._mustStay}) key: switch (expectedPrefType(key)) { + 'bool' => true, + 'int' => 1, + 'double' => 1.5, + 'stringList' => ['audit value'], + _ => 'audit value', + }, + }); final path = await LocalDb.exportCopy(); final prefs = (await exportedPrefs(path))!; for (final key in _mustStay) { From 1f04a6591a031fe876a1333edbf29d9ca4641c9e Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 18:03:48 -0700 Subject: [PATCH 11/15] fix(backup): an import no longer drops Monday from the alarm schedule The import pages each table by rowid starting at `rowid > 0`. alarm_schedule is keyed on its INTEGER `weekday`, so that column is the rowid and weekday 0 (Monday) was never read. The cursor now starts below every rowid. The settings_complete weekday test only caught this on days when tomorrow is a Monday; the new test checks all seven weekdays. --- lib/data/db.dart | 5 +++- test/backup/import_rowid_zero_test.dart | 34 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 test/backup/import_rowid_zero_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index a99eb2ca2..e40bf81e7 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -12343,7 +12343,10 @@ class LocalDb { final isArchive = t == SubstrateArchive.table; final pageSize = isArchive ? 8 : 2000; const rowidKey = '_rowid'; - var lastRowid = 0; + // Start below every rowid, not at 0: a table keyed on its own + // INTEGER column (alarm_schedule's weekday) has that column as its + // rowid, and `rowid > 0` dropped Monday (weekday 0) from every import. + var lastRowid = -0x8000000000000000; final projection = isArchive ? SubstrateArchive.metaColumns.join(', ') : '*'; diff --git a/test/backup/import_rowid_zero_test.dart b/test/backup/import_rowid_zero_test.dart new file mode 100644 index 000000000..c8c9d4583 --- /dev/null +++ b/test/backup/import_rowid_zero_test.dart @@ -0,0 +1,34 @@ +// The import pages each table by rowid. alarm_schedule is keyed on `weekday`, +// so its rowid IS the weekday, and a cursor that started at `rowid > 0` never +// read Monday (weekday 0): a restored schedule lost its Monday alarm. The +// settings_complete weekday test only hit this when tomorrow was a Monday. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/data/db.dart'; + +import 'backup_support.dart'; + +void main() { + late BackupEnv env; + setUpAll(() async => env = await BackupEnv.create('bkx_rowid_zero_test.db')); + tearDownAll(() async => env.dispose()); + + test('every weekday of the alarm schedule survives export and import, ' + 'Monday (weekday 0, rowid 0) included', () async { + await env.wipe(); + for (var weekday = 0; weekday < 7; weekday++) { + await LocalDb.setAlarmScheduleDay(weekday: weekday, hour: 6, + minute: weekday, enabled: true, smartWindowMinutes: 10); + } + final path = await LocalDb.exportCopy(); + await env.wipe(); + expect(await LocalDb.alarmScheduleRows(), isEmpty); + + await LocalDb.importFromDbFile(path); + + final rows = await LocalDb.alarmScheduleRows(); + expect({for (final r in rows) r['weekday']: r['minute']}, + {for (var weekday = 0; weekday < 7; weekday++) weekday: weekday}); + }); +} From 1535082ab2eae382d9a52ec652bcdd9b059e31b6 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 18:19:05 -0700 Subject: [PATCH 12/15] fix(backup): the bulk restore and raw archive merge keep rows at rowid 0 or below The same cursor bug as the alarm schedule, on the other restore paths. The bulk chunk bounds and the raw archive merge started at `rowid > 0`, and the bulk path skipped a table outright when its highest rowid was 0 or below. A legacy table keyed on its own INTEGER column (a zero counter) lost that row. Every restore cursor now starts at one shared constant below every rowid. --- lib/data/db.dart | 24 ++++++++++++++---------- test/import_bulk_restore_test.dart | 30 +++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index e40bf81e7..0dd7de215 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -217,6 +217,13 @@ List sessionWindowRows>(List rows) { /// widen [derivableSourceSql] and must NOT widen this one. const String kPrimaryBandSourceSql = 'source IS NULL'; +/// The keyset cursor's start when a restore pages a backup table by rowid. +/// Below every rowid, not 0: a table keyed on its own INTEGER column has that +/// column as its rowid, so a row can sit at 0 or below (alarm_schedule's +/// Monday is weekday 0, and a legacy table can hold a zero counter), and a +/// cursor that starts at `rowid > 0` never reads it. +const int _kBeforeEveryRowid = -0x8000000000000000; + class LocalDb { static Database? _db; static String dbName = 'openstrap.db'; @@ -11846,7 +11853,7 @@ class LocalDb { .any((c) => c['name'] == 'device_id'); final deviceSql = hasDevice ? 'device_id' : "'$kPrimaryDeviceId' AS device_id"; - var after = 0; + var after = _kBeforeEveryRowid; while (true) { final page = await src.rawQuery( 'SELECT rowid AS _r, $deviceSql, $hexSql AS hex, counter, ' @@ -12030,7 +12037,7 @@ class LocalDb { final maxRowid = Sqflite.firstIntValue( await db.rawQuery('SELECT MAX(rowid) FROM imp.${quoteSql(t)}'), ); - if (maxRowid == null || maxRowid <= 0) { + if (maxRowid == null) { counts[t] = 0; return true; } @@ -12146,7 +12153,7 @@ class LocalDb { // `rec_ts INTEGER PRIMARY KEY` table has rowids near 1.8e9, which // fixed 25k ranges walked as ~70k mostly empty transactions. final ends = []; - for (var lo = 0;;) { + for (var lo = _kBeforeEveryRowid;;) { final hi = Sqflite.firstIntValue(await db.rawQuery( 'SELECT MAX(r) FROM (SELECT rowid AS r FROM imp.${quoteSql(t)} ' 'WHERE rowid > ? ORDER BY rowid LIMIT $chunkRows)', @@ -12183,12 +12190,12 @@ class LocalDb { AND ${numericSql(sourceColumn('beat_index'))} GROUP BY 1, 2) c LEFT JOIN temp._restore_rr_src_max x ON x.d = c.d AND x.k = c.k''', - [i == 0 ? 0 : ends[i - 1], ends[i]]); + [i == 0 ? _kBeforeEveryRowid : ends[i - 1], ends[i]]); } } - var lower = 0; + var lower = _kBeforeEveryRowid; for (final upper in ends) { - final first = lower == 0; + final first = lower == _kBeforeEveryRowid; await db.transaction((txn) async { if (t == 'day_result' && first) { // Inside the first chunk's transaction, so a day the engine @@ -12343,10 +12350,7 @@ class LocalDb { final isArchive = t == SubstrateArchive.table; final pageSize = isArchive ? 8 : 2000; const rowidKey = '_rowid'; - // Start below every rowid, not at 0: a table keyed on its own - // INTEGER column (alarm_schedule's weekday) has that column as its - // rowid, and `rowid > 0` dropped Monday (weekday 0) from every import. - var lastRowid = -0x8000000000000000; + var lastRowid = _kBeforeEveryRowid; final projection = isArchive ? SubstrateArchive.metaColumns.join(', ') : '*'; diff --git a/test/import_bulk_restore_test.dart b/test/import_bulk_restore_test.dart index 989f7d478..0204f908f 100644 --- a/test/import_bulk_restore_test.dart +++ b/test/import_bulk_restore_test.dart @@ -623,7 +623,8 @@ void main() { expect(sql, matches(RegExp(r'rowid\s*<=\s*\?', caseSensitive: false))); final args = prep[i]['arguments'] as List; expect(args, hasLength(2)); - expect(args[0], i * 25000); + // The first chunk starts below every rowid (rows can sit at 0 or below). + expect(args[0], i == 0 ? -0x8000000000000000 : i * 25000); expect(args[1], inInclusiveRange(i == 0 ? 25000 : n, (i + 1) * 25000)); } }, timeout: const Timeout(Duration(minutes: 5))); @@ -730,6 +731,33 @@ void main() { r['beat_index']], [0, 1, 2, 3]); }); + // A legacy table keyed on its own INTEGER column has that column as its + // rowid, so a zero counter is rowid 0. The chunk bounds started at + // `rowid > 0`, and a table whose highest rowid was 0 was skipped outright. + for (final counters in [ + [0, 1], + [-1, 0], + ]) { + test('bulk restore keeps rows at rowid $counters', () async { + final path = p.join(tmp.path, 'zero_rowid_src.db'); + await databaseFactoryFfi.deleteDatabase(path); + final src = await databaseFactoryFfi.openDatabase(path); + await src.execute('CREATE TABLE samples ' + '(counter INTEGER PRIMARY KEY, ts INTEGER, hr INTEGER)'); + for (final (i, c) in counters.indexed) { + await src.insert('samples', {'counter': c, 'ts': _t0 + i, 'hr': 60 + i}); + } + await src.close(); + await freshLocal(); + await LocalDb.importFromDbFile(path); + final rows = await (await LocalDb.instance) + .query('samples', columns: ['ts', 'hr'], orderBy: 'ts'); + expect(rows, [ + for (final (i, _) in counters.indexed) {'ts': _t0 + i, 'hr': 60 + i}, + ]); + }); + } + test('sparse source rowids are paged by row, not by rowid range', () async { final path = p.join(tmp.path, 'sparse_rowid_src.db'); await databaseFactoryFfi.deleteDatabase(path); From 9673b1c83f6dc22868a02740190d573ddc9713a0 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 18:54:39 -0700 Subject: [PATCH 13/15] fix(backup): an import no longer undoes Cancel-all Cancel-all deletes every alarm_schedule row, so the restore could not tell a cancelled schedule from one never set and refilled it from an older backup, which the post-import reload then re-armed. Cancel-all now sets the device-only pref alarm_schedule_cleared (not backed up); configuring any weekday clears it. A non-fresh import with the marker set skips the backup's alarm_schedule rows. A fresh install restores as before. --- lib/data/backup_extras.dart | 2 + lib/data/db.dart | 17 ++++- lib/state/app_state.dart | 13 +++- test/backup/settings_audit_guard_test.dart | 2 +- test/backup/settings_complete_test.dart | 89 ++++++++++++++++++++++ 5 files changed, 117 insertions(+), 6 deletions(-) diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index 34027ee6e..bcd398127 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -38,6 +38,8 @@ const Map kPrefsNotBackedUp = { 'telemetry_outbox': 'queued diagnostics belong to this install and its consent', 'device_alerts.low_armed': 'low-battery latch belongs to the old band; ' 'the new band must establish its own battery state', + 'alarm_schedule_cleared': 'records that Cancel-all ran on this phone, so an ' + 'import cannot bring a cancelled alarm back', 'alarm_epoch': 'arm belongs to the old band; the restored schedule must arm again', 'alarm_epoch_confirmed': 'confirmation belongs to an arm on the old band', 'alarm_set_at_ms': 'confirmation replay floor for the old band arm', diff --git a/lib/data/db.dart b/lib/data/db.dart index 0dd7de215..a9a51101f 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11686,7 +11686,14 @@ class LocalDb { await src.close(); } final fresh = hasSettings && await _backupPrefsFreshInstall(db); - final counts = await _mergeFromDbFile(path); + // Cancel-all deletes the schedule rows, so an empty table cannot tell + // "cancelled" from "never set": the marker says which, and a cancelled + // install keeps it empty. A fresh install has nothing to protect. + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + final cancelled = !fresh && prefs.getBool('alarm_schedule_cleared') == true; + final counts = await _mergeFromDbFile(path, + skip: cancelled ? const {'alarm_schedule'} : const {}); final summary = await restoreBackupPrefs(path, counts, freshInstall: fresh); // Persist only after settings restore succeeds, including a settings-only // backup without an onboarding choice. A later restore keeps local edits. @@ -11917,13 +11924,15 @@ class LocalDb { String path, { List? only, bool tolerant = false, + Set skip = const {}, }) => _withReaderInvalidated(() => - _mergeFromDbFileImpl(path, only: only, tolerant: tolerant)); + _mergeFromDbFileImpl(path, only: only, tolerant: tolerant, skip: skip)); static Future> _mergeFromDbFileImpl( String path, { List? only, bool tolerant = false, + Set skip = const {}, }) async { final db = await instance; final src = await openDatabase(path, readOnly: true); @@ -12312,6 +12321,10 @@ class LocalDb { : (srcEdge == null || localEdge > srcEdge ? localEdge : srcEdge); } for (final t in (only ?? tables)) { + if (skip.contains(t)) { + counts[t] = 0; + continue; + } if (t == 'raw_archive') { try { counts[t] = await _mergeRawArchive(src, db); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index a502615d8..b48380436 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -4892,9 +4892,9 @@ class AppState extends ChangeNotifier { /// One-time 49→50 seed: a legacy single-alarm value with nothing yet in /// `alarm_schedule` becomes that weekday's slot. Safe to call on every - /// launch — it is a no-op the moment ANY row exists, including a schedule - /// the user has since cleared via Cancel-all, which must stay cleared - /// rather than resurrect the old value. + /// launch — it is a no-op the moment ANY row exists, and after Cancel-all + /// (which deletes the rows) `_savedAlarm` is null, so a schedule the user + /// cleared stays cleared rather than resurrecting the old value. Future _seedAlarmScheduleFromLegacyIfNeeded() async { try { if (_savedAlarm == null) return; @@ -4955,6 +4955,8 @@ class AppState extends ChangeNotifier { enabled: next.enabled, smartWindowMinutes: next.smartWindowMinutes, ); + // The user chose a slot: the schedule is no longer "cancelled". + await (await SharedPreferences.getInstance()).remove('alarm_schedule_cleared'); await _loadAlarmSchedule(); notifyListeners(); // The 7pm "no alarm set for tonight" check keys off the schedule: a night @@ -5460,6 +5462,11 @@ class AppState extends ChangeNotifier { // An arm that landed during the await owns the persisted epoch now. if (_armGen == gen) await prefs.remove('alarm_epoch'); if (_armGen == gen) await prefs.remove('alarm_epoch_confirmed'); + // 'alarm_schedule_cleared' tells an import that an empty schedule means + // "cancelled", not "never set". Marked BEFORE the rows go: a crash + // between the two leaves a marker over rows (harmless), never an empty + // table an import could refill. + await prefs.setBool('alarm_schedule_cleared', true); await LocalDb.clearAlarmSchedule(); _schedule = fillDefaultAlarmSchedule(const []); notifyListeners(); diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart index 4187d1c97..51edb999c 100644 --- a/test/backup/settings_audit_guard_test.dart +++ b/test/backup/settings_audit_guard_test.dart @@ -62,7 +62,7 @@ const _mustStay = { 'coach_api_key_present', 'coach_chatgpt_present', 'coach_chatgpt_client_id', 'coach_chatgpt_host_id', 'coach_chatgpt_welcomed', 'coach_base_url', 'backup.folder', 'backup.last_run_ms', 'backup.last_error', 'health_upload_last_ts', - 'alarm_epoch', 'alarm_epoch_confirmed', 'alarm_set_at_ms', + 'alarm_schedule_cleared', 'alarm_epoch', 'alarm_epoch_confirmed', 'alarm_set_at_ms', 'alarm_pending_until', 'alarm_latch_alert', 'alarm_probe_pending', 'alarm_fired_at', 'live.session_draft', 'live_session_checkpoint', 'telemetry_outbox', }; diff --git a/test/backup/settings_complete_test.dart b/test/backup/settings_complete_test.dart index a521f6e1e..ce8f63388 100644 --- a/test/backup/settings_complete_test.dart +++ b/test/backup/settings_complete_test.dart @@ -95,6 +95,95 @@ void main() { }); } + group('Cancel-all survives an import', () { + late BleEngine engine; + late List writes; + late AppState app; + + Future connect() async { + engine = BleEngine(onRecord: (_, _) async {}, onState: (_) {}); + writes = []; + engine.debugInstallFakeLink(band: proto.BandProfile.gen4, + onWrite: (bytes) async { + final inner = proto.parseFrame(bytes, profile: proto.BandProfile.gen4)!.inner; + if (inner[2] == proto.Cmd.setAlarmTime) { + writes.add(inner[2]); + final reply = proto.buildFrame([ + 0x24, 0x94, proto.Cmd.setAlarmTime, inner[1], 0x01, + proto.AlarmStatus.validInputPattern, + ], profile: proto.BandProfile.gen4); + engine.debugProcessImmediateFrame( + proto.parseFrame(reply, profile: proto.BandProfile.gen4)!); + } else if (inner[2] == proto.Cmd.disableAlarm) { + final reply = proto.buildFrame( + [0x24, 0x94, proto.Cmd.disableAlarm, inner[1], 0x01, 0x00], + profile: proto.BandProfile.gen4); + engine.debugProcessImmediateFrame( + proto.parseFrame(reply, profile: proto.BandProfile.gen4)!); + } + return true; + }); + app = AppState.forTesting(engine: engine); + addTearDown(app.dispose); + app.device.connection = 'connected'; + } + + // A schedule exported, then cancelled on a install that has data. + Future exportThenCancel() async { + await wipeAll(); + await env.seedData(); + await LocalDb.setAlarmScheduleDay( + weekday: 2, hour: 7, minute: 15, enabled: true); + final path = await LocalDb.exportCopy(); + await connect(); + await app.disableAlarm(); + expect(await LocalDb.alarmScheduleRows(), isEmpty); + writes.clear(); + return path; + } + + test('an import does not bring a cancelled schedule back', () async { + final path = await exportThenCancel(); + await app.importEdgeBackup(path); + expect(await LocalDb.alarmScheduleRows(), isEmpty); + expect(app.alarmSchedule.where((e) => e.enabled), isEmpty); + expect(writes, isEmpty, reason: 'nothing re-arms the band'); + }); + + test('configuring a weekday after Cancel-all lets a later import fill ' + 'absent slots again', () async { + final path = await exportThenCancel(); + await app.setScheduleDay(weekday: 4, hour: 6, minute: 0, enabled: true); + await app.importEdgeBackup(path); + final byDay = { + for (final r in await LocalDb.alarmScheduleRows()) r['weekday']: r, + }; + expect(byDay.keys.toSet(), {2, 4}); + expect(byDay[2]!['enabled'], 1); + expect(byDay[4]!['hour'], 6, reason: 'the local row still wins'); + }); + + test('a fresh install still restores the schedule', () async { + await wipeAll(); + await LocalDb.setAlarmScheduleDay( + weekday: 2, hour: 7, minute: 15, enabled: true); + final path = await LocalDb.exportCopy(); + await wipeAll(); + await LocalDb.importFromDbFile(path); + expect(await LocalDb.alarmScheduleRows(), hasLength(1)); + }); + + test('the cancel marker is device state: it is not exported', () async { + final path = await exportThenCancel(); + expect((await exportedPrefs(path))!.keys, isNot(contains('alarm_schedule_cleared'))); + await wipeAll(); + await setPrefs({'alarm_schedule_cleared': true}); + final out = await LocalDb.exportCopy(); + expect((await exportedPrefs(out))?.keys ?? [], + isNot(contains('alarm_schedule_cleared'))); + }); + }); + test('a DB-only alarm restore establishes settings before a later import', () async { await wipeAll(); From 1f43d8be7a7170b24e067566e4ffb2217cdc96c1 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:32:56 +0530 Subject: [PATCH 14/15] backup: companion_url no longer travels (a restored file could redirect uploads), refuse non-finite doubles on restore --- lib/data/backup_extras.dart | 7 +++++-- test/backup/settings_audit_guard_test.dart | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index bcd398127..80f857027 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -29,6 +29,8 @@ const Map kPrefsNotBackedUp = { 'coach_chatgpt_welcomed': 'the new sign-in must show its welcome on this phone', 'coach_base_url': 'a custom server address can carry an access key; ' 'a non-default one is reported for re-entry', + 'companion_url': 'a server address a restored file must not be able to set; ' + 'it redirects announcements, updates and uploads', 'backup.folder': 'storage-access URI permission belongs to this phone', 'backup.last_run_ms': 'status of this install\'s automatic backup', 'backup.last_error': 'status of this install\'s automatic backup', @@ -101,7 +103,6 @@ const Map kPrefTypes = { 'coach_model': 'string', 'coach_timeout_seconds': 'int', 'coach_use_chatgpt': 'bool', - 'companion_url': 'string', 'cycle_tracking_enabled': 'bool', 'dev.mode': 'bool', 'device_alerts.charge_shown_day': 'int', @@ -253,7 +254,9 @@ Future _write(SharedPreferences prefs, String key, case 'int': return prefs.setInt(key, int.parse(value)); case 'double': - return prefs.setDouble(key, double.parse(value)); + final d = double.parse(value); + if (!d.isFinite) throw const FormatException('invalid preference number'); + return prefs.setDouble(key, d); case 'string': return prefs.setString(key, value); case 'stringList': diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart index 51edb999c..5d55e803c 100644 --- a/test/backup/settings_audit_guard_test.dart +++ b/test/backup/settings_audit_guard_test.dart @@ -17,7 +17,7 @@ const _mustTravel = { 'onboard.skipped_pairing', 'onboard.saw_profile_setup', 'backup.cadence', 'gesture_double_tap', 'band.strap_name', 'health_sync', 'phone_steps', 'cycle_tracking_enabled', 'update_checks_enabled', - 'companion_url', 'nutrition.barcode_lookup', 'share.map_tiles', 'dev.mode', + 'nutrition.barcode_lookup', 'share.map_tiles', 'dev.mode', 'coach_api', 'coach_model', 'coach_timeout_seconds', 'coach_use_chatgpt', 'ai_morning_enabled', 'ai_morning_min', 'ai_evening_enabled', 'ai_evening_min', @@ -60,7 +60,7 @@ const _mustStay = { 'paired_worn_on', 'ble.ask_add_pending', 'consent_telemetry', 'consent_health_data', 'consent_chosen', 'coach_api_key_present', 'coach_chatgpt_present', 'coach_chatgpt_client_id', - 'coach_chatgpt_host_id', 'coach_chatgpt_welcomed', 'coach_base_url', + 'coach_chatgpt_host_id', 'coach_chatgpt_welcomed', 'coach_base_url', 'companion_url', 'backup.folder', 'backup.last_run_ms', 'backup.last_error', 'health_upload_last_ts', 'alarm_schedule_cleared', 'alarm_epoch', 'alarm_epoch_confirmed', 'alarm_set_at_ms', 'alarm_pending_until', 'alarm_latch_alert', 'alarm_probe_pending', 'alarm_fired_at', From 900f5af265e7e56717b409ae372e9ea676b04a06 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Tue, 10 Nov 2026 15:44:51 -0700 Subject: [PATCH 15/15] fix(backup): read the Cancel-all marker only when the backup carries a schedule 9673b1c8 read SharedPreferences on every import, so every import test without a prefs mock (36 tests in 11 files) failed before merging a row. Skipping an absent or empty alarm_schedule changes nothing, so the marker is now consulted only when the backup has schedule rows. Database- only imports no longer touch the preference store. Co-Authored-By: Claude Opus 5.5 --- lib/data/db.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index a9a51101f..234facc22 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11680,18 +11680,26 @@ class LocalDb { // before merge, which can make a new phone look established. final src = await openDatabase(path, readOnly: true, singleInstance: false); final bool hasSettings; + final bool hasSchedule; try { hasSettings = await _objectType(src, 'backup_prefs') == 'table'; + hasSchedule = await _objectType(src, 'alarm_schedule') == 'table' && + (await src.rawQuery('SELECT 1 FROM alarm_schedule LIMIT 1')) + .isNotEmpty; } finally { await src.close(); } final fresh = hasSettings && await _backupPrefsFreshInstall(db); // Cancel-all deletes the schedule rows, so an empty table cannot tell // "cancelled" from "never set": the marker says which, and a cancelled - // install keeps it empty. A fresh install has nothing to protect. - final prefs = await SharedPreferences.getInstance(); - await prefs.reload(); - final cancelled = !fresh && prefs.getBool('alarm_schedule_cleared') == true; + // install keeps it empty. A fresh install has nothing to protect, and a + // backup with no schedule rows has nothing to bring back. + var cancelled = false; + if (hasSchedule && !fresh) { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + cancelled = prefs.getBool('alarm_schedule_cleared') == true; + } final counts = await _mergeFromDbFile(path, skip: cancelled ? const {'alarm_schedule'} : const {}); final summary = await restoreBackupPrefs(path, counts, freshInstall: fresh);