From d006cd03417ba4dee1569471286eb12509f25b99 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Wed, 11 Nov 2026 09:32:03 +0100 Subject: [PATCH 1/4] Recognise the app's own backup by content, not only by name (#606) A backup renamed on its way through Drive or a file manager is still a SQLite file, so the import router handed it to the NOOP importer, which refused it for having no hrSample table. The router now opens a SQLite or gzipped file that is not named as a backup and routes it to the backup importer when it holds this app's tables. --- lib/import/import_container.dart | 51 +++++++++++++++++++++++ lib/ui2/onboarding/welcome.dart | 23 ++++------- test/import_routing_test.dart | 71 ++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 16 deletions(-) diff --git a/lib/import/import_container.dart b/lib/import/import_container.dart index 8075847b4..0d1ed9a8d 100644 --- a/lib/import/import_container.dart +++ b/lib/import/import_container.dart @@ -32,6 +32,7 @@ import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; /// What the first bytes of the picked file say it is. enum ImportContainer { @@ -147,6 +148,56 @@ bool noopCsvFirstRecordMatches(String text, {bool truncated = false}) { return false; } +/// True when [path] is a database THIS app wrote: an export, an auto-backup, +/// or a quarantined copy — judged by CONTENT when the name does not say so. +/// +/// The router used to go by name alone (`.db`, `.db.gz`, `.db.unopenable-`). +/// A backup that came back off Drive, a mail attachment or a file manager +/// under any other name is still a SQLite file, so it fell through to +/// [isNoopExport], which claims every SQLite file, and the user restoring +/// their own history was told "That database is not a NOOP backup" (#606). +/// `LocalDb.importFromDbFile` already reads by magic bytes for the same +/// reason; this makes the router agree with it. +/// +/// A name that says it is ours is still believed without opening the file, so +/// a damaged backup reaches the backup importer and gets ITS message. +Future isEdgeBackup(String path) async { + final lower = path.toLowerCase(); + if (lower.endsWith('.db') || + lower.endsWith('.db.gz') || + lower.contains('.db.unopenable-')) { + return true; + } + final kind = await sniffFile(path); + if (kind != ImportContainer.sqlite && kind != ImportContainer.gzip) { + return false; + } + // Unwraps the gzip; null when it does not hold a database. + final resolved = await resolveNoopDatabase(path); + if (resolved == null) return false; + try { + final src = await openDatabase(resolved.path, + readOnly: true, singleInstance: false); + try { + final tables = { + for (final r in await src + .rawQuery("SELECT name FROM sqlite_master WHERE type = 'table'")) + r['name'], + }; + // `hrSample` is NOOP's spine; `samples` and `day_result` are ours. + return !tables.contains('hrSample') && + (tables.contains('samples') || tables.contains('day_result')); + } finally { + await src.close(); + } + } catch (_) { + // Unreadable: whoever picks it up next owns the message. + return false; + } finally { + await resolved.dispose(); + } +} + /// True when [path] is a NOOP export — judged by CONTENT, not by name. /// /// The onboarding router used to switch on the extension: `.noopbak`/`.zip` diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index e290466d8..7fd5f9161 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -411,7 +411,11 @@ Future runImport( // multi-select and this used to return inside the winning branch, so a // backup selected alongside a vendor CSV imported the backup and threw the // CSV away without a word. - final db = [...plain.where(_isDbBackup), ...decrypted]; + final ownDb = {}; + for (final p in plain) { + if (await isEdgeBackup(p)) ownDb.add(p); + } + final db = [...plain.where(ownDb.contains), ...decrypted]; // Raw-vs-vendor is decided by what the file HOLDS, not by what it is called. // See [isNoopExport]: routing on the extension sent NOOP's raw-sensor `.csv` // to the vendor importer and WHOOP's `.zip` to the NOOP one — both files @@ -419,12 +423,12 @@ Future runImport( final raw = []; final csv = []; for (final p in plain) { - if (_isDbBackup(p)) continue; + if (ownDb.contains(p)) continue; (await isNoopExport(p) ? raw : csv).add(p); } if (decrypted.isNotEmpty) sources.add('Encrypted backup'); - if (plain.any(_isDbBackup)) sources.add('OpenStrap backup'); + if (ownDb.isNotEmpty) sources.add('OpenStrap backup'); try { for (final p in db) { days += await app.importEdgeBackup(p); @@ -624,19 +628,6 @@ Future decryptToTemp(String path, String passphrase) async { return dest; } -bool _isDbBackup(String path) { - final p = path.toLowerCase(); - // `.db.unopenable-` is what a corrupt-database rebuild quarantines the - // old file as, and the rebuilt card points the user straight at it. Matching - // only the `.db` suffix handed that SQLite file to the vendor-CSV importer. - // `.db.gz` is what the app's own automatic backup writes (auto_backup.dart's - // kBackupExtension). Leaving it out sent a user restoring their own backup - // down the vendor-CSV path, which is the one import that has to work. - return p.endsWith('.db') || - p.endsWith('.db.gz') || - p.contains('.db.unopenable-'); -} - class WelcomeView extends StatelessWidget { final bool busy; final ImportOutcome? outcome; diff --git a/test/import_routing_test.dart b/test/import_routing_test.dart index 69194a107..702196fe5 100644 --- a/test/import_routing_test.dart +++ b/test/import_routing_test.dart @@ -17,6 +17,7 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/ui2/onboarding/welcome.dart'; @@ -28,6 +29,13 @@ class _RoutingSpy extends AppState { final noop = []; final vendor = []; + final backup = []; + + @override + Future importEdgeBackup(String path) async { + backup.add(path); + return 1; + } @override Future importNoopCsv(String path, @@ -146,6 +154,69 @@ void main() { expect(app.vendor, isEmpty); }); + // #606: the app's own backup, renamed on its way through Drive or a file + // manager, was a SQLite file the NOOP path claimed: "That database is not a + // NOOP backup — it has no `hrSample` table". + group('own backup by content', () { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + Future sqlite(String name, String table) async { + final path = '${tmp.path}/$name'; + final db = await openDatabase(path, singleInstance: false); + await db.execute('CREATE TABLE $table (id INTEGER PRIMARY KEY)'); + await db.close(); + return path; + } + + test('a renamed backup goes to the backup importer', () async { + final app = _RoutingSpy(); + final path = await sqlite('openstrap-backup (1)', 'samples'); + + final out = await runImport(app, [path]); + + expect(app.backup, [path]); + expect(app.noop, isEmpty, reason: 'this is the #606 refusal'); + expect(out.source, contains('OpenStrap backup')); + }); + + test('a renamed gzipped auto-backup goes to the backup importer', + () async { + final app = _RoutingSpy(); + final plain = await sqlite('auto', 'day_result'); + final path = await write( + 'backup-2026-10-18.gz', gzip.encode(File(plain).readAsBytesSync())); + + await runImport(app, [path]); + + expect(app.backup, [path]); + expect(app.noop, isEmpty); + expect(app.vendor, isEmpty); + }); + + test('a bare NOOP database still goes to the NOOP importer', () async { + final app = _RoutingSpy(); + final path = await sqlite('noop-backup.sqlite', 'hrSample'); + + await runImport(app, [path]); + + expect(app.noop, [path]); + expect(app.backup, isEmpty); + }); + + test('a file named .db is still ours without being opened', () async { + final app = _RoutingSpy(); + final path = await write('damaged.db', utf8.encode('not a database')); + + await runImport(app, [path]); + + expect(app.backup, [path], + reason: 'the backup importer owns the message for a damaged backup'); + }); + }); + test('a journal export re-saved as cp1252 is named, not handed to the ' 'vendor importer', () async { final app = _RoutingSpy(); From b86b9fe9f480ddbc14b88296181c4b0b9d6d65f8 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Wed, 11 Nov 2026 10:26:53 +0100 Subject: [PATCH 2/4] Backup detection: require the app's own table set, and never throw One generic table name such as samples in a foreign database no longer makes it an OpenStrap backup. The check also no longer throws, since it runs while a decrypted backup may be in the temp directory. --- lib/import/import_container.dart | 30 +++++++++++++++++++----------- test/import_routing_test.dart | 29 ++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/lib/import/import_container.dart b/lib/import/import_container.dart index 0d1ed9a8d..e450c354e 100644 --- a/lib/import/import_container.dart +++ b/lib/import/import_container.dart @@ -168,14 +168,18 @@ Future isEdgeBackup(String path) async { lower.contains('.db.unopenable-')) { return true; } - final kind = await sniffFile(path); - if (kind != ImportContainer.sqlite && kind != ImportContainer.gzip) { - return false; - } - // Unwraps the gzip; null when it does not hold a database. - final resolved = await resolveNoopDatabase(path); - if (resolved == null) return false; + // Never throws: this runs while a decrypted backup may be sitting in the + // temp directory, before the caller's cleanup is armed. An unreadable file + // is simply not claimed, and whoever picks it up next owns the message. + ResolvedNoopDatabase? resolved; try { + final kind = await sniffFile(path); + if (kind != ImportContainer.sqlite && kind != ImportContainer.gzip) { + return false; + } + // Unwraps the gzip; null when it does not hold a database. + resolved = await resolveNoopDatabase(path); + if (resolved == null) return false; final src = await openDatabase(resolved.path, readOnly: true, singleInstance: false); try { @@ -184,17 +188,21 @@ Future isEdgeBackup(String path) async { .rawQuery("SELECT name FROM sqlite_master WHERE type = 'table'")) r['name'], }; - // `hrSample` is NOOP's spine; `samples` and `day_result` are ours. + // `hrSample` is NOOP's spine. Ours is the raw ledger, the baselines and + // a per-day result table TOGETHER: one generic name such as `samples` + // in somebody else's database must not get its rows merged into the + // user's history. return !tables.contains('hrSample') && - (tables.contains('samples') || tables.contains('day_result')); + tables.contains('samples') && + tables.contains('baselines') && + (tables.contains('day_result') || tables.contains('derived_day')); } finally { await src.close(); } } catch (_) { - // Unreadable: whoever picks it up next owns the message. return false; } finally { - await resolved.dispose(); + await resolved?.dispose(); } } diff --git a/test/import_routing_test.dart b/test/import_routing_test.dart index 702196fe5..c688bb20e 100644 --- a/test/import_routing_test.dart +++ b/test/import_routing_test.dart @@ -18,6 +18,7 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/import/import_container.dart'; import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/ui2/onboarding/welcome.dart'; @@ -163,17 +164,21 @@ void main() { databaseFactory = databaseFactoryFfi; }); - Future sqlite(String name, String table) async { + Future sqlite(String name, List tables) async { final path = '${tmp.path}/$name'; final db = await openDatabase(path, singleInstance: false); - await db.execute('CREATE TABLE $table (id INTEGER PRIMARY KEY)'); + for (final t in tables) { + await db.execute('CREATE TABLE $t (id INTEGER PRIMARY KEY)'); + } await db.close(); return path; } + const ours = ['samples', 'baselines', 'day_result']; + test('a renamed backup goes to the backup importer', () async { final app = _RoutingSpy(); - final path = await sqlite('openstrap-backup (1)', 'samples'); + final path = await sqlite('openstrap-backup (1)', ours); final out = await runImport(app, [path]); @@ -185,7 +190,7 @@ void main() { test('a renamed gzipped auto-backup goes to the backup importer', () async { final app = _RoutingSpy(); - final plain = await sqlite('auto', 'day_result'); + final plain = await sqlite('auto', ['samples', 'baselines', 'derived_day']); final path = await write( 'backup-2026-10-18.gz', gzip.encode(File(plain).readAsBytesSync())); @@ -198,7 +203,7 @@ void main() { test('a bare NOOP database still goes to the NOOP importer', () async { final app = _RoutingSpy(); - final path = await sqlite('noop-backup.sqlite', 'hrSample'); + final path = await sqlite('noop-backup.sqlite', ['hrSample', ...ours]); await runImport(app, [path]); @@ -206,6 +211,20 @@ void main() { expect(app.backup, isEmpty); }); + test('one generic table name does not make a database ours', () async { + final app = _RoutingSpy(); + final path = await sqlite('someone-elses.sqlite', ['samples']); + + await runImport(app, [path]); + + expect(app.backup, isEmpty, + reason: 'its rows would be merged into the user\'s history'); + }); + + test('a file that vanished is not claimed and does not throw', () async { + expect(await isEdgeBackup('${tmp.path}/gone.sqlite'), isFalse); + }); + test('a file named .db is still ours without being opened', () async { final app = _RoutingSpy(); final path = await write('damaged.db', utf8.encode('not a database')); From 288e87eeec3b78b1d70e19ed710ce83b450ff7f9 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:32:42 +0530 Subject: [PATCH 3/4] own-backup sniff: failed inspection is not 'not ours', and sniff runs inside the temp cleanup --- lib/import/import_container.dart | 10 +++++++--- lib/ui2/onboarding/welcome.dart | 26 +++++++++++++++++--------- test/import_routing_test.dart | 18 ++++++++++++++++-- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/lib/import/import_container.dart b/lib/import/import_container.dart index e450c354e..234564fdc 100644 --- a/lib/import/import_container.dart +++ b/lib/import/import_container.dart @@ -161,7 +161,11 @@ bool noopCsvFirstRecordMatches(String text, {bool truncated = false}) { /// /// A name that says it is ours is still believed without opening the file, so /// a damaged backup reaches the backup importer and gets ITS message. -Future isEdgeBackup(String path) async { +/// +/// Null means the inspection itself failed (the file would not open or +/// query), which is not the same as "not ours": the caller must not hand it to +/// [isNoopExport], which claims every SQLite file. +Future isEdgeBackup(String path) async { final lower = path.toLowerCase(); if (lower.endsWith('.db') || lower.endsWith('.db.gz') || @@ -170,7 +174,7 @@ Future isEdgeBackup(String path) async { } // Never throws: this runs while a decrypted backup may be sitting in the // temp directory, before the caller's cleanup is armed. An unreadable file - // is simply not claimed, and whoever picks it up next owns the message. + // is reported as null, and the caller shows the generic unreadable-file path. ResolvedNoopDatabase? resolved; try { final kind = await sniffFile(path); @@ -200,7 +204,7 @@ Future isEdgeBackup(String path) async { await src.close(); } } catch (_) { - return false; + return null; } finally { await resolved?.dispose(); } diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index 7fd5f9161..fe4c77f59 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -412,24 +412,32 @@ Future runImport( // backup selected alongside a vendor CSV imported the backup and threw the // CSV away without a word. final ownDb = {}; - for (final p in plain) { - if (await isEdgeBackup(p)) ownDb.add(p); - } - final db = [...plain.where(ownDb.contains), ...decrypted]; // Raw-vs-vendor is decided by what the file HOLDS, not by what it is called. // See [isNoopExport]: routing on the extension sent NOOP's raw-sensor `.csv` // to the vendor importer and WHOOP's `.zip` to the NOOP one — both files // fine, both refused, both with advice for the other file. final raw = []; final csv = []; - for (final p in plain) { - if (ownDb.contains(p)) continue; - (await isNoopExport(p) ? raw : csv).add(p); - } + final db = []; if (decrypted.isNotEmpty) sources.add('Encrypted backup'); - if (ownDb.isNotEmpty) sources.add('OpenStrap backup'); try { + // Inside the cleanup boundary: a throw while sniffing must not leave a + // decrypted backup in the temp directory. + for (final p in plain) { + final own = await isEdgeBackup(p); + if (own == true) { + ownDb.add(p); + } else if (own == null) { + // Inspection failed: not claimable as ours, and not a NOOP export + // either, so the vendor path's generic message is the honest one. + csv.add(p); + } else { + (await isNoopExport(p) ? raw : csv).add(p); + } + } + if (ownDb.isNotEmpty) sources.add('OpenStrap backup'); + db.addAll([...plain.where(ownDb.contains), ...decrypted]); for (final p in db) { days += await app.importEdgeBackup(p); // The rows are in and the rollup rebuild threw. AppState's own note: diff --git a/test/import_routing_test.dart b/test/import_routing_test.dart index c688bb20e..2ff0e6c30 100644 --- a/test/import_routing_test.dart +++ b/test/import_routing_test.dart @@ -221,8 +221,22 @@ void main() { reason: 'its rows would be merged into the user\'s history'); }); - test('a file that vanished is not claimed and does not throw', () async { - expect(await isEdgeBackup('${tmp.path}/gone.sqlite'), isFalse); + test('a file that vanished reports a failed inspection, not a throw', + () async { + expect(await isEdgeBackup('${tmp.path}/gone.sqlite'), isNull); + }); + + test('a database that will not open is not sent to the NOOP importer', + () async { + final app = _RoutingSpy(); + final path = await write('broken.sqlite', + [...utf8.encode('SQLite format 3\x00'), ...List.filled(200, 7)]); + + await runImport(app, [path]); + + expect(app.noop, isEmpty, + reason: 'the NOOP check claims every SQLite file'); + expect(app.vendor, [path]); }); test('a file named .db is still ours without being opened', () async { From aa6acab71d2064800783afa476c94b20429e388b Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:55:15 +0530 Subject: [PATCH 4/4] onboarding import: unreadable file at the csv sniff goes to the vendor path instead of throwing out of runImport --- lib/ui2/onboarding/welcome.dart | 8 +++++++- test/import_routing_test.dart | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index fe4c77f59..c37a90ed7 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -482,7 +482,13 @@ Future runImport( // comes back as `FileSystemException: Failed to decode data using encoding // 'utf-8'`, which no catch below was going to turn into advice. The vendor // path unwraps archives (and gzip) properly, so hand them straight over. - if (await sniffFile(p) != ImportContainer.text) { + // An unreadable file is the vendor path's to report (it is caught below + // and named); throwing here would lose the counts already landed. + ImportContainer? kind; + try { + kind = await sniffFile(p); + } catch (_) {} + if (kind != ImportContainer.text) { vendor.add(p); continue; } diff --git a/test/import_routing_test.dart b/test/import_routing_test.dart index 2ff0e6c30..fae092f97 100644 --- a/test/import_routing_test.dart +++ b/test/import_routing_test.dart @@ -239,6 +239,17 @@ void main() { expect(app.vendor, [path]); }); + test('a vanished file after a backup does not lose the backup counts', + () async { + final app = _RoutingSpy(); + final good = await write('mine.db', utf8.encode('x')); + + final out = await runImport(app, [good, '${tmp.path}/gone.bin']); + + expect(app.backup, [good]); + expect(out.days, greaterThan(0)); + }); + test('a file named .db is still ours without being opened', () async { final app = _RoutingSpy(); final path = await write('damaged.db', utf8.encode('not a database'));