Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/BackupProvenance.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

/// Build provenance for exports (#1410): a `manifest.json` entry recording which build produced a
/// `.noopbak`, and an `APP_VERSION_CHANGED` event marking each in-app version transition so a SINGLE
/// export answers "what ran when" across the whole retention window. Pure JSON only — the ZIP container
/// and the event write live in the app layer; these are the byte-parity contract with Android.
///
/// Neither is telemetry: both are LOCAL metadata inside the user's own backup, nothing leaves the device.

/// The `manifest.json` entry inside a `.noopbak` — "which build produced this file" (#1410 tier 1).
/// Sibling to `BackupSettings`; not restored, read only to classify a file (helps #746 route by what a
/// file SAYS it is rather than probing tables).
public enum BackupManifest {
/// Canonical entry name inside the `.noopbak` ZIP. Matches the Android exporter byte-for-byte.
public static let entryName = "manifest.json"

/// Deterministic (`.sortedKeys`) JSON: `appBuild`, `appVersion`, `exportedAt` (unix ms), `platform`
/// ("apple"/"android"), `schemaVersion` (the DB's native schema integer for that platform). The twin
/// of Android `BackupManifest.json`; keys + kinds identical, values platform-specific.
public static func json(appVersion: String, appBuild: String, platform: String,
schemaVersion: Int, exportedAtMs: Int64) -> String {
let obj: [String: Any] = [
"appVersion": appVersion,
"appBuild": appBuild,
"platform": platform,
"schemaVersion": schemaVersion,
"exportedAt": exportedAtMs,
]
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]) else { return "{}" }
return String(decoding: data, as: UTF8.self)
}
}

/// The `APP_VERSION_CHANGED` event appended on an in-app version transition (#1410 tier 2). The one piece
/// no other means can reconstruct: a single export then answers "what ran when" retroactively.
public enum AppVersionEvent {
/// The `event.kind` for a version transition. Rides the existing `event` table (`payloadJSON` string).
public static let kind = "APP_VERSION_CHANGED"

/// Record a transition only when a PRIOR version was seen AND it differs — a first launch (nil prior)
/// records nothing (there is no transition), so the table never carries a spurious "from null" row.
public static func shouldRecord(lastSeen: String?, current: String) -> Bool {
guard let lastSeen, !lastSeen.isEmpty else { return false }
return lastSeen != current
}

/// Deterministic (`.sortedKeys`) payload: `from`, `schemaVersion`, `to`. Twin of Android
/// `AppVersionEvent.payloadJson`.
public static func payloadJson(from: String, to: String, schemaVersion: Int) -> String {
let obj: [String: Any] = ["from": from, "to": to, "schemaVersion": schemaVersion]
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]) else { return "{}" }
return String(decoding: data, as: UTF8.self)
}
}
15 changes: 14 additions & 1 deletion Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import WhoopProtocol
/// OpenWhoop persistence library — decoded streams are durable; raw frames are a
/// transient, compressed, prunable outbox. Built on GRDB/SQLite.
public enum WhoopStoreInfo {
/// Bumped whenever the migrator gains a new migration.
/// The store schema-version marker, bumped per migration. Surfaced in the backup manifest (#1410) so an
/// export records the platform's schema version (a platform-scoped indicator — Android reports its Room
/// version independently; the two numbering schemes are not expected to match).
public static let schemaVersion = 18
}

Expand Down Expand Up @@ -172,6 +174,17 @@ public actor WhoopStore {
try checkpointWALImpl()
}

/// #1410: append one app-level event (e.g. `APP_VERSION_CHANGED`) onto the event table. Idempotent on
/// the `(deviceId, ts, kind)` primary key. Twin of Android `WhoopRepository.recordEvent`.
public func recordEvent(deviceId: String, ts: Int, kind: String, payloadJSON: String) async throws {
try syncWrite { db in
try db.execute(sql: """
INSERT INTO event (deviceId, ts, kind, payloadJSON) VALUES (?, ?, ?, ?)
ON CONFLICT(deviceId, ts, kind) DO NOTHING
""", arguments: [deviceId, ts, kind, payloadJSON])
}
}

/// Non-async so GRDB's synchronous `writeWithoutTransaction` overload is chosen (mirrors the
/// syncRead/syncWrite pattern). Runs on the actor's executor, off the main thread.
private func checkpointWALImpl() throws {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import XCTest
@testable import WhoopStore

/// Pins the #1410 build-provenance JSON. The expected strings are byte-identical to the Kotlin twin
/// (`BackupProvenanceTest.kt`) for the same inputs — `.sortedKeys` compact JSON, numbers unquoted — so an
/// analyst reads one shape across platforms.
final class BackupProvenanceTests: XCTestCase {

func test_manifest_json_is_sorted_and_parity_with_kotlin() {
// Same inputs the Kotlin test uses (platform "android") → must produce the identical string.
XCTAssertEqual(
BackupManifest.json(appVersion: "10.1.1", appBuild: "221", platform: "android",
schemaVersion: 30, exportedAtMs: 1_723_900_000_000),
#"{"appBuild":"221","appVersion":"10.1.1","exportedAt":1723900000000,"platform":"android","schemaVersion":30}"#
)
}

func test_versionEvent_payload_is_sorted() {
XCTAssertEqual(
AppVersionEvent.payloadJson(from: "10.1.0", to: "10.1.1", schemaVersion: 30),
#"{"from":"10.1.0","schemaVersion":30,"to":"10.1.1"}"#
)
}

func test_shouldRecord_only_on_a_real_transition() {
XCTAssertFalse(AppVersionEvent.shouldRecord(lastSeen: nil, current: "10.1.1")) // first launch
XCTAssertFalse(AppVersionEvent.shouldRecord(lastSeen: "", current: "10.1.1"))
XCTAssertFalse(AppVersionEvent.shouldRecord(lastSeen: "10.1.1", current: "10.1.1")) // unchanged
XCTAssertTrue(AppVersionEvent.shouldRecord(lastSeen: "10.1.0", current: "10.1.1")) // transition
}
}
26 changes: 26 additions & 0 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ final class AppModel: ObservableObject {
#endif
await self.repo.refresh() // surface any imported data at once
await self.wireSourceCoordinator() // dormant unless a generic strap is active
await self.recordAppVersionChangeIfNeeded() // #1410: stamp an update transition once
try? await Task.sleep(nanoseconds: 6_000_000_000) // give the first offload a moment
// FIX 2(a): DEFER the heavy one-shot 4000-day heal/rescore while an import is in flight. A
// large Apple Health import is the worst-case launch overlap , running a 4000-iteration heal
Expand Down Expand Up @@ -472,6 +473,31 @@ final class AppModel: ObservableObject {
/// untouched. The coordinator only acts if/when a non-WHOOP strap becomes the active device.
/// `startWhoop`/`stopWhoop` are thin closures over BLEManager's EXISTING public methods (via the
/// model's `scan()` / `disconnect()`), so the coordinator never references BLEManager directly.
/// #1410: record an `APP_VERSION_CHANGED` event on the first launch after an update. UserDefaults holds
/// the last-seen version; `"noop-app"` is a synthetic non-strap deviceId sentinel (strap ids are UUIDs,
/// so it can't collide) — the same sentinel the Android twin uses.
private func recordAppVersionChangeIfNeeded() async {
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
let last = UserDefaults.standard.string(forKey: "noop.lastSeenVersion")
guard AppVersionEvent.shouldRecord(lastSeen: last, current: current), let last else {
// First launch (last == nil) or unchanged: nothing to record, just anchor the pointer.
UserDefaults.standard.set(current, forKey: "noop.lastSeenVersion")
return
}
// A real transition: advance the pointer only once the event is durably recorded, so a not-yet-ready
// store or a failed insert retries next launch instead of silently dropping the change.
guard let store = await repo.storeHandle() else { return }
let payload = AppVersionEvent.payloadJson(from: last, to: current,
schemaVersion: WhoopStoreInfo.schemaVersion)
do {
try await store.recordEvent(deviceId: "noop-app", ts: Int(Date().timeIntervalSince1970),
kind: AppVersionEvent.kind, payloadJSON: payload)
UserDefaults.standard.set(current, forKey: "noop.lastSeenVersion")
} catch {
// insert failed — leave last-seen so the transition is retried next launch
}
}

private func wireSourceCoordinator() async {
guard sourceCoordinator == nil, let store = await repo.storeHandle() else { return }
let registry = DeviceRegistry(store: DeviceRegistryStore(dbQueue: store.registryWriter))
Expand Down
42 changes: 31 additions & 11 deletions Strand/Data/DataBackup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ enum DataBackup {
if let complaint = DatabaseIntegrity.quickCheckFailure(atPath: dbURL.path) {
throw ExportIntegrityFailure(complaint: complaint)
}
try writeBackupZip(dbURL: dbURL, to: dest, settingsJSON: settingsJSON)
try writeBackupZip(dbURL: dbURL, to: dest, settingsJSON: settingsJSON, manifestJSON: currentManifestJSON())
// #1014 (write-side): the SOURCE is verified above, but the PRODUCED file can still be torn by a
// full disk / dying filesystem / flaky cloud-sync mid-write, and such a truncated `.noopbak`
// otherwise "restores" into an empty store — caught only by the import-side quick_check much later.
Expand All @@ -171,18 +171,37 @@ enum DataBackup {
/// entry) and deflate compression match the Android exporter byte-for-byte at the container level,
/// so a `.noopbak` produced on either platform imports on the other. `settingsJSON == nil` writes
/// the legacy single-entry ZIP. Mirrors the `Archive` idiom in `WhoopCsvExporter`.
private static func writeBackupZip(dbURL: URL, to dest: URL, settingsJSON: Data?) throws {
/// #1410: this build's provenance manifest (Bundle version/build + GRDB schema + export time) as JSON.
private static func currentManifestJSON() -> Data {
let info = Bundle.main.infoDictionary
let version = info?["CFBundleShortVersionString"] as? String ?? "?"
let build = info?["CFBundleVersion"] as? String ?? "?"
let json = BackupManifest.json(appVersion: version, appBuild: build, platform: "apple",
schemaVersion: WhoopStoreInfo.schemaVersion,
exportedAtMs: Int64(Date().timeIntervalSince1970 * 1000))
return Data(json.utf8)
}

private static func writeBackupZip(dbURL: URL, to dest: URL, settingsJSON: Data?, manifestJSON: Data) throws {
let archive = try Archive(url: dest, accessMode: .create)
try archive.addEntry(with: backupEntryName, fileURL: dbURL, compressionMethod: .deflate)
guard let settingsJSON else { return }
// Stage the JSON through a temp file so the settings entry uses the exact same file-URL
// addEntry idiom as the DB entry (one container code path, no provider-API variant to drift).
let fm = FileManager.default
let tmpJSON = fm.temporaryDirectory
.appendingPathComponent("noop-settings-\(UUID().uuidString).json")
try settingsJSON.write(to: tmpJSON)
defer { try? fm.removeItem(at: tmpJSON) }
try archive.addEntry(with: BackupSettings.entryName, fileURL: tmpJSON, compressionMethod: .deflate)
// Stage each JSON through a temp file so it uses the exact same file-URL addEntry idiom as the DB
// entry (one container code path, no provider-API variant to drift).
if let settingsJSON {
let tmpJSON = fm.temporaryDirectory
.appendingPathComponent("noop-settings-\(UUID().uuidString).json")
try settingsJSON.write(to: tmpJSON)
defer { try? fm.removeItem(at: tmpJSON) }
try archive.addEntry(with: BackupSettings.entryName, fileURL: tmpJSON, compressionMethod: .deflate)
}
// #1410: manifest LAST (after the DB + optional settings) and ALWAYS written — even a legacy
// nil-settings backup states which build produced it.
let tmpManifest = fm.temporaryDirectory
.appendingPathComponent("noop-manifest-\(UUID().uuidString).json")
try manifestJSON.write(to: tmpManifest)
defer { try? fm.removeItem(at: tmpManifest) }
try archive.addEntry(with: BackupManifest.entryName, fileURL: tmpManifest, compressionMethod: .deflate)
}

/// This device's whitelisted profile/display settings (see `BackupSettings.whitelist`) as the
Expand Down Expand Up @@ -243,7 +262,8 @@ enum DataBackup {
let fm = FileManager.default
if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) }
try writeBackupZip(dbURL: dbURL, to: dest,
settingsJSON: settings.flatMap { BackupSettings.encode($0) })
settingsJSON: settings.flatMap { BackupSettings.encode($0) },
manifestJSON: currentManifestJSON())
}

// MARK: - Import
Expand Down
53 changes: 53 additions & 0 deletions android/app/src/main/java/com/noop/data/BackupProvenance.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.noop.data

/**
* Build provenance for exports (#1410): a `manifest.json` entry recording which build produced a
* `.noopbak`, and an `APP_VERSION_CHANGED` event marking each in-app version transition so a SINGLE
* export answers "what ran when" across the whole retention window. Pure JSON only — the ZIP container
* and the event write live in the app layer; this is the byte-parity twin of Swift `BackupProvenance.swift`.
*
* Neither is telemetry: both are LOCAL metadata inside the user's own backup, nothing leaves the device.
*
* The JSON is a minimal, sorted-key object built by hand so it is byte-identical to Swift's
* `JSONSerialization(.sortedKeys)` and testable in a plain JVM (no `org.json` stub).
*/
private fun sortedJsonObject(entries: List<Pair<String, Any>>): String =
entries.sortedBy { it.first }.joinToString(separator = ",", prefix = "{", postfix = "}") { (k, v) ->
val value = when (v) {
is String -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
else -> v.toString() // Int/Long → unquoted numeric literal
}
"\"$k\":$value"
}

/** The `manifest.json` entry inside a `.noopbak` — "which build produced this file" (#1410 tier 1). */
object BackupManifest {
/** Canonical entry name inside the `.noopbak` ZIP. Matches the Swift exporter byte-for-byte. */
const val ENTRY_NAME = "manifest.json"

/** Twin of Swift `BackupManifest.json`; keys + kinds identical, values platform-specific. */
fun json(appVersion: String, appBuild: String, platform: String, schemaVersion: Int, exportedAtMs: Long): String =
sortedJsonObject(
listOf(
"appVersion" to appVersion,
"appBuild" to appBuild,
"platform" to platform,
"schemaVersion" to schemaVersion,
"exportedAt" to exportedAtMs,
)
)
}

/** The `APP_VERSION_CHANGED` event appended on an in-app version transition (#1410 tier 2). */
object AppVersionEvent {
/** The `event.kind` for a version transition. Rides the existing `event` table (`payloadJSON` string). */
const val KIND = "APP_VERSION_CHANGED"

/** Record only when a PRIOR version was seen AND it differs — first launch (null/blank prior) records nothing. */
fun shouldRecord(lastSeen: String?, current: String): Boolean =
!lastSeen.isNullOrEmpty() && lastSeen != current

/** Twin of Swift `AppVersionEvent.payloadJson`: deterministic `from`, `schemaVersion`, `to`. */
fun payloadJson(from: String, to: String, schemaVersion: Int): String =
sortedJsonObject(listOf("from" to from, "to" to to, "schemaVersion" to schemaVersion))
}
14 changes: 14 additions & 0 deletions android/app/src/main/java/com/noop/data/DataBackup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ object DataBackup {

/** Entry name of the optional whitelisted-settings JSON (#1000). Matches the Apple exporter. */
private const val SETTINGS_ENTRY_NAME = BackupSettingsCodec.ENTRY_NAME
// #1410: a `manifest.json` entry recording which build produced this file (read only to classify, never
// restored). Written LAST so older importers that stop at the first .sqlite entry are unaffected.
private const val MANIFEST_ENTRY_NAME = BackupManifest.ENTRY_NAME

private const val MAX_BACKUP_SQLITE_BYTES = 2_147_483_648L
private const val MAX_BACKUP_SETTINGS_BYTES = 1_048_576L
Expand Down Expand Up @@ -140,6 +143,14 @@ object DataBackup {
// legacy single-entry ZIP. The DB entry stays FIRST — older importers stop at the first
// `.sqlite` entry, so entry order is part of the cross-platform container contract.
val settingsJson = BackupSettingsBridge.snapshotJson(appContext)
// #1410: build provenance for this export — which build wrote the file.
val manifestJson = BackupManifest.json(
appVersion = com.noop.BuildConfig.VERSION_NAME,
appBuild = com.noop.BuildConfig.VERSION_CODE.toString(),
platform = "android",
schemaVersion = WhoopDatabase.SCHEMA_VERSION,
exportedAtMs = System.currentTimeMillis(),
)

val resolver = appContext.contentResolver
val output = resolver.openOutputStream(uri)
Expand All @@ -162,6 +173,9 @@ object DataBackup {
zip.write(settingsJson.toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
zip.putNextEntry(ZipEntry(MANIFEST_ENTRY_NAME))
zip.write(manifestJson.toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/java/com/noop/data/WhoopDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ abstract class WhoopDatabase : RoomDatabase() {

companion object {
const val DB_NAME = "noop_whoop.db"
/** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup
* manifest (#1410) so an export states its schema. Bump both together on a migration. */
const val SCHEMA_VERSION = 30

@Volatile
private var instance: WhoopDatabase? = null
Expand Down
5 changes: 5 additions & 0 deletions android/app/src/main/java/com/noop/data/WhoopRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,11 @@ class WhoopRepository(
inBandSec = row.inBandSec, belowSec = row.belowSec, aboveSec = row.aboveSec,
pushCount = row.pushCount, easeCount = row.easeCount, hrSource = row.hrSource,
)

/** #1410: append one app-level event (e.g. APP_VERSION_CHANGED) onto the event table. */
suspend fun recordEvent(deviceId: String, ts: Long, kind: String, payloadJSON: String) {
dao.insertEvents(listOf(EventRow(deviceId, ts, kind, payloadJSON)))
}
suspend fun recentLiveSessions(deviceId: String, limit: Int): List<LiveSessionRow> =
dao.recentLiveSessions(deviceId, limit)

Expand Down
Loading