diff --git a/Packages/WhoopStore/Sources/WhoopStore/ForeignBackupImport.swift b/Packages/WhoopStore/Sources/WhoopStore/ForeignBackupImport.swift new file mode 100644 index 0000000000..6e70ae865f --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/ForeignBackupImport.swift @@ -0,0 +1,446 @@ +import Foundation +import GRDB + +/// Content-based cross-fork / cross-platform `.noopbak` restore by ROW COPY (#222 family). +/// +/// UNVERIFIED ON macOS — READ FIRST. This file was authored on a non-Apple host and has NOT been +/// compiled or run through `swift test` / `xcodebuild`. It is the Swift twin of Android's +/// `DataBackup.reconcileForeignBackup` / `planRowCopyImport`, added to honour the cross-platform +/// parity contract, but the CI-gated `swift-packages` leg and the app build must both be run on a Mac +/// before merge (`cd Packages/WhoopStore && swift test --filter ForeignBackupImportTests`, then build +/// the `Strand` app target — `swift-packages` does NOT compile the app). +/// +/// A normal restore file-swaps the backup over the live store, which is only valid when the backup +/// came from THIS engine (GRDB): an Android/Room `.noopbak` carries our data tables but Room's +/// `room_master_table` bookkeeping instead of `grdb_migrations`, so dropping it in place strands the +/// store — the migrator then re-runs `v1` and throws `table "device" already exists` forever (#222, +/// the failure `WhoopStore.quarantineIncompatibleDatabase` was added to contain). The old guard +/// REJECTED such a backup and pointed the user at the WHOOP-format CSV. This reconciles it instead: +/// COPY the live GRDB store (inheriting our exact schema + `grdb_migrations` identity), ATTACH the +/// foreign backup, and row-copy the intersection of shared tables/columns into the copy, which the +/// caller then swaps in through the normal snapshot/rollback path. +/// +/// Version-agnostic BY DESIGN: it reads tables/columns from `PRAGMA table_info` at run time, never a +/// schema version — GRDB always reports `user_version 0` and the Room forks reuse the same integers, +/// so version is unusable for routing. Any fork's backup (an ahead or behind Android Room fork, or the +/// GRDB store itself) lands by LOGICAL data alone. A target column the backup lacks is handled by the +/// SAME rule on both platforms: a NOT NULL column with no schema default gets a typed zero literal in +/// the SELECT (so `INSERT OR IGNORE` can't silently drop the whole table on that constraint), while a +/// nullable or defaulted column is simply omitted and SQLite fills NULL / the column default. Mirrors +/// the Android planner byte-for-byte at the DATA level: same shared-table set, same target-column-order +/// intersection, same NOT NULL-no-default fill, same `INSERT OR IGNORE` dedup, same REPLACE-clears-first +/// semantics — so the same `.noopbak` reconciled on either platform yields the same rows. Dedup is +/// SQLite's own PK conflict resolution, never a Swift `hashValue`, so nothing platform-specific +/// crosses the backup boundary. +/// +/// Lives in the package (not the app's `DataBackup`) for the same reason `BackupSettings` and +/// `DatabaseIntegrity` do: the planner is pure and the executor is pure GRDB, so both are +/// unit-testable headlessly against throwaway SQLite files, never the user's live store. +public enum ForeignBackupImport { + + /// How a backup's rows fold into the target store. + public enum ImportMode { + /// Keep existing rows; a primary-key clash is dropped (`INSERT OR IGNORE`). + case merge + /// Restore semantics: clear each shared table first, then insert the backup's rows. + case replace + } + + /// SQLite / Room / GRDB bookkeeping tables — NEVER copied. Copying them would overwrite the + /// target's own identity, autoincrement counters, or migration ledger and corrupt the store. + /// `sqlite_`-prefixed tables (`sqlite_sequence`, `sqlite_stat1`, …) are excluded by prefix too. + /// Matches the Android `HOUSEKEEPING_TABLES` set exactly. + static let housekeepingTables: Set = [ + "android_metadata", "sqlite_sequence", "room_master_table", "grdb_migrations", + ] + + /// One target column as `PRAGMA table_info` reports it, carrying exactly the three facts the copy + /// needs: its `name`, its declared `type` (for the typed-zero literal), and whether it is `notNull` + /// with no `hasDefault`. That last pair is load-bearing: a NOT NULL column with no schema default + /// that the backup lacks must be FILLED (a typed zero) rather than omitted, or `INSERT OR IGNORE` + /// drops the whole table's rows on the constraint. Twin of the fields the Android `readSchema` + /// reads from its own `PRAGMA table_info` (name, notNull, dflt_value). + public struct ColumnInfo: Equatable { + public let name: String + /// The declared type text (`INTEGER`, `TEXT`, `BLOB`, `REAL`, …); empty for an untyped column. + public let type: String + public let notNull: Bool + public let hasDefault: Bool + /// Part of the PRIMARY KEY or a UNIQUE index — a column the planner must NEVER constant-fill: every + /// row would take the same value and `INSERT OR IGNORE` would collapse the table to one row. Read + /// from `PRAGMA table_info` (`pk` > 0) plus the UNIQUE indexes in `PRAGMA index_list` / `index_info`. + /// Twin of the Android `SchemaColumn.key`. + public let key: Bool + public init(name: String, type: String = "", notNull: Bool = false, hasDefault: Bool = false, + key: Bool = false) { + self.name = name + self.type = type + self.notNull = notNull + self.hasDefault = hasDefault + self.key = key + } + } + + /// A content-based import plan: the SQL to run (inside one transaction with the backup ATTACHed as + /// `src`), plus what didn't line up — surfaced to the user as warnings, never a hard error. + /// Twin of the Android `RowCopyPlan`. + public struct RowCopyPlan: Equatable { + /// The ordered statements to run against the reconciled copy with the backup ATTACHed as `src`. + public let statements: [String] + /// Target tables absent from the backup — no data to import for them. + public let missingTables: [String] + /// Backup tables with no home in the target — their rows are skipped. + public let droppedTables: [String] + /// Per table, source-absent columns that are NULLABLE or carry a schema default — omitted from + /// the copy so SQLite fills NULL / the column default (the rows still land). + public let missingColumns: [String: [String]] + /// Per table, source-absent columns that are NOT NULL with NO schema default — FILLED with a + /// typed zero literal in the SELECT so the rows are kept (never dropped by the constraint under + /// `INSERT OR IGNORE`). On a store whose twin column carries a default (e.g. GRDB's `synced`) + /// the same column lands in `missingColumns` instead, but the resulting cell is the same zero, + /// so the two platforms store byte-identical rows. + public let filledColumns: [String: [String]] + /// Per table, source-absent NOT NULL-no-default KEY (PK / UNIQUE) columns filled with the source + /// `rowid` (a per-row-unique id) so the rows import without a constant collapsing the table. Maps + /// table -> those key column(s). Twin of the Android `synthesizedKeyColumns`. + public let synthesizedKeyColumns: [String: [String]] + /// Tables an INSERT was emitted for — the set the executor's row-count backstop verifies. + public let copiedTables: [String] + + public init(statements: [String], missingTables: [String], droppedTables: [String], + missingColumns: [String: [String]], filledColumns: [String: [String]], + synthesizedKeyColumns: [String: [String]] = [:], copiedTables: [String] = []) { + self.statements = statements + self.missingTables = missingTables + self.droppedTables = droppedTables + self.missingColumns = missingColumns + self.filledColumns = filledColumns + self.synthesizedKeyColumns = synthesizedKeyColumns + self.copiedTables = copiedTables + } + + /// Human-readable warnings, empty when the backup lines up cleanly. Walks the column maps SORTED + /// so the text is deterministic and matches the order the Android `warnings()` produces (its + /// `LinkedHashMap` is filled in sorted-table order). NOTE the NOT NULL-no-default "filled …" + /// lines are NOT emitted here — they carry a kept-row COUNT the pure planner can't know, so the + /// GRDB executor appends them (see `reconcile`); the Android reconcile appends its twin the same + /// way. What the planner DOES guarantee is that a filled column is never reported as "imported + /// empty": its rows are kept, not dropped. + public func warnings() -> [String] { + var out: [String] = [] + if !missingTables.isEmpty { + out.append("No data in this backup for: \(missingTables.joined(separator: ", ")).") + } + if !droppedTables.isEmpty { + out.append("Skipped tables not in this app: \(droppedTables.joined(separator: ", ")).") + } + // A key column the backup didn't carry, filled with a generated id so the rows still import — + // byte-identical to the Android twin's line, after the dropped-tables note on both platforms. + for table in synthesizedKeyColumns.keys.sorted() { + let cols = (synthesizedKeyColumns[table] ?? []).joined(separator: ", ") + out.append("\(table): generated ids for the key column(s) \(cols) this backup didn't carry.") + } + for table in missingColumns.keys.sorted() { + let cols = (missingColumns[table] ?? []).joined(separator: ", ") + out.append("\(table) is missing fields \(cols) (imported empty).") + } + return out + } + } + + /// Errors thrown by `reconcile`. The executor throws on any failure and never swallows, so the + /// caller's snapshot/rollback path is never handed a half-built file. + public enum ReconcileError: Error, LocalizedError { + /// There is no live store to inherit a schema + identity from (a fresh install). + case noLiveStore + /// A `.replace` copy of `table` landed fewer rows than the backup held (`got` < `expected`): a + /// schema constraint silently dropped the rest, so the import was aborted rather than commit a + /// truncated table. Twin of the Android reconcile's row-count backstop. + case rowCountShortfall(table: String, expected: Int, got: Int) + + public var errorDescription: String? { + switch self { + case .noLiveStore: + return "There is no live NOOP store to reconcile the backup against." + case let .rowCountShortfall(table, expected, got): + return "Importing \"\(table)\" kept only \(got) of \(expected) rows (a schema constraint dropped the rest)." + } + } + } + + // MARK: - Planner (pure) + + /// Plan a version-agnostic row copy from `source` into `target`. `target` is a `table -> ordered + /// ColumnInfo` map (each column's name + type + NOT NULL / default facts, read at run time from + /// `PRAGMA table_info`); `source` is a `table -> ordered column names` map (only membership is + /// consulted). It needs no schema version and never touches GRDB's identity. For every DATA table in + /// BOTH, copy the columns in TARGET order: + /// - a target column PRESENT in the source is copied straight (`SELECT \`col\``); + /// - a target column ABSENT from the source that is NOT NULL with NO default is FILLED with a + /// typed zero literal (INTEGER/REAL → `0`, TEXT → `''`, BLOB → `x''`), because omitting it would + /// let `INSERT OR IGNORE` drop the whole table's rows on the NOT NULL constraint (the #222-family + /// row-drop bug the Android twin fixes); + /// - EXCEPT when that source-absent NOT NULL-no-default column is a KEY (PK / UNIQUE member): a constant + /// fill would collapse the table under `INSERT OR IGNORE`, so it is filled with the source `rowid` + /// (per-row-unique) instead, keeping the rows without collapsing (`synthesizedKeyColumns`); + /// - a target column ABSENT from the source that is nullable OR carries a default is OMITTED, so + /// SQLite fills NULL / the column default and the rows still land. + /// `.replace` clears each target table first (restore semantics); `.merge` keeps existing rows on a + /// PK clash. Mismatched tables/columns become `RowCopyPlan` warnings, not failures. Twin of the + /// Android `planRowCopyImport` — the same decision procedure, so the same schemas yield the same SQL + /// and (given each platform's real schema) byte-identical stored rows. + public static func planRowCopyImport( + target: [String: [ColumnInfo]], + source: [String: [String]], + mode: ImportMode + ) -> RowCopyPlan { + func dataTables(_ keys: Set) -> Set { + keys.filter { !housekeepingTables.contains($0) && !$0.hasPrefix("sqlite_") } + } + let tgt = dataTables(Set(target.keys)) + let src = dataTables(Set(source.keys)) + var statements: [String] = [] + var copiedTables: [String] = [] + var missingColumns: [String: [String]] = [:] + var filledColumns: [String: [String]] = [:] + var synthesizedKeyColumns: [String: [String]] = [:] + for table in tgt.intersection(src).sorted() { + let sourceCols = Set(source[table] ?? []) + // Target column ORDER is preserved so the INSERT and SELECT lists line up 1:1. + var cols: [String] = [] // the INSERT column list + var sel: [String] = [] // the SELECT expression list, positionally aligned with `cols` + var missing: [String] = [] // source-absent nullable/defaulted cols (SQLite fills NULL/default) + var filled: [String] = [] // source-absent NOT NULL-no-default cols (filled with a typed zero) + var synthKeys: [String] = [] // source-absent NOT NULL-no-default KEY cols (filled with rowid) + for column in (target[table] ?? []) { + if sourceCols.contains(column.name) { + cols.append(quoteId(column.name)) + sel.append(quoteId(column.name)) + } else if column.notNull && !column.hasDefault && column.key { + // A KEY column the backup lacks can't be CONSTANT-filled — every row would take the same + // value and INSERT OR IGNORE would collapse the table. Fill the source `rowid` (per-row + // unique) so the rows import; the executor's row-count backstop still catches any drop. + cols.append(quoteId(column.name)) + sel.append("rowid") + synthKeys.append(column.name) + } else if column.notNull && !column.hasDefault { + // Keep the row: emit a typed zero so the NOT NULL constraint is satisfied. Omitting it + // would let INSERT OR IGNORE drop every row of this table. + cols.append(quoteId(column.name)) + sel.append(zeroLiteral(forDeclaredType: column.type)) + filled.append(column.name) + } else { + // Nullable or defaulted: omit it; SQLite fills NULL / the column default. Row lands. + missing.append(column.name) + } + } + if !synthKeys.isEmpty { synthesizedKeyColumns[table] = synthKeys } + if !missing.isEmpty { missingColumns[table] = missing } + if !filled.isEmpty { filledColumns[table] = filled } + if cols.isEmpty { continue } + let colList = cols.joined(separator: ", ") + let selList = sel.joined(separator: ", ") + if mode == .replace { statements.append("DELETE FROM main.\(quoteId(table))") } + statements.append( + "INSERT OR IGNORE INTO main.\(quoteId(table)) (\(colList)) SELECT \(selList) FROM src.\(quoteId(table))") + copiedTables.append(table) + } + return RowCopyPlan( + statements: statements, + missingTables: tgt.subtracting(src).sorted(), + droppedTables: src.subtracting(tgt).sorted(), + missingColumns: missingColumns, + filledColumns: filledColumns, + synthesizedKeyColumns: synthesizedKeyColumns, + copiedTables: copiedTables + ) + } + + /// A backtick-quoted identifier with embedded backticks DOUBLED — twin of the Android `quoteId`, so a + /// foreign backup's table/column name (interpolated into the PRAGMA / row-copy SQL, which can't bind an + /// identifier parameter) can't break out of its quoting. + static func quoteId(_ id: String) -> String { + "`" + id.replacingOccurrences(of: "`", with: "``") + "`" + } + + /// The typed zero literal SQLite stores for a source-absent NOT NULL column with no default, chosen + /// by the column's type affinity so the INSERT never trips the NOT NULL constraint. Follows SQLite's + /// five affinity rules on the declared type: INTEGER/REAL → `0`, TEXT → `''`, BLOB → `x''`, and + /// NUMERIC / untyped → `0`. Twin of the Android `zeroLiteral`, so both platforms emit the same + /// literal for the same declared type. + static func zeroLiteral(forDeclaredType type: String) -> String { + let t = type.uppercased() + if t.contains("INT") { return "0" } + if t.contains("CHAR") || t.contains("CLOB") || t.contains("TEXT") { return "''" } + if t.contains("BLOB") { return "x''" } + if t.contains("REAL") || t.contains("FLOA") || t.contains("DOUB") { return "0" } + return "0" // NUMERIC / untyped → numeric zero (SQLite NUMERIC affinity) + } + + // MARK: - Executor (GRDB) + + /// Reconcile a foreign / cross-platform backup at `stagedBackupURL` into a NEW file at `workURL` + /// carrying THIS app's exact schema + `grdb_migrations` identity, by CLONING the live GRDB store at + /// `liveDatabaseURL` and row-copying the backup's data into the clone. Returns the `RowCopyPlan` + /// warnings; the reconciled file is left at `workURL` for the caller to swap in through the normal + /// snapshot/rollback path. THROWS on any failure (never a partial file). Twin of the Android + /// `DataBackup.reconcileForeignBackup` — same resulting DATA; the clone mechanism differs by engine + /// (Android file-copies the SQLite; here GRDB's page-level backup reads a complete committed + /// snapshot through a source connection, so nothing still in the live store's `-wal` is missed). + /// + /// `.replace` (the cross-fork restore mode) clears every shared table in the clone before inserting, + /// so the live rows carried over by the clone are dropped and only the backup's rows remain. + /// `.merge` keeps the clone's live rows and lets `INSERT OR IGNORE` drop a backup row on a PK clash. + @discardableResult + public static func reconcile( + liveDatabaseURL: URL, + stagedBackupURL: URL, + workURL: URL, + mode: ImportMode + ) throws -> [String] { + let fm = FileManager.default + guard fm.fileExists(atPath: liveDatabaseURL.path) else { throw ReconcileError.noLiveStore } + + // Fresh work file: drop any stale copy + its WAL/SHM siblings. + for suffix in ["", "-wal", "-shm"] { try? fm.removeItem(atPath: workURL.path + suffix) } + + do { + let warnings = try buildReconciled( + liveURL: liveDatabaseURL, stagedURL: stagedBackupURL, workURL: workURL, mode: mode) + // The connections are closed now (their queues went out of scope), and the checkpoint folded + // the WAL back into the single file — drop the now-empty sidecars so `workURL` is self-contained. + for suffix in ["-wal", "-shm"] { try? fm.removeItem(atPath: workURL.path + suffix) } + return warnings + } catch { + // A torn reconcile must leave NO scratch behind: drop the half-built work file AND its + // WAL/SHM sidecars on ANY throw (not just the success path), so a failed cross-fork import + // can never leave a partial file for the caller's swap. Twin of the Android reconcile's + // try/finally cleanup. + for suffix in ["", "-wal", "-shm"] { try? fm.removeItem(atPath: workURL.path + suffix) } + throw error + } + } + + /// Clone the live store into `workURL` (GRDB page-level backup), then ATTACH the staged backup and + /// row-copy the shared intersection inside one transaction, then checkpoint. Foreign keys are + /// DISABLED for the clone (matching the Android `SQLiteDatabase` default) so a raw intersection copy + /// can't trip a constraint the source row order doesn't guarantee; the resulting rows are identical + /// either way (the schema declares no cross-table foreign keys). The queues are local bindings, so + /// they close when this function returns — before `reconcile` deletes the WAL/SHM sidecars. + private static func buildReconciled(liveURL: URL, stagedURL: URL, workURL: URL, mode: ImportMode) throws -> [String] { + var config = Configuration() + config.foreignKeysEnabled = false + + // Page-level clone of the live store into the (empty) work file, read READ-ONLY so the live store + // the app still has open is never mutated. Inherits its exact schema + grdb_migrations identity + + // committed rows (including any still in the live `-wal`). Both connections are scoped to this + // block so they close before the reopen below — the clone rewrites the work file's page-1 header + // (to the source's WAL mode), so the row-copy runs on a FRESH connection that reads that header + // cleanly rather than a stale journal-mode view left over from the empty-file open. + do { + let workQueue = try DatabaseQueue(path: workURL.path, configuration: config) + var liveConfig = Configuration() + liveConfig.readonly = true + let liveQueue = try DatabaseQueue(path: liveURL.path, configuration: liveConfig) + try liveQueue.backup(to: workQueue) + } + + let workQueue = try DatabaseQueue(path: workURL.path, configuration: config) + var warnings: [String] = [] + try workQueue.writeWithoutTransaction { db in + let target = try readSchema(db, schema: "main") + // ATTACH / DETACH must sit OUTSIDE a transaction; the copy itself runs inside one. + try db.execute(sql: "ATTACH DATABASE ? AS src", arguments: [stagedURL.path]) + let source = try readSchema(db, schema: "src") + // Only column NAMES matter for the source (membership); the fill decision is driven by the + // TARGET's NOT NULL / default facts, which `readSchema` carries on `target`. + let plan = planRowCopyImport( + target: target, source: source.mapValues { $0.map(\.name) }, mode: mode) + try db.inTransaction { + for statement in plan.statements { try db.execute(sql: statement) } + // Row-count backstop (REPLACE restore only). Each copied table was cleared then re-filled + // from the source, so on a clean import `landed == source`. A shortfall means + // `INSERT OR IGNORE` silently dropped rows on a constraint the planner didn't model — a + // CHECK, or a UNIQUE the source didn't enforce — so THROW (rolls this transaction back) + // rather than commit a quietly-truncated table. The planner already skips the key-column + // collapse; this catches the rest, before the caller's swap touches the live store. MERGE is + // exempt (its PK-clash drops are intentional). `src` is still ATTACHed inside the closure. + if mode == .replace { + for table in plan.copiedTables { + let sourceRows = try Int.fetchOne(db, sql: "SELECT count(*) FROM src.\(quoteId(table))") ?? 0 + let landed = try Int.fetchOne(db, sql: "SELECT count(*) FROM main.\(quoteId(table))") ?? 0 + if landed < sourceRows { + throw ReconcileError.rowCountShortfall(table: table, expected: sourceRows, got: landed) + } + } + } + return .commit + } + try db.execute(sql: "DETACH DATABASE src") + // wal_checkpoint returns a row, so it must be FETCHED, not run as a bare statement. + _ = try Row.fetchAll(db, sql: "PRAGMA wal_checkpoint(TRUNCATE)") + warnings = plan.warnings() + // Append the NOT NULL-no-default "filled …" lines the pure planner can't: they carry the + // kept-row COUNT, read here from the reconciled table so the warning is honest about how many + // rows were kept (never dropped by the constraint). Sorted by table so the order is + // deterministic; the Android reconcile appends its twin the same way. + for table in plan.filledColumns.keys.sorted() { + let cols = (plan.filledColumns[table] ?? []).joined(separator: ", ") + let kept = try Int.fetchOne(db, sql: "SELECT count(*) FROM main.`\(table)`") ?? 0 + warnings.append("\(table): filled \(cols) with defaults (kept \(kept) rows).") + } + } + return warnings + } + + /// `table -> ordered ColumnInfo`, read from `PRAGMA .table_info` on `schema` (`main` or the + /// ATTACHed `src` alias). Column order follows the pragma's natural order, and each column carries + /// its `name`, declared `type`, `notnull` flag, whether `dflt_value` is set, AND whether it is a KEY + /// (PK / UNIQUE member, via `keyColumns`) — exactly the fields the Android `readSchema` reads, so both + /// platforms build the same intersection, make the same NOT NULL-no-default fill decision, and skip the + /// same collapse-prone tables. + static func readSchema(_ db: Database, schema: String) throws -> [String: [ColumnInfo]] { + var out: [String: [ColumnInfo]] = [:] + let tables = try String.fetchAll( + db, sql: "SELECT name FROM \(schema).sqlite_master WHERE type = 'table'") + for table in tables { + let keyCols = try keyColumns(db, schema: schema, table: table) + let rows = try Row.fetchAll(db, sql: "PRAGMA \(schema).table_info(\(quoteId(table)))") + out[table] = rows.map { row in + let name: String = row["name"] ?? "" + let type: String = row["type"] ?? "" + let notNull: Int = row["notnull"] ?? 0 + // `dflt_value` holds the default's SQL text, or SQL NULL when the column has no default — + // so a non-nil decode means "has a schema default". + let dflt: String? = row["dflt_value"] + return ColumnInfo(name: name, type: type, notNull: notNull != 0, hasDefault: dflt != nil, + key: keyCols.contains(name)) + } + } + return out + } + + /// Column names of `table` that participate in the PRIMARY KEY or any UNIQUE index — the columns the + /// planner must never constant-fill. PK members come from `PRAGMA table_info` (`pk` > 0); UNIQUE members + /// from each `unique` index in `PRAGMA index_list`, expanded via `PRAGMA index_info`. Twin of the + /// Android `keyColumns`. + static func keyColumns(_ db: Database, schema: String, table: String) throws -> Set { + var keys: Set = [] + for row in try Row.fetchAll(db, sql: "PRAGMA \(schema).table_info(\(quoteId(table)))") { + let name: String = row["name"] ?? "" + let pk: Int = row["pk"] ?? 0 + if pk != 0 && !name.isEmpty { keys.insert(name) } + } + var uniqueIndexes: [String] = [] + for row in try Row.fetchAll(db, sql: "PRAGMA \(schema).index_list(\(quoteId(table)))") { + let unique: Int = row["unique"] ?? 0 + if unique != 0, let name = row["name"] as String? { uniqueIndexes.append(name) } + } + for index in uniqueIndexes { + for row in try Row.fetchAll(db, sql: "PRAGMA \(schema).index_info(\(quoteId(index)))") { + if let name = row["name"] as String? { keys.insert(name) } + } + } + return keys + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/ForeignBackupImportTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/ForeignBackupImportTests.swift new file mode 100644 index 0000000000..a2fcfd1108 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ForeignBackupImportTests.swift @@ -0,0 +1,411 @@ +import XCTest +import GRDB +@testable import WhoopStore + +/// Pins the cross-fork row-copy importer (#222 family) — the Swift twin of Android's +/// `DataBackup.reconcileForeignBackup` / `planRowCopyImport`. Two layers: +/// 1. the PURE planner (`planRowCopyImport`), asserted exactly against small synthetic schemas, so +/// the shared-table/column intersection, the NOT NULL-no-default typed-zero fill, +/// REPLACE-clears-first, and warnings match Android; and +/// 2. the GRDB EXECUTOR (`reconcile`), which copies the live GRDB store and row-copies a foreign +/// (Room) backup into it, asserted on the RESULTING ROWS — the byte-level data parity that +/// matters, incl. `INSERT OR IGNORE` dedup with NO Swift `hashValue` crossing the boundary, and +/// the row-DROP regression (a source-missing NOT NULL column with no default is FILLED, not +/// dropped). +/// +/// UNVERIFIED ON macOS: authored on a non-Apple host. Run `swift test --filter +/// ForeignBackupImportTests` on a Mac before merge (WhoopStore is GRDB-linked; it does not build on +/// Linux — `sqlite3.h not found`). +final class ForeignBackupImportTests: XCTestCase { + + /// Terse `ColumnInfo` builder for the planner arrange blocks. Defaults to a nullable, no-default + /// TEXT column (the common "just a name" case); flip `notNull` / `hasDefault` / `type` where the + /// fill decision is under test. + private func col(_ name: String, _ type: String = "TEXT", + notNull: Bool = false, hasDefault: Bool = false, key: Bool = false) -> ForeignBackupImport.ColumnInfo { + ForeignBackupImport.ColumnInfo(name: name, type: type, notNull: notNull, hasDefault: hasDefault, key: key) + } + + // MARK: - Planner (pure) + + func testPlannerCopiesTargetColumnIntersectionInReplaceMode() { + // target A has an extra (nullable) column `y` the source lacks, C the source lacks entirely; + // source has an extra table D with no home in the target. + let target = ["A": [col("id"), col("x"), col("y")], "B": [col("id")], "C": [col("id")]] + let source = ["A": ["id", "x"], "B": ["id"], "D": ["id"]] + + let plan = ForeignBackupImport.planRowCopyImport(target: target, source: source, mode: .replace) + + XCTAssertEqual(plan.statements, [ + "DELETE FROM main.`A`", + "INSERT OR IGNORE INTO main.`A` (`id`, `x`) SELECT `id`, `x` FROM src.`A`", + "DELETE FROM main.`B`", + "INSERT OR IGNORE INTO main.`B` (`id`) SELECT `id` FROM src.`B`", + ]) + XCTAssertEqual(plan.missingTables, ["C"]) + XCTAssertEqual(plan.droppedTables, ["D"]) + XCTAssertEqual(plan.missingColumns, ["A": ["y"]]) + XCTAssertTrue(plan.filledColumns.isEmpty) + XCTAssertEqual(plan.warnings(), [ + "No data in this backup for: C.", + "Skipped tables not in this app: D.", + "A is missing fields y (imported empty).", + ]) + } + + func testPlannerMergeModeEmitsNoDeletes() { + let target = ["A": [col("id"), col("x")], "B": [col("id")]] + let source = ["A": ["id", "x"], "B": ["id"]] + + let plan = ForeignBackupImport.planRowCopyImport(target: target, source: source, mode: .merge) + + XCTAssertEqual(plan.statements, [ + "INSERT OR IGNORE INTO main.`A` (`id`, `x`) SELECT `id`, `x` FROM src.`A`", + "INSERT OR IGNORE INTO main.`B` (`id`) SELECT `id` FROM src.`B`", + ]) + XCTAssertTrue(plan.warnings().isEmpty) + } + + func testPlannerExcludesHousekeepingAndSqlitePrefixedTables() { + // Every bookkeeping table lives in BOTH sides; none may appear in the plan or the warnings. + let housekeeping: [String: [ForeignBackupImport.ColumnInfo]] = [ + "android_metadata": [col("a")], "sqlite_sequence": [col("b")], + "room_master_table": [col("c")], "grdb_migrations": [col("d")], + "sqlite_stat1": [col("e")], + ] + let housekeepingNames = ["android_metadata": ["a"], "sqlite_sequence": ["b"], + "room_master_table": ["c"], "grdb_migrations": ["d"], + "sqlite_stat1": ["e"]] + let target = housekeeping.merging(["hrSample": [col("deviceId"), col("ts")]]) { a, _ in a } + let source = housekeepingNames.merging(["hrSample": ["deviceId", "ts"]]) { a, _ in a } + + let plan = ForeignBackupImport.planRowCopyImport(target: target, source: source, mode: .replace) + + XCTAssertEqual(plan.statements, [ + "DELETE FROM main.`hrSample`", + "INSERT OR IGNORE INTO main.`hrSample` (`deviceId`, `ts`) SELECT `deviceId`, `ts` FROM src.`hrSample`", + ]) + XCTAssertTrue(plan.missingTables.isEmpty) + XCTAssertTrue(plan.droppedTables.isEmpty) + XCTAssertTrue(plan.warnings().isEmpty) + } + + func testPlannerFillsNotNullNoDefaultColumnsWithTypedZeroSoRowsAreKept() { + // `flag` (INTEGER NOT NULL, no default), `label` (TEXT NOT NULL, no default) and `blob` + // (BLOB NOT NULL, no default) are all ABSENT from the source. Omitting them would let + // INSERT OR IGNORE drop every row of the table on the NOT NULL constraint (the #222-family + // row-drop bug). Instead each is FILLED with its type's zero literal (0 / '' / x'') so the rows + // land. A source-absent NULLABLE column and a source-absent DEFAULTED column are still just + // OMITTED (SQLite fills NULL / the default) — the existing "imported empty" wording. + let target = ["t": [ + col("id", "TEXT", notNull: true), + col("flag", "INTEGER", notNull: true), + col("label", "TEXT", notNull: true), + col("blob", "BLOB", notNull: true), + col("untyped", "", notNull: true), // untyped → numeric 0 (parity with Android) + col("note", "TEXT"), // nullable → omitted + col("count", "INTEGER", notNull: true, hasDefault: true), // NOT NULL but defaulted → omitted + ]] + let source = ["t": ["id"]] + + let plan = ForeignBackupImport.planRowCopyImport(target: target, source: source, mode: .merge) + + // COLS = present ∪ NOT NULL-no-default; SEL fills the absent NOT NULL-no-default cols with a + // typed zero, positionally aligned. `note` / `count` are omitted from both lists. + XCTAssertEqual(plan.statements, [ + "INSERT OR IGNORE INTO main.`t` (`id`, `flag`, `label`, `blob`, `untyped`) SELECT `id`, 0, '', x'', 0 FROM src.`t`", + ]) + XCTAssertEqual(plan.filledColumns, ["t": ["flag", "label", "blob", "untyped"]]) + XCTAssertEqual(plan.missingColumns, ["t": ["note", "count"]]) + // The pure planner reports only the omitted (nullable/defaulted) columns as "imported empty"; + // the FILLED columns are never called empty — their kept-row count line is added by the executor. + XCTAssertEqual(plan.warnings(), ["t is missing fields note, count (imported empty)."]) + } + + func testPlannerFillsMissingNotNullKeyColumnWithRowidSoRowsImport() { + // hrSample's PK column `ts` is NOT NULL, no default, and a KEY; the backup renamed it (`stamp`) so + // it is source-absent. A CONSTANT fill would give every row the same key and INSERT OR IGNORE would + // collapse the table; filling the source `rowid` (per-row-unique) keeps every row. A sibling whose + // key is present copies straight. Twin of the Android + // `sourceMissingNotNullNoDefaultKeyColumnFillsItWithRowidSoRowsImport`. + let target = [ + "hrSample": [col("deviceId", "TEXT", notNull: true, key: true), + col("ts", "INTEGER", notNull: true, key: true), + col("bpm", "INTEGER")], + "sleepSession": [col("deviceId", "TEXT", notNull: true, key: true), + col("startTs", "INTEGER", notNull: true, key: true), + col("efficiency", "REAL")], + ] + let source = ["hrSample": ["deviceId", "stamp", "bpm"], + "sleepSession": ["deviceId", "startTs", "efficiency"]] + + let plan = ForeignBackupImport.planRowCopyImport(target: target, source: source, mode: .replace) + + XCTAssertEqual(plan.statements, [ + "DELETE FROM main.`hrSample`", + "INSERT OR IGNORE INTO main.`hrSample` (`deviceId`, `ts`, `bpm`) SELECT `deviceId`, rowid, `bpm` FROM src.`hrSample`", + "DELETE FROM main.`sleepSession`", + "INSERT OR IGNORE INTO main.`sleepSession` (`deviceId`, `startTs`, `efficiency`) SELECT `deviceId`, `startTs`, `efficiency` FROM src.`sleepSession`", + ]) + XCTAssertEqual(plan.synthesizedKeyColumns, ["hrSample": ["ts"]]) + XCTAssertEqual(plan.copiedTables, ["hrSample", "sleepSession"]) + XCTAssertTrue(plan.warnings().contains( + "hrSample: generated ids for the key column(s) ts this backup didn't carry.")) + } + + func testPlannerOmitsNullableKeyColumnRatherThanRowidFillingIt() { + // Only a NOT NULL-no-default key is rowid-filled; a nullable key column the source lacks is OMITTED + // (SQLite fills NULL, which never collides in a UNIQUE index), so it needs no synthetic id. Locks + // that the rowid fill fires on the (NOT NULL ∧ no-default ∧ key) triple, not on `key` alone. Twin of + // the Android `aSourceMissingKeyColumnThatIsNullableIsOmittedNotRowidFilled`. + let target = ["t": [col("id", "INTEGER", notNull: true, key: true), col("altKey", "TEXT", key: true)]] + let source = ["t": ["id"]] + + let plan = ForeignBackupImport.planRowCopyImport(target: target, source: source, mode: .merge) + + XCTAssertEqual(plan.statements, ["INSERT OR IGNORE INTO main.`t` (`id`) SELECT `id` FROM src.`t`"]) + XCTAssertTrue(plan.synthesizedKeyColumns.isEmpty) + XCTAssertEqual(plan.missingColumns, ["t": ["altKey"]]) + } + + // MARK: - Executor (GRDB, real files) + + func testReconcileReplaceClearsLocalRowsAndImportsForeignRows() throws { + let live = tempPath() + let backup = tempPath() + let work = tempPath() + defer { [live, backup, work].forEach(remove) } + + try makeLiveStore(at: live) { db in + try db.execute(sql: "INSERT INTO device (id, name) VALUES ('local', 'WHOOP')") + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm, synced) VALUES ('local', 10, 60, 0)") + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm, synced) VALUES ('local', 11, 61, 0)") + } + try makeForeignRoomBackup(at: backup) { db in + try db.execute(sql: "INSERT INTO device (id, name) VALUES ('foreign', 'WHOOP')") + // hrSample WITHOUT the `synced` column (an older Room fork) → it imports as the default. + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('foreign', 10, 70)") + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('foreign', 20, 72)") + } + + let warnings = try ForeignBackupImport.reconcile( + liveDatabaseURL: URL(fileURLWithPath: live), + stagedBackupURL: URL(fileURLWithPath: backup), + workURL: URL(fileURLWithPath: work), + mode: .replace) + + try readWork(work) { db in + // REPLACE cleared the shared tables, so ONLY foreign rows remain. + XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT count(*) FROM hrSample"), 2) + XCTAssertEqual(try String.fetchAll(db, sql: "SELECT deviceId FROM hrSample ORDER BY ts"), + ["foreign", "foreign"]) + XCTAssertEqual(try Int.fetchAll(db, sql: "SELECT ts FROM hrSample ORDER BY ts"), [10, 20]) + XCTAssertEqual(try Int.fetchAll(db, sql: "SELECT bpm FROM hrSample ORDER BY ts"), [70, 72]) + // `synced` was absent from the source → on GRDB it carries a schema default (NOT NULL + // DEFAULT 0), so the copy omits it and SQLite fills 0, never NULL. (On Android the twin + // column has no default and is instead filled with a typed 0 — same stored cell.) + XCTAssertEqual(try Int.fetchAll(db, sql: "SELECT synced FROM hrSample ORDER BY ts"), [0, 0]) + + XCTAssertEqual(try String.fetchAll(db, sql: "SELECT id FROM device ORDER BY id"), + ["foreign"], "REPLACE dropped the local device row") + + // Identity preserved: the reconciled copy is still a valid GRDB store. + XCTAssertTrue(try tableExists(db, "grdb_migrations")) + // The Room-only table was NOT created in the target. + XCTAssertFalse(try tableExists(db, "roomOnlyTable")) + } + + // Warnings surface the asymmetry (exact for the deterministic entries; spot-checked otherwise). + XCTAssertTrue(warnings.contains("Skipped tables not in this app: roomOnlyTable.")) + XCTAssertTrue(warnings.contains("hrSample is missing fields synced (imported empty).")) + XCTAssertTrue(warnings.contains { $0.hasPrefix("No data in this backup for:") && $0.contains("battery") }) + // Sidecars folded away — the reconciled file is self-contained. + XCTAssertFalse(FileManager.default.fileExists(atPath: work + "-wal")) + } + + func testReconcileFillsNotNullNoDefaultColumnAndKeepsRows() throws { + // The row-DROP regression, mirrored on real files (the Android twin's regression test). A target + // table carries an INTEGER NOT NULL column with NO schema default; the foreign fork's table + // LACKS that column. A naive `INSERT OR IGNORE … SELECT` that omitted it would hit the NOT NULL + // constraint and DROP every row; the planner fills it with a typed zero, so the rows are KEPT + // with 0. + let live = tempPath() + let backup = tempPath() + let work = tempPath() + defer { [live, backup, work].forEach(remove) } + + try makeLiveStore(at: live) { db in + // GRDB emits no SQL default unless `.defaults(to:)` is used, so this is a genuine NOT + // NULL-no-default column — the same shape as the Room `= 0`-with-no-SQL-default column the + // Android regression covers. + try db.execute(sql: """ + CREATE TABLE syncFlags (deviceId TEXT NOT NULL, ts INTEGER NOT NULL, flag INTEGER NOT NULL, + PRIMARY KEY (deviceId, ts)) + """) + try db.execute(sql: "INSERT INTO syncFlags (deviceId, ts, flag) VALUES ('local', 1, 1)") + } + try makeForeignRoomBackup(at: backup) { db in + // The foreign fork's table has no `flag` column. + try db.execute(sql: """ + CREATE TABLE syncFlags (deviceId TEXT NOT NULL, ts INTEGER NOT NULL, PRIMARY KEY (deviceId, ts)) + """) + try db.execute(sql: "INSERT INTO syncFlags (deviceId, ts) VALUES ('foreign', 10)") + try db.execute(sql: "INSERT INTO syncFlags (deviceId, ts) VALUES ('foreign', 20)") + } + + let warnings = try ForeignBackupImport.reconcile( + liveDatabaseURL: URL(fileURLWithPath: live), + stagedBackupURL: URL(fileURLWithPath: backup), + workURL: URL(fileURLWithPath: work), + mode: .replace) + + try readWork(work) { db in + // Both foreign rows were KEPT (not dropped by the NOT NULL constraint), `flag` filled with 0. + XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT count(*) FROM syncFlags"), 2) + XCTAssertEqual(try Int.fetchAll(db, sql: "SELECT ts FROM syncFlags ORDER BY ts"), [10, 20]) + XCTAssertEqual(try Int.fetchAll(db, sql: "SELECT flag FROM syncFlags ORDER BY ts"), [0, 0]) + } + + // The fill is surfaced honestly as a KEPT (not a dropped / "imported empty") line, with the count. + XCTAssertTrue(warnings.contains("syncFlags: filled flag with defaults (kept 2 rows).")) + } + + func testReconcileMergeKeepsLocalRowsAndDropsPkClash() throws { + let live = tempPath() + let backup = tempPath() + let work = tempPath() + defer { [live, backup, work].forEach(remove) } + + try makeLiveStore(at: live) { db in + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm, synced) VALUES ('local', 10, 60, 0)") + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm, synced) VALUES ('local', 11, 61, 0)") + } + try makeForeignRoomBackup(at: backup) { db in + // ('local', 10) clashes with a live PK; ('foreign', 20) is new. + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('local', 10, 999)") + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('foreign', 20, 72)") + } + + try ForeignBackupImport.reconcile( + liveDatabaseURL: URL(fileURLWithPath: live), + stagedBackupURL: URL(fileURLWithPath: backup), + workURL: URL(fileURLWithPath: work), + mode: .merge) + + try readWork(work) { db in + let hr = try Row.fetchAll(db, sql: "SELECT deviceId, ts, bpm FROM hrSample ORDER BY deviceId, ts") + XCTAssertEqual(hr.count, 3, "two local rows kept + one new foreign row") + // INSERT OR IGNORE kept the LOCAL value on the PK clash — the foreign 999 was dropped. + let clash = try Int.fetchOne(db, + sql: "SELECT bpm FROM hrSample WHERE deviceId = 'local' AND ts = 10") + XCTAssertEqual(clash, 60, "the clashing local row must win under INSERT OR IGNORE") + let added = try Int.fetchOne(db, + sql: "SELECT bpm FROM hrSample WHERE deviceId = 'foreign' AND ts = 20") + XCTAssertEqual(added, 72, "the non-clashing foreign row was imported") + } + } + + func testReconcileReplaceAbortsWhenAConstraintDropsRows() throws { + // The row-count backstop. The target `tag` table carries a UNIQUE(label) the foreign fork lacks; + // the fork's two rows share a label, which the target forbids. A REPLACE copy would DELETE then + // INSERT OR IGNORE both, silently dropping the second on the UNIQUE constraint — landing 1 of 2 + // rows. The backstop must catch the shortfall and THROW (rolling back), so a quietly-truncated + // table is never committed, and the torn reconcile leaves no scratch behind. Twin of the Android + // reconcile's REPLACE row-count check. + let live = tempPath() + let backup = tempPath() + let work = tempPath() + defer { [live, backup, work].forEach(remove) } + + try makeLiveStore(at: live) { db in + try db.execute(sql: """ + CREATE TABLE tag (deviceId TEXT NOT NULL, ts INTEGER NOT NULL, label TEXT NOT NULL, + PRIMARY KEY (deviceId, ts), UNIQUE (label)) + """) + } + try makeForeignRoomBackup(at: backup) { db in + // Same table, but NO UNIQUE(label) — so two rows can (and do) share a label. + try db.execute(sql: """ + CREATE TABLE tag (deviceId TEXT NOT NULL, ts INTEGER NOT NULL, label TEXT NOT NULL, + PRIMARY KEY (deviceId, ts)) + """) + try db.execute(sql: "INSERT INTO tag (deviceId, ts, label) VALUES ('foreign', 1, 'dup')") + try db.execute(sql: "INSERT INTO tag (deviceId, ts, label) VALUES ('foreign', 2, 'dup')") + } + + XCTAssertThrowsError(try ForeignBackupImport.reconcile( + liveDatabaseURL: URL(fileURLWithPath: live), + stagedBackupURL: URL(fileURLWithPath: backup), + workURL: URL(fileURLWithPath: work), + mode: .replace)) { error in + guard case let ForeignBackupImport.ReconcileError.rowCountShortfall(table, expected, got) = error else { + return XCTFail("expected rowCountShortfall, got \(error)") + } + XCTAssertEqual(table, "tag") + XCTAssertEqual(expected, 2) + XCTAssertEqual(got, 1) + } + // A torn reconcile leaves no scratch file for the caller's swap. + XCTAssertFalse(FileManager.default.fileExists(atPath: work)) + } + + func testReconcileRejectsMissingLiveStore() { + let backup = tempPath() + let work = tempPath() + defer { [backup, work].forEach(remove) } + try? makeForeignRoomBackup(at: backup) { _ in } + + XCTAssertThrowsError(try ForeignBackupImport.reconcile( + liveDatabaseURL: URL(fileURLWithPath: tempPath()), + stagedBackupURL: URL(fileURLWithPath: backup), + workURL: URL(fileURLWithPath: work), + mode: .replace)) { error in + XCTAssertEqual(error as? ForeignBackupImport.ReconcileError, .noLiveStore) + } + } + + // MARK: - Fixtures + + private func tempPath() -> String { + FileManager.default.temporaryDirectory + .appendingPathComponent("noop-foreign-\(UUID().uuidString).sqlite").path + } + + /// Remove a store file and its WAL/SHM siblings. + private func remove(_ path: String) { + for suffix in ["", "-wal", "-shm"] { try? FileManager.default.removeItem(atPath: path + suffix) } + } + + /// A real GRDB store at `path` (full NOOP schema + `grdb_migrations`), seeded then CLOSED so + /// `reconcile` copies a fully-committed, handle-free file. + private func makeLiveStore(at path: String, seed: (Database) throws -> Void) throws { + let queue = try DatabaseQueue(path: path) + try WhoopStore.makeMigrator().migrate(queue) + try queue.write { db in try seed(db) } + } + + /// A foreign (Android/Room) backup at `path`: Room's `room_master_table` marker, the shared + /// `device`/`hrSample` tables (hrSample WITHOUT `synced`, an older fork), and a Room-only table with + /// no home in the target. Closed on return. + private func makeForeignRoomBackup(at path: String, seed: (Database) throws -> Void) throws { + let queue = try DatabaseQueue(path: path) + try queue.write { db in + try db.execute(sql: "CREATE TABLE room_master_table (id INTEGER PRIMARY KEY, identity_hash TEXT)") + try db.execute(sql: "CREATE TABLE device (id TEXT PRIMARY KEY, mac TEXT, name TEXT, firstSeen INTEGER, lastSeen INTEGER)") + try db.execute(sql: "CREATE TABLE hrSample (deviceId TEXT NOT NULL, ts INTEGER NOT NULL, bpm INTEGER NOT NULL, PRIMARY KEY (deviceId, ts))") + try db.execute(sql: "CREATE TABLE roomOnlyTable (id TEXT PRIMARY KEY, note TEXT)") + try seed(db) + } + } + + private func readWork(_ path: String, _ block: (Database) throws -> Void) throws { + let queue = try DatabaseQueue(path: path) + try queue.read { db in try block(db) } + } + + private func tableExists(_ db: Database, _ name: String) throws -> Bool { + try Bool.fetchOne(db, + sql: "SELECT count(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?", + arguments: [name]) ?? false + } +} diff --git a/Strand/Data/DataBackup.swift b/Strand/Data/DataBackup.swift index 8187a1017b..e283f73f90 100644 --- a/Strand/Data/DataBackup.swift +++ b/Strand/Data/DataBackup.swift @@ -36,8 +36,10 @@ enum DataBackup { /// Export wrote the backup to `url`. case exported(URL) /// Import succeeded; a relaunch is required for it to take effect. `sidecar` is where the - /// previous database was preserved, in case the user wants to roll back. - case imported(sidecar: URL) + /// previous database was preserved, in case the user wants to roll back. `warnings` is empty + /// for a same-fork restore; a cross-fork reconcile (see `restore`) surfaces the tables/columns + /// that didn't line up here so the UI can show them without failing the import. + case imported(sidecar: URL, warnings: [String]) /// The user dismissed the save/open panel — nothing happened, show nothing loud. case cancelled /// Something went wrong; `message` is user-facing. @@ -280,8 +282,12 @@ enum DataBackup { // If the picked file is a .noopbak ZIP, extract the SQLite entry to a temp dir first. // Legacy plain-SQLite files fall straight through. The extracted dir is cleaned up below. let fm = FileManager.default - let source: URL + var source: URL let extractedDir: URL? + // A cross-fork reconcile (below) writes a self-contained SQLite copy here that becomes the + // effective `source`; cleaned up on the way out, alongside `extractedDir`. + var reconciledFile: URL? + var reconcileWarnings: [String] = [] if isZipFile(at: pickedSource) { let tmpExtract = fm.temporaryDirectory @@ -307,22 +313,58 @@ enum DataBackup { extractedDir = nil } defer { if let d = extractedDir { try? fm.removeItem(at: d) } } + defer { + if let f = reconciledFile { + for suffix in ["", "-wal", "-shm"] { try? fm.removeItem(atPath: f.path + suffix) } + } + } // Validate: must be a real SQLite database (magic header "SQLite format 3\0"). guard isSQLiteFile(at: source) else { return .failure(String(localized: "That file isn't a NOOP backup. It doesn't look like a SQLite database.")) } - // Reject any backup that isn't a clean GRDB (this-app) backup. The magic check passes for ANY - // SQLite file, so an Android (Room) backup — or any other SQLite file that happens to carry our - // table names without our `grdb_migrations` bookkeeping — would otherwise replace the live DB - // and leave the migrator re-running v1 forever (`table "device" already exists`, #222). A valid - // NOOP-Mac/iOS backup always carries `grdb_migrations`; reject everything else that holds data. - let backupTables = sqliteTableNames(at: source) + // Route the backup by its migrator bookkeeping. A clean GRDB (this-app) backup carries + // `grdb_migrations` and file-swaps straight in below — that same-fork path is UNCHANGED. A + // foreign one (Android/Room's `room_master_table`, or an unknown-but-populated store) can't + // file-swap: dropping it over our GRDB store strands the migrator, which re-runs v1 forever + // (`table "device" already exists`, #222). Instead of the old outright refusal, RECONCILE it — + // copy the live GRDB store (our exact schema + `grdb_migrations` identity) and row-copy the + // backup's shared tables/columns into the copy (`ForeignBackupImport`, the WhoopStore twin of + // Android's `reconcileForeignBackup`), then swap that reconciled file in through the exact same + // hardened path below. REPLACE = restore semantics: clear each shared table, then insert. + // + // A schema read that FAILS (nil) — a file that carries the SQLite magic but can't be opened or + // queried (truncated, torn, or not really a database) — is refused HERE, before anything touches + // the live store. Returning empty and falling through would route it to the raw file-swap, which + // would "restore" a silently empty store; the honest move is to stop with the live data intact. + guard let backupTables = sqliteTableNames(at: source) else { + return .failure(String(localized: "Couldn't read this backup's database. Your current data is untouched. Try an earlier backup file.")) + } let origin = backupOrigin(of: backupTables) let holdsData = backupTables.contains("device") || backupTables.contains("hrSample") if origin == .android || (origin == .unknown && holdsData) { - return .failure(String(localized: "This isn't a NOOP backup from this app. It's missing the migration bookkeeping a NOOP backup carries (it looks like an Android backup or another app's database), and restoring it would strand your store. To move your history across platforms, export the WHOOP-format CSV on the other device (Settings → Export data) and import that here, or import your original WHOOP / Apple Health export.")) + guard fm.fileExists(atPath: dbPath) else { + // No live store to inherit a schema + identity from yet (fresh install): keep the honest + // refusal — there is nothing to reconcile the foreign rows into. + return .failure(String(localized: "This looks like a backup from another NOOP platform, but there's no NOOP store on this \(Platform.deviceNoun) yet to merge it into. Open NOOP once to set up your store, then import again — or move your history with the WHOOP-format CSV (Settings → Export data).")) + } + let work = fm.temporaryDirectory + .appendingPathComponent("noop-reconciled-\(UUID().uuidString).sqlite") + reconciledFile = work + do { + reconcileWarnings = try ForeignBackupImport.reconcile( + liveDatabaseURL: URL(fileURLWithPath: dbPath), + stagedBackupURL: source, + workURL: work, + mode: .replace) + } catch { + return .failure(String(localized: "Couldn't reconcile that cross-platform backup. Your current data was left untouched. \(error.localizedDescription)")) + } + // The reconciled file is a valid, checkpointed GRDB store (it carries `grdb_migrations`), so + // it passes the integrity gate below and opens without the #222 quarantine. `extractedDir` + // is left pointing at the ORIGINAL extract dir so a `settings.json` entry still applies. + source = work } // #1014 defence-in-depth: both gates above read only the FIRST pages of the file — the @@ -421,7 +463,7 @@ enum DataBackup { // #57 debug: record when a restore swapped the DB, so the export can correlate a restore with a // later write stall (a restore not followed by a relaunch is the #57 failure). UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "backup.lastRestoreAt") - return .imported(sidecar: sidecar) + return .imported(sidecar: sidecar, warnings: reconcileWarnings) } catch { return .failure(String(localized: "Import failed: \(error.localizedDescription)")) } @@ -491,27 +533,38 @@ enum DataBackup { return .unknown } - /// Every table name in a SQLite file, opened READ-ONLY through the system SQLite so the probed - /// file is never mutated. Returns an empty set on any failure — the caller treats that as - /// `.unknown` and falls through to the existing behaviour. - private static func sqliteTableNames(at url: URL) -> Set { - var db: OpaquePointer? - guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { - sqlite3_close(db) - return [] - } - defer { sqlite3_close(db) } - var stmt: OpaquePointer? - let sql = "SELECT name FROM sqlite_master WHERE type = 'table'" - guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } - defer { sqlite3_finalize(stmt) } - var names: Set = [] - while sqlite3_step(stmt) == SQLITE_ROW { - if let c = sqlite3_column_text(stmt, 0) { - names.insert(String(cString: c)) + /// Every table name in a SQLite file. Opens READ-ONLY first (so a healthy file is never mutated); + /// on an OPEN failure retries READ-WRITE, because a checkpointed WAL-header backup can't be + /// read-only-opened without its `-shm` sibling (mirrors `DatabaseIntegrity.quickCheckFailure` and + /// the Android probe's read-write fallback — the probed file is a staged temp copy or the picked + /// backup, and a read-write open only runs standard SQLite recovery, never a content change). + /// + /// Distinguishes an EMPTY-but-valid file (opened + queried, no tables → empty set, a legitimate + /// pre-migration store) from a file it COULD NOT READ (both opens failed, or the query failed → + /// `nil`). The caller must surface `nil` as an honest failure and NOT fall through to the raw + /// file-swap: an unreadable file behind a valid magic header would otherwise "restore" into a + /// silently empty store. + private static func sqliteTableNames(at url: URL) -> Set? { + for flags in [SQLITE_OPEN_READONLY, SQLITE_OPEN_READWRITE] { + var db: OpaquePointer? + if sqlite3_open_v2(url.path, &db, flags, nil) != SQLITE_OK { + sqlite3_close(db) + continue // read-only may fail on a WAL-header file — retry read-write before giving up + } + defer { sqlite3_close(db) } + var stmt: OpaquePointer? + let sql = "SELECT name FROM sqlite_master WHERE type = 'table'" + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + var names: Set = [] + while sqlite3_step(stmt) == SQLITE_ROW { + if let c = sqlite3_column_text(stmt, 0) { + names.insert(String(cString: c)) + } } + return names } - return names + return nil // neither open mode succeeded → a genuine read failure, not an empty database } /// Read the first 4 bytes and check for the ZIP PK magic (`PK\x03\x04`). diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 5233921146..d5f070fbec 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -1800,9 +1800,16 @@ struct SettingsView: View { backupAlertTitle = String(localized: "Backup exported") backupAlertMessage = String(localized: "Saved to \(url.lastPathComponent). Copy this file to your other \(Platform.deviceNoun) and use Import there to restore everything.") showBackupAlert = true - case .imported: + case .imported(_, let warnings): backupAlertTitle = String(localized: "Backup imported") - backupAlertMessage = String(localized: "Your data has been restored. Quit and reopen NOOP for it to take effect.") + var message = String(localized: "Your data has been restored. Quit and reopen NOOP for it to take effect.") + if !warnings.isEmpty { + // A cross-platform backup was reconciled row-by-row; show what didn't line up so the + // import is honest about any gaps (never a silent partial restore). + message += "\n\n" + String(localized: "This was a backup from another NOOP platform, merged into your store:") + message += "\n" + warnings.joined(separator: "\n") + } + backupAlertMessage = message showBackupAlert = true case .failure(let message): backupAlertTitle = String(localized: "Backup problem") diff --git a/android/app/src/main/java/com/noop/data/DataBackup.kt b/android/app/src/main/java/com/noop/data/DataBackup.kt index a91dd25cf7..feb6992887 100644 --- a/android/app/src/main/java/com/noop/data/DataBackup.kt +++ b/android/app/src/main/java/com/noop/data/DataBackup.kt @@ -61,13 +61,38 @@ object DataBackup { private val ZIP_MAGIC: ByteArray = byteArrayOf(0x50, 0x4B, 0x03, 0x04) + /** Which foreign platform / fork a picked backup came from, used to word the import confirmation. */ + enum class ForeignBackupKind { IOS, ANDROID_FORK } + /** Outcome of an [importFrom] call. On success the app must be restarted. */ sealed interface ImportResult { - /** The new database is in place; tell the user to relaunch NOOP. */ - data object NeedsRestart : ImportResult + /** + * The new database is in place; tell the user to relaunch NOOP. [warnings] is empty for a + * plain same-app restore; a cross-platform / cross-fork row-copy import (see + * [reconcileForeignBackup]) fills it with what the two schemas didn't share (tables/columns + * only one side has), for the UI to surface after the restore. Never a hard error. + */ + data class NeedsRestart(val warnings: List = emptyList()) : ImportResult + + /** + * The picked file is a FOREIGN NOOP backup (the iOS/GRDB store, or a divergent Android NOOP + * fork) whose rows CAN be merged in by [reconcileForeignBackup], but doing so is a deliberate + * cross-platform act, so it isn't done silently. The UI shows a confirm dialog keyed on [kind] + * and, on approval, re-invokes [importFrom] with `confirmedForeign = true`. + */ + data class NeedsConfirmation(val kind: ForeignBackupKind) : ImportResult /** Import failed and the original database is untouched. */ data class Failed(val message: String) : ImportResult + + /** + * Like [Failed] the live database FILE is intact and untouched, but a cross-fork reconcile + * (see [reconcileForeignBackup]) failed AFTER the live Room singleton was closed for the merge, + * so its DAOs now point at a closed connection (the #57 stale-handle hazard). The reconcile only + * READS the live file and writes scratch, so the data is safe; the UI shows [message], then + * relaunches the app on dismiss so Room re-opens the intact store fresh. + */ + data class FailedNeedsRestart(val message: String) : ImportResult } /** @@ -143,10 +168,17 @@ object DataBackup { * Accepts both the new `.noopbak` (ZIP) format and legacy plain `.sqlite`/`.noopdb` * files so older backups keep working after the format upgrade. * + * A same-app Room backup (any version this app's migrator can open) restores by the fast file + * swap below. A FOREIGN backup — the iOS/GRDB store, or a divergent Android NOOP fork whose schema + * this build can't migrate forward — is instead merged in row-by-row by [reconcileForeignBackup], + * but only once the caller passes [confirmedForeign] true (the first call returns + * [ImportResult.NeedsConfirmation] so the UI can ask). Detection is by schema CONTENT, never by + * version (GRDB always reports user_version 0, forks reuse the same integers). + * * On any error the current database is left exactly as it was. On success the caller * MUST instruct the user to fully restart the app. */ - fun importFrom(context: Context, uri: Uri): ImportResult { + fun importFrom(context: Context, uri: Uri, confirmedForeign: Boolean = false): ImportResult { val appContext = context.applicationContext val resolver = appContext.contentResolver @@ -199,25 +231,34 @@ object DataBackup { return ImportResult.Failed("The backup archive doesn't contain a valid NOOP database.") } - // 3b. Origin check (parity with the Apple side's GRDB-origin rejection). The SQLite magic - // passes for ANY SQLite file: a GRDB (Mac/iOS NOOP) backup or some other app's database - // would otherwise sail through and REPLACE the live Room store, stranding the user. Read - // the backup's table names READ-ONLY and reject anything that isn't a Room (this-app) - // backup but still holds real data. Empty/pre-migration files fall through to Room's - // open-time migrator, exactly as before. + // 3b. Route by backup CONTENT, not version (the SQLite magic passes for ANY SQLite file, and + // the header's user_version is unusable here — GRDB always writes 0 and Room forks reuse + // the same integers). Read the table names + the dailyMetric columns READ-ONLY: + // - our own Room store (any version this app's migrator can open) keeps the fast file + // swap restore below; + // - a FOREIGN backup — the iOS/GRDB store, or a divergent Android NOOP fork whose schema + // this build can't migrate forward (a raw swap would then strand or wipe it) — is + // merged in row-by-row by [reconcileForeignBackup], but only after the user confirms + // (a cross-platform restore is a deliberate act, so it is never silent); + // - a file that holds data yet carries neither migrator's bookkeeping is some other + // app's database and is still refused outright. + // Empty/pre-migration files fall through to Room's open-time migrator, exactly as before. + var importWarnings: List = emptyList() + // A NULL schema read means the backup's SQLite could not be OPENED or queried AT ALL — distinct + // from a readable-but-empty file, which reads as an empty set and falls through to Room's + // open-time migrator exactly as before. Refuse a null honestly instead of falling through to the + // raw file-swap, which would drop an unreadable file over the live store. val backupTables = sqliteTableNames(tempSqlite) - when (backupOriginOf(backupTables)) { - BackupOrigin.MAC -> - return rejectForeign( - tempSqlite, - tempSettings, - "This isn't a NOOP backup from this app. It looks like a backup from the Mac or " + - "iOS NOOP app (it carries that platform's migration bookkeeping). Restoring it here " + - "would strand your store. To move your history across platforms, export the " + - "WHOOP-format CSV on the other device (Settings → Export data) and import that here.", - ) - BackupOrigin.UNKNOWN -> - if (holdsData(backupTables)) { + ?: return rejectForeign( + tempSqlite, + tempSettings, + "Couldn't read this backup's database. Your current data is untouched. Try an earlier " + + "backup file.", + ) + val backupDailyMetricColumns = sqliteColumnNames(tempSqlite, "dailyMetric") ?: emptySet() + when (val foreign = foreignBackupKind(backupTables, backupDailyMetricColumns)) { + null -> + if (backupOriginOf(backupTables) == BackupOrigin.UNKNOWN && holdsData(backupTables)) { return rejectForeign( tempSqlite, tempSettings, @@ -226,7 +267,58 @@ object DataBackup { "strand your store.", ) } - BackupOrigin.ANDROID -> Unit // our own backup, proceed. + else -> { + // Gate the cross-platform merge behind an explicit confirmation; nothing has touched + // the live DB yet, so returning here leaves it exactly as it was. + if (!confirmedForeign) { + tempSqlite.delete() + tempSettings.delete() + return ImportResult.NeedsConfirmation(foreign) + } + // Fresh-install contract (twin of the Swift restore): a foreign row-copy inherits THIS + // app's exact schema + Room identity from the live store. If NOOP has never opened its + // store on this device there is nothing to reconcile into, so refuse honestly rather than + // create an empty store and merge into it. Checked BEFORE the WhoopDatabase.get() below, + // which would otherwise create that empty store. + val liveDb = appContext.getDatabasePath(WhoopDatabase.DB_NAME) + if (!liveDb.exists()) { + return rejectForeign( + tempSqlite, + tempSettings, + "This looks like a backup from another NOOP platform, but there's no NOOP store " + + "on this device yet to merge it into. Open NOOP once to set up your store, " + + "then import again - or move your history with a WHOOP-format CSV export.", + ) + } + // Row-copy the foreign rows into a file carrying THIS app's schema + identity, then + // REPLACE the staged file with it so the ordinary integrity/snapshot/swap path below + // lands it. Checkpoint + close the live store first so the file the reconcile reads is + // quiescent. + importWarnings = runCatching { + WhoopDatabase.get(appContext).query("PRAGMA wal_checkpoint(TRUNCATE)", null) + .use { it.moveToFirst() } + WhoopDatabase.close() + val (reconciled, warnings) = + reconcileForeignBackup(appContext, liveDb, tempSqlite, ImportMode.REPLACE) + reconciled.copyTo(tempSqlite, overwrite = true) + reconciled.delete() + // Reopen the live store so the shared gates below (3c integrity check) run with it + // OPEN, exactly as the same-app restore does; step 4 re-closes it for the swap. This + // keeps a downstream failure on the ordinary Failed path instead of stranding closed DAOs. + WhoopDatabase.get(appContext) + warnings + }.getOrElse { e -> + tempSqlite.delete() + tempSettings.delete() + // The live DB FILE is untouched (reconcile only reads it + writes scratch), but the + // Room singleton is now CLOSED (closed above so the reconcile read a quiescent file). + // A plain Failed here would strand the app on stale/closed DAOs (the #57 hazard); + // FailedNeedsRestart makes the UI show the error, then relaunch on the intact live DB. + return ImportResult.FailedNeedsRestart( + "Couldn't bring this backup into NOOP's format: ${e.message}" + ) + } + } } // 3c. #1014 defence-in-depth: gates 3 and 3b read only the FIRST pages of the file — the @@ -321,6 +413,12 @@ object DataBackup { if (tempSettings.exists()) { runCatching { BackupSettingsBridge.apply(appContext, tempSettings.readText(Charsets.UTF_8)) + }.onFailure { e -> + // Non-fatal to the DB restore (the rows already landed), but don't swallow it: surface it + // as a restore warning so the user knows their profile/display settings didn't come back + // and can re-enter them, instead of silently reverting to the device's current values. + importWarnings = importWarnings + + "Your saved profile and display settings couldn't be re-applied: ${e.message}" } tempSettings.delete() } @@ -333,7 +431,7 @@ object DataBackup { com.noop.ui.NoopPrefs.of(appContext).edit() .putLong("backup.lastRestoreAt", System.currentTimeMillis() / 1000L).apply() } - return ImportResult.NeedsRestart + return ImportResult.NeedsRestart(importWarnings) } // ── Container staging (pure file/stream layer, unit-tested under real file I/O) ────── @@ -517,13 +615,26 @@ object DataBackup { return tableNames.any { it !in housekeeping && !it.startsWith("sqlite_") } } - /** Every table name in [file], opened READ-ONLY so the probed file is never mutated. Empty on - * failure. Carries [PRESERVE_ON_CORRUPTION] (#1014): without an explicit handler the framework - * default would DELETE the staged file when the open reports SQLITE_NOTADB/CORRUPT. */ - private fun sqliteTableNames(file: File): Set { - val db = runCatching { + /** Open [file] for a schema PROBE: read-only first, then falling back to read-write exactly as + * [sqliteQuickCheckFailure] does. A checkpointed WAL `.noopbak` carries a WAL-mode header that + * pre-3.22 SQLite (API 26/27, minSdk 26) cannot open read-only without an initialized `-shm`, so a + * read-only-only probe would spuriously fail on valid Android 8.x backups. Returns null when NEITHER + * open succeeds. Both opens carry [PRESERVE_ON_CORRUPTION] (#1014) so a probe can never delete what + * it probes; a read-write open only runs standard SQLite recovery, never a content change, and every + * probed file is a staged temp copy this app owns. */ + private fun openReadableForProbe(file: File): SQLiteDatabase? = + runCatching { SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY, PRESERVE_ON_CORRUPTION) - }.getOrNull() ?: return emptySet() + }.recoverCatching { + SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READWRITE, PRESERVE_ON_CORRUPTION) + }.getOrNull() + + /** Every table name in [file], or NULL when the database could not be OPENED or queried at all — + * distinct from a readable-but-empty file, which returns an EMPTY set. The caller ([importFrom]) + * turns a null into an honest "couldn't read this backup" refusal instead of falling through to the + * raw file-swap. Opened via [openReadableForProbe] so a WAL `.noopbak` still reads on API 26/27. */ + private fun sqliteTableNames(file: File): Set? { + val db = openReadableForProbe(file) ?: return null return try { val names = LinkedHashSet() db.rawQuery("SELECT name FROM sqlite_master WHERE type = 'table'", null).use { c -> @@ -531,7 +642,27 @@ object DataBackup { } names } catch (e: Exception) { - emptySet() + null + } finally { + runCatching { db.close() } + } + } + + /** Column names of [table] in [file], or NULL when the database could not be OPENED or queried at + * all. A readable file that simply LACKS [table] returns an EMPTY set (not null). Opened via + * [openReadableForProbe] like [sqliteTableNames]. Used by the fork-marker check ([foreignBackupKind]) + * to spot a column another fork carries but this build's schema doesn't. */ + private fun sqliteColumnNames(file: File, table: String): Set? { + val db = openReadableForProbe(file) ?: return null + return try { + val names = LinkedHashSet() + db.rawQuery("PRAGMA table_info(${quoteId(table)})", null).use { c -> + val ni = c.getColumnIndex("name") + while (c.moveToNext()) if (ni >= 0) c.getString(ni)?.let(names::add) + } + names + } catch (e: Exception) { + null } finally { runCatching { db.close() } } @@ -608,6 +739,363 @@ object DataBackup { runCatching { db.close() } } } + + // ── Cross-platform / cross-fork row-copy import (version-agnostic) ──────────── + + /** + * Decide whether [tableNames] + [dailyMetricColumns] describe a FOREIGN backup this app should + * reconcile (row-copy) rather than file-swap, and if so from where. Content-based, never + * version-based: + * - a GRDB store (`grdb_migrations`) is the iOS / Mac NOOP app → [ForeignBackupKind.IOS]; + * - a Room store carrying a table or column THIS build's schema doesn't have — a divergent + * Android NOOP fork — → [ForeignBackupKind.ANDROID_FORK]: its ahead/renamed schema can't be + * brought forward by this app's Room migrator, so a raw file swap would strand or wipe it. + * Returns null for our OWN Room backup (including an older one the migrator can still open, and a + * fork that is merely BEHIND — a version difference, not a content divergence) and for an + * empty/unrecognised file: those keep the existing same-app restore / open-time-migrator path. + * + * The fork-only markers are the two tables/columns no upstream NOOP schema carries: the + * `spo2PctSample` table and `dailyMetric.skinTempAbsC`. Pure (no DB open) so it is unit-tested + * directly on the plain JVM. + */ + fun foreignBackupKind(tableNames: Set, dailyMetricColumns: Set): ForeignBackupKind? { + if (tableNames.contains("grdb_migrations")) return ForeignBackupKind.IOS + if (tableNames.contains("room_master_table")) { + if (tableNames.contains("spo2PctSample") || dailyMetricColumns.contains("skinTempAbsC")) { + return ForeignBackupKind.ANDROID_FORK + } + } + return null + } + + /** How a foreign backup's rows fold into the target store. */ + enum class ImportMode { MERGE, REPLACE } + + /** + * One column's identity plus the two `PRAGMA table_info` facts the row copy needs to keep a + * source-absent NOT NULL column instead of dropping its rows: whether it is NOT NULL, and whether it + * carries a schema DEFAULT (`dflt_value`). [type] is the declared type, the affinity source for the + * typed zero literal a filled column gets. + * + * The distinction is load-bearing across platforms: Room emits NO SQL default for a Kotlin `= 0` + * property, so an Android `synced` flag is NOT NULL with no default, whereas the GRDB twin of the + * same column is written `NOT NULL DEFAULT 0`. Omitting such a column from an `INSERT OR IGNORE` + * makes SQLite drop EVERY row (a NOT NULL violation, silently ignored) instead of filling a default — + * which is why the copy must read these facts and fill the column explicitly. + */ + internal data class SchemaColumn( + val name: String, + val type: String, + val notNull: Boolean, + val hasDefault: Boolean, + /** + * True when this column is part of the table's PRIMARY KEY or any UNIQUE index. A source-absent + * NOT NULL-no-default column that is a KEY must NOT be CONSTANT-filled: every row would get the + * SAME typed zero, so `INSERT OR IGNORE` would treat all but the first as a key clash and collapse + * the whole table to one row. The planner fills it with the source `rowid` (per-row-unique) instead, + * so the rows import. Read from `PRAGMA table_info` (`pk` > 0) plus `PRAGMA index_list` / `index_info`. + */ + val key: Boolean = false, + ) + + /** + * A content-based import plan: the SQL to run, plus what didn't line up (surfaced to the user as + * warnings, never as a hard error). [statements] run inside one transaction with `src` ATTACHed. + */ + internal data class RowCopyPlan( + val statements: List, + /** Target tables absent from the backup — no data to import for them. */ + val missingTables: List, + /** Backup tables with no home in the target — their rows are skipped. */ + val droppedTables: List, + /** Per table, source-absent target columns that are NULLABLE or carry a DEFAULT — omitted from the + * INSERT so SQLite fills NULL / the default. */ + val missingColumns: Map>, + /** Per table, source-absent target columns that are NOT NULL with NO default — KEPT in the INSERT + * and filled with a typed zero literal, so `INSERT OR IGNORE` can't silently drop the row. */ + val filledColumns: Map> = emptyMap(), + /** Per table, source-absent NOT NULL-no-default KEY (PK / UNIQUE) columns filled with the source + * `rowid` (a per-row-unique id) so the rows import without a constant collapsing the table. Maps + * the table to those key column(s). */ + val synthesizedKeyColumns: Map> = emptyMap(), + /** Tables an INSERT was actually emitted for — the set the reconcile row-count backstop verifies. */ + val copiedTables: List = emptyList(), + ) { + /** Human-readable warnings, empty when the backup lines up cleanly. */ + fun warnings(): List = buildList { + if (missingTables.isNotEmpty()) add("No data in this backup for: ${missingTables.joinToString(", ")}.") + if (droppedTables.isNotEmpty()) add("Skipped tables not in this app: ${droppedTables.joinToString(", ")}.") + // A key column the backup didn't carry, filled with a generated id so the rows still import. + synthesizedKeyColumns.forEach { (t, cols) -> + add("$t: generated ids for the key column(s) ${cols.joinToString(", ")} this backup didn't carry.") + } + // Filled columns are KEPT (their rows survive) — say so, never "imported empty", which would + // wrongly imply the rows were dropped. Listed before the omitted-column notes for stable order. + filledColumns.forEach { (t, cols) -> add("$t: filled ${cols.joinToString(", ")} with defaults.") } + missingColumns.forEach { (t, cols) -> add("$t is missing fields ${cols.joinToString(", ")} (imported empty).") } + } + } + + /** SQLite / Room / GRDB bookkeeping tables — never row-copied: they carry the target's own + * identity, autoincrement counters and migration ledger, which copying would corrupt. */ + private val HOUSEKEEPING_TABLES = + setOf("android_metadata", "sqlite_sequence", "room_master_table", "grdb_migrations") + + /** + * Plan a version-agnostic row copy from [source] into [target] — each a `table -> ordered columns` + * map (with per-column NOT NULL / default facts) read at runtime from `PRAGMA table_info`, so it + * needs no schema version and never touches Room's identity. For every data table in BOTH: + * - copy the intersection of columns (target column order preserved); + * - a target column ABSENT from the source that is NOT NULL with NO default is KEPT and filled with + * a typed zero literal — otherwise `INSERT OR IGNORE` would drop the whole table's rows on the + * NOT NULL violation (Room emits no SQL default for a Kotlin `= 0`, e.g. `synced`); + * - EXCEPT when that source-absent NOT NULL-no-default column is a KEY (PK / UNIQUE member): a constant + * fill would collapse the table under `INSERT OR IGNORE`, so it is filled with the source `rowid` + * (per-row-unique) instead, keeping the rows without collapsing (see [synthesizedKeyColumns]); + * - a source-absent column that is nullable or has a default is omitted (SQLite fills NULL/default). + * Other type/quoting differences across forks/platforms are irrelevant (SQLite coerces on insert). + * [ImportMode.REPLACE] clears each target table first (restore semantics); [ImportMode.MERGE] keeps + * existing rows on a PK clash. Both use `INSERT OR IGNORE`, so a clash is skipped, never overwritten. + * Mismatched tables/columns become [RowCopyPlan] warnings, not failures. + */ + internal fun planRowCopyImport( + target: Map>, + source: Map>, + mode: ImportMode, + ): RowCopyPlan { + fun dataTables(keys: Set) = keys.filter { it !in HOUSEKEEPING_TABLES && !it.startsWith("sqlite_") } + val tgt = dataTables(target.keys).toSet() + val src = dataTables(source.keys).toSet() + val stmts = ArrayList() + val copied = ArrayList() + val missingCols = LinkedHashMap>() + val filledCols = LinkedHashMap>() + val synthKeyCols = LinkedHashMap>() + for (t in (tgt intersect src).sorted()) { + val srcCols = source[t]!!.map { it.name }.toSet() + val insertCols = ArrayList() + val selectExprs = ArrayList() + val omitted = ArrayList() + val filled = ArrayList() + val synthKeys = ArrayList() + for (col in target[t]!!) { + when { + // Shared column: straight copy, target order preserved. + col.name in srcCols -> { + insertCols.add(col.name) + selectExprs.add(quoteId(col.name)) + } + // Source-absent NOT NULL-no-default column that is a KEY (PK / UNIQUE member): a CONSTANT + // fill would give every row the same value and INSERT OR IGNORE would collapse the table + // to one row. Fill the source `rowid` instead — a per-row-unique id — so the rows import + // without collapsing (the row-count backstop still catches any true shortfall). + col.notNull && !col.hasDefault && col.key -> { + insertCols.add(col.name) + selectExprs.add("rowid") + synthKeys.add(col.name) + } + // Source-absent NOT NULL with no default (not a key): MUST be kept + filled, or + // INSERT OR IGNORE drops the whole row on the NOT NULL violation. Fill a typed zero. + col.notNull && !col.hasDefault -> { + insertCols.add(col.name) + selectExprs.add(typedZeroLiteral(col.type)) + filled.add(col.name) + } + // Source-absent nullable / has-default: omit it, SQLite fills NULL / the default. + else -> omitted.add(col.name) + } + } + if (synthKeys.isNotEmpty()) synthKeyCols[t] = synthKeys + if (omitted.isNotEmpty()) missingCols[t] = omitted + if (filled.isNotEmpty()) filledCols[t] = filled + if (insertCols.isEmpty()) continue + val colList = insertCols.joinToString(", ") { quoteId(it) } + val selList = selectExprs.joinToString(", ") + if (mode == ImportMode.REPLACE) stmts.add("DELETE FROM main.${quoteId(t)}") + stmts.add("INSERT OR IGNORE INTO main.${quoteId(t)} ($colList) SELECT $selList FROM src.${quoteId(t)}") + copied.add(t) + } + return RowCopyPlan( + statements = stmts, + missingTables = (tgt - src).sorted(), + droppedTables = (src - tgt).sorted(), + missingColumns = missingCols, + filledColumns = filledCols, + synthesizedKeyColumns = synthKeyCols, + copiedTables = copied, + ) + } + + /** The zero literal a source-absent NOT NULL-no-default column is filled with, by declared type: + * TEXT (`''`), BLOB (`x''`), everything else — INTEGER, REAL, NUMERIC, and an untyped column — + * numeric `0`. Matches the Swift twin (`ForeignBackupImport.zeroLiteral`) exactly, so a column + * filled on either platform lands the same byte. The untyped case aligns to Swift's `0` purely for + * parity (both platforms must emit the same literal); it doesn't arise in either real Room/GRDB + * schema, since both always declare an affinity keyword. */ + private fun typedZeroLiteral(declaredType: String): String { + val t = declaredType.uppercase() + return when { + t.contains("CHAR") || t.contains("CLOB") || t.contains("TEXT") -> "''" + t.contains("BLOB") -> "x''" + else -> "0" + } + } + + /** A backtick-quoted SQL identifier with any embedded backtick DOUBLED, so a foreign backup's table + * or column name (interpolated into the PRAGMA / row-copy statements below — PRAGMA can't bind an + * identifier parameter) can't break out of its quoting. Bounded even without this — every mutating + * statement targets trusted `main` names — but a stray backtick in a source identifier would + * otherwise throw mid-read; doubling keeps the read honest. Twin of the Swift `quoteId`. */ + private fun quoteId(id: String): String = "`" + id.replace("`", "``") + "`" + + /** Row count of [table] in [schema] ('main' or the ATTACHed 'src'), for the reconcile backstop. */ + private fun rowCount(db: SQLiteDatabase, schema: String, table: String): Long = + db.rawQuery("SELECT count(*) FROM $schema.${quoteId(table)}", null).use { + if (it.moveToFirst()) it.getLong(0) else 0L + } + + /** Names of every column of [table] in [schema] that participates in the PRIMARY KEY or any UNIQUE + * index — the columns the planner must NOT constant-fill (a shared value would collapse the table + * under `INSERT OR IGNORE`). PK members come from `PRAGMA table_info` (`pk` > 0); UNIQUE members from + * each `unique` index in `PRAGMA index_list`, expanded via `PRAGMA index_info`. */ + private fun keyColumns(db: SQLiteDatabase, schema: String, table: String): Set { + val keys = LinkedHashSet() + db.rawQuery("PRAGMA $schema.table_info(${quoteId(table)})", null).use { c -> + val ni = c.getColumnIndex("name") + val pi = c.getColumnIndex("pk") + while (c.moveToNext()) { + val name = if (ni >= 0) c.getString(ni) else null + val isPk = pi >= 0 && c.getInt(pi) != 0 + if (name != null && isPk) keys.add(name) + } + } + val uniqueIndexes = ArrayList() + db.rawQuery("PRAGMA $schema.index_list(${quoteId(table)})", null).use { il -> + val nameIdx = il.getColumnIndex("name") + val uniqIdx = il.getColumnIndex("unique") + while (il.moveToNext()) { + val unique = uniqIdx >= 0 && il.getInt(uniqIdx) != 0 + val idxName = if (nameIdx >= 0) il.getString(nameIdx) else null + if (unique && idxName != null) uniqueIndexes.add(idxName) + } + } + for (idx in uniqueIndexes) { + db.rawQuery("PRAGMA $schema.index_info(${quoteId(idx)})", null).use { ii -> + val cn = ii.getColumnIndex("name") + while (ii.moveToNext()) if (cn >= 0) ii.getString(cn)?.let(keys::add) + } + } + return keys + } + + /** `table -> ordered [SchemaColumn]s` from `PRAGMA table_info` on [schema] ('main' or an ATTACHed + * alias). Reads each column's declared type, NOT NULL flag (`notnull`), whether it carries a + * DEFAULT (`dflt_value` non-null), AND whether it is a KEY (PK / UNIQUE member, via [keyColumns]), + * so the planner can keep + fill a source-absent NOT NULL-no-default column — but never a KEY one, + * which it skips — instead of silently dropping the table's rows. */ + private fun readSchema(db: SQLiteDatabase, schema: String): Map> { + val out = LinkedHashMap>() + db.rawQuery("SELECT name FROM $schema.sqlite_master WHERE type = 'table'", null).use { tc -> + while (tc.moveToNext()) { + val t = tc.getString(0) ?: continue + val keyCols = keyColumns(db, schema, t) + val cols = ArrayList() + db.rawQuery("PRAGMA $schema.table_info(${quoteId(t)})", null).use { cc -> + val ni = cc.getColumnIndex("name") + val ti = cc.getColumnIndex("type") + val nn = cc.getColumnIndex("notnull") + val df = cc.getColumnIndex("dflt_value") + while (cc.moveToNext()) { + val name = (if (ni >= 0) cc.getString(ni) else null) ?: continue + val type = (if (ti >= 0) cc.getString(ti) else null) ?: "" + val notNull = nn >= 0 && cc.getInt(nn) != 0 + val hasDefault = df >= 0 && !cc.isNull(df) + cols.add(SchemaColumn(name, type, notNull, hasDefault, key = name in keyCols)) + } + } + out[t] = cols + } + } + return out + } + + /** + * Reconcile a foreign / cross-platform [stagedBackup] into a file carrying THIS app's exact schema + * + Room identity, by COPYING [liveDbFile] (a valid store) and row-copying the backup's data into + * it — REPLACE clears each shared table, then inserts the column intersection. Version-agnostic: + * reads by table/column via [readSchema], never by schema version, so any fork's or the iOS/GRDB + * backup lands by its logical data alone. Returns the reconciled file + [RowCopyPlan] warnings; the + * caller swaps the returned file in through the normal snapshot / rollback path. THROWS on any SQL + * failure (never swallows) so a torn reconcile can never masquerade as a good restore; on ANY throw + * the half-built work file and its `-wal` / `-shm` sidecars are deleted, so a failed reconcile never + * leaks a partial store into the cache. The live store must already exist. + */ + fun reconcileForeignBackup( + appContext: Context, + liveDbFile: File, + stagedBackup: File, + mode: ImportMode, + ): Pair> { + require(liveDbFile.exists()) { "no live store to reconcile the backup against" } + val work = File(appContext.cacheDir, "import-reconciled.db") + val artifacts = listOf(work, File(work.path + "-wal"), File(work.path + "-shm")) + artifacts.forEach { it.delete() } + var handedOff = false + try { + liveDbFile.copyTo(work, overwrite = true) // inherits this app's schema + identity; rows cleared below + val warnings: List + val db = SQLiteDatabase.openDatabase(work.path, null, SQLiteDatabase.OPEN_READWRITE, PRESERVE_ON_CORRUPTION) + try { + val target = readSchema(db, "main") + db.execSQL("ATTACH DATABASE ? AS src", arrayOf(stagedBackup.path)) + val source = readSchema(db, "src") + val plan = planRowCopyImport(target, source, mode) + db.beginTransaction() + try { + for (s in plan.statements) db.execSQL(s) + // Row-count backstop (REPLACE restore only). Each copied table was cleared then re-filled + // from the source, so on a clean import `landed == source`. A shortfall means + // `INSERT OR IGNORE` silently dropped rows on a constraint the planner didn't model — a + // CHECK, or a UNIQUE the source didn't enforce — so ABORT (throw, rolling back) instead + // of committing a quietly-truncated table. The planner already prevents the key-column + // collapse by skipping such tables; this catches everything else, before the swap path + // ever touches the live store. MERGE is exempt: its `INSERT OR IGNORE` PK-clash drops + // are intentional (existing rows win). `src` is still ATTACHed here (DETACH is below). + if (mode == ImportMode.REPLACE) { + for (t in plan.copiedTables) { + val sourceRows = rowCount(db, "src", t) + val landed = rowCount(db, "main", t) + if (landed < sourceRows) { + throw IllegalStateException( + "table \"$t\" kept only $landed of $sourceRows rows " + + "(a schema constraint dropped the rest)" + ) + } + } + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + db.execSQL("DETACH DATABASE src") + // wal_checkpoint returns a row, so it must go through rawQuery, not execSQL. Fold the WAL + // so `work` is a self-contained file for the swap. + db.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { it.moveToFirst() } + warnings = plan.warnings() + } finally { + db.close() + } + File(work.path + "-wal").delete() + File(work.path + "-shm").delete() + handedOff = true + return work to warnings + } finally { + // On ANY throw (copy, open, SQL, checkpoint) the work file was never handed to the caller — + // delete it AND its -wal/-shm sidecars so nothing partial survives. On success `work` is + // returned and only its (now-empty) sidecars were dropped above. + if (!handedOff) artifacts.forEach { runCatching { it.delete() } } + } + } } /** diff --git a/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt b/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt index 8d8d8c28f3..4992c72306 100644 --- a/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt +++ b/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt @@ -78,35 +78,56 @@ fun BackupSyncScreen() { var snapshots by remember { mutableStateOf>(emptyList()) } var showSnapshotPicker by remember { mutableStateOf(false) } var pendingRestore by remember { mutableStateOf?>(null) } + // A foreign (iOS/GRDB or other-fork) backup that needs the cross-platform-import confirmation, and + // the row-copy warnings to surface once such an import lands (empty for a plain same-app restore). + var pendingForeign by remember { mutableStateOf?>(null) } + var importWarnings by remember { mutableStateOf>(emptyList()) } + // A cross-fork reconcile that FAILED after the live DB was closed for the merge (see + // DataBackup.ImportResult.FailedNeedsRestart). The live database file is UNTOUCHED — the reconcile + // only reads it and writes scratch — but the Room singleton is now closed, so the app must relaunch + // to reopen it on the intact data (the #57 stale-DAO hazard). Holds the failure message to show + // before that forced relaunch. + var pendingFailedRestart by remember { mutableStateOf(null) } - // Runs the actual destructive restore for a chosen backup Uri, off the main thread. - fun runRestore(uri: Uri) { + // #57: the restore CLOSED and swapped the database file. The long-lived WhoopRepository + BLE client + // still hold a DAO on the OLD (now-closed) connection, so any strap sync would fail with "connection + // pool has been closed" — and, worse, empty/metadata history ENDs would still ack and trim the strap + // PAST records we can't store, discarding real history. Relaunching the process re-opens Room against + // the restored file. Do it automatically rather than trust the user to read a toast (which is exactly + // how #57 happened). NonCancellable: this runs in the screen's scope, cancelled the instant the user + // navigates away, but the restart is a data-safety guarantee (the DB is already swapped), so it must + // complete even if the composition leaves. + fun restartNoop(message: String = "Backup restored — restarting NOOP…") { + scope.launch { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + withContext(NonCancellable) { + delay(800) // let the toast render before the process dies + val ctx = context.applicationContext + ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) + ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + ?.let { ctx.startActivity(it) } + Runtime.getRuntime().exit(0) + } + } + } + + // Runs the actual destructive restore for a chosen backup Uri, off the main thread. A foreign backup + // returns NeedsConfirmation first (nothing touched yet); the caller re-runs with confirmedForeign=true + // after the cross-platform-import dialog. A cross-fork import can land warnings (missing tables/ + // columns) — those are shown in a dialog whose "Restart" button does the relaunch, so they aren't + // lost to the auto-restart. + fun runRestore(uri: Uri, confirmedForeign: Boolean = false) { busy = true scope.launch { - val r = withContext(Dispatchers.IO) { DataBackup.importFrom(context, uri) } + val r = withContext(Dispatchers.IO) { DataBackup.importFrom(context, uri, confirmedForeign) } busy = false when (r) { - is DataBackup.ImportResult.NeedsRestart -> { - // #57: the restore CLOSED and swapped the database file. The long-lived WhoopRepository + - // BLE client still hold a DAO on the OLD (now-closed) connection, so any strap sync would - // fail with "connection pool has been closed" — and, worse, empty/metadata history ENDs - // would still ack and trim the strap PAST records we can't store, discarding real history. - // Relaunching the process re-opens Room against the restored file. Do it automatically - // rather than trust the user to read a toast (which is exactly how #57 happened). - Toast.makeText(context, "Backup restored — restarting NOOP…", Toast.LENGTH_LONG).show() - // NonCancellable: this coroutine runs in the screen's scope, which is cancelled the - // instant the user navigates away. The restart is a data-safety guarantee (the DB is - // already swapped), so it must complete even if the composition leaves — otherwise the - // user could keep syncing into the closed DB, the very bug we're fixing. - withContext(NonCancellable) { - delay(800) // let the toast render before the process dies - val ctx = context.applicationContext - ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) - ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - ?.let { ctx.startActivity(it) } - Runtime.getRuntime().exit(0) - } - } + is DataBackup.ImportResult.NeedsConfirmation -> + pendingForeign = uri to r.kind + is DataBackup.ImportResult.NeedsRestart -> + if (r.warnings.isEmpty()) restartNoop() else importWarnings = r.warnings + is DataBackup.ImportResult.FailedNeedsRestart -> + pendingFailedRestart = r.message is DataBackup.ImportResult.Failed -> Toast.makeText(context, r.message, Toast.LENGTH_LONG).show() } @@ -429,6 +450,90 @@ fun BackupSyncScreen() { }, ) } + + // Cross-platform / cross-fork confirmation. The chosen backup is a foreign NOOP store (iOS/GRDB or a + // different Android build); NOOP copies its rows into this app's format rather than swapping the file. + // Confirming re-runs the import with confirmedForeign=true. + pendingForeign?.let { (uri, kind) -> + val (title, body) = when (kind) { + DataBackup.ForeignBackupKind.IOS -> + uiString(R.string.l10n_backup_sync_screen_import_an_ios_backup_cdce911a) to + uiString(R.string.l10n_backup_sync_screen_importing_an_ios_backup_into_android_is_tha_fa165da3) + DataBackup.ForeignBackupKind.ANDROID_FORK -> + uiString(R.string.l10n_backup_sync_screen_import_a_backup_from_another_noop_build_cddb47ad) to + uiString(R.string.l10n_backup_sync_screen_this_backup_is_from_a_different_noop_build_n_d18acf8c) + } + AlertDialog( + onDismissRequest = { pendingForeign = null }, + containerColor = Palette.surfaceOverlay, + title = { Text(title, style = NoopType.title2, color = Palette.textPrimary) }, + text = { Text(body, style = NoopType.subhead, color = Palette.textSecondary) }, + confirmButton = { + TextButton(onClick = { + pendingForeign = null + runRestore(uri, confirmedForeign = true) + }) { + Text(uiString(R.string.l10n_backup_sync_screen_import_d6fbc9d2), style = NoopType.body, color = Palette.accent) + } + }, + dismissButton = { + TextButton(onClick = { pendingForeign = null }) { + Text(uiString(R.string.l10n_backup_sync_screen_cancel_77dfd213), style = NoopType.body, color = Palette.textSecondary) + } + }, + ) + } + + // Post-import warnings from a cross-fork row copy (tables/columns the two schemas didn't share). Shown + // BEFORE the relaunch so they aren't lost to the auto-restart; "Restart" does the relaunch. + if (importWarnings.isNotEmpty()) { + AlertDialog( + onDismissRequest = { }, + containerColor = Palette.surfaceOverlay, + title = { Text(uiString(R.string.l10n_backup_sync_screen_imported_with_notes_15850487), style = NoopType.title2, color = Palette.textPrimary) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + uiString(R.string.l10n_backup_sync_screen_the_backup_was_imported_but_some_of_it_didn_1b387c8a), + style = NoopType.subhead, color = Palette.textSecondary, + ) + importWarnings.forEach { w -> + Text(uiString(R.string.l10n_backup_sync_screen_1_s_5ff08eae, w), style = NoopType.footnote, color = Palette.textTertiary) + } + } + }, + confirmButton = { + TextButton(onClick = { + importWarnings = emptyList() + restartNoop() + }) { + Text(uiString(R.string.l10n_backup_sync_screen_restart_b134bd55), style = NoopType.body, color = Palette.accent) + } + }, + ) + } + + // A cross-fork reconcile FAILED after the live DB was closed for the merge. The live database file is + // UNTOUCHED (the reconcile only reads it + writes scratch), but the Room singleton is now closed, so + // the app must relaunch to reopen it on the intact data — otherwise every DAO is left on a closed + // connection (the #57 stale-DAO hazard). The dialog can't be dismissed without restarting; the single + // "Restart" button runs the same relaunch as the success path, coming back on the intact live data. + pendingFailedRestart?.let { message -> + AlertDialog( + onDismissRequest = { }, + containerColor = Palette.surfaceOverlay, + title = { Text(uiString(R.string.l10n_backup_sync_screen_import_didn_t_finish_dcae86b3), style = NoopType.title2, color = Palette.textPrimary) }, + text = { Text(message, style = NoopType.subhead, color = Palette.textSecondary) }, + confirmButton = { + TextButton(onClick = { + pendingFailedRestart = null + restartNoop("Restarting NOOP…") + }) { + Text(uiString(R.string.l10n_backup_sync_screen_restart_b134bd55), style = NoopType.body, color = Palette.accent) + } + }, + ) + } } /** diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 4719d0709c..e22842ca44 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -110,6 +110,8 @@ import com.noop.ingest.RawSensorExport import com.noop.ingest.WhoopCsvExporter import com.noop.update.UpdateCheck import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.roundToInt @@ -396,6 +398,16 @@ fun SettingsScreen( fun mutate(block: () -> Unit) { block(); rev++ } var backupBusy by remember { mutableStateOf(false) } + // A foreign (iOS/GRDB or other-fork) backup awaiting the cross-platform-import confirmation, and the + // row-copy warnings to show once such an import lands (empty for a plain same-app restore). + var pendingForeignImport by remember { mutableStateOf?>(null) } + var importWarnings by remember { mutableStateOf>(emptyList()) } + // A cross-fork reconcile that FAILED after the live DB was closed for the merge (see + // DataBackup.ImportResult.FailedNeedsRestart). The live database file is UNTOUCHED — the reconcile + // only reads it and writes scratch — but the Room singleton is now closed, so the app must relaunch + // to reopen it on the intact data (the #57 stale-DAO hazard). Holds the failure message to show + // before that forced relaunch. + var pendingFailedRestart by remember { mutableStateOf(null) } // Re-scan must request the runtime Bluetooth permission before scanning — without this the // button calls connect() directly and silently no-ops on Android 12+ when the permission was @@ -598,21 +610,50 @@ fun SettingsScreen( } } - val importLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument(), - ) { uri -> - if (uri == null) { backupBusy = false; return@rememberLauncherForActivityResult } + // Relaunch NOOP so Room re-opens against the on-disk database. Used only after a cross-fork reconcile + // FAILS (DataBackup.ImportResult.FailedNeedsRestart): importFrom closed the live Room singleton before + // the merge and returns without reopening it, so every DAO is now on a closed connection (the #57 + // stale-DAO hazard). The live DB file is intact — the merge only reads it + writes scratch — so the + // relaunch simply comes back on the user's existing data. NonCancellable: the restart is a data-safety + // guarantee (the singleton is already closed), so it must finish even if this composition leaves. + fun restartNoop(message: String) { + scope.launch { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + withContext(NonCancellable) { + delay(800) // let the toast render before the process dies + val ctx = context.applicationContext + ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) + ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + ?.let { ctx.startActivity(it) } + Runtime.getRuntime().exit(0) + } + } + } + + // Runs the whole-store import off the main thread. A foreign backup (iOS/GRDB or another Android NOOP + // build) returns NeedsConfirmation first (nothing touched yet); the cross-platform-import dialog then + // re-runs this with confirmedForeign=true. A cross-fork row copy can carry warnings (tables/columns the + // two schemas didn't share) — those are surfaced in a dialog rather than lost in a toast. + fun runImport(uri: Uri, confirmedForeign: Boolean = false) { + backupBusy = true scope.launch { val result = withContext(Dispatchers.IO) { - DataBackup.importFrom(context, uri) + DataBackup.importFrom(context, uri, confirmedForeign) } backupBusy = false when (result) { - is DataBackup.ImportResult.NeedsRestart -> Toast.makeText( - context, - "Backup imported. Fully close and reopen NOOP for it to take effect.", - Toast.LENGTH_LONG, - ).show() + is DataBackup.ImportResult.NeedsConfirmation -> + pendingForeignImport = uri to result.kind + is DataBackup.ImportResult.NeedsRestart -> + // A successful swap (same-app OR cross-fork) leaves Room CLOSED — importFrom never + // reopens it — so the app MUST relaunch to come back on the new store, exactly as + // BackupSyncScreen's runRestore does. A toast that only ASKS the user to reopen strands + // the app on closed DAOs (the #57 stale-DAO state: empty-history ENDs acking/trimming + // the strap). No warnings → relaunch now; warnings → the dialog's Restart button does it. + if (result.warnings.isEmpty()) restartNoop("Backup restored — restarting NOOP…") + else importWarnings = result.warnings + is DataBackup.ImportResult.FailedNeedsRestart -> + pendingFailedRestart = result.message is DataBackup.ImportResult.Failed -> Toast.makeText( context, result.message, Toast.LENGTH_LONG, ).show() @@ -620,6 +661,13 @@ fun SettingsScreen( } } + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri == null) { backupBusy = false; return@rememberLauncherForActivityResult } + runImport(uri) + } + // Modern Photo Picker for the optional profile photo (no READ_EXTERNAL_STORAGE permission needed). // Returns a single image Uri (or null if cancelled); we decode + downscale + persist off the main // thread via ProfileAvatarStore, which updates the live avatar everywhere. Stored only on this phone. @@ -2818,6 +2866,93 @@ fun SettingsScreen( } } } + + // Cross-platform / cross-fork import confirmation. The picked backup is a foreign NOOP store + // (iOS/GRDB or a different Android build); NOOP copies its rows into this app's format rather + // than swapping the file. Confirming re-runs the import with confirmedForeign=true. + pendingForeignImport?.let { (uri, kind) -> + val (title, body) = when (kind) { + DataBackup.ForeignBackupKind.IOS -> + uiString(R.string.l10n_settings_screen_import_an_ios_backup_cdce911a) to + uiString(R.string.l10n_settings_screen_importing_an_ios_backup_into_android_is_tha_fa165da3) + DataBackup.ForeignBackupKind.ANDROID_FORK -> + uiString(R.string.l10n_settings_screen_import_a_backup_from_another_noop_build_cddb47ad) to + uiString(R.string.l10n_settings_screen_this_backup_is_from_a_different_noop_build_n_d18acf8c) + } + AlertDialog( + onDismissRequest = { pendingForeignImport = null }, + containerColor = Palette.surfaceOverlay, + title = { Text(title, style = NoopType.title2, color = Palette.textPrimary) }, + text = { Text(body, style = NoopType.subhead, color = Palette.textSecondary) }, + confirmButton = { + TextButton(onClick = { + pendingForeignImport = null + runImport(uri, confirmedForeign = true) + }) { + Text(uiString(R.string.l10n_settings_screen_import_d6fbc9d2), style = NoopType.body, color = Palette.accent) + } + }, + dismissButton = { + TextButton(onClick = { pendingForeignImport = null }) { + Text(uiString(R.string.l10n_settings_screen_cancel_77dfd213), style = NoopType.body, color = Palette.textSecondary) + } + }, + ) + } + + // Post-import warnings from a cross-fork row copy (tables/columns the two schemas didn't share). + // Only reached AFTER a cross-fork swap, which left Room CLOSED — so the button RELAUNCHES (like + // BackupSyncScreen's warnings dialog), never merely dismisses. A plain "OK" that only cleared the + // dialog would leave the app on closed DAOs (the #57 stale-DAO hazard). Shown before the relaunch + // so the notes aren't lost to it. + if (importWarnings.isNotEmpty()) { + AlertDialog( + onDismissRequest = { }, + containerColor = Palette.surfaceOverlay, + title = { Text(uiString(R.string.l10n_settings_screen_imported_with_notes_15850487), style = NoopType.title2, color = Palette.textPrimary) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + uiString(R.string.l10n_settings_screen_the_backup_was_imported_but_some_of_it_didn_1b387c8a), + style = NoopType.subhead, color = Palette.textSecondary, + ) + importWarnings.forEach { w -> + Text(uiString(R.string.l10n_settings_screen_1_s_5ff08eae, w), style = NoopType.footnote, color = Palette.textTertiary) + } + } + }, + confirmButton = { + TextButton(onClick = { + importWarnings = emptyList() + restartNoop("Restarting NOOP…") + }) { + Text(uiString(R.string.l10n_settings_screen_restart_b134bd55), style = NoopType.body, color = Palette.accent) + } + }, + ) + } + + // A cross-fork reconcile FAILED after the live DB was closed for the merge. The live database + // file is UNTOUCHED (the reconcile only reads it + writes scratch), but the Room singleton is now + // closed, so the app must relaunch to reopen it on the intact data — otherwise every DAO is left + // on a closed connection (the #57 stale-DAO hazard). The dialog can't be dismissed without + // restarting; the single "Restart" button relaunches, coming back on the intact live data. + pendingFailedRestart?.let { message -> + AlertDialog( + onDismissRequest = { }, + containerColor = Palette.surfaceOverlay, + title = { Text(uiString(R.string.l10n_settings_screen_import_didn_t_finish_dcae86b3), style = NoopType.title2, color = Palette.textPrimary) }, + text = { Text(message, style = NoopType.subhead, color = Palette.textSecondary) }, + confirmButton = { + TextButton(onClick = { + pendingFailedRestart = null + restartNoop("Restarting NOOP…") + }) { + Text(uiString(R.string.l10n_settings_screen_restart_b134bd55), style = NoopType.body, color = Palette.accent) + } + }, + ) + } } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index b5e794f73b..286efce9bf 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2,6 +2,26 @@ + Dieses Backup stammt aus einer anderen NOOP-Version. NOOP überträgt die Daten in das Format dieser App. Felder, die diese Version nicht teilt, werden möglicherweise nicht übernommen. + Das Backup wurde importiert, aber einiges passte nicht zu dieser App: + Neu starten + Ein iOS-Backup wird nach Android importiert – ist das richtig? NOOP überträgt die Daten in das Format dieser App. Felder, die die iOS-App nicht teilt, werden möglicherweise nicht übernommen. + Importiert mit Hinweisen + Import nicht abgeschlossen + Importieren + Ein iOS-Backup importieren? + Ein Backup aus einer anderen NOOP-Version importieren? + • %1$s + Dieses Backup stammt aus einer anderen NOOP-Version. NOOP überträgt die Daten in das Format dieser App. Felder, die diese Version nicht teilt, werden möglicherweise nicht übernommen. + Das Backup wurde importiert, aber einiges passte nicht zu dieser App: + Neu starten + Ein iOS-Backup wird nach Android importiert – ist das richtig? NOOP überträgt die Daten in das Format dieser App. Felder, die die iOS-App nicht teilt, werden möglicherweise nicht übernommen. + Importiert mit Hinweisen + Import nicht abgeschlossen + Importieren + Ein iOS-Backup importieren? + Ein Backup aus einer anderen NOOP-Version importieren? + • %1$s Ruhe, Ladung und Anstrengung von heute, mit Live-Herzfrequenz und Band-Akku auf einen Blick. NOOP Kompakt Kompaktes Widget für Ruhe, Ladung und Anstrengung, mit Live-Herzfrequenz und Band-Akku. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index ccc1ac2b04..02045c107f 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -76,12 +76,19 @@ %1$s %2$s Vigilar señales tempranas de enfermedad Cuando toco dos veces + • %1$s Carpeta de copias Copia y sincronización Tiempo de respaldo Cancelar Elegir una copia de seguridad Copia de seguridad automática diaria + Vas a importar una copia de seguridad de iOS en Android, ¿es correcto? NOOP copiará sus datos al formato de esta app. Puede que los campos que la app de iOS no comparte no se conserven. + Importado con observaciones + La importación no se completó + Importar + ¿Importar una copia de seguridad de iOS? + ¿Importar una copia de seguridad de otra versión de NOOP? %1$s Mantenga las últimas instantáneas %1$s @@ -91,9 +98,12 @@ ¿Reemplazar todos los datos actuales? Reemplazar todos los datos actuales con %1$s? Esto no puede ser deshecho. Reemplazar los datos de este dispositivo con una de sus copias de seguridad. Esto sobrescribe los datos actuales, + Reiniciar Restaurar Restaurar desde una copia de seguridad… Roughly cuando la copia de seguridad diaria funciona (mejor esfuerzo - el sistema puede deslizarlo un poco). + Esta copia de seguridad es de otra versión de NOOP. NOOP copiará sus datos al formato de esta app. Puede que los campos que esa versión no comparte no se conserven. + La copia de seguridad se importó, pero parte de su contenido no coincidía con esta app: Consejo: una aplicación de escritorio Drive / Dropbox auto-syncs una carpeta elegida. Por teléfono, ahorre a un Escribe una copia de seguridad de fecha nueva en tu carpeta una vez al día en el momento siguiente, manteniendo Un ritmo de relajación, no un control cardíaco. Nunca marca un ritmo por debajo de un nivel seguro y puedes parar cuando quieras. Si tu frecuencia cardíaca no se calma, te lo diremos claramente. @@ -566,9 +576,19 @@ puntuaciónCardHighlight Estas son aproximaciones independientes de una correa de consumo, construida sobre ciencia abierta: no VS WHOOP + • %1$s avanzadoChevron + Vas a importar una copia de seguridad de iOS en Android, ¿es correcto? NOOP copiará sus datos al formato de esta app. Puede que los campos que la app de iOS no comparte no se conserven. + Importado con observaciones + La importación no se completó + Importar + ¿Importar una copia de seguridad de iOS? + ¿Importar una copia de seguridad de otra versión de NOOP? · %1$s %1$s, %2$s + Esta copia de seguridad es de otra versión de NOOP. NOOP copiará sus datos al formato de esta app. Puede que los campos que esa versión no comparte no se conserven. + La copia de seguridad se importó, pero parte de su contenido no coincidía con esta app: + Reiniciar Conciencia del ciclo NOOP puede leer una fase de ciclo menstrual gruesa de su temperatura nocturna de la piel, Activar el seguimiento del ciclo diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 987e7d5d01..b5e68c9b7a 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -76,12 +76,19 @@ %1$s %2$s Surveiller les signes précoces de maladie Quand je double-tape + • %1$s Dossier de sauvegarde Sauvegarde et synchronisation Temps de sauvegarde Annuler Choisir une sauvegarde Sauvegarde automatique quotidienne + Vous importez une sauvegarde iOS vers Android, est-ce bien le cas ? NOOP copiera ses données dans le format de cette app. Les champs que l\'app iOS ne partage pas pourraient ne pas être conservés. + Importé avec remarques + L\'import ne s\'est pas terminé + Importer + Importer une sauvegarde iOS ? + Importer une sauvegarde d\'une autre version de NOOP ? %1$s Garder les derniers instantanés %1$s @@ -91,9 +98,12 @@ Remplacer toutes les données actuelles? Remplacer toutes les données actuelles par %1$s ? Cela ne peut être annulé. Remplacez les données de cet appareil par une de vos sauvegardes. Ceci écrase les données actuelles, + Redémarrer Restaurer Restaurer à partir d\'une sauvegarde… À peu près quand la sauvegarde quotidienne fonctionne (meilleur effort — le système peut glisser un peu). + Cette sauvegarde provient d\'une autre version de NOOP. NOOP copiera ses données dans le format de cette app. Les champs que cette version ne partage pas pourraient ne pas être conservés. + La sauvegarde a été importée, mais une partie ne correspondait pas à cette app : Conseil : une application Drive / Dropbox synchronise automatiquement un dossier choisi. Au téléphone, épargnez jusqu\'à Ecrit une nouvelle sauvegarde datée dans votre dossier une fois par jour à l\'heure ci-dessous, en conservant Un rythme de relaxation, pas un contrôle cardiaque. Il ne cadence jamais en dessous d\'un rythme sûr et vous pouvez arrêter à tout moment. Si votre fréquence cardiaque ne se stabilise pas, nous vous le dirons clairement. @@ -566,9 +576,19 @@ scoreCardHighlight Il s\'agit d\'approximations indépendantes d\'une sangle de consommation, fondée sur la science ouverte: VS WHOOP + • %1$s avancéChevron + Vous importez une sauvegarde iOS vers Android, est-ce bien le cas ? NOOP copiera ses données dans le format de cette app. Les champs que l\'app iOS ne partage pas pourraient ne pas être conservés. + Importé avec remarques + L\'import ne s\'est pas terminé + Importer + Importer une sauvegarde iOS ? + Importer une sauvegarde d\'une autre version de NOOP ? · %1$s %1$s, %2$s + Cette sauvegarde provient d\'une autre version de NOOP. NOOP copiera ses données dans le format de cette app. Les champs que cette version ne partage pas pourraient ne pas être conservés. + La sauvegarde a été importée, mais une partie ne correspondait pas à cette app : + Redémarrer Conscience du cycle NOOP peut lire une phase de cycle menstruel grossière à partir de votre température de peau nocturne, Activer le suivi du cycle diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index e1ad148275..f3092491e9 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -3,6 +3,26 @@ NOOP + Esta cópia de segurança é de outra versão do NOOP. O NOOP vai copiar os dados para o formato desta app. Os campos que essa versão não partilha poderão não ser transferidos. + A cópia de segurança foi importada, mas parte dela não correspondia a esta app: + Reiniciar + Vai importar uma cópia de segurança do iOS para o Android — é isso mesmo? O NOOP vai copiar os dados para o formato desta app. Os campos que a app de iOS não partilha poderão não ser transferidos. + Importado com notas + A importação não terminou + Importar + Importar uma cópia de segurança do iOS? + Importar uma cópia de segurança de outra versão do NOOP? + • %1$s + Esta cópia de segurança é de outra versão do NOOP. O NOOP vai copiar os dados para o formato desta app. Os campos que essa versão não partilha poderão não ser transferidos. + A cópia de segurança foi importada, mas parte dela não correspondia a esta app: + Reiniciar + Vai importar uma cópia de segurança do iOS para o Android — é isso mesmo? O NOOP vai copiar os dados para o formato desta app. Os campos que a app de iOS não partilha poderão não ser transferidos. + Importado com notas + A importação não terminou + Importar + Importar uma cópia de segurança do iOS? + Importar uma cópia de segurança de outra versão do NOOP? + • %1$s Descanso, Carga e Esforço de hoje, com frequência cardíaca em direto e bateria da bracelete num relance. NOOP Compacto Widget compacto de Descanso, Carga e Esforço, com frequência cardíaca em direto e bateria da bracelete. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4e5bd7257c..eab6e92dcb 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,6 +1,26 @@ NOOP + This backup is from a different NOOP build. NOOP will copy its data into this app\'s format. Any fields that build doesn\'t share may not carry over. + The backup was imported, but some of it didn\'t line up with this app: + Restart + Importing an iOS backup into Android — is that correct? NOOP will copy its data into this app\'s format. Any fields the iOS app doesn\'t share may not carry over. + Imported with notes + Import didn\'t finish + Import + Import an iOS backup? + Import a backup from another NOOP build? + • %1$s + This backup is from a different NOOP build. NOOP will copy its data into this app\'s format. Any fields that build doesn\'t share may not carry over. + The backup was imported, but some of it didn\'t line up with this app: + Restart + Importing an iOS backup into Android — is that correct? NOOP will copy its data into this app\'s format. Any fields the iOS app doesn\'t share may not carry over. + Imported with notes + Import didn\'t finish + Import + Import an iOS backup? + Import a backup from another NOOP build? + • %1$s Today\'s Rest, Charge and Effort, with live heart rate and strap battery at a glance. NOOP Compact Compact Rest, Charge and Effort widget, with live heart rate and strap battery. diff --git a/android/app/src/test/java/com/noop/data/CrossForkImportDetectionTest.kt b/android/app/src/test/java/com/noop/data/CrossForkImportDetectionTest.kt new file mode 100644 index 0000000000..7daa0f30c1 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/CrossForkImportDetectionTest.kt @@ -0,0 +1,132 @@ +package com.noop.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Content-marker detection for the cross-fork import: [DataBackup.foreignBackupKind] (the row-copy + * router), cross-checked against [DataBackup.backupOriginOf] and [DataBackup.holdsData]. All three are + * pure functions over a backup's table-name set (+ the `dailyMetric` column set), so they are pinned + * here on the plain JVM with the [CrossForkSchemaFixtures] descriptors — no live SQLite needed. + * + * The routing is CONTENT-based, never version-based (GRDB always reports user_version 0, Room forks + * reuse the same integers), so each of the four field shapes must classify by what tables/columns it + * actually carries: + * - own Android store (Room) → reconcile NOT needed (fast file-swap restore); + * - iOS/GRDB store → IOS; + * - a behind Android fork (Room) → reconcile NOT needed (a version gap is not a divergence); + * - an ahead Android fork (Room) → ANDROID_FORK. + */ +class CrossForkImportDetectionTest { + + private val fx = CrossForkSchemaFixtures + + // ── foreignBackupKind: the four field shapes ──────────────────────────────── + + @Test fun ownAndroidStoreDoesNotReconcile() { + // Arrange: this app's own Room store — room_master_table, no upstream-absent marker. + val shape = fx.ownAndroidStore + + // Act + val kind = DataBackup.foreignBackupKind(fx.tablesOf(shape), fx.dailyMetricColumnsOf(shape)) + + // Assert: null → keeps the fast file-swap restore, no row-copy. + assertNull(kind) + } + + @Test fun iosGrdbStoreIsAnIosBackup() { + // Arrange: the iOS/GRDB store — grdb_migrations bookkeeping. + val shape = fx.iosGrdbStore + + // Act + val kind = DataBackup.foreignBackupKind(fx.tablesOf(shape), fx.dailyMetricColumnsOf(shape)) + + // Assert + assertEquals(DataBackup.ForeignBackupKind.IOS, kind) + } + + @Test fun behindForkDoesNotReconcile() { + // Arrange: a Room fork a few migrations behind — no ppgWaveformSample, no upstream-absent marker. + val shape = fx.behindAndroidFork + + // Act + val kind = DataBackup.foreignBackupKind(fx.tablesOf(shape), fx.dailyMetricColumnsOf(shape)) + + // Assert: a version gap is not a content divergence → open-time migrator handles it, no row-copy. + assertNull(kind) + } + + @Test fun aheadForkIsAnotherAndroidFork() { + // Arrange: the ahead Android fork carrying the upstream-absent spo2PctSample table + skinTempAbsC. + val shape = fx.aheadAndroidFork + + // Act + val kind = DataBackup.foreignBackupKind(fx.tablesOf(shape), fx.dailyMetricColumnsOf(shape)) + + // Assert + assertEquals(DataBackup.ForeignBackupKind.ANDROID_FORK, kind) + } + + // ── foreignBackupKind: the two upstream-absent markers, each on its own ────── + + @Test fun theMarkerTableAloneMarksAnAndroidFork() { + // Arrange: a Room store carrying spo2PctSample but a dailyMetric WITHOUT skinTempAbsC. + val tables = setOf("room_master_table", "hrSample", "dailyMetric", "spo2PctSample") + val dailyMetricColumns = setOf("deviceId", "day", "restingHr") + + // Act / Assert + assertEquals( + DataBackup.ForeignBackupKind.ANDROID_FORK, + DataBackup.foreignBackupKind(tables, dailyMetricColumns), + ) + } + + @Test fun theMarkerColumnAloneMarksAnAndroidFork() { + // Arrange: a Room store WITHOUT spo2PctSample, but dailyMetric carries the upstream-absent skinTempAbsC. + val tables = setOf("room_master_table", "hrSample", "dailyMetric") + val dailyMetricColumns = setOf("deviceId", "day", "skinTempAbsC") + + // Act / Assert + assertEquals( + DataBackup.ForeignBackupKind.ANDROID_FORK, + DataBackup.foreignBackupKind(tables, dailyMetricColumns), + ) + } + + @Test fun grdbBookkeepingWinsOverAMarkerLookingColumn() { + // Arrange: a degenerate store carrying BOTH grdb_migrations and an upstream-absent column. GRDB is + // checked first, so it classifies as the iOS store (never a Room fork). + val tables = setOf("grdb_migrations", "dailyMetric") + val dailyMetricColumns = setOf("deviceId", "skinTempAbsC") + + // Act / Assert + assertEquals(DataBackup.ForeignBackupKind.IOS, DataBackup.foreignBackupKind(tables, dailyMetricColumns)) + } + + @Test fun anEmptyOrUnrecognisedFileDoesNotReconcile() { + assertNull(DataBackup.foreignBackupKind(emptySet(), emptySet())) + assertNull(DataBackup.foreignBackupKind(setOf("android_metadata", "sqlite_sequence"), emptySet())) + } + + // ── Cross-check the origin classifier + data probe agree on each shape ─────── + + @Test fun backupOriginMatchesEachShapesBookkeeping() { + assertEquals(DataBackup.BackupOrigin.ANDROID, DataBackup.backupOriginOf(fx.tablesOf(fx.ownAndroidStore))) + assertEquals(DataBackup.BackupOrigin.MAC, DataBackup.backupOriginOf(fx.tablesOf(fx.iosGrdbStore))) + assertEquals(DataBackup.BackupOrigin.ANDROID, DataBackup.backupOriginOf(fx.tablesOf(fx.behindAndroidFork))) + assertEquals(DataBackup.BackupOrigin.ANDROID, DataBackup.backupOriginOf(fx.tablesOf(fx.aheadAndroidFork))) + } + + @Test fun everyPopulatedShapeHoldsData() { + // holdsData sees past the housekeeping tables to the real content each shape carries. + assertTrue(DataBackup.holdsData(fx.tablesOf(fx.ownAndroidStore))) + assertTrue(DataBackup.holdsData(fx.tablesOf(fx.iosGrdbStore))) + assertTrue(DataBackup.holdsData(fx.tablesOf(fx.behindAndroidFork))) + assertTrue(DataBackup.holdsData(fx.tablesOf(fx.aheadAndroidFork))) + // Housekeeping-only carries no user content. + assertFalse(DataBackup.holdsData(setOf("android_metadata", "sqlite_sequence", "room_master_table"))) + } +} diff --git a/android/app/src/test/java/com/noop/data/CrossForkImportPlanTest.kt b/android/app/src/test/java/com/noop/data/CrossForkImportPlanTest.kt new file mode 100644 index 0000000000..554cce1cbe --- /dev/null +++ b/android/app/src/test/java/com/noop/data/CrossForkImportPlanTest.kt @@ -0,0 +1,372 @@ +package com.noop.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-JVM tests for the version-agnostic row-copy planner [DataBackup.planRowCopyImport]. The planner + * is a pure function over two `table -> ordered [DataBackup.SchemaColumn]s` maps, so it is pinned here + * without Robolectric or a live SQLite; the real DB executor ([DataBackup.reconcileForeignBackup]) only + * ATTACHes the two stores and runs the SQL this planner emits, so covering the plan covers the copy. + * + * The target is always this app's own store ([CrossForkSchemaFixtures.ownAndroidStore]); each of the four + * field shapes is exercised as the SOURCE, asserting the table+column intersection, the emitted + * `INSERT OR IGNORE` (and, in REPLACE, the leading `DELETE`) statements, and the missing-table / + * dropped-table / missing-column / filled-column warnings. The same statement/quoting shape is what + * [DataBackup.reconcileForeignBackup] runs inside one transaction with the backup ATTACHed as `src`. + * + * The critical case these tests LOCK: `stepSample` / `ppgHrSample` carry a NOT NULL-no-default `synced` + * flag (Room emits no SQL default for a Kotlin `= 0`). A source that lacks the column must FILL it with a + * typed zero literal — omitting it would make `INSERT OR IGNORE` drop the whole table's rows on the + * NOT NULL violation. The iOS/GRDB source below lacks `synced` and proves the fill keeps the rows. + */ +class CrossForkImportPlanTest { + + private val fx = CrossForkSchemaFixtures + private val target = CrossForkSchemaFixtures.ownAndroidStore + + // ── Shape A: own Android store — the clean, same-schema baseline ───────────── + + @Test fun sameStoreMergePlanIsAFullCopyWithNoWarnings() { + // Arrange: source == target (restoring the app's own Room store into itself). + // Act + val plan = DataBackup.planRowCopyImport(target, fx.ownAndroidStore, DataBackup.ImportMode.MERGE) + + // Assert: every data table copies its full column set (incl. the NOT NULL `synced`, present on + // both sides so it copies straight); housekeeping (room_master_table, android_metadata, + // sqlite_sequence) is filtered out; nothing is missing, dropped, or filled. + assertEquals( + listOf( + "INSERT OR IGNORE INTO main.`dailyMetric` (`deviceId`, `day`, `restingHr`, `avgHrv`, `skinTempDevC`, `spo2Red`, `spo2Ir`) SELECT `deviceId`, `day`, `restingHr`, `avgHrv`, `skinTempDevC`, `spo2Red`, `spo2Ir` FROM src.`dailyMetric`", + "INSERT OR IGNORE INTO main.`hrSample` (`deviceId`, `ts`, `bpm`) SELECT `deviceId`, `ts`, `bpm` FROM src.`hrSample`", + "INSERT OR IGNORE INTO main.`ppgHrSample` (`deviceId`, `ts`, `bpm`, `conf`, `synced`) SELECT `deviceId`, `ts`, `bpm`, `conf`, `synced` FROM src.`ppgHrSample`", + "INSERT OR IGNORE INTO main.`ppgWaveformSample` (`deviceId`, `ts`, `samples`) SELECT `deviceId`, `ts`, `samples` FROM src.`ppgWaveformSample`", + "INSERT OR IGNORE INTO main.`rrInterval` (`deviceId`, `ts`, `rrMs`, `seq`) SELECT `deviceId`, `ts`, `rrMs`, `seq` FROM src.`rrInterval`", + "INSERT OR IGNORE INTO main.`sleepSession` (`deviceId`, `startTs`, `endTs`, `efficiency`) SELECT `deviceId`, `startTs`, `endTs`, `efficiency` FROM src.`sleepSession`", + "INSERT OR IGNORE INTO main.`stepSample` (`deviceId`, `ts`, `counter`, `activityClass`, `synced`) SELECT `deviceId`, `ts`, `counter`, `activityClass`, `synced` FROM src.`stepSample`", + ), + plan.statements, + ) + assertEquals(emptyList(), plan.missingTables) + assertEquals(emptyList(), plan.droppedTables) + assertTrue(plan.missingColumns.isEmpty()) + assertTrue(plan.filledColumns.isEmpty()) + assertEquals(emptyList(), plan.warnings()) + } + + @Test fun housekeepingTablesAreNeverCopied() { + val plan = DataBackup.planRowCopyImport(target, fx.ownAndroidStore, DataBackup.ImportMode.MERGE) + assertTrue( + plan.statements.none { + it.contains("room_master_table") || it.contains("android_metadata") || it.contains("sqlite_sequence") + }, + ) + } + + // ── Shape B: iOS (GRDB) — REPLACE restore + the CRITICAL filled-column case ─── + + @Test fun iosGrdbReplacePlanKeepsNotNullRowsByFillingSyncedAndFlagsOnDeviceOnlyColumns() { + // Arrange: the iOS/GRDB store — same column names by the parity contract, plus a GRDB-only + // collector table (dropped), a dailyMetric that lacks the on-device-only spo2Red/spo2Ir, and + // stepSample/ppgHrSample that lack the Android-only NOT NULL-no-default `synced`. + // Act + val plan = DataBackup.planRowCopyImport(target, fx.iosGrdbStore, DataBackup.ImportMode.REPLACE) + + // Assert: REPLACE clears each shared table first, then INSERT OR IGNORE (never INSERT OR REPLACE). + // For stepSample/ppgHrSample the source-absent `synced` is FILLED with the typed zero literal `0` + // (INTEGER affinity) so the rows are KEPT, not dropped — the critical regression, locked. + assertEquals( + listOf( + "DELETE FROM main.`dailyMetric`", + "INSERT OR IGNORE INTO main.`dailyMetric` (`deviceId`, `day`, `restingHr`, `avgHrv`, `skinTempDevC`) SELECT `deviceId`, `day`, `restingHr`, `avgHrv`, `skinTempDevC` FROM src.`dailyMetric`", + "DELETE FROM main.`hrSample`", + "INSERT OR IGNORE INTO main.`hrSample` (`deviceId`, `ts`, `bpm`) SELECT `deviceId`, `ts`, `bpm` FROM src.`hrSample`", + "DELETE FROM main.`ppgHrSample`", + "INSERT OR IGNORE INTO main.`ppgHrSample` (`deviceId`, `ts`, `bpm`, `conf`, `synced`) SELECT `deviceId`, `ts`, `bpm`, `conf`, 0 FROM src.`ppgHrSample`", + "DELETE FROM main.`ppgWaveformSample`", + "INSERT OR IGNORE INTO main.`ppgWaveformSample` (`deviceId`, `ts`, `samples`) SELECT `deviceId`, `ts`, `samples` FROM src.`ppgWaveformSample`", + "DELETE FROM main.`rrInterval`", + "INSERT OR IGNORE INTO main.`rrInterval` (`deviceId`, `ts`, `rrMs`, `seq`) SELECT `deviceId`, `ts`, `rrMs`, `seq` FROM src.`rrInterval`", + "DELETE FROM main.`sleepSession`", + "INSERT OR IGNORE INTO main.`sleepSession` (`deviceId`, `startTs`, `endTs`, `efficiency`) SELECT `deviceId`, `startTs`, `endTs`, `efficiency` FROM src.`sleepSession`", + "DELETE FROM main.`stepSample`", + "INSERT OR IGNORE INTO main.`stepSample` (`deviceId`, `ts`, `counter`, `activityClass`, `synced`) SELECT `deviceId`, `ts`, `counter`, `activityClass`, 0 FROM src.`stepSample`", + ), + plan.statements, + ) + // The two on-device-only columns the GRDB backup can't carry import empty (nullable → omitted). + assertEquals(mapOf("dailyMetric" to listOf("spo2Red", "spo2Ir")), plan.missingColumns) + // The NOT NULL-no-default `synced` on each of the two tables was KEPT + filled, not dropped. + assertEquals( + mapOf("ppgHrSample" to listOf("synced"), "stepSample" to listOf("synced")), + plan.filledColumns, + ) + assertEquals(emptyList(), plan.missingTables) + assertEquals(listOf("healthKitCursor"), plan.droppedTables) + assertTrue(plan.warnings().any { it.contains("dailyMetric is missing fields spo2Red, spo2Ir") }) + assertTrue(plan.warnings().any { it.contains("Skipped tables not in this app") && it.contains("healthKitCursor") }) + // Never "imported empty" for the kept rows — the filled columns report "filled ... with defaults". + assertTrue(plan.warnings().any { it.contains("ppgHrSample: filled synced with defaults") }) + assertTrue(plan.warnings().any { it.contains("stepSample: filled synced with defaults") }) + assertTrue(plan.warnings().none { it.contains("stepSample is missing fields") }) + } + + // ── Shape C: a behind Android fork (Room) — MERGE keeps existing rows ──────── + + @Test fun behindForkMergePlanFlagsMissingTablesAndOlderColumns() { + // Arrange: a fork a few migrations behind — no ppgWaveformSample, no stepSample/ppgHrSample, no + // rrInterval.seq, and a dailyMetric without the v7 sleep aggregates. (In the live import this shape + // carries no upstream-absent marker and so restores by file-swap, not reconcile; the planner is + // still exercised here to prove the intersection/warning maths for an OLDER schema.) + // Act + val plan = DataBackup.planRowCopyImport(target, fx.behindAndroidFork, DataBackup.ImportMode.MERGE) + + // Assert: MERGE emits INSERT OR IGNORE only (no DELETE); only the shared columns copy. + assertEquals( + listOf( + "INSERT OR IGNORE INTO main.`dailyMetric` (`deviceId`, `day`, `restingHr`, `avgHrv`) SELECT `deviceId`, `day`, `restingHr`, `avgHrv` FROM src.`dailyMetric`", + "INSERT OR IGNORE INTO main.`hrSample` (`deviceId`, `ts`, `bpm`) SELECT `deviceId`, `ts`, `bpm` FROM src.`hrSample`", + "INSERT OR IGNORE INTO main.`rrInterval` (`deviceId`, `ts`, `rrMs`) SELECT `deviceId`, `ts`, `rrMs` FROM src.`rrInterval`", + "INSERT OR IGNORE INTO main.`sleepSession` (`deviceId`, `startTs`, `endTs`, `efficiency`) SELECT `deviceId`, `startTs`, `endTs`, `efficiency` FROM src.`sleepSession`", + ), + plan.statements, + ) + // The newer tables have no data to import; the older dailyMetric/rrInterval columns import empty. + assertEquals(listOf("ppgHrSample", "ppgWaveformSample", "stepSample"), plan.missingTables) + assertEquals(emptyList(), plan.droppedTables) + assertEquals( + mapOf( + "dailyMetric" to listOf("skinTempDevC", "spo2Red", "spo2Ir"), + "rrInterval" to listOf("seq"), + ), + plan.missingColumns, + ) + // The behind fork lacks the whole stepSample/ppgHrSample tables (missing TABLES), not the column + // within a shared table, so nothing is filled. + assertTrue(plan.filledColumns.isEmpty()) + assertTrue(plan.warnings().any { it.contains("No data in this backup for") && it.contains("ppgWaveformSample") }) + } + + // ── Shape D: an ahead Android fork (Room) — REPLACE restore ────────────────── + + @Test fun aheadForkReplacePlanDropsMarkerTableAndIgnoresMarkerColumn() { + // Arrange: the ahead fork carrying the upstream-absent spo2PctSample table + dailyMetric.skinTempAbsC. + // Being an Android Room fork, its stepSample/ppgHrSample DO carry `synced`, so those copy straight. + // Act + val plan = DataBackup.planRowCopyImport(target, fx.aheadAndroidFork, DataBackup.ImportMode.REPLACE) + + // Assert: shared tables copy in full; the upstream-absent skinTempAbsC has no home here so the copy + // runs over the shared columns only (never a failure), and the upstream-absent table is dropped. + assertEquals( + listOf( + "DELETE FROM main.`dailyMetric`", + "INSERT OR IGNORE INTO main.`dailyMetric` (`deviceId`, `day`, `restingHr`, `avgHrv`, `skinTempDevC`, `spo2Red`, `spo2Ir`) SELECT `deviceId`, `day`, `restingHr`, `avgHrv`, `skinTempDevC`, `spo2Red`, `spo2Ir` FROM src.`dailyMetric`", + "DELETE FROM main.`hrSample`", + "INSERT OR IGNORE INTO main.`hrSample` (`deviceId`, `ts`, `bpm`) SELECT `deviceId`, `ts`, `bpm` FROM src.`hrSample`", + "DELETE FROM main.`ppgHrSample`", + "INSERT OR IGNORE INTO main.`ppgHrSample` (`deviceId`, `ts`, `bpm`, `conf`, `synced`) SELECT `deviceId`, `ts`, `bpm`, `conf`, `synced` FROM src.`ppgHrSample`", + "DELETE FROM main.`ppgWaveformSample`", + "INSERT OR IGNORE INTO main.`ppgWaveformSample` (`deviceId`, `ts`, `samples`) SELECT `deviceId`, `ts`, `samples` FROM src.`ppgWaveformSample`", + "DELETE FROM main.`rrInterval`", + "INSERT OR IGNORE INTO main.`rrInterval` (`deviceId`, `ts`, `rrMs`, `seq`) SELECT `deviceId`, `ts`, `rrMs`, `seq` FROM src.`rrInterval`", + "DELETE FROM main.`sleepSession`", + "INSERT OR IGNORE INTO main.`sleepSession` (`deviceId`, `startTs`, `endTs`, `efficiency`) SELECT `deviceId`, `startTs`, `endTs`, `efficiency` FROM src.`sleepSession`", + "DELETE FROM main.`stepSample`", + "INSERT OR IGNORE INTO main.`stepSample` (`deviceId`, `ts`, `counter`, `activityClass`, `synced`) SELECT `deviceId`, `ts`, `counter`, `activityClass`, `synced` FROM src.`stepSample`", + ), + plan.statements, + ) + assertEquals(emptyList(), plan.missingTables) + assertEquals(listOf("spo2PctSample"), plan.droppedTables) + // skinTempAbsC is a source-only column with no target home — ignored, not a missing-column warning. + assertTrue(plan.missingColumns.isEmpty()) + // `synced` is present on both sides here, so nothing is filled. + assertTrue(plan.filledColumns.isEmpty()) + assertTrue(plan.statements.none { it.contains("skinTempAbsC") }) + assertTrue(plan.warnings().any { it.contains("Skipped tables not in this app") && it.contains("spo2PctSample") }) + } + + // ── The fill contract in isolation (NOT NULL-no-default vs nullable) ───────── + + @Test fun sourceMissingNotNullNoDefaultColumnIsFilledWhileNullableColumnIsOmitted() { + // A target table with: a shared column, a source-missing NOT NULL-no-default column (must be + // FILLED with a typed zero literal or INSERT OR IGNORE drops every row), and a source-missing + // NULLABLE column (safely omitted so SQLite fills NULL). + val target = mapOf( + "t" to listOf( + DataBackup.SchemaColumn("id", "INTEGER", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("synced", "INTEGER", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("note", "TEXT", notNull = false, hasDefault = false), + ), + ) + val source = mapOf( + "t" to listOf(DataBackup.SchemaColumn("id", "INTEGER", notNull = true, hasDefault = false)), + ) + + val plan = DataBackup.planRowCopyImport(target, source, DataBackup.ImportMode.MERGE) + + // `synced` appears in the INSERT column list with a typed zero literal in its SELECT slot; `note` + // is omitted from both lists entirely. + assertEquals( + listOf("INSERT OR IGNORE INTO main.`t` (`id`, `synced`) SELECT `id`, 0 FROM src.`t`"), + plan.statements, + ) + assertEquals(mapOf("t" to listOf("synced")), plan.filledColumns) + assertEquals(mapOf("t" to listOf("note")), plan.missingColumns) + assertTrue(plan.warnings().any { it == "t: filled synced with defaults." }) + assertTrue(plan.warnings().any { it == "t is missing fields note (imported empty)." }) + } + + @Test fun aSourceMissingColumnThatHasAnExplicitDefaultIsOmittedNotFilled() { + // A NOT NULL column that DOES carry a schema default (e.g. the GRDB `NOT NULL DEFAULT 0` twin of a + // Room `synced`) is safely omitted — SQLite fills the declared default, so no literal is needed. + val target = mapOf( + "t" to listOf( + DataBackup.SchemaColumn("id", "INTEGER", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("synced", "INTEGER", notNull = true, hasDefault = true), + ), + ) + val source = mapOf( + "t" to listOf(DataBackup.SchemaColumn("id", "INTEGER", notNull = true, hasDefault = false)), + ) + + val plan = DataBackup.planRowCopyImport(target, source, DataBackup.ImportMode.MERGE) + + assertEquals( + listOf("INSERT OR IGNORE INTO main.`t` (`id`) SELECT `id` FROM src.`t`"), + plan.statements, + ) + assertTrue(plan.filledColumns.isEmpty()) + assertEquals(mapOf("t" to listOf("synced")), plan.missingColumns) + } + + @Test fun filledZeroLiteralFollowsColumnAffinity() { + // Each declared type gets its own zero literal: TEXT → '', BLOB → x'', and everything else — + // INTEGER, REAL, and an UNTYPED column — numeric 0. The untyped case matches the Swift twin's + // zeroLiteral exactly (both emit 0), the one point the two used to diverge (Kotlin emitted x''). + val target = mapOf( + "t" to listOf( + DataBackup.SchemaColumn("k", "INTEGER", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("i", "INTEGER", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("r", "REAL", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("s", "TEXT", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("b", "BLOB", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("u", "", notNull = true, hasDefault = false), + ), + ) + val source = mapOf( + "t" to listOf(DataBackup.SchemaColumn("k", "INTEGER", notNull = true, hasDefault = false)), + ) + + val plan = DataBackup.planRowCopyImport(target, source, DataBackup.ImportMode.MERGE) + + assertEquals( + listOf("INSERT OR IGNORE INTO main.`t` (`k`, `i`, `r`, `s`, `b`, `u`) SELECT `k`, 0, 0, '', x'', 0 FROM src.`t`"), + plan.statements, + ) + } + + // ── The key-collapse guard: a source-absent NOT NULL-no-default KEY column is filled with rowid ──── + + @Test fun sourceMissingNotNullNoDefaultKeyColumnFillsItWithRowidSoRowsImport() { + // hrSample's PK column `ts` is NOT NULL, no default, and a KEY. The backup renamed it (`stamp`), + // so `ts` is source-absent. A CONSTANT fill would give every row the SAME key and INSERT OR IGNORE + // would collapse the table to one row (the reviewer's data-loss hole). Filling the source `rowid` + // (per-row-unique) instead keeps every row without collapsing — the real rrInterval-`seq` case. A + // sibling table whose key IS present copies straight. + val target = mapOf( + "hrSample" to listOf( + DataBackup.SchemaColumn("deviceId", "TEXT", notNull = true, hasDefault = false, key = true), + DataBackup.SchemaColumn("ts", "INTEGER", notNull = true, hasDefault = false, key = true), + DataBackup.SchemaColumn("bpm", "INTEGER", notNull = false, hasDefault = false), + ), + "sleepSession" to listOf( + DataBackup.SchemaColumn("deviceId", "TEXT", notNull = true, hasDefault = false, key = true), + DataBackup.SchemaColumn("startTs", "INTEGER", notNull = true, hasDefault = false, key = true), + DataBackup.SchemaColumn("efficiency", "REAL", notNull = false, hasDefault = false), + ), + ) + val source = mapOf( + "hrSample" to listOf( + DataBackup.SchemaColumn("deviceId", "TEXT", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("stamp", "INTEGER", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("bpm", "INTEGER", notNull = false, hasDefault = false), + ), + "sleepSession" to listOf( + DataBackup.SchemaColumn("deviceId", "TEXT", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("startTs", "INTEGER", notNull = true, hasDefault = false), + DataBackup.SchemaColumn("efficiency", "REAL", notNull = false, hasDefault = false), + ), + ) + + val plan = DataBackup.planRowCopyImport(target, source, DataBackup.ImportMode.REPLACE) + + // hrSample imports with `ts` filled from the source rowid; sleepSession copies straight. + assertEquals( + listOf( + "DELETE FROM main.`hrSample`", + "INSERT OR IGNORE INTO main.`hrSample` (`deviceId`, `ts`, `bpm`) SELECT `deviceId`, rowid, `bpm` FROM src.`hrSample`", + "DELETE FROM main.`sleepSession`", + "INSERT OR IGNORE INTO main.`sleepSession` (`deviceId`, `startTs`, `efficiency`) SELECT `deviceId`, `startTs`, `efficiency` FROM src.`sleepSession`", + ), + plan.statements, + ) + assertEquals(mapOf("hrSample" to listOf("ts")), plan.synthesizedKeyColumns) + assertEquals(listOf("hrSample", "sleepSession"), plan.copiedTables) + assertTrue( + plan.warnings().any { + it == "hrSample: generated ids for the key column(s) ts this backup didn't carry." + }, + ) + } + + @Test fun aSourceMissingKeyColumnThatIsNullableIsOmittedNotRowidFilled() { + // Only a NOT NULL-no-default key is rowid-filled. A nullable key column the source lacks is safely + // OMITTED — SQLite fills NULL, and NULLs never collide in a UNIQUE index — so it needs no synthetic + // id. This locks that the rowid fill fires on the (NOT NULL ∧ no-default ∧ key) triple, not on `key` + // alone. + val target = mapOf( + "t" to listOf( + DataBackup.SchemaColumn("id", "INTEGER", notNull = true, hasDefault = false, key = true), + DataBackup.SchemaColumn("altKey", "TEXT", notNull = false, hasDefault = false, key = true), + ), + ) + val source = mapOf( + "t" to listOf(DataBackup.SchemaColumn("id", "INTEGER", notNull = true, hasDefault = false)), + ) + + val plan = DataBackup.planRowCopyImport(target, source, DataBackup.ImportMode.MERGE) + + assertEquals( + listOf("INSERT OR IGNORE INTO main.`t` (`id`) SELECT `id` FROM src.`t`"), + plan.statements, + ) + assertTrue(plan.synthesizedKeyColumns.isEmpty()) + assertEquals(mapOf("t" to listOf("altKey")), plan.missingColumns) + } + + // ── Mode contract shared across shapes ────────────────────────────────────── + + @Test fun replaceEmitsOneDeletePerInsertWhileMergeEmitsNone() { + val replace = DataBackup.planRowCopyImport(target, fx.aheadAndroidFork, DataBackup.ImportMode.REPLACE) + val inserts = replace.statements.count { it.startsWith("INSERT") } + assertEquals(inserts, replace.statements.count { it.startsWith("DELETE") }) + + val merge = DataBackup.planRowCopyImport(target, fx.aheadAndroidFork, DataBackup.ImportMode.MERGE) + assertEquals(0, merge.statements.count { it.startsWith("DELETE") }) + assertEquals(inserts, merge.statements.count { it.startsWith("INSERT") }) + } + + @Test fun everyInsertIsOrIgnoreNeverOrReplace() { + val shapes = listOf(fx.ownAndroidStore, fx.iosGrdbStore, fx.behindAndroidFork, fx.aheadAndroidFork) + for (source in shapes) { + for (mode in DataBackup.ImportMode.values()) { + val plan = DataBackup.planRowCopyImport(target, source, mode) + plan.statements.filter { it.startsWith("INSERT") }.forEach { + assertTrue(it, it.startsWith("INSERT OR IGNORE INTO main.")) + } + } + } + } +} diff --git a/android/app/src/test/java/com/noop/data/CrossForkSchemaFixtures.kt b/android/app/src/test/java/com/noop/data/CrossForkSchemaFixtures.kt new file mode 100644 index 0000000000..41e654aaaa --- /dev/null +++ b/android/app/src/test/java/com/noop/data/CrossForkSchemaFixtures.kt @@ -0,0 +1,124 @@ +package com.noop.data + +/** + * Small in-test schema descriptors for the cross-fork import tests. Each shape is a + * `table -> ordered [DataBackup.SchemaColumn]s` map, exactly what [DataBackup.readSchema] returns at + * runtime from `PRAGMA table_info` — so the pure router ([DataBackup.foreignBackupKind]) and planner + * ([DataBackup.planRowCopyImport]) can be driven with no live SQLite, no Robolectric, no binary blobs. + * + * The four shapes mirror the real stores a user could pick to restore into this app's own Android build: + * - [ownAndroidStore] — this app's own Room store (also the reconcile TARGET); + * - [iosGrdbStore] — the iOS/GRDB store (same column names by the cross-platform parity contract); + * - [behindAndroidFork] — an Android fork a few migrations BEHIND this build; + * - [aheadAndroidFork] — an Android fork AHEAD of this build, carrying upstream-absent markers. + * + * Columns are realistic subsets of the live entities (see `Entities.kt`), trimmed to the fields that + * make the intersection / warning behaviour observable. The distinguishing facts, per the schema map: + * - the own store's `dailyMetric` carries `skinTempDevC`, NOT `skinTempAbsC`, and has the + * on-device-only `spo2Red` / `spo2Ir`; it also carries the `ppgWaveformSample` table; + * - the own store's `stepSample` / `ppgHrSample` carry a `synced` flag that is NOT NULL with NO + * default — Room emits no SQL default for a Kotlin `= 0`, the exact shape that made a naive + * `INSERT OR IGNORE` drop whole tables' rows (the critical regression these tests lock); + * - the iOS/GRDB store lacks the on-device-only `spo2Red` / `spo2Ir` (imports never bank them), + * carries a GRDB-only collector table, and its `stepSample` / `ppgHrSample` lack the Android-only + * `synced` column — so reconciling it must FILL `synced` with a default, never drop the rows; + * - the BEHIND fork has no `ppgWaveformSample`, no `stepSample` / `ppgHrSample`, no `seq` on + * `rrInterval`, and none of the v7 sleep aggregate columns — but carries NO upstream-absent marker + * (a version gap, not a content divergence); + * - the AHEAD fork carries the two upstream-absent markers: the `spo2PctSample` table and + * `dailyMetric.skinTempAbsC`. + */ +internal object CrossForkSchemaFixtures { + + // A nullable-no-default column: present in the source -> copied; absent -> SQLite fills NULL. The + // declared type is irrelevant to such columns (it only matters for a filled NOT NULL-no-default one), + // so the concise helper defaults it. + private fun col(name: String, type: String = "INTEGER") = + DataBackup.SchemaColumn(name, type, notNull = false, hasDefault = false) + + private fun cols(vararg names: String): List = names.map { col(it) } + + // A NOT NULL column with NO schema default (Room's `= 0`). Source-absent -> the planner MUST fill it + // with a typed zero literal, else INSERT OR IGNORE drops the row. + private fun notNullNoDefault(name: String, type: String = "INTEGER") = + DataBackup.SchemaColumn(name, type, notNull = true, hasDefault = false) + + /** Shape A — this app's own Android (Room) store. Also the reconcile TARGET. */ + val ownAndroidStore: Map> = mapOf( + // Housekeeping — the planner must filter these out; the router reads `room_master_table`. + "room_master_table" to cols("id", "identity_hash"), + "android_metadata" to cols("locale"), + "sqlite_sequence" to cols("name", "seq"), + // Data. + "hrSample" to cols("deviceId", "ts", "bpm"), + "rrInterval" to cols("deviceId", "ts", "rrMs", "seq"), + "dailyMetric" to cols("deviceId", "day", "restingHr", "avgHrv", "skinTempDevC", "spo2Red", "spo2Ir"), + "ppgWaveformSample" to cols("deviceId", "ts", "samples"), + "sleepSession" to cols("deviceId", "startTs", "endTs", "efficiency"), + // These two carry the NOT NULL-no-default `synced` flag (Room's `= 0`), the shape that made a + // naive INSERT OR IGNORE drop the whole table's rows when a source lacked the column. + "stepSample" to listOf( + col("deviceId"), col("ts"), col("counter"), col("activityClass"), notNullNoDefault("synced"), + ), + "ppgHrSample" to listOf( + col("deviceId"), col("ts"), col("bpm"), col("conf"), notNullNoDefault("synced"), + ), + ) + + /** Shape B — the iOS/GRDB store. Same logical column names by the parity contract; `grdb_migrations` + * bookkeeping; a GRDB-only collector table (`healthKitCursor`); `dailyMetric` lacks the on-device-only + * `spo2Red` / `spo2Ir`; `stepSample` / `ppgHrSample` LACK the Android-only `synced` column, so + * reconciling this store must FILL `synced` with a default rather than drop the rows. */ + val iosGrdbStore: Map> = mapOf( + "grdb_migrations" to cols("identifier"), + "hrSample" to cols("deviceId", "ts", "bpm"), + "rrInterval" to cols("deviceId", "ts", "rrMs", "seq"), + "dailyMetric" to cols("deviceId", "day", "restingHr", "avgHrv", "skinTempDevC"), + "ppgWaveformSample" to cols("deviceId", "ts", "samples"), + "sleepSession" to cols("deviceId", "startTs", "endTs", "efficiency"), + "healthKitCursor" to cols("id", "anchor"), + "stepSample" to cols("deviceId", "ts", "counter", "activityClass"), + "ppgHrSample" to cols("deviceId", "ts", "bpm", "conf"), + ) + + /** Shape C — a behind Android fork (Room), a few migrations behind this build: no `ppgWaveformSample`, + * no `stepSample` / `ppgHrSample`, no `seq` on `rrInterval`, `dailyMetric` without the v7 sleep + * aggregates. Carries NO upstream-absent marker, so the live import keeps it on the ordinary + * open-time-migrator restore. */ + val behindAndroidFork: Map> = mapOf( + "room_master_table" to cols("id", "identity_hash"), + "hrSample" to cols("deviceId", "ts", "bpm"), + "rrInterval" to cols("deviceId", "ts", "rrMs"), + "dailyMetric" to cols("deviceId", "day", "restingHr", "avgHrv"), + "sleepSession" to cols("deviceId", "startTs", "endTs", "efficiency"), + ) + + /** Shape D — an ahead Android fork (Room), ahead of this build: carries the upstream-absent + * `spo2PctSample` table and `dailyMetric.skinTempAbsC`, so it can't be brought forward by this + * build's Room migrator — the content-based router reroutes it to the row-copy reconcile. Being an + * Android Room fork, its `stepSample` / `ppgHrSample` DO carry the NOT NULL-no-default `synced`. */ + val aheadAndroidFork: Map> = mapOf( + "room_master_table" to cols("id", "identity_hash"), + "hrSample" to cols("deviceId", "ts", "bpm"), + "rrInterval" to cols("deviceId", "ts", "rrMs", "seq"), + "dailyMetric" to cols( + "deviceId", "day", "restingHr", "avgHrv", "skinTempDevC", "skinTempAbsC", "spo2Red", "spo2Ir", + ), + "ppgWaveformSample" to cols("deviceId", "ts", "samples"), + "sleepSession" to cols("deviceId", "startTs", "endTs", "efficiency"), + "spo2PctSample" to cols("deviceId", "ts", "pct"), + "stepSample" to listOf( + col("deviceId"), col("ts"), col("counter"), col("activityClass"), notNullNoDefault("synced"), + ), + "ppgHrSample" to listOf( + col("deviceId"), col("ts"), col("bpm"), col("conf"), notNullNoDefault("synced"), + ), + ) + + /** The `sqlite_master` table-name set the router reads. */ + fun tablesOf(shape: Map>): Set = shape.keys + + /** The `dailyMetric` column-name set the router reads for the fork-marker column probe. */ + fun dailyMetricColumnsOf(shape: Map>): Set = + shape["dailyMetric"]?.map { it.name }?.toSet() ?: emptySet() +}