diff --git a/lib/app.dart b/lib/app.dart index 3e58c40e0..645789558 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,34 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver }); } + void _onSettingsRestored() { + if (!mounted) return; + 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 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..bcd398127 --- /dev/null +++ b/lib/data/backup_extras.dart @@ -0,0 +1,344 @@ +// 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'; + +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', + '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', + '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', + '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', + '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', + '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', + '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', +}; + +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 [], + 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; +} + +/// 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', + '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) { + 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'}); + } + 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'}); + } + }); + } 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'); + } +} + +/// 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 { + 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(); + for (final row in await src.query('backup_prefs')) { + final key = row['key'] as String; + if (!_backedUp(key) || (!freshInstall && prefs.containsKey(key))) { + continue; + } + // 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 = expectedPrefType(key); + if (want != null && row['type'] != want) { + refused.add(key); + continue; + } + 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); + } + } + final needs = []; + final unreadable = []; + if (await _hasTable(src, 'backup_secrets') && + (await src.query('backup_secrets', where: 'key = ?', + whereArgs: ['coach_api_key'])).isNotEmpty) { + try { + final key = await const FlutterSecureStorage().read(key: 'coach_api_key') + .timeout(const Duration(seconds: 6)); + if (key == null || key.isEmpty) needs.add('coach_api_key'); + } catch (_) { + unreadable.add('coach_api_key'); + } + } + if (await _hasTable(src, 'backup_secrets') && + (await src.query('backup_secrets', where: 'key = ?', + whereArgs: ['coach_base_url'])).isNotEmpty && + _needsServerReentry((await SharedPreferences.getInstance()) + .getString('coach_base_url'))) { + 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), + refusedPrefs: List.unmodifiable(refused)); + } finally { + await src.close(); + } +} diff --git a/lib/data/db.dart b/lib/data/db.dart index c3ab9f1f6..a9a51101f 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; @@ -216,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'; @@ -292,6 +300,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', @@ -10727,11 +10737,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 +10780,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 +11616,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 +11632,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 +11644,63 @@ class LocalDb { } } + /// 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; + } + 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']}; + 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; + // 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); + // 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. + if (hasSettings) await setCursor(_kSettingsImported, '1'); + return summary; + } + /// Merge every table [tables] names from the database file at [path] into /// this one. /// @@ -11646,6 +11718,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', @@ -11748,6 +11821,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'), @@ -11785,7 +11860,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, ' @@ -11849,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); @@ -11969,7 +12046,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; } @@ -12085,7 +12162,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)', @@ -12122,12 +12199,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 @@ -12244,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); @@ -12282,7 +12363,7 @@ class LocalDb { final isArchive = t == SubstrateArchive.table; final pageSize = isArchive ? 8 : 2000; const rowidKey = '_rowid'; - var lastRowid = 0; + var lastRowid = _kBeforeEveryRowid; final projection = isArchive ? SubstrateArchive.metaColumns.join(', ') : '*'; @@ -12622,6 +12703,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/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fe1fa7f57..616f99098 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3472,5 +3472,19 @@ "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.", + "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.", + "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": { + "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..d40903899 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16331,5 +16331,19 @@ "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.", + "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.", + "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": { + "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..a2c3c76a6 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2890,5 +2890,19 @@ "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.", + "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.", + "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": { + "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..cd106dc01 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2861,5 +2861,19 @@ "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.", + "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.", + "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": { + "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..9e879ed1a 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2861,5 +2861,19 @@ "type": "int" } } + }, + "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": { + "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..53fb2ed76 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7904,5 +7904,19 @@ "type": "int" } } + }, + "welcomeCoachKeyReentry": "API-ключ тренера остаётся в защищённом хранилище и не включён в резервную копию. Введите его снова в настройках ИИ, чтобы пользоваться тренером на этом телефоне.", + "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"}} + }, + "welcomeSettingsSkippedBody": "Для них остаются значения этого телефона.", + "welcomeCoachChatGptReentry": "Данные входа в ChatGPT остаются в защищённом хранилище и не включены в резервную копию. Войдите снова в настройках ИИ, чтобы пользоваться тренером на этом телефоне.", + "welcomeCoachServerReentry": "Адрес вашего собственного сервера коуча не сохранён в резервной копии, потому что адрес может содержать ключ. Введите его снова в настройках ИИ.", + "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..e6b99d95a 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16325,5 +16325,19 @@ "type": "int" } } + }, + "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": { + "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..18fef6f1f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2861,5 +2861,19 @@ "type": "int" } } + }, + "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": { + "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..b48380436 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,41 @@ 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; + 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. + await _loadProfile(loadOnboardChoice: false); + final prefs = await SharedPreferences.getInstance(); + CompanionClient.overrideUrl = prefs.getString(_kCompanionUrl); + await gestureSettings.bootstrap(); + 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)); + } catch (e) { + _log('[import] coach settings reload failed: $e'); + } + settingsRestoredSignal.value++; + } + Future importEdgeBackup(String path) async { + lastImportNeedsReentry = const []; + lastImportPrefsRestored = 0; + lastImportPrefsRefused = 0; importRollupError = null; lastImportArchiveSkipped = 0; lastImportArchiveRestored = 0; @@ -741,12 +776,17 @@ 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); + await _adoptRestoredSettings(counts); 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 @@ -4852,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; @@ -4915,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 @@ -5420,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/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..aa89cb08e 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -77,6 +77,11 @@ class ImportOutcome { /// re-importing corrects the (marker, date) rows it names. 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: /// note is 40122 characters". Shown up to a cap, because a validation the /// user cannot see is a silent drop. @@ -103,6 +108,9 @@ class ImportOutcome { this.archiveBucketsRestored = 0, this.journalRows = 0, this.labRows = 0, + this.prefsRestored = 0, + this.prefsRefused = 0, + this.needsReentry = const [], this.rejectedRows = const [], this.error, this.rollupError, @@ -120,6 +128,8 @@ class ImportOutcome { /// the gate waits for them instead of moving on to pairing. bool get needsReading => lostSomething || + needsReentry.isNotEmpty || + prefsRefused > 0 || rejectedRows.isNotEmpty || readError != null || rollupError != null; @@ -132,7 +142,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 +376,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, prefsRefused = 0; + final needsReentry = {}; final corruptTables = {}; var archiveSkipped = 0, archiveRestored = 0; final rejected = []; @@ -434,6 +446,9 @@ Future runImport( rollupError ??= app.importRollupError; archiveSkipped += app.lastImportArchiveSkipped; archiveRestored += app.lastImportArchiveRestored; + prefsRestored += app.lastImportPrefsRestored; + prefsRefused += app.lastImportPrefsRefused; + needsReentry.addAll(app.lastImportNeedsReentry); } } finally { // The decrypted copy is the whole health record in plaintext. It exists @@ -557,6 +572,9 @@ Future runImport( archiveBucketsRestored: archiveRestored, journalRows: journalRows, labRows: labRows, + prefsRestored: prefsRestored, + prefsRefused: prefsRefused, + needsReentry: needsReentry.toList(), rejectedRows: rejected, rollupError: rollupError, // A file that would not decrypt is reported the same way a file that would @@ -744,6 +762,40 @@ 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.', + icon: LucideIcons.fileWarning, + ); + + 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, + ), + 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 Widget build(BuildContext c) { final p = P.of(c); @@ -778,11 +830,13 @@ 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 && o.prefsRefused == 0) return nothing; return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ nothing, const SizedBox(height: S.x3), - StatusCard( + 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', _archiveSkipped(o, l), @@ -825,11 +879,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 +908,14 @@ class ImportReport extends StatelessWidget { ), ]), ), + if (o.needsReentry.isNotEmpty) ...[ + 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/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 new file mode 100644 index 000000000..d7a112eb8 --- /dev/null +++ b/test/backup/backup_support.dart @@ -0,0 +1,143 @@ +// 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'; +import 'package:openstrap_edge/data/backup_extras.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'); + } +} + +Map policyMap(String name) => switch (name) { + 'kPrefsNotBackedUp' => kPrefsNotBackedUp, + 'kSecretsNotBackedUp' => kSecretsNotBackedUp, + _ => throw ArgumentError.value(name), +}; + +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) => isA() + .having((r) => r.needsReentry, 'needsReentry', expected); 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}); + }); +} 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/prefs_roundtrip_test.dart b/test/backup/prefs_roundtrip_test.dart new file mode 100644 index 000000000..6a3881e7a --- /dev/null +++ b/test/backup/prefs_roundtrip_test.dart @@ -0,0 +1,168 @@ +// 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('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)); + 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..51edb999c --- /dev/null +++ b/test/backup/settings_audit_guard_test.dart @@ -0,0 +1,259 @@ +// 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/backup_extras.dart' show expectedPrefType; +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_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', + // 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 = { + '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', + '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_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', +}; + +// 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)!, +}; + +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 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) { + 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', '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(); + // 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) { + 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); + // 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'); + } + } + }); + + 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..ce8f63388 --- /dev/null +++ b/test/backup/settings_complete_test.dart @@ -0,0 +1,669 @@ +// 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 '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/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'; +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')); + tearDownAll(() async => env.dispose()); + + Future wipeAll() async { + await env.wipe(); + 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]); + }); + } + + 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(); + 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(); + 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('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('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 backup without settings cannot hand this phone its 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.execute('DROP TABLE backup_prefs'); + await copy.close(); + await wipeAll(); + await LocalDb.importFromDbFile(path); + expect(await LocalDb.getCursor('backup_settings_imported'), isNull, + 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 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); + 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(); + 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('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(); + 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'}); + 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(); + 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..5fa46f27c --- /dev/null +++ b/test/backup/settings_paths_test.dart @@ -0,0 +1,168 @@ +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_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'; +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')); + tearDownAll(() async => env.dispose()); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + 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 path = await LocalDb.exportCopy(includePrefs: false); + final tables = await tablesOf(path); + expect(tables.where((t) => t.startsWith('backup_')), isEmpty); + // 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 { + 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('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'}); + final path = await LocalDb.exportCopy(); + await env.wipe(); + final theme = ThemeController.seed(AppThemeChoice.light, Brightness.light); + addTearDown(theme.dispose); + await LocalDb.importFromDbFile(path); + 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..c775f3d5b --- /dev/null +++ b/test/backup/settings_presentation_test.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +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()); + }); + + testWidgets('a wrong-typed clock setting is refused; the other settings still apply', + (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, 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); + // 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); + 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', + 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); + } + }); + } + + 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); + }); +} 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/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); 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, ); }