Skip to content

fix(backup): preserve deleted sleep suppression across Apple restore - #155

Draft
kvnloo wants to merge 2 commits into
mainfrom
up/2720-backup-deleted-sleep-loses-its-sup
Draft

kvnloo wants to merge 2 commits into
mainfrom
up/2720-backup-deleted-sleep-loses-its-sup

Conversation

@kvnloo

@kvnloo kvnloo commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Problem

Fixes ryanbr#2720
Upstream issue: ryanbr#2720 (open; 0 comments).

A fresh Apple .noopbak restore recovers raw samples and the deleted sleep row's absence, but not the suppression tombstone. A later detection pass can recreate the deliberately deleted night. The issue reproduces this with synthetic data and no physical strap.

Root cause

At verified kvnloo/noop:main == ryanbr/noop:main 2a1de7609843b095505274ad6fc811757210be80:

  • Strand/Data/Repository.swift:1688-1709 keeps the global sleep.dismissedSessions tombstones in UserDefaults, outside the backed-up GRDB SQLite file. Apple deleted-night list visibility uses the separate sleep.dismissedSessions.hiddenFromList defaults key (the subject of ongoing upstream PR sleep: list deleted nights on iOS and macOS, with recompute and hide ryanbr/noop#2660).
  • Strand/Data/DataBackup.swift:225-279,284-298 archives only SQLite, optional whitelisted settings.json, and manifest.json; Strand/Data/DataBackup.swift:548-568 reapplies only whitelisted settings. The tombstone keys are intentionally absent from Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift.
  • Android's android/app/src/main/java/com/noop/data/Entities.kt:585-595 persists device-scoped dismissedSleep rows (including managementVisible) inside Room. Its whole-database android/app/src/main/java/com/noop/data/DataBackup.kt:297-305 export already carries them.

Change

  • On Apple export, add an optional, versioned sleep-deletions.json ZIP entry even when no profile settings exist, recording version: 1, scope: "global", canonical startTs:endTs suppression spans and hiddenFromList tokens. This preserves Apple global/id-free semantics without fabricating Android device IDs or changing the cross-platform settings.json whitelist.
  • Validate token grammar, uniqueness, marker count (≤500), visibility subset, global scope and version before replacing SQLite. Reject duplicated metadata entries and metadata exceeding 131,072 uncompressed bytes; the SQLite oversize override never bypasses that cap. Orphaned hidden tokens are pruned when exporting.
  • Apply both marker lists only after the database swap and post-swap SQLite integrity check succeed. A present empty snapshot clears both; older archives without the entry leave existing defaults untouched.
  • No BLE, scoring, schema, design or UI-string changes. Android remains unchanged because its device-scoped deletion metadata is already included in Room backups; Apple restores still reject Room databases, as before.

Tests

UNRUN. No CI/workflow dispatch; GitHub Actions is disabled in kvnloo/noop. Five new real-ZIP/XCTest regressions in StrandTests/BackupSyncRoundTripTests.swift:

  1. testDeletedSleepMarkersAndVisibilitySurviveFreshRestore — two deletions and one hidden entry survive fresh defaults/SQLite restore; marker-only ZIP is independent of settings.json.
  2. testExplicitEmptyDeletionSnapshotClearsExistingSuppression — both old lists become empty.
  3. testLegacyBackupWithoutDeletionMetadataDoesNotClearExistingSuppression — old ZIP does not mutate existing defaults.
  4. testMalformedDeletionMetadataRejectsRestoreBeforeDatabaseSwap — an invalid marker rejects and preserves exact DB bytes/defaults.
  5. testOversizedDeletionMetadataRejectsWithoutOversizeOverride — 131,073 > 131,072 bytes fails even with allowOversize=true, preserving DB/defaults.

Hand-checked oracles: 1,700,000,000:1,700,028,800 is 28,800 seconds = 8 h. Candidate [1,700,010,000,1,700,013,600) overlaps that span; [1,700,200,000,1,700,203,600) does not. Two markers, one hidden → two suppression windows and one hidden management entry after fresh restore; an explicitly empty list → zero. Header/DB integrity tests remain in the existing suite. Tests are not claimed to pass.

Hermes/macOS runner command on this exact head:

xcodegen generate
xcodebuild -scheme Strand -configuration Debug -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO -only-testing:StrandTests/BackupSyncRoundTripTests test

Then run the full Strand tests and Apple NOOPiOS build. CI run link: not run.
Exact path-triggered upstream jobs: App build (macOS + iOS) / build (Strand, …), App build (macOS + iOS) / build (NOOPiOS, …), i18n Coverage / check, Source Hygiene / doc-comments, and Tools Python CI / linux-capture. Not Android CI or Swift Packages CI (no matching path change).

Hardware needed

No strap required for the pure archival fix or synthetic regressions. After head-exact automated checks, test an Apple fresh-install export/restore with a real deleted night and the next detection pass on macOS/iOS; check the management list after upstream #2660 is resolved. No BLE bytes changed.

Upstream readiness

DRAFT / HOLD, not green or approved. Before promotion: Hermes runs the exact-head suites above, independently reviews container/restore semantics, verifies on-device restore/relaunch and reconciles the adjacent open #2660 changes. No upstream issue/PR/comment/reaction/review was made; no merge or default/channel branch writes. Only this branch and fork Draft PR are in scope.

Implemented by ChatGPT (mobile app, GitHub connector) for Kevin's NOOP factory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backup: deleted sleep loses its suppression marker after a fresh Apple restore

1 participant