Repository navigation
Import: recognise the app's own backup by content, not only by name #701
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d006cd0
b86b9fe
288e87e
aa6acab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,68 @@ 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. | ||
| /// | ||
| /// 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<bool?> isEdgeBackup(String path) async { | ||
| final lower = path.toLowerCase(); | ||
| if (lower.endsWith('.db') || | ||
| lower.endsWith('.db.gz') || | ||
| lower.contains('.db.unopenable-')) { | ||
| return true; | ||
| } | ||
| // 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 reported as null, and the caller shows the generic unreadable-file path. | ||
| 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 { | ||
| final tables = { | ||
| for (final r in await src | ||
| .rawQuery("SELECT name FROM sqlite_master WHERE type = 'table'")) | ||
| r['name'], | ||
| }; | ||
| // `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('baselines') && | ||
| (tables.contains('day_result') || tables.contains('derived_day')); | ||
|
Comment on lines
+201
to
+202
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } finally { | ||
| await src.close(); | ||
| } | ||
| } catch (_) { | ||
| return null; | ||
| } finally { | ||
|
sourcery-ai[bot] marked this conversation as resolved.
Resolved
|
||
| 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` | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -411,21 +411,33 @@ Future<ImportOutcome> 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 = <String>{}; | ||
| // 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 = <String>[]; | ||
| final csv = <String>[]; | ||
| for (final p in plain) { | ||
| if (_isDbBackup(p)) continue; | ||
| (await isNoopExport(p) ? raw : csv).add(p); | ||
| } | ||
| final db = <String>[]; | ||
|
|
||
| if (decrypted.isNotEmpty) sources.add('Encrypted backup'); | ||
| if (plain.any(_isDbBackup)) 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); | ||
|
Comment on lines
+431
to
+434
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unreadable files in a mixed selection can abort the import after the database write. When Handle the failure where
|
||
| } 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: | ||
|
|
@@ -470,7 +482,13 @@ Future<ImportOutcome> 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; | ||
| } | ||
|
|
@@ -624,19 +642,6 @@ Future<String> decryptToTemp(String path, String passphrase) async { | |
| return dest; | ||
| } | ||
|
|
||
| bool _isDbBackup(String path) { | ||
| final p = path.toLowerCase(); | ||
| // `.db.unopenable-<ms>` 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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid inflating gzip inputs twice.
For a renamed gzip backup, this call inflates the entire file and deletes the copy.
LocalDb.importFromDbFilethen inflates the same file again. Gzipped CSV inputs also incur an extra inflate before their importer runs. Reuse the resolved file through the import, or avoid full extraction during classification. (raw.githubusercontent.com)