From 01a9311e1d6957d58ff61f44e4bd4f9c301d8584 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 30 Jul 2026 16:00:25 -0700 Subject: [PATCH 01/31] Add multi-device recording controls --- Project.swift | 18 +- ...ataInspector.SwiftDataInspector_iPhone.png | 4 +- Where/AGENTS.md | 8 +- Where/TODOs.md | 4 +- Where/Tools/upgrade-backup.rb | 10 +- Where/Where/AGENTS.md | 4 + Where/Where/README.md | 27 ++ Where/WhereCore/AGENTS.md | 5 + Where/WhereCore/README.md | 19 +- .../Sources/Backup/BackupArchive.swift | 24 +- .../Sources/Backup/BackupCoordinator.swift | 23 ++ .../Sources/Backup/BackupService.swift | 4 + .../Devices/DeviceRecordingController.swift | 334 ++++++++++++++++++ .../Devices/LocationHistoryReader.swift | 22 ++ .../Sources/Devices/RecordingDevice.swift | 144 ++++++++ .../RecordingDeviceConfiguration.swift | 28 ++ .../Sources/Devices/RecordingDeviceID.swift | 35 ++ .../Devices/RecordingPolicyChange.swift | 39 ++ .../Devices/RecordingPolicyFilter.swift | 29 ++ .../Sources/Location/LocationIngestor.swift | 14 +- .../Sources/Location/LocationSample.swift | 19 + .../Sources/Persistence/SwiftDataStore.swift | 183 ++++++++++ .../Sources/Persistence/WhereStore.swift | 20 +- .../RecentActivitySummarizer.swift | 5 +- .../Sources/Reporting/ReportReader.swift | 13 +- .../Sources/WhereServices+Intents.swift | 2 + Where/WhereCore/Sources/WhereServices.swift | 27 +- .../Sources/Widgets/WidgetDataReader.swift | 5 +- .../Tests/BackupCoordinatorTests.swift | 31 +- .../WhereCore/Tests/BackupServiceTests.swift | 43 ++- .../DeviceRecordingControllerTests.swift | 208 +++++++++++ .../Tests/LocationIngestorTests.swift | 22 +- .../Tests/RecordingPolicyFilterTests.swift | 116 ++++++ Where/WhereCore/Tests/ReportReaderTests.swift | 34 ++ .../WhereCore/Tests/SwiftDataStoreTests.swift | 36 ++ .../WhereCore/Tests/WhereServicesTests.swift | 38 ++ Where/WhereUI/AGENTS.md | 2 +- Where/WhereUI/README.md | 13 +- .../DevicesSettingsViewSnapshotTests.swift | 10 + .../devices.Default_iPad.png | 3 + .../devices.Default_iPad_accessibility.png | 3 + .../devices.Default_iPad_ax5.png | 3 + .../devices.Default_iPad_contrast.png | 3 + .../devices.Default_iPad_dark.png | 3 + .../devices.Default_iPhone.png | 3 + .../devices.Default_iPhone_accessibility.png | 3 + .../devices.Default_iPhone_ax5.png | 3 + .../devices.Default_iPhone_contrast.png | 3 + .../devices.Default_iPhone_dark.png | 3 + .../settings.Default_iPad_accessibility.png | 4 +- .../settings.Default_iPad_ax5.png | 4 +- .../settings.Default_iPhone.png | 4 +- .../settings.Default_iPhone_accessibility.png | 4 +- .../settings.Default_iPhone_ax5.png | 4 +- .../settings.Default_iPhone_contrast.png | 4 +- .../settings.Default_iPhone_dark.png | 4 +- .../settings.Default_iPhone_rtl.png | 4 +- .../settings.DemoMode_iPhone.png | 4 +- .../settings.DemoMode_iPhone_dark.png | 4 +- .../Flyover/WhereFlyoverCatalog.swift | 2 +- .../CurrentRecordingDeviceProvider.swift | 41 +++ .../WhereUI/Sources/Launch/WhereLaunch.swift | 2 + .../Sources/Logging/WhereSessionLog.swift | 6 +- Where/WhereUI/Sources/Model/WhereScope.swift | 1 + .../WhereUI/Sources/Model/WhereSession.swift | 164 +++++++-- .../Sources/Onboarding/OnboardingView.swift | 5 + .../Sources/Preview/PreviewSupport.swift | 50 +++ .../Sources/Resources/Localizable.xcstrings | 286 ++++++++++++--- .../Settings/DeviceSettingsRowModel.swift | 63 ++++ .../Settings/DeviceSettingsSection.swift | 180 ++++++++++ .../Settings/DevicesSettingsModel.swift | 141 ++++++++ .../Settings/DevicesSettingsView.swift | 166 +++++++++ .../Settings/LocationSettingsView.swift | 137 ------- .../Sources/Settings/SettingsRow.swift | 8 +- .../Sources/Settings/SettingsSearch.swift | 18 +- .../Sources/Settings/SettingsView.swift | 18 +- .../CurrentRecordingDeviceProviderTests.swift | 21 ++ .../Tests/DeviceSettingsRowModelTests.swift | 96 +++++ .../Tests/DevicesSettingsModelTests.swift | 80 +++++ .../Tests/LocationSettingsViewTests.swift | 15 - Where/WhereUI/Tests/SettingsSearchTests.swift | 6 +- Where/WhereUI/Tests/Support/TestStore.swift | 16 + .../Tests/SwiftDataInspectorWiringTests.swift | 2 + .../Tests/WhereSessionTrackingTests.swift | 79 +++++ 84 files changed, 2967 insertions(+), 328 deletions(-) create mode 100644 Where/WhereCore/Sources/Devices/DeviceRecordingController.swift create mode 100644 Where/WhereCore/Sources/Devices/LocationHistoryReader.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDevice.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceID.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift create mode 100644 Where/WhereCore/Tests/DeviceRecordingControllerTests.swift create mode 100644 Where/WhereCore/Tests/RecordingPolicyFilterTests.swift create mode 100644 Where/WhereUI/SnapshotTests/DevicesSettingsViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png create mode 100644 Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift create mode 100644 Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift create mode 100644 Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift create mode 100644 Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift create mode 100644 Where/WhereUI/Sources/Settings/DevicesSettingsView.swift delete mode 100644 Where/WhereUI/Sources/Settings/LocationSettingsView.swift create mode 100644 Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift create mode 100644 Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift create mode 100644 Where/WhereUI/Tests/DevicesSettingsModelTests.swift delete mode 100644 Where/WhereUI/Tests/LocationSettingsViewTests.swift diff --git a/Project.swift b/Project.swift index 29c947c9..31fecea3 100644 --- a/Project.swift +++ b/Project.swift @@ -42,6 +42,21 @@ let whereAppGroupEntitlements: Entitlements = .dictionary([ "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]), ]) +/// The app additionally owns the CloudKit container that mirrors its +/// SwiftData store. Extensions deliberately keep the App Group-only +/// entitlement above: they write the shared local store and let the app's +/// CloudKit-backed container publish those changes when it next opens. +let whereAppEntitlements: Entitlements = .dictionary([ + "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]), + "com.apple.developer.icloud-container-identifiers": .array([ + .string("iCloud.com.stuff.where"), + ]), + "com.apple.developer.icloud-services": .array([.string("CloudKit")]), + "com.apple.developer.ubiquity-kvstore-identifier": .string( + "$(TeamIdentifierPrefix)com.stuff.where", + ), +]) + /// The environment the LFS reference images were recorded on, and the single /// source of truth for it. /// @@ -166,6 +181,7 @@ let project = Project( infoPlist: .extendingDefault(with: [ "UILaunchScreen": .dictionary([:]), "UIApplicationSupportsIndirectInputEvents": .boolean(true), + "UIBackgroundModes": .array([.string("remote-notification")]), // Stated explicitly rather than left to Tuist's `1.0` / `1` // defaults, because Settings > About shows them: the version a // user reads off the screen should be one this manifest chose. @@ -180,7 +196,7 @@ let project = Project( ]), sources: ["Where/Where/Sources/**"], resources: ["Where/Where/Resources/**"], - entitlements: whereAppGroupEntitlements, + entitlements: whereAppEntitlements, // Writes `WhereGitSHA` / `WhereGitStatus` into the built Info.plist // for Settings > About. A *post* script so it lands after "Process // Info.plist" and before signing, and `basedOnDependencyAnalysis: diff --git a/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png b/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png index a7b9f6e1..c7620de6 100644 --- a/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png +++ b/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c94a58ffbf68c715e476ff057ea433419de94d9837d33062f99425d12e6c3a4a -size 164339 +oid sha256:fc40eef4bb514f8f39a6490fd0bc3e68a728943fe7cda07ba1153c0046c321e9 +size 164512 diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 87058e82..3c886149 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -26,7 +26,7 @@ WhereUI — the app target stays tiny. | Layer | Where | Owns | |-------|-------|------| | **Domain / services** | `WhereCore` (`WhereServices` collaborators) | Rules, detection, aggregation, persistence, side effects. Unit-test here. | -| **View model** | `WhereUI` (`WhereModel`, the `WhereSession` coordinator, the scoped `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. | +| **View model** | `WhereUI` (`WhereModel`, the `WhereSession` coordinator, the scoped `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel` / `DevicesSettingsModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. | | **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings. Never store I/O, detection, or cache/throttle policy. | When in doubt: if the behavior would still be correct without SwiftUI, it @@ -62,6 +62,12 @@ Rules the code enforces and agents must preserve: `CoreLocationSource` in production, `ScriptedLocationSource` in tests/previews. The one-shot `requestCurrentLocation()` returns `nil` rather than throwing when no fix is available. +- **Automatic location policy is per installation and append-only.** Stamp + every automatic GPS sample with its `RecordingDeviceID`; write enable/disable + events with an effective timestamp; route every user-facing sample read + through `LocationHistoryReader`. A synced cutoff hides later raw samples + immediately while the target device is still pending, and raw/legacy/manual + history is never deleted or hidden without an attributable device policy. - **Manual entries carry a `ManualEntryAudit`**; `DayJournal`'s write methods take an explicit `audit:` (no default). An additive backfill can't downgrade an authoritative row's regions, but the newer audit always wins. diff --git a/Where/TODOs.md b/Where/TODOs.md index 5dc8790f..6e822d1b 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -33,9 +33,6 @@ The item format and the placement rule live in the root - fix(WhereUI) [quick-win]: `PresenceTimelineList` returns `[]` whenever `report.report` is nil (`:12`), so the Timeline segment of Your Year renders the "no stays" empty state while the year is still loading (and during a year switch) — unlike the Calendar segment beside it, which gates on `loadState`. (audit 2026-07-26) - refactor(WhereUI) [needs-design]: Extract a shared `ReportLoadGate`. The same `YearReportModel.loadState` gate is copy-pasted across `LocationsView.swift:60`, `ElsewhereView.swift:50`, `ResolutionView.swift:58`, and `CalendarContentView.swift:60`, and `PresenceTimelineList` skipped it entirely (above). One gate view would cover all five. (audit 2026-07-26) - fix(WhereUI) [quick-win]: The Elsewhere entry card renders raw inflection markup instead of an agreed region count — it shows literally `^[3 region](inflect: true)`. `locations.elsewhere.subtitle` is authored for automatic grammar agreement (`^[%lld region](inflect: true)`), but the string-catalog compiler passes that markup through **verbatim** into the compiled `Localizable.strings` (unlike a real plural such as `primary.elsewhereOnly.description`, which compiles to an `NSStringLocalizedFormatKey` dict), and flattening the resource to a `String` never runs the inflection engine. Pre-existing — the catalog entry is byte-identical on `main` and predates the String Catalog symbol migration. Fix by either rendering the resource directly so SwiftUI applies inflection (`Text(.locationsElsewhereSubtitle(regionCount))` in `ElsewhereSummaryCard`, dropping the `WhereFormat` hop) or replacing the markup with an explicit plural variation. `WhereFormatTests.elsewhereCardSubtitleInflectsTheRegionCount` pins the expected output behind `withKnownIssue`, so it trips as soon as this is fixed. The bug is also baked into the `locations.Loaded_iPad.png` reference (ledgered in the broken-snapshots cluster below) — re-record that image when this lands. (agent) -- fix(WhereUI) [needs-design]: Serialize `WhereSession.trackingEnabled` mutations. The setter spawns an unserialized `Task` per assignment (`WhereSession.swift:441`), so rapid on/off leaves start/stop racing: `startTracking()` sets `wantsTracking = true` on entry and never re-reads intent before `reconcileTracking()`, so a `stopTracking()` that runs mid-flight gets undone. Coalesce behind one in-flight task (or a generation token) and re-check intent before reconciling. (audit 2026-07-26) - - fix(WhereUI) [needs-design]: Split the toggle binding — `wantsTracking` for user intent vs `isTracking` for effective GPS state. `wantsTracking` already exists internally and is persisted, but the public `trackingEnabled` binds effective state for both read *and* write, so the switch animates back on its own while a start is in flight. (audit 2026-07-26) - - test(WhereUI) [quick-win]: Add an adversarial test for toggle ordering (stop while a slow scripted `startTracking()` is in flight); `WhereSessionTrackingTests` covers only the launch/foreground paths today. (audit 2026-07-26) - refactor(WhereUI) [needs-design]: Split `WhereSession` into an always-on coordinator + a presentation view-model whose lifetime scopes its subscriptions. **Partial progress (July 2026):** `YearReportModel` is now scene-scoped in `MainTabs` — `activate()` / `deactivate()` on `scenePhase` drive `observeDataChanges()` and refresh, closing the headless-relaunch rescan leak that previously wired the subscription through launch `syncAuth`. `ResolveModel`, `BackupModel`, and `RemindersSettingsModel` are already view-scoped. Remaining: the coordinator is still ~460 lines mixing tracking intent, authorization, reset, and region-style mirrors; finish extracting presentation collaborators and drive any leftover reactive work from scene lifetime. (agent) - test(WhereUI) [quick-win]: `ManualDayView`'s range mode has no test coverage — including its capture-only code. The deleted `manualDayViewHostsAddModes` hosted a *range-prefilled* add (two `DatePicker`s), but the `addPrefill` snapshot case in `ManualDayView.swift` is a single day (`start == end` → `dateSpan = .singleDay`), so no test ever renders the `.range` branch — live or stand-in. The range stand-in code has never executed, and the From/Through picker row rendering is unpinned. Fix: add an `AddRange` snapshot case with a multi-day `MissingDayRange` prefill (the Resolve backfill flow the deleted test existed for). (From the July 2026 snapshot-testing PR review.) - test(WhereUI) [needs-design]: `RegionMapView`'s live `Map` branch is no longer constructed by any test. The deleted `regionMapViewHosts` mounted the real MapKit `Map` (polygon building via `clLocationCoordinates`, `mapStyle`); under capture the view always takes the `SnapshotMapStandIn` branch, so a crash or regression in the production map path — which every real user sees — would ship untested. The stand-in substitution is what the framework carve-out sanctions; the gap is purely coverage. Fix: keep one lightweight hosting test for the live branch in `WhereUITests` (this specific surface is the exception the "no hosting smoke tests" rule shouldn't swallow) — until then, this entry records the accepted gap. (From the July 2026 snapshot-testing PR review.) @@ -103,6 +100,7 @@ re-recording: # Completed issues +- fix(WhereUI): Serialize automatic-recording changes and separate desired from effective state. (Resolved 2026-07-30: the fire-and-forget `trackingEnabled` binding was replaced by awaited `DevicesSettingsModel` intents over the reentrancy-safe `DeviceRecordingController`; policy is per-device and append-only, current-device acknowledgement mirrors physical GPS state, and same-clock rapid changes are ordered by a focused adversarial test. The Devices UI now renders intent, pending acknowledgement, permission state, and rollback independently.) - fix(WhereUI) [quick-win]: `resolution.Empty_iPhone` and `..._dark` baked in the **real-world date** and drifted every day — the reference read "Jan 1 – Jul 25 / 206 days" because that is when it was recorded, and it had been silently wrong every day since, passing only because two digit glyphs are 0.046% of the image. (Resolved: `PreviewSupport.previewServices()` now passes `now: { referenceNow }`, which `WhereServices` already threads into every collaborator including the `DataIssueScanner` that computes the missing-days range. `referenceNow`'s own doc comment names "missing-day math" as a reason it exists, so this was a fixture bug against a documented intent rather than a new pin. The two references were re-recorded once and now read "Jan 1 – Jul 14 / 195 days", derived from the pinned instant. Surfaced by `./test --review`, which reported it at max channel delta 255 while the suite still reported green.) - fix(WhereUI): `resolution.Empty` never rendered the empty state, and its capture raced a live store scan — which turned `main` red (run 30402846712) the first time CI lost that race, baking the `AppIconLoadingView` placeholder over 91.7% of `Empty_iPhone`. `PreviewSupport.resolveModel(seededWithIssues: false)` skipped `setDataIssues` entirely, so the fixture came back with `hasLoaded == false` — which `ResolutionView` can't distinguish from "the first scan hasn't landed" — and the view showed the placeholder until its `.task(id:)` scan of the empty in-memory store returned the whole year as missing days. So the *reference* was that scan's output (a populated list titled "Missing days"), not the all-clear state the case names, and every capture was a race the settle loop can't see: a pixel-stable placeholder settles clean, exactly as in the `root.LoggedIn` entry below. Previously masked by the ~1s that `drainInFlightAnimations` wasted per capture; removing that waste (#151) exposed it. (Resolved: both fixture modes now seed — `setDataIssues([])` for the empty one, which is what marks it loaded *and* `isSeeded`, so the view's `load(...)` is a no-op and the first rendered frame is final. The case is now fully synchronous, independent of the store and of `now`, and the two references were re-recorded once to the "All clear" state — coverage the suite never had, since `WithIssues` already pins the populated list. `ResolveModelTests` gained two guards: the fixture is loaded up front in both modes, and `load(...)` leaves a seeded fixture alone against a store whose scan does find issues.) ## Deferred snapshot-test flakiness diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 4b2ed47e..985c9916 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -23,7 +23,8 @@ # old joined key and recovering any legacy epoch value to a calendar day. # - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes # `primaryRegions` from the tracked ids (null appearance, listed order) when -# absent, and sets `formatVersion` to 2 (the current version). +# absent, adds empty device/policy tables, stamps legacy samples with null +# device provenance, and sets `formatVersion` to 3 (the current version). # # Idempotent: re-running on an already-upgraded archive is a no-op (it only # touches legacy `date` / `key` fields and unmapped region ids). @@ -40,7 +41,7 @@ require "set" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 2 +CURRENT_FORMAT_VERSION = 3 # Former enum-case region ids -> current catalog ids. `canada` / `other` are # unchanged but listed so an already-current id passes through untouched. @@ -178,6 +179,11 @@ def upgrade_manifest(manifest) manifest["primaryRegions"] ||= manifest["trackedRegions"].each_with_index.map do |id, index| { "region" => id, "appearance" => nil, "order" => index } end + Array(manifest["samples"]).each do |sample| + sample["recordingDeviceID"] = nil unless sample.key?("recordingDeviceID") + end + manifest["recordingDevices"] ||= [] + manifest["recordingPolicyChanges"] ||= [] manifest["formatVersion"] = CURRENT_FORMAT_VERSION warnings.uniq.each { |message| warn "warning: #{message}" } manifest diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index 6dd96b39..f0c6b435 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -47,6 +47,10 @@ layering, and the domain rules this target merely starts up. *behind* the onboarding gate, so this target opens nothing at startup; the intents stack derives from whatever scope the launch resolves, in the `onServicesReady` hook. +- **Only the app owns the CloudKit capability.** Keep its App Group, CloudKit + container (`iCloud.com.stuff.where`), and remote-notification background mode + together in `Project.swift`; widgets and the share extension stay App + Group-only and never open a CloudKit container. - **Nothing here may assume the user has a store.** `didFinishLaunching` starts the ambient log sources and drives the launch; anything wanting the user's data waits for `.ready` and checks what it got — the Spotlight indexing after diff --git a/Where/Where/README.md b/Where/Where/README.md index dafc29dd..f97f9385 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -41,3 +41,30 @@ The target is declared in [`Project.swift`](../../Project.swift). Generate and open the workspace with `./ide`, or install to a connected iPhone from the command line with [`./Where/install`](../install) (macOS only, needs a signing team — see [`Where/AGENTS.md`](../AGENTS.md#installing-to-a-device)). + +## CloudKit rollout and device validation + +The app target owns `iCloud.com.stuff.where` plus the remote-notification +background mode. Widgets and the share extension intentionally have only the +App Group entitlement: they write/read local shared artifacts, while the app's +single SwiftData container owns CloudKit mirroring. Debug uses `.localOnly`; +exercise sync with a Release-signed build. + +Before shipping a schema change: + +1. Install a Release build against the Development CloudKit environment and + open the store so SwiftData initializes the additive schema. +2. Inspect the new fields/record types in CloudKit Console, then deploy that + schema to Production before distributing the build. +3. On two devices signed into the same iCloud account, open Settings → Devices + and verify both generic hardware profiles arrive; rename one and verify the + nickname syncs. +4. From the carried device, turn automatic recording off for the left-behind + device. Verify its row says it is waiting, and that locations at/after the + cutoff disappear from reports as soon as the policy syncs. +5. Open the left-behind device. Verify it stops monitoring, acknowledges Off, + and the waiting state clears on the carried device. Re-enable it and verify + new locations appear again. +6. Archive the non-current device and verify it is hidden without losing older + report history. Export and replace-import a backup and verify device names, + raw samples, policy history, and archived state round-trip. diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 135ac558..827c1702 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -85,6 +85,11 @@ internal shape. `ScriptedLocationSource` in tests/previews; `requestCurrentLocation()` returns `nil`, never throws, and backs `LocationIngestor.captureTodayIfNeeded(now:)`. +- **`DeviceRecordingController` owns automatic-recording policy and physical + GPS state.** Keep policy events append-only, serialize mutations across + awaits, stamp every ingested GPS sample with the current installation id, + and apply `LocationHistoryReader` to every user-facing projection. Backups + alone read the lossless raw samples and full policy/device tables. - **Tracked regions live in the store, not preferences** — one `SDTrackedRegion` row per region so cross-device edits merge; read as a `Set` defaulting to the four. `RegionAttribution` derives the attributor diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 69323432..41111e06 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -35,7 +35,9 @@ one it belongs to rather than to a god-object: which surface and persist each region's picked `RegionAppearance` — color token, emoji, SF Symbol — and pick order alongside the synced rows) — one row per region, defaulting to the four until the user chooses in the onboarding / - Settings region picker. + Settings region picker. It also stores one `RecordingDevice` profile per + installation plus the append-only `RecordingPolicyChange` timeline used to + control automatic recording across devices. - **`RegionAttribution`** — a live `RegionAttributing` built from the tracked regions that rebuilds on `changes()` (a local edit or a remote import), so the app + App Intents process attribute against the same synced set. Assemble @@ -83,7 +85,16 @@ one it belongs to rather than to a god-object: (returns `nil`, never throws, when no fix is available). - **`LocationIngestor`** — monitoring, the persist-with-retry queue, and authorization; after each committed sample it reconciles the badge/reminders - and republishes the widget snapshot. + and republishes the widget snapshot. Every automatic sample is stamped with + the current installation's `RecordingDeviceID`. +- **`DeviceRecordingController`** — serializes per-device enable/disable + policy with the current installation's physical `LocationIngestor`. A remote + disable is effective at its timestamp as soon as it syncs; the target device + later acknowledges that event after it has stopped. +- **`LocationHistoryReader`** — the shared policy-aware read boundary used by + reports, widgets, recent activity, and foreground capture checks. It filters + GPS samples during disabled intervals while keeping raw storage, backups, + legacy samples without provenance, and user-asserted samples lossless. ### Detection, notifications & the rest @@ -188,6 +199,10 @@ store so the retry queue can't repopulate it mid-erase. the live `ModelContainer` is surfaced only for the read-only debug inspector. - **Always-location.** Background day tracking needs Always; `requestPermission()` throws `LocationPermissionDeniedError` on denial / restriction. +- **Strong remote cutoff.** Turning a device off does not depend on that device + being online before reports become correct: once the policy event syncs, + samples at or after its effective timestamp are excluded. Its row remains + "waiting" until the target installation physically stops and acknowledges it. - **Failures surface.** Store methods are `async throws`; errors are logged via `WhereLog` and left observable — never swallowed into an empty default. - **Foundation Models may be unavailable.** `RecentActivitySummarizer` reports a diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index db37184e..0b0eb970 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -8,20 +8,20 @@ import RegionKit /// /// The arrays mirror the SwiftData tables exactly (`SDLocationSample` / /// `SDEvidence` / `SDManualDay` / `SDDismissedIssue` / `SDTrackedRegion`) via -/// their value-type representations, so an export captures everything and an -/// import can upsert it back row-for-row. +/// their value-type representations, plus `SDRecordingDevice` / +/// `SDRecordingPolicyChange`, so an export captures everything and an import +/// can upsert it back row-for-row. public struct BackupArchive: Codable, Sendable, Hashable { /// Bumped whenever the archive's on-disk shape changes in a way older /// readers can't understand, so an importer can refuse a file it doesn't /// know how to read instead of silently dropping data (see /// `BackupService.readArchive`, which rejects any other version). /// - /// v2 adds `primaryRegions` (each tracked region's picked appearance + pick - /// order). There's no in-app decode fallback for a pre-v2 archive — it's - /// reshaped out of band by `Tools/upgrade-backup.rb` (which synthesizes - /// `primaryRegions` from `trackedRegions`), matching the module's - /// no-migration-on-read rule (see `AGENTS.md`). - public static let currentFormatVersion = 2 + /// v3 adds sample device provenance plus the synced recording-device and + /// append-only policy tables. There's no in-app decode fallback for an older + /// archive — it is reshaped out of band by `Tools/upgrade-backup.rb`, + /// matching the module's no-migration-on-read rule (see `AGENTS.md`). + public static let currentFormatVersion = 3 public let formatVersion: Int public let exportedAt: Date @@ -40,6 +40,10 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// brings back the *look*, not just the region set. Import restores from /// this; `trackedRegions` is the derived id list. public let primaryRegions: [PrimaryRegion] + /// Every synced device profile, including archived devices. + public let recordingDevices: [RecordingDevice] + /// The full append-only policy timeline for every device. + public let recordingPolicyChanges: [RecordingPolicyChange] /// One entry per evidence record that has blob bytes in the archive. /// Evidence without bytes simply has no entry here. public let assets: [BackupAssetEntry] @@ -53,6 +57,8 @@ public struct BackupArchive: Codable, Sendable, Hashable { dismissedIssues: [DismissedIssue], trackedRegions: [Region], primaryRegions: [PrimaryRegion], + recordingDevices: [RecordingDevice] = [], + recordingPolicyChanges: [RecordingPolicyChange] = [], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -63,6 +69,8 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.dismissedIssues = dismissedIssues self.trackedRegions = trackedRegions self.primaryRegions = primaryRegions + self.recordingDevices = recordingDevices + self.recordingPolicyChanges = recordingPolicyChanges self.assets = assets } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index fd7b9743..18c94267 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -33,6 +33,8 @@ public actor BackupCoordinator { public let manualDayCount: Int public let dismissedIssueCount: Int public let trackedRegionCount: Int + public let recordingDeviceCount: Int + public let recordingPolicyChangeCount: Int public init( sampleCount: Int, @@ -40,12 +42,16 @@ public actor BackupCoordinator { manualDayCount: Int, dismissedIssueCount: Int, trackedRegionCount: Int, + recordingDeviceCount: Int = 0, + recordingPolicyChangeCount: Int = 0, ) { self.sampleCount = sampleCount self.evidenceCount = evidenceCount self.manualDayCount = manualDayCount self.dismissedIssueCount = dismissedIssueCount self.trackedRegionCount = trackedRegionCount + self.recordingDeviceCount = recordingDeviceCount + self.recordingPolicyChangeCount = recordingPolicyChangeCount } } @@ -117,6 +123,8 @@ public actor BackupCoordinator { manualDays: store.allManualDays(), dismissedIssues: store.allDismissedIssues(), primaryRegions: store.primaryRegions(), + recordingDevices: store.recordingDevices(), + recordingPolicyChanges: store.recordingPolicyChanges(), ) } let evidence = tables.evidence @@ -145,6 +153,8 @@ public actor BackupCoordinator { // The bare ids ride alongside the primary regions for older readers. trackedRegions: tables.primaryRegions.map(\.region), primaryRegions: tables.primaryRegions, + recordingDevices: tables.recordingDevices, + recordingPolicyChanges: tables.recordingPolicyChanges, blobs: blobs, ) }.value @@ -162,6 +172,8 @@ public actor BackupCoordinator { let manualDays: [DayPresence] let dismissedIssues: [DismissedIssue] let primaryRegions: [PrimaryRegion] + let recordingDevices: [RecordingDevice] + let recordingPolicyChanges: [RecordingPolicyChange] } /// Delete the most recent export's staging directory now, rather than @@ -228,6 +240,7 @@ public actor BackupCoordinator { let blobs = result.blobs let total = archive.samples.count + archive.evidence.count + archive.manualDays.count + archive.dismissedIssues.count + + archive.recordingDevices.count + archive.recordingPolicyChanges.count try await Self.logger.measure(.importWrite) { try await store.perform { @@ -263,6 +276,14 @@ public actor BackupCoordinator { try await store.restoreDismissedIssue(dismissal) report() } + for device in archive.recordingDevices { + try await store.setRecordingDevice(device) + report() + } + for change in archive.recordingPolicyChanges { + try await store.addRecordingPolicyChange(change) + report() + } // Primary regions (with their picked looks) round-trip like any // other data. On `.replace` the store was cleared above, so write // the archive's set exactly; on `.merge` union it into the current @@ -294,6 +315,8 @@ public actor BackupCoordinator { manualDayCount: archive.manualDays.count, dismissedIssueCount: archive.dismissedIssues.count, trackedRegionCount: archive.primaryRegions.count, + recordingDeviceCount: archive.recordingDevices.count, + recordingPolicyChangeCount: archive.recordingPolicyChanges.count, ) } diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 5b80978b..91dc8555 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -82,6 +82,8 @@ public struct BackupService: Sendable { dismissedIssues: [DismissedIssue] = [], trackedRegions: [Region] = [], primaryRegions: [PrimaryRegion] = [], + recordingDevices: [RecordingDevice] = [], + recordingPolicyChanges: [RecordingPolicyChange] = [], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, @@ -116,6 +118,8 @@ public struct BackupService: Sendable { dismissedIssues: dismissedIssues, trackedRegions: trackedRegions, primaryRegions: primaryRegions, + recordingDevices: recordingDevices, + recordingPolicyChanges: recordingPolicyChanges, assets: assetEntries, ) try Self.logger.measure(.encodeManifest) { diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift new file mode 100644 index 00000000..3bf4c410 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -0,0 +1,334 @@ +import Foundation + +/// Serializes the synced recording policy with this device's physical GPS +/// lifecycle. +/// +/// Policy writes take effect historically at their timestamp immediately on +/// every device that has synced them. The target device later acknowledges the +/// latest event after it has started or stopped its local `LocationIngestor`. +public actor DeviceRecordingController { + private let store: any WhereStore + private let ingestor: LocationIngestor + public nonisolated let currentDevice: CurrentRecordingDevice + private let now: @Sendable () -> Date + + /// Reentrancy-safe gate: each public mutation/reconcile holds it across + /// awaits, so a rapid toggle cannot let an older start finish after a newer + /// stop. Actor isolation alone is insufficient because actors are reentrant. + private var isExclusive = false + private var waiters: [CheckedContinuation] = [] + private var acceptsOperations = true + + init( + store: any WhereStore, + ingestor: LocationIngestor, + currentDevice: CurrentRecordingDevice, + now: @escaping @Sendable () -> Date, + ) { + self.store = store + self.ingestor = ingestor + self.currentDevice = currentDevice + self.now = now + } + + /// Register this installation if needed, migrate its initial desired state + /// from local preferences, then make physical monitoring match the latest + /// synced policy and authorization. + @discardableResult + public func reconcile( + initialEnabled: Bool, + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + await beginExclusive() + defer { endExclusive() } + try requireActive() + return try await reconcileLocked( + initialEnabled: initialEnabled, + authorization: authorization, + ) + } + + /// Active device configurations, current device first and then by most + /// recent check-in. + public func devices(initialEnabled: Bool) async throws -> [RecordingDeviceConfiguration] { + await beginExclusive() + defer { endExclusive() } + try requireActive() + try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) + return try await configurationsLocked(includeArchived: false) + } + + /// Append a desired-state change. For this installation, reconcile and + /// acknowledge it before returning. A remote installation will show pending + /// until that device receives and applies the CloudKit row. + @discardableResult + public func setEnabled( + _ enabled: Bool, + for deviceID: RecordingDeviceID, + initialEnabled: Bool, + ) async throws -> [RecordingDeviceConfiguration] { + await beginExclusive() + defer { endExclusive() } + try requireActive() + try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) + + let device = try await store.recordingDevices().first(where: { $0.id == deviceID }) + let changes = try await store.recordingPolicyChanges() + let change = RecordingPolicyChange( + id: UUID(), + deviceID: deviceID, + effectiveAt: Self.nextEffectiveDate( + proposed: now(), + after: Self.latestPolicy(for: deviceID, in: changes), + ), + isEnabled: enabled, + ) + try await store.perform { + try await store.addRecordingPolicyChange(change) + if enabled, let device, device.archivedAt != nil { + try await store.setRecordingDevice(device.unarchived()) + } + } + + if deviceID == currentDevice.id { + let authorization = await ingestor.authorizationStatus() + _ = try await reconcileLocked( + initialEnabled: initialEnabled, + authorization: authorization, + ) + } + return try await configurationsLocked(includeArchived: false) + } + + /// Change the synced, user-editable nickname. Empty/whitespace-only text + /// clears the nickname and falls back to the generic system label. + public func rename( + _ deviceID: RecordingDeviceID, + to nickname: String, + initialEnabled: Bool, + ) async throws -> [RecordingDeviceConfiguration] { + await beginExclusive() + defer { endExclusive() } + try requireActive() + try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) + guard let device = try await store.recordingDevices().first(where: { $0.id == deviceID }) + else { return try await configurationsLocked(includeArchived: false) } + + let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + let renamed = device.renamed(trimmed.isEmpty ? nil : trimmed) + guard renamed != device else { + return try await configurationsLocked(includeArchived: false) + } + try await store.perform { + try await store.setRecordingDevice(renamed) + } + return try await configurationsLocked(includeArchived: false) + } + + /// Hide a non-current stale device and append an off cutoff atomically. + /// Policy history and raw samples remain available to backups. + public func archive( + _ deviceID: RecordingDeviceID, + initialEnabled: Bool, + ) async throws -> [RecordingDeviceConfiguration] { + precondition(deviceID != currentDevice.id, "The current device cannot archive itself.") + await beginExclusive() + defer { endExclusive() } + try requireActive() + try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) + guard let device = try await store.recordingDevices().first(where: { $0.id == deviceID }) + else { return try await configurationsLocked(includeArchived: false) } + + let date = now() + let changes = try await store.recordingPolicyChanges() + let change = RecordingPolicyChange( + id: UUID(), + deviceID: deviceID, + effectiveAt: Self.nextEffectiveDate( + proposed: date, + after: Self.latestPolicy(for: deviceID, in: changes), + ), + isEnabled: false, + ) + try await store.perform { + try await store.addRecordingPolicyChange(change) + try await store.setRecordingDevice(device.archived(at: date)) + } + return try await configurationsLocked(includeArchived: false) + } + + /// Permanently close this stack's policy/write gate and quiesce GPS before + /// reset wipes the store. A queued observer reconciliation resumes behind + /// this gate, sees the closed state, and cannot recreate the just-erased + /// current-device rows. + func quiesce() async { + await beginExclusive() + defer { endExclusive() } + acceptsOperations = false + await ingestor.quiesce() + } + + /// A failed reset retains the session, so reopen its operation gate. The + /// next lifecycle reconciliation decides whether GPS should resume. + func resumeAfterFailedReset() async { + await beginExclusive() + defer { endExclusive() } + acceptsOperations = true + } + + private func reconcileLocked( + initialEnabled: Bool, + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) + let policies = try await store.recordingPolicyChanges() + guard let latest = Self.latestPolicy(for: currentDevice.id, in: policies) else { + preconditionFailure( + "Current recording device was registered without an initial policy.", + ) + } + + let status: RecordingDeviceStatus + if latest.isEnabled, authorization.allowsBackgroundTracking { + await ingestor.start() + status = .recording + } else { + await ingestor.stop() + status = latest.isEnabled ? .permissionRequired : .off + } + + guard let device = try await store.recordingDevices() + .first(where: { $0.id == currentDevice.id }) + else { + preconditionFailure("Current recording device disappeared during reconciliation.") + } + let checkIn = now() + let needsAcknowledgement = device.lastAppliedPolicyChangeID != latest.id + || device.status != status + let needsPeriodicCheckIn = checkIn.timeIntervalSince(device.lastSeenAt) >= 15 * 60 + let acknowledged = if needsAcknowledgement || needsPeriodicCheckIn { + device.acknowledging( + policyChangeID: latest.id, + status: status, + at: checkIn, + ) + } else { + device + } + if acknowledged != device { + try await store.perform { + try await store.setRecordingDevice(acknowledged) + } + } + return RecordingDeviceConfiguration( + device: acknowledged, + isEnabled: latest.isEnabled, + latestPolicyChangeID: latest.id, + ) + } + + private func ensureCurrentDeviceLocked(initialEnabled: Bool) async throws { + let devices = try await store.recordingDevices() + let policies = try await store.recordingPolicyChanges() + let existing = devices.first(where: { $0.id == currentDevice.id }) + let latest = Self.latestPolicy(for: currentDevice.id, in: policies) + guard existing == nil || latest == nil else { return } + + let date = now() + let profile = existing ?? RecordingDevice( + id: currentDevice.id, + systemName: currentDevice.systemName, + nickname: nil, + kind: currentDevice.kind, + registeredAt: date, + lastSeenAt: date, + archivedAt: nil, + lastAppliedPolicyChangeID: nil, + status: .off, + ) + let initialChange = latest ?? RecordingPolicyChange( + id: UUID(), + deviceID: currentDevice.id, + effectiveAt: date, + isEnabled: initialEnabled, + ) + try await store.perform { + if existing == nil { + try await store.setRecordingDevice(profile) + } + if latest == nil { + try await store.addRecordingPolicyChange(initialChange) + } + } + } + + private func configurationsLocked( + includeArchived: Bool, + ) async throws -> [RecordingDeviceConfiguration] { + async let devices = store.recordingDevices() + async let policies = store.recordingPolicyChanges() + let (resolvedDevices, resolvedPolicies) = try await (devices, policies) + return resolvedDevices + .filter { + includeArchived || $0.archivedAt == nil || $0.id == currentDevice.id + } + .map { device in + let latest = Self.latestPolicy(for: device.id, in: resolvedPolicies) + return RecordingDeviceConfiguration( + device: device, + isEnabled: latest?.isEnabled ?? true, + latestPolicyChangeID: latest?.id, + ) + } + .sorted { lhs, rhs in + if lhs.id == currentDevice.id { return true } + if rhs.id == currentDevice.id { return false } + if lhs.device.lastSeenAt != rhs.device.lastSeenAt { + return lhs.device.lastSeenAt > rhs.device.lastSeenAt + } + return lhs.id.storeURL.absoluteString < rhs.id.storeURL.absoluteString + } + } + + private static func latestPolicy( + for deviceID: RecordingDeviceID, + in changes: [RecordingPolicyChange], + ) -> RecordingPolicyChange? { + changes + .filter { $0.deviceID == deviceID } + .max { RecordingPolicyChange.isOrderedBefore($0, $1) } + } + + /// Preserve the local order of rapid actions even when the injected clock + /// returns the same instant for both. UUID ordering remains the convergent + /// tie-break for genuinely concurrent changes written on different devices. + private static func nextEffectiveDate( + proposed: Date, + after latest: RecordingPolicyChange?, + ) -> Date { + guard let latest, proposed <= latest.effectiveAt else { return proposed } + return latest.effectiveAt.addingTimeInterval(0.000_001) + } + + private func requireActive() throws { + guard acceptsOperations else { throw CancellationError() } + } + + private func beginExclusive() async { + if isExclusive { + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } else { + isExclusive = true + } + } + + private func endExclusive() { + if waiters.isEmpty { + isExclusive = false + } else { + waiters.removeFirst().resume() + } + } +} diff --git a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift new file mode 100644 index 00000000..5f020b04 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Shared policy-aware read path for every user-facing projection of location +/// history. The store remains a raw, lossless persistence boundary; this reader +/// applies the effective device cutoffs before data reaches reports or widgets. +public struct LocationHistoryReader: Sendable { + private let store: any WhereStore + + public init(store: any WhereStore) { + self.store = store + } + + public func samples(in interval: DateInterval) async throws -> [LocationSample] { + async let samples = store.samples(in: interval) + async let policyChanges = store.recordingPolicyChanges() + let (resolvedSamples, resolvedPolicyChanges) = try await (samples, policyChanges) + return RecordingPolicyFilter.visibleSamples( + resolvedSamples, + policyChanges: resolvedPolicyChanges, + ) + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDevice.swift b/Where/WhereCore/Sources/Devices/RecordingDevice.swift new file mode 100644 index 00000000..ea966462 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDevice.swift @@ -0,0 +1,144 @@ +import Foundation + +/// Broad hardware family used to choose an icon without persisting a +/// user-visible device name supplied by the operating system. +public enum RecordingDeviceKind: String, Codable, Sendable, Hashable { + case phone + case tablet + case other +} + +/// The last effective recording state acknowledged by a device. +public enum RecordingDeviceStatus: String, Codable, Sendable, Hashable { + case recording + case off + case permissionRequired +} + +/// Synced profile for one device that can contribute automatic locations. +/// +/// `nickname` is user-editable and synced. `systemName` is only a generic +/// hardware label such as “iPhone” or “iPad”; Where deliberately does not ask +/// for the user-assigned-device-name entitlement. +public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { + public let id: RecordingDeviceID + public let systemName: String + public let nickname: String? + public let kind: RecordingDeviceKind + public let registeredAt: Date + public let lastSeenAt: Date + public let archivedAt: Date? + public let lastAppliedPolicyChangeID: UUID? + public let status: RecordingDeviceStatus + + public init( + id: RecordingDeviceID, + systemName: String, + nickname: String?, + kind: RecordingDeviceKind, + registeredAt: Date, + lastSeenAt: Date, + archivedAt: Date?, + lastAppliedPolicyChangeID: UUID?, + status: RecordingDeviceStatus, + ) { + self.id = id + self.systemName = systemName + self.nickname = nickname + self.kind = kind + self.registeredAt = registeredAt + self.lastSeenAt = lastSeenAt + self.archivedAt = archivedAt + self.lastAppliedPolicyChangeID = lastAppliedPolicyChangeID + self.status = status + } + + public var displayName: String { + let trimmed = nickname?.trimmingCharacters(in: .whitespacesAndNewlines) + return if let trimmed, !trimmed.isEmpty { trimmed } else { systemName } + } + + func renamed(_ nickname: String?) -> RecordingDevice { + RecordingDevice( + id: id, + systemName: systemName, + nickname: nickname, + kind: kind, + registeredAt: registeredAt, + lastSeenAt: lastSeenAt, + archivedAt: archivedAt, + lastAppliedPolicyChangeID: lastAppliedPolicyChangeID, + status: status, + ) + } + + func archived(at date: Date) -> RecordingDevice { + RecordingDevice( + id: id, + systemName: systemName, + nickname: nickname, + kind: kind, + registeredAt: registeredAt, + lastSeenAt: lastSeenAt, + archivedAt: date, + lastAppliedPolicyChangeID: lastAppliedPolicyChangeID, + status: status, + ) + } + + func unarchived() -> RecordingDevice { + RecordingDevice( + id: id, + systemName: systemName, + nickname: nickname, + kind: kind, + registeredAt: registeredAt, + lastSeenAt: lastSeenAt, + archivedAt: nil, + lastAppliedPolicyChangeID: lastAppliedPolicyChangeID, + status: status, + ) + } + + func acknowledging( + policyChangeID: UUID, + status: RecordingDeviceStatus, + at date: Date, + ) -> RecordingDevice { + RecordingDevice( + id: id, + systemName: systemName, + nickname: nickname, + kind: kind, + registeredAt: registeredAt, + lastSeenAt: date, + archivedAt: archivedAt, + lastAppliedPolicyChangeID: policyChangeID, + status: status, + ) + } +} + +/// Local, non-synced description used to register this installation in the +/// synced device list. +public struct CurrentRecordingDevice: Sendable, Hashable { + public let id: RecordingDeviceID + public let systemName: String + public let kind: RecordingDeviceKind + + public init(id: RecordingDeviceID, systemName: String, kind: RecordingDeviceKind) { + self.id = id + self.systemName = systemName + self.kind = kind + } + + /// Deterministic identity for tests and previews that do not care which + /// installation is current. + public static let preview = CurrentRecordingDevice( + id: RecordingDeviceID( + rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + ), + systemName: "iPhone", + kind: .phone, + ) +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift new file mode 100644 index 00000000..c2cbc6c7 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift @@ -0,0 +1,28 @@ +import Foundation + +/// One row shown by device-management UI: the synced profile plus its latest +/// desired policy and whether that policy has been acknowledged by the device. +public struct RecordingDeviceConfiguration: Identifiable, Sendable, Hashable { + public let device: RecordingDevice + public let isEnabled: Bool + public let latestPolicyChangeID: UUID? + + public var id: RecordingDeviceID { + device.id + } + + public var isPending: Bool { + guard let latestPolicyChangeID else { return false } + return device.lastAppliedPolicyChangeID != latestPolicyChangeID + } + + public init( + device: RecordingDevice, + isEnabled: Bool, + latestPolicyChangeID: UUID?, + ) { + self.device = device + self.isEnabled = isEnabled + self.latestPolicyChangeID = latestPolicyChangeID + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceID.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceID.swift new file mode 100644 index 00000000..487cfbf8 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceID.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Stable identity of one installation that can record automatic locations. +/// +/// The value encodes as a single `store://devices/` URL so the same +/// identity is readable in backups, SwiftData, and structured logs without +/// exposing a raw, stringly-typed key. +public struct RecordingDeviceID: Hashable, Sendable, Identifiable, WhereStoreURLCodable { + public let rawValue: UUID + + public var id: RecordingDeviceID { + self + } + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + + public var storeURL: URL { + StoreURL.url( + collection: "devices", + type: rawValue.uuidString.lowercased(), + items: [:], + ) + } + + public init?(storeURL: URL) { + guard let parts = StoreURL.parts(of: storeURL), + parts.collection == "devices", + parts.items.isEmpty, + let rawValue = UUID(uuidString: parts.type) + else { return nil } + self.rawValue = rawValue + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift new file mode 100644 index 00000000..4d3238c0 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Append-only change to automatic recording policy for one device. +/// +/// The timestamp is the effective historical cutoff. A device that has not +/// received the CloudKit change may briefly keep producing raw samples, but +/// every report filters those samples from this instant onward. +public struct RecordingPolicyChange: Identifiable, Codable, Sendable, Hashable { + public let id: UUID + public let deviceID: RecordingDeviceID + public let effectiveAt: Date + public let isEnabled: Bool + + public init( + id: UUID, + deviceID: RecordingDeviceID, + effectiveAt: Date, + isEnabled: Bool, + ) { + self.id = id + self.deviceID = deviceID + self.effectiveAt = effectiveAt + self.isEnabled = isEnabled + } +} + +extension RecordingPolicyChange { + /// Deterministic latest-wins ordering. UUID text breaks equal-timestamp + /// ties so devices that receive concurrent CloudKit rows converge. + static func isOrderedBefore( + _ lhs: RecordingPolicyChange, + _ rhs: RecordingPolicyChange, + ) -> Bool { + if lhs.effectiveAt != rhs.effectiveAt { + return lhs.effectiveAt < rhs.effectiveAt + } + return lhs.id.uuidString < rhs.id.uuidString + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift new file mode 100644 index 00000000..aa20e20f --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Applies device recording policy to raw location samples. +/// +/// Policy changes are append-only and evaluated at each sample timestamp. +/// Legacy samples without a device ID remain visible because no device policy +/// can be attributed to them safely. +public enum RecordingPolicyFilter { + public static func visibleSamples( + _ samples: [LocationSample], + policyChanges: [RecordingPolicyChange], + ) -> [LocationSample] { + let timelines = Dictionary(grouping: policyChanges, by: \.deviceID) + .mapValues { $0.sorted(by: RecordingPolicyChange.isOrderedBefore) } + + return samples.filter { sample in + guard sample.source.isGPS, let deviceID = sample.recordingDeviceID else { + return true + } + guard let timeline = timelines[deviceID] else { + return true + } + let latest = timeline.last { change in + change.effectiveAt <= sample.timestamp + } + return latest?.isEnabled ?? true + } + } +} diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index e0e2e768..b765f47d 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -29,6 +29,7 @@ public actor LocationIngestor { private let store: any WhereStore private let locationSource: any LocationSource + private let recordingDeviceID: RecordingDeviceID private let calendar: Calendar private let onPersisted: PostPersistHook /// Durable mirror of `retryQueue`, so a backlog survives the process dying @@ -89,6 +90,7 @@ public actor LocationIngestor { init( store: any WhereStore, locationSource: any LocationSource, + recordingDeviceID: RecordingDeviceID, calendar: Calendar, outbox: any LocationOutbox = NoOpLocationOutbox(), retryQueueCapacity: Int = 1000, @@ -97,6 +99,7 @@ public actor LocationIngestor { precondition(retryQueueCapacity > 0, "retryQueueCapacity must be positive") self.store = store self.locationSource = locationSource + self.recordingDeviceID = recordingDeviceID self.calendar = calendar self.outbox = outbox self.retryQueueCapacity = retryQueueCapacity @@ -134,7 +137,7 @@ public actor LocationIngestor { if !restored.isEmpty { Self.logger { .restoredBacklog(count: restored.count) } } - retryQueue = restored + retryQueue + retryQueue = restored.map { $0.recorded(by: recordingDeviceID) } + retryQueue } // Flush anything that failed to persist before this session started, // before we (re)attach the stream consumer. @@ -262,8 +265,12 @@ public actor LocationIngestor { } let interval = DateInterval(start: startOfDay, end: endOfDay) do { - let existing = try await store.samples(in: interval) - if existing.contains(where: \.source.isGPS) { return } + let existing = try await LocationHistoryReader(store: store).samples(in: interval) + if existing.contains(where: { + $0.source.isGPS + && ($0.recordingDeviceID == recordingDeviceID + || $0.recordingDeviceID == nil) + }) { return } } catch { // Fail closed: if today's samples can't be read we skip rather than // risk logging a duplicate fix. Surfaced, not silently swallowed. @@ -324,6 +331,7 @@ public actor LocationIngestor { /// failure. Drains any backlog first so a single transient outage doesn't /// permanently reorder samples on disk. private func processIngestedSample(_ sample: LocationSample) async { + let sample = sample.recorded(by: recordingDeviceID) let drainedDays = await drainRetryQueue() do { try await store.perform { try await store.add(sample: sample) } diff --git a/Where/WhereCore/Sources/Location/LocationSample.swift b/Where/WhereCore/Sources/Location/LocationSample.swift index b3776b7a..b0e2b7d4 100644 --- a/Where/WhereCore/Sources/Location/LocationSample.swift +++ b/Where/WhereCore/Sources/Location/LocationSample.swift @@ -103,6 +103,9 @@ public struct LocationSample: Identifiable, Hashable, Codable, Sendable { public let coordinate: Coordinate public let horizontalAccuracy: Double public let source: SampleSource + /// Installation that produced an automatic GPS sample. Nil for legacy + /// samples and user-asserted/manual data. + public let recordingDeviceID: RecordingDeviceID? public init( id: UUID = UUID(), @@ -110,11 +113,27 @@ public struct LocationSample: Identifiable, Hashable, Codable, Sendable { coordinate: Coordinate, horizontalAccuracy: Double, source: SampleSource, + recordingDeviceID: RecordingDeviceID? = nil, ) { self.id = id self.timestamp = timestamp self.coordinate = coordinate self.horizontalAccuracy = horizontalAccuracy self.source = source + self.recordingDeviceID = recordingDeviceID + } + + /// Stamp an automatic sample with the installation that received it. + /// User-asserted samples intentionally remain device-agnostic. + func recorded(by deviceID: RecordingDeviceID) -> LocationSample { + guard source.isGPS else { return self } + return LocationSample( + id: id, + timestamp: timestamp, + coordinate: coordinate, + horizontalAccuracy: horizontalAccuracy, + source: source, + recordingDeviceID: deviceID, + ) } } diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index de4268e3..8842956f 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -131,6 +131,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { SDManualDay.self, SDDismissedIssue.self, SDTrackedRegion.self, + SDRecordingDevice.self, + SDRecordingPolicyChange.self, ]) // On-disk storage lives in the App Group container so the share // extension (and any other sibling process) writes into the same store @@ -253,6 +255,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { SDManualDay.self, SDDismissedIssue.self, SDTrackedRegion.self, + SDRecordingDevice.self, + SDRecordingPolicyChange.self, ] } @@ -469,6 +473,83 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } + // MARK: - Recording devices + + public func recordingDevices() async throws -> [RecordingDevice] { + let context = readContext() + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.lastSeenAt, order: .reverse)], + ) + descriptor.includePendingChanges = true + let values = try context.fetch(descriptor).compactMap { record in + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value + } + // CloudKit cannot enforce uniqueness. Converge duplicate rows by taking + // the most recently seen profile for each stable installation id. + return Dictionary(grouping: values, by: \.id) + .compactMap { _, duplicates in + duplicates.max { $0.lastSeenAt < $1.lastSeenAt } + } + .sorted { + if $0.lastSeenAt != $1.lastSeenAt { return $0.lastSeenAt > $1.lastSeenAt } + return $0.id.storeURL.absoluteString < $1.id.storeURL.absoluteString + } + } + + public func setRecordingDevice(_ device: RecordingDevice) async throws { + let context = mutationContext() + let id = device.id.rawValue + let existing = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id }), + ) + if let first = existing.first { + first.update(from: device) + for duplicate in existing.dropFirst() { + context.delete(duplicate) + } + } else { + context.insert(SDRecordingDevice(value: device)) + } + } + + public func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { + let context = readContext() + var descriptor = FetchDescriptor( + sortBy: [ + SortDescriptor(\.effectiveAt), + SortDescriptor(\.id), + ], + ) + descriptor.includePendingChanges = true + let values = try context.fetch(descriptor).compactMap { record in + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value + } + // Keep one value per event id if CloudKit delivers duplicate rows. + return Dictionary(grouping: values, by: \.id) + .compactMap { _, duplicates in duplicates.first } + .sorted(by: RecordingPolicyChange.isOrderedBefore) + } + + public func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { + let context = mutationContext() + let id = change.id + let existing = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id }), + ) + if let first = existing.first { + first.update(from: change) + for duplicate in existing.dropFirst() { + context.delete(duplicate) + } + } else { + context.insert(SDRecordingPolicyChange(value: change)) + } + } + public func write(evidence: Evidence, blob: Data?) async throws { let context = mutationContext() let id = evidence.id @@ -691,6 +772,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { for tracked in try context.fetch(FetchDescriptor()) { context.delete(tracked) } + for device in try context.fetch(FetchDescriptor()) { + context.delete(device) + } + for policy in try context.fetch(FetchDescriptor()) { + context.delete(policy) + } } public func dismissedIssueIDs() async throws -> Set { @@ -905,6 +992,9 @@ final class SDLocationSample { /// `.other` label is not preserved here (fetch the `Evidence` row /// for that). var evidenceKindRaw: String? + /// Installation that produced an automatic sample. Nil on legacy rows and + /// manual/evidence-implied samples. + var recordingDeviceID: UUID? init() {} @@ -922,6 +1012,7 @@ final class SDLocationSample { sourceRaw = value.source.discriminator evidenceId = value.source.evidenceId evidenceKindRaw = value.source.evidenceKind?.discriminator + recordingDeviceID = value.recordingDeviceID?.rawValue } func toValue() -> LocationSample? { @@ -939,6 +1030,7 @@ final class SDLocationSample { coordinate: Coordinate(latitude: latitude, longitude: longitude), horizontalAccuracy: horizontalAccuracy, source: source, + recordingDeviceID: recordingDeviceID.map(RecordingDeviceID.init(rawValue:)), ) } } @@ -1156,3 +1248,94 @@ final class SDTrackedRegion { orderIndex = order } } + +/// One synced installation profile. Every field is optional because CloudKit +/// may materialize a partial row before all fields arrive. +@Model +final class SDRecordingDevice { + var id: UUID? + var systemName: String? + var nickname: String? + var kindRaw: String? + var registeredAt: Date? + var lastSeenAt: Date? + var archivedAt: Date? + var lastAppliedPolicyChangeID: UUID? + var statusRaw: String? + + init() {} + + convenience init(value: RecordingDevice) { + self.init() + update(from: value) + } + + func update(from value: RecordingDevice) { + id = value.id.rawValue + systemName = value.systemName + nickname = value.nickname + kindRaw = value.kind.rawValue + registeredAt = value.registeredAt + lastSeenAt = value.lastSeenAt + archivedAt = value.archivedAt + lastAppliedPolicyChangeID = value.lastAppliedPolicyChangeID + statusRaw = value.status.rawValue + } + + func toValue() -> RecordingDevice? { + guard let id, + let systemName, + let kindRaw, + let kind = RecordingDeviceKind(rawValue: kindRaw), + let registeredAt, + let lastSeenAt, + let statusRaw, + let status = RecordingDeviceStatus(rawValue: statusRaw) + else { return nil } + return RecordingDevice( + id: RecordingDeviceID(rawValue: id), + systemName: systemName, + nickname: nickname, + kind: kind, + registeredAt: registeredAt, + lastSeenAt: lastSeenAt, + archivedAt: archivedAt, + lastAppliedPolicyChangeID: lastAppliedPolicyChangeID, + status: status, + ) + } +} + +/// Append-only desired recording state. Optional columns keep the CloudKit +/// schema additive and tolerant of partially synced rows. +@Model +final class SDRecordingPolicyChange { + var id: UUID? + var deviceID: UUID? + var effectiveAt: Date? + var isEnabled: Bool? + + init() {} + + convenience init(value: RecordingPolicyChange) { + self.init() + update(from: value) + } + + func update(from value: RecordingPolicyChange) { + id = value.id + deviceID = value.deviceID.rawValue + effectiveAt = value.effectiveAt + isEnabled = value.isEnabled + } + + func toValue() -> RecordingPolicyChange? { + guard let id, let deviceID, let effectiveAt, let isEnabled else { return nil } + return RecordingPolicyChange( + id: id, + deviceID: RecordingDeviceID(rawValue: deviceID), + effectiveAt: effectiveAt, + isEnabled: isEnabled, + ) + } +} diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index f7f1300c..388039d9 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -8,9 +8,9 @@ import RegionKit /// All methods are `async throws` so the production CloudKit-backed /// implementation has somewhere to surface I/O errors. /// -/// All mutating methods (`add(sample:)`, `write(evidence:blob:)`, -/// `setManualDay`, `clearManualDay`, `clear(in:)`, and the -/// `EvidenceBlobStore` writers) +/// All mutating methods (`add(sample:)`, `setRecordingDevice`, +/// `addRecordingPolicyChange`, `write(evidence:blob:)`, `setManualDay`, +/// `clearManualDay`, `clear(in:)`, and the `EvidenceBlobStore` writers) /// MUST be called from inside a `perform { ... }` block — the block /// boundary is what owns the underlying write transaction. The /// production `SwiftDataStore` implementation traps with a @@ -44,6 +44,20 @@ public protocol WhereStore: Sendable { func samples(in interval: DateInterval) async throws -> [LocationSample] func allSamples() async throws -> [LocationSample] + /// Every synced device profile, including archived devices. Callers decide + /// whether archived rows belong in their surface. + func recordingDevices() async throws -> [RecordingDevice] + + /// Upsert one synced device profile by ``RecordingDevice/id``. Must run + /// inside `perform { ... }`. + func setRecordingDevice(_ device: RecordingDevice) async throws + + /// Every append-only recording-policy event, oldest first. + func recordingPolicyChanges() async throws -> [RecordingPolicyChange] + + /// Add or update one policy event by id. Must run inside `perform { ... }`. + func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws + func write(evidence: Evidence, blob: Data?) async throws func evidence(in interval: DateInterval) async throws -> [Evidence] /// Every evidence record in the store, regardless of `capturedAt`. Used diff --git a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift index 579a40f3..b5e52b15 100644 --- a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift +++ b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift @@ -104,6 +104,9 @@ public actor RecentActivitySummarizer { private let calendar: Calendar private let now: @Sendable () -> Date private let segmentLimit: Int + private var history: LocationHistoryReader { + LocationHistoryReader(store: store) + } private static let logger = WhereLog.recentActivity(RecentActivitySummarizerLog.self) @@ -130,7 +133,7 @@ public actor RecentActivitySummarizer { /// model, or a generation error. public func summary(for window: RecentActivityWindow) async throws -> RecentActivitySummary { let interval = window.interval(now: now(), calendar: calendar) - let samples = try await store.samples(in: interval) + let samples = try await history.samples(in: interval) guard !samples.isEmpty else { Self.logger { .skippedNoSamples } return .empty diff --git a/Where/WhereCore/Sources/Reporting/ReportReader.swift b/Where/WhereCore/Sources/Reporting/ReportReader.swift index 151fd5cb..01c071be 100644 --- a/Where/WhereCore/Sources/Reporting/ReportReader.swift +++ b/Where/WhereCore/Sources/Reporting/ReportReader.swift @@ -14,6 +14,9 @@ public struct ReportReader: Sendable { let store: any WhereStore let aggregator: DayAggregator let attributor: any RegionAttributing + private var history: LocationHistoryReader { + LocationHistoryReader(store: store) + } /// The half-open date interval covering `year` in the aggregator's calendar. func yearInterval(year: Int) -> DateInterval { @@ -34,7 +37,7 @@ public struct ReportReader: Sendable { public func yearReport(for year: Int) async throws -> YearReport { try await Self.logger.measure(.yearReport, budget: .seconds(1)) { let interval = aggregator.yearInterval(year: year) - let samples = try await store.samples(in: interval) + let samples = try await history.samples(in: interval) let manuals = try await store.manualDays(in: dayRange(for: year)) return aggregator.report( for: year, @@ -53,7 +56,7 @@ public struct ReportReader: Sendable { /// raw); the `DaySamples` grouping is itself deferred until a detector asks. public func dataIssueReads(for year: Int) async throws -> DataIssueReads { try await Self.logger.measure(.dataIssueReads, budget: .seconds(2)) { - let samples = try await store.samples(in: aggregator.yearInterval(year: year)) + let samples = try await history.samples(in: aggregator.yearInterval(year: year)) let manuals = try await store.manualDays(in: dayRange(for: year)) let report = aggregator.report( for: year, @@ -92,7 +95,7 @@ public struct ReportReader: Sendable { public func locations(in region: Region, year: Int) async throws -> [RegionDayLocations] { try await Self.logger.measure(.regionLocations, budget: .seconds(1)) { let interval = aggregator.yearInterval(year: year) - let samples = try await store.samples(in: interval) + let samples = try await history.samples(in: interval) return aggregator.locations(in: region, samples: samples, attributor: attributor) } } @@ -108,7 +111,7 @@ public struct ReportReader: Sendable { guard let end = aggregator.calendar.date(byAdding: .day, value: 1, to: start) else { return [:] } - let samples = try await store.samples(in: DateInterval(start: start, end: end)) + let samples = try await history.samples(in: DateInterval(start: start, end: end)) return aggregator.pointsByRegion(onDay: day, samples: samples, attributor: attributor) } } @@ -119,7 +122,7 @@ public struct ReportReader: Sendable { public func representativeCoordinates(for year: Int) async throws -> [Region: Coordinate] { try await Self.logger.measure(.representativeCoordinates, budget: .seconds(1)) { let interval = aggregator.yearInterval(year: year) - let samples = try await store.samples(in: interval) + let samples = try await history.samples(in: interval) return aggregator.representativeCoordinates(samples: samples, attributor: attributor) } } diff --git a/Where/WhereCore/Sources/WhereServices+Intents.swift b/Where/WhereCore/Sources/WhereServices+Intents.swift index 144b64dd..1ae86a55 100644 --- a/Where/WhereCore/Sources/WhereServices+Intents.swift +++ b/Where/WhereCore/Sources/WhereServices+Intents.swift @@ -26,6 +26,7 @@ extension WhereServices { WhereServices( store: base.store, locationSource: IdleLocationSource(), + currentDevice: base.currentDevice, attributor: base.attributor, aggregator: base.aggregator, reminderScheduler: base.reminderScheduler, @@ -49,6 +50,7 @@ extension WhereServices { try await make( store: store, locationSource: IdleLocationSource(), + currentDevice: .preview, reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), issueAlertScheduler: NoopDataIssueAlertScheduler(), diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index f1d5de62..f125e84b 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -30,6 +30,9 @@ public struct WhereServices: Sendable { public let issueAlerts: DataIssueAlertReconciler /// Live GPS ingestion: monitoring, retry queue, authorization. public let ingestor: LocationIngestor + /// Synced per-device recording intent and the current installation's + /// serialized physical start/stop reconciliation. + public let recording: DeviceRecordingController /// User-sourced writes: manual days, backfills, clears, evidence. public let journal: DayJournal /// Backup export / import. @@ -66,6 +69,9 @@ public struct WhereServices: Sendable { /// The clock the stack was built with, retained so a derived stack can't /// diverge from an injected test/preview clock. let now: @Sendable () -> Date + /// Installation identity used to stamp automatic samples. Retained so a + /// derived App Intents stack preserves the same composition value. + let currentDevice: CurrentRecordingDevice /// The live SwiftData container when the backing store is the production /// `SwiftDataStore`; `nil` for non-SwiftData stores (e.g. test fakes). /// Surfaced only for read-only debug tooling (the SwiftData inspector) so @@ -88,6 +94,7 @@ public struct WhereServices: Sendable { public init( store: any WhereStore, locationSource: any LocationSource, + currentDevice: CurrentRecordingDevice = .preview, attributor: any RegionAttributing = RegionAttributor.shared, aggregator: DayAggregator = DayAggregator(), reminderScheduler: any LoggingReminderScheduling = NoopLoggingReminderScheduler(), @@ -155,6 +162,7 @@ public struct WhereServices: Sendable { let ingestor = LocationIngestor( store: store, locationSource: locationSource, + recordingDeviceID: currentDevice.id, calendar: aggregator.calendar, outbox: locationOutbox, onPersisted: { outcome in @@ -179,6 +187,12 @@ public struct WhereServices: Sendable { } }, ) + let recording = DeviceRecordingController( + store: store, + ingestor: ingestor, + currentDevice: currentDevice, + now: now, + ) let journal = DayJournal( store: store, aggregator: aggregator, @@ -210,6 +224,7 @@ public struct WhereServices: Sendable { self.issueAlerts = issueAlerts self.widgets = widgets self.ingestor = ingestor + self.recording = recording self.journal = journal self.backup = backup self.resolution = resolution @@ -222,6 +237,7 @@ public struct WhereServices: Sendable { self.issueAlertScheduler = issueAlertScheduler self.widgetRefresher = widgetRefresher self.now = now + self.currentDevice = currentDevice modelContainer = (store as? SwiftDataStore)?.inspectorContainer } @@ -238,6 +254,7 @@ public struct WhereServices: Sendable { public static func make( store: any WhereStore, locationSource: any LocationSource, + currentDevice: CurrentRecordingDevice, aggregator: DayAggregator = DayAggregator(), reminderScheduler: any LoggingReminderScheduling, summaryScheduler: any DailySummaryScheduling, @@ -258,6 +275,7 @@ public struct WhereServices: Sendable { return WhereServices( store: store, locationSource: locationSource, + currentDevice: currentDevice, attributor: attribution, aggregator: aggregator, reminderScheduler: reminderScheduler, @@ -319,8 +337,13 @@ public struct WhereServices: Sendable { /// on persistence failure so the caller can surface it rather than silently /// half-erasing. public func reset() async throws { - await ingestor.quiesce() - try await journal.eraseAllData() + await recording.quiesce() + do { + try await journal.eraseAllData() + } catch { + await recording.resumeAfterFailedReset() + throw error + } // `eraseAllData()` commits, which pings `store.changes()` and the // scanner self-invalidates off it — but that observation is async. Drop // the cache inline too so it's provably empty by the time `reset()` diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift index 6879639f..e333b02c 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift @@ -68,6 +68,9 @@ public struct WidgetDataReader: Sendable { private let store: any WhereStore private let aggregator: DayAggregator private let attributor: any RegionAttributing + private var history: LocationHistoryReader { + LocationHistoryReader(store: store) + } public init( store: any WhereStore, @@ -89,7 +92,7 @@ public struct WidgetDataReader: Sendable { let year = calendarDay.year let interval = aggregator.yearInterval(year: year) let dayRange = CalendarDay.yearRange(year) - let samples = try await store.samples(in: interval) + let samples = try await history.samples(in: interval) let manualDays = try await store.manualDays(in: dayRange) let report = aggregator.report( for: year, diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index dbd2132e..2dfc1f5d 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -46,9 +46,14 @@ struct BackupCoordinatorTests { id: .borderDrift(day: CalendarDay(year: 2026, month: 4, day: 1)), dismissedAt: Date(timeIntervalSince1970: 1_700_000_000), ) + private static let recordingDeviceID = RecordingDeviceID( + rawValue: UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")!, + ) + private static let recordingPolicyID = + UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")! - /// Seed all four tables (sample, evidence + blob, manual day, dismissed - /// issue) directly into a store so backup tests don't depend on the journal. + /// Seed every persisted domain directly into a store so backup tests don't + /// depend on the journal or recording controller. private static func seed(_ store: SwiftDataStore) async throws { try await store.perform { try await store.add(sample: sample(at: "2026-03-15T12:00:00-07:00")) @@ -59,6 +64,23 @@ struct BackupCoordinatorTests { regions: [.newYork], )) try await store.restoreDismissedIssue(dismissal) + try await store.setRecordingDevice(RecordingDevice( + id: recordingDeviceID, + systemName: "iPad", + nickname: "Travel iPad", + kind: .tablet, + registeredAt: dismissal.dismissedAt, + lastSeenAt: dismissal.dismissedAt, + archivedAt: nil, + lastAppliedPolicyChangeID: recordingPolicyID, + status: .recording, + )) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: recordingPolicyID, + deviceID: recordingDeviceID, + effectiveAt: dismissal.dismissedAt, + isEnabled: true, + )) } } @@ -76,6 +98,8 @@ struct BackupCoordinatorTests { #expect(summary.evidenceCount == 1) #expect(summary.manualDayCount == 1) #expect(summary.dismissedIssueCount == 1) + #expect(summary.recordingDeviceCount == 1) + #expect(summary.recordingPolicyChangeCount == 1) #expect(try await destination.store.allSamples() == source.store.allSamples()) #expect(try await destination.store.allEvidence() == source.store.allEvidence()) @@ -84,6 +108,9 @@ struct BackupCoordinatorTests { #expect(try await destination.store.allDismissedIssues() == source.store .allDismissedIssues()) #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) + #expect(try await destination.store.recordingDevices() == source.store.recordingDevices()) + #expect(try await destination.store.recordingPolicyChanges() == source.store + .recordingPolicyChanges()) #expect(try await destination.store.evidenceBlob(for: Self.evidence.id) == Self.blob) // An import that lands new data runs the post-import hook once. #expect(await destination.onImport.count == 1) diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index a0757de4..8cd64bf4 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -12,6 +12,11 @@ struct BackupServiceTests { private static let evidenceWithBlobId = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! private static let evidenceNoBlobId = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + private static let recordingDeviceID = RecordingDeviceID( + rawValue: UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!, + ) + private static let recordingPolicyID = + UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")! private static func sampleFixtures() -> [LocationSample] { [ @@ -21,6 +26,7 @@ struct BackupServiceTests { coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), horizontalAccuracy: 5, source: .gpsVisit, + recordingDeviceID: recordingDeviceID, ), LocationSample( id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, @@ -32,6 +38,33 @@ struct BackupServiceTests { ] } + private static func recordingDeviceFixtures() -> [RecordingDevice] { + [ + RecordingDevice( + id: recordingDeviceID, + systemName: "iPad", + nickname: "Travel iPad", + kind: .tablet, + registeredAt: exportDate, + lastSeenAt: exportDate, + archivedAt: nil, + lastAppliedPolicyChangeID: recordingPolicyID, + status: .recording, + ), + ] + } + + private static func recordingPolicyFixtures() -> [RecordingPolicyChange] { + [ + RecordingPolicyChange( + id: recordingPolicyID, + deviceID: recordingDeviceID, + effectiveAt: exportDate, + isEnabled: true, + ), + ] + } + private static func evidenceFixtures() -> [Evidence] { [ Evidence( @@ -84,12 +117,16 @@ struct BackupServiceTests { let blobs: [UUID: Data] = [Self.evidenceWithBlobId: Data("boarding-pass-pdf".utf8)] let dismissedIssues = Self.dismissedIssueFixtures() + let recordingDevices = Self.recordingDeviceFixtures() + let recordingPolicies = Self.recordingPolicyFixtures() let url = try service.makeArchiveFile( samples: samples, evidence: evidence, manualDays: manualDays, dismissedIssues: dismissedIssues, + recordingDevices: recordingDevices, + recordingPolicyChanges: recordingPolicies, blobs: blobs, exportedAt: Self.exportDate, ) @@ -107,6 +144,8 @@ struct BackupServiceTests { #expect(result.archive.manualDays == manualDays) // Dismissals round-trip verbatim, id and timestamp. #expect(result.archive.dismissedIssues == dismissedIssues) + #expect(result.archive.recordingDevices == recordingDevices) + #expect(result.archive.recordingPolicyChanges == recordingPolicies) // Only the evidence with bytes gets an asset; the other is metadata-only. #expect(result.archive.assets.map(\.evidenceId) == [Self.evidenceWithBlobId]) #expect(result.blobs == blobs) @@ -258,6 +297,8 @@ struct BackupServiceTests { ), PrimaryRegion(region: .newYork, appearance: nil, order: 1), ], + recordingDevices: Self.recordingDeviceFixtures(), + recordingPolicyChanges: Self.recordingPolicyFixtures(), assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, filename: "assets/\(Self.evidenceWithBlobId.uuidString)", @@ -273,7 +314,7 @@ struct BackupServiceTests { let decoded = try decoder.decode(BackupArchive.self, from: data) #expect(decoded == archive) - #expect(decoded.formatVersion == 2) + #expect(decoded.formatVersion == BackupArchive.currentFormatVersion) } @Test func readingAFileThatIsNotAZipThrows() throws { diff --git a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift new file mode 100644 index 00000000..9eb7b98f --- /dev/null +++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift @@ -0,0 +1,208 @@ +import Foundation +import Testing +@_spi(Testing) @testable import WhereCore + +struct DeviceRecordingControllerTests { + private static let now = WhereCoreTestSupport.iso("2026-07-30T12:00:00-07:00") + + private static func makeServices( + authorization: LocationAuthorizationStatus, + ) throws -> (WhereServices, SwiftDataStore) { + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: authorization), + currentDevice: .preview, + now: { now }, + ) + return (services, store) + } + + @Test func firstReconcileRegistersMigratedIntentAndAcknowledgesRecording() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + + let configuration = try await services.recording.reconcile( + initialEnabled: true, + authorization: .always, + ) + + #expect(configuration.id == CurrentRecordingDevice.preview.id) + #expect(configuration.isEnabled) + #expect(configuration.isPending == false) + #expect(configuration.device.status == .recording) + #expect(await services.ingestor.isActive) + #expect(try await store.recordingDevices().count == 1) + #expect(try await store.recordingPolicyChanges().count == 1) + } + + @Test func enabledWithoutAlwaysPermissionIsAcknowledgedAsPermissionRequired() async throws { + let (services, _) = try Self.makeServices(authorization: .whenInUse) + + let configuration = try await services.recording.reconcile( + initialEnabled: true, + authorization: .whenInUse, + ) + + #expect(configuration.isEnabled) + #expect(configuration.isPending == false) + #expect(configuration.device.status == .permissionRequired) + #expect(await services.ingestor.isActive == false) + } + + @Test func rapidChangesWithTheSameClockValueKeepInvocationOrder() async throws { + let (services, _) = try Self.makeServices(authorization: .always) + _ = try await services.recording.setEnabled( + true, + for: CurrentRecordingDevice.preview.id, + initialEnabled: false, + ) + let devices = try await services.recording.setEnabled( + false, + for: CurrentRecordingDevice.preview.id, + initialEnabled: false, + ) + let current = try #require( + devices.first(where: { $0.id == CurrentRecordingDevice.preview.id }), + ) + + #expect(current.isEnabled == false) + #expect(current.device.status == .off) + #expect(current.isPending == false) + #expect(await services.ingestor.isActive == false) + } + + @Test func remoteDisableIsPendingUntilThatDeviceAcknowledges() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + _ = try await services.recording.reconcile( + initialEnabled: true, + authorization: .always, + ) + let remoteID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), + ) + let initialPolicyID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")) + try await store.perform { + try await store.setRecordingDevice(RecordingDevice( + id: remoteID, + systemName: "iPad", + nickname: "Travel iPad", + kind: .tablet, + registeredAt: Self.now, + lastSeenAt: Self.now, + archivedAt: nil, + lastAppliedPolicyChangeID: initialPolicyID, + status: .recording, + )) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: initialPolicyID, + deviceID: remoteID, + effectiveAt: Self.now.addingTimeInterval(-60), + isEnabled: true, + )) + } + + let devices = try await services.recording.setEnabled( + false, + for: remoteID, + initialEnabled: true, + ) + let remote = try #require(devices.first(where: { $0.id == remoteID })) + + #expect(remote.isEnabled == false) + #expect(remote.isPending) + #expect(remote.device.status == .recording) + } + + @Test func archivingTurnsRemoteDeviceOffAndHidesItAtomically() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + _ = try await services.recording.reconcile( + initialEnabled: true, + authorization: .always, + ) + let remoteID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), + ) + try await store.perform { + try await store.setRecordingDevice(RecordingDevice( + id: remoteID, + systemName: "iPad", + nickname: nil, + kind: .tablet, + registeredAt: Self.now, + lastSeenAt: Self.now, + archivedAt: nil, + lastAppliedPolicyChangeID: nil, + status: .off, + )) + } + + let visible = try await services.recording.archive( + remoteID, + initialEnabled: true, + ) + + #expect(visible.contains(where: { $0.id == remoteID }) == false) + let archived = try #require( + try await store.recordingDevices().first(where: { $0.id == remoteID }), + ) + #expect(archived.archivedAt == Self.now) + let latest = try #require( + try await store.recordingPolicyChanges().last(where: { $0.deviceID == remoteID }), + ) + #expect(latest.isEnabled == false) + } + + @Test func archivedCurrentDeviceCanSeeItselfAndReenable() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + let policyID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")) + try await store.perform { + try await store.setRecordingDevice(RecordingDevice( + id: CurrentRecordingDevice.preview.id, + systemName: "iPhone", + nickname: nil, + kind: .phone, + registeredAt: Self.now, + lastSeenAt: Self.now, + archivedAt: Self.now, + lastAppliedPolicyChangeID: policyID, + status: .off, + )) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: policyID, + deviceID: CurrentRecordingDevice.preview.id, + effectiveAt: Self.now, + isEnabled: false, + )) + } + + let before = try await services.recording.devices(initialEnabled: false) + #expect(before.map(\.id) == [CurrentRecordingDevice.preview.id]) + + let after = try await services.recording.setEnabled( + true, + for: CurrentRecordingDevice.preview.id, + initialEnabled: false, + ) + let current = try #require(after.first) + #expect(current.isEnabled) + #expect(current.device.archivedAt == nil) + #expect(current.device.status == .recording) + } + + @Test func quiescedControllerCannotRecreateRowsAfterReset() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + _ = try await services.recording.reconcile( + initialEnabled: true, + authorization: .always, + ) + + await services.recording.quiesce() + try await store.perform { try await store.clearAll() } + + await #expect(throws: CancellationError.self) { + _ = try await services.recording.devices(initialEnabled: true) + } + #expect(try await store.recordingDevices().isEmpty) + #expect(try await store.recordingPolicyChanges().isEmpty) + } +} diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index cf4afc36..c475fae9 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -51,6 +51,7 @@ struct LocationIngestorTests { LocationIngestor( store: store, locationSource: source, + recordingDeviceID: CurrentRecordingDevice.preview.id, calendar: WhereCoreTestSupport.calendar(), outbox: outbox, retryQueueCapacity: retryQueueCapacity, @@ -115,7 +116,9 @@ struct LocationIngestorTests { // wait on it directly rather than on the sample count — a count poll can // observe the committed row before `onPersisted` records the outcome. try await waitUntil { await recorder.last?.liveSample != nil } - #expect(try await store.allSamples().count == 1) + let stored = try await store.allSamples() + #expect(stored.count == 1) + #expect(stored.first?.recordingDeviceID == CurrentRecordingDevice.preview.id) } @Test func captureTodaySkipsWhenGPSSampleAlreadyExistsToday() async throws { @@ -181,6 +184,7 @@ struct LocationIngestorTests { let ingestor = LocationIngestor( store: store, locationSource: source, + recordingDeviceID: CurrentRecordingDevice.preview.id, calendar: WhereCoreTestSupport.calendar(), onPersisted: { outcome in await recorder.record(outcome) }, ) @@ -532,6 +536,22 @@ private actor ToggleFailingStore: WhereStore { try await backing.allSamples() } + func recordingDevices() async throws -> [RecordingDevice] { + try await backing.recordingDevices() + } + + func setRecordingDevice(_ device: RecordingDevice) async throws { + try await backing.setRecordingDevice(device) + } + + func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { + try await backing.recordingPolicyChanges() + } + + func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { + try await backing.addRecordingPolicyChange(change) + } + func write(evidence: Evidence, blob: Data?) async throws { try await backing.write(evidence: evidence, blob: blob) } diff --git a/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift b/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift new file mode 100644 index 00000000..a82e76ba --- /dev/null +++ b/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift @@ -0,0 +1,116 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct RecordingPolicyFilterTests { + private static let deviceID = RecordingDeviceID( + rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + ) + + private static func sample( + _ timestamp: String, + source: SampleSource = .gpsVisit, + deviceID: RecordingDeviceID? = Self.deviceID, + ) -> LocationSample { + LocationSample( + timestamp: WhereCoreTestSupport.iso(timestamp), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: source, + recordingDeviceID: deviceID, + ) + } + + private static func policy( + _ timestamp: String, + enabled: Bool, + id: String, + ) -> RecordingPolicyChange { + RecordingPolicyChange( + id: UUID(uuidString: id)!, + deviceID: deviceID, + effectiveAt: WhereCoreTestSupport.iso(timestamp), + isEnabled: enabled, + ) + } + + @Test func disabledIntervalIsExcludedAndReenabledIntervalReturns() { + let before = Self.sample("2026-03-01T08:00:00-08:00") + let during = Self.sample("2026-03-02T08:00:00-08:00") + let after = Self.sample("2026-03-03T08:00:00-08:00") + let policies = [ + Self.policy( + "2026-03-02T00:00:00-08:00", + enabled: false, + id: "10000000-0000-0000-0000-000000000000", + ), + Self.policy( + "2026-03-03T00:00:00-08:00", + enabled: true, + id: "20000000-0000-0000-0000-000000000000", + ), + ] + + let visible = RecordingPolicyFilter.visibleSamples( + [before, during, after], + policyChanges: policies, + ) + + #expect(visible.map(\.id) == [before.id, after.id]) + } + + @Test func cutoffTimestampIsInclusive() { + let cutoff = Self.policy( + "2026-03-02T08:00:00-08:00", + enabled: false, + id: "10000000-0000-0000-0000-000000000000", + ) + let sample = Self.sample("2026-03-02T08:00:00-08:00") + + #expect(RecordingPolicyFilter.visibleSamples( + [sample], + policyChanges: [cutoff], + ).isEmpty) + } + + @Test func legacyAndUserAssertedSamplesRemainVisible() { + let legacy = Self.sample("2026-03-02T08:00:00-08:00", deviceID: nil) + let manual = Self.sample( + "2026-03-02T09:00:00-08:00", + source: .manual, + deviceID: Self.deviceID, + ) + let cutoff = Self.policy( + "2026-03-01T00:00:00-08:00", + enabled: false, + id: "10000000-0000-0000-0000-000000000000", + ) + + let visible = RecordingPolicyFilter.visibleSamples( + [legacy, manual], + policyChanges: [cutoff], + ) + + #expect(visible.map(\.id) == [legacy.id, manual.id]) + } + + @Test func equalTimestampPoliciesConvergeByID() { + let disabled = Self.policy( + "2026-03-02T00:00:00-08:00", + enabled: false, + id: "10000000-0000-0000-0000-000000000000", + ) + let enabled = Self.policy( + "2026-03-02T00:00:00-08:00", + enabled: true, + id: "20000000-0000-0000-0000-000000000000", + ) + let sample = Self.sample("2026-03-02T08:00:00-08:00") + + #expect(RecordingPolicyFilter.visibleSamples( + [sample], + policyChanges: [enabled, disabled], + ) == [sample]) + } +} diff --git a/Where/WhereCore/Tests/ReportReaderTests.swift b/Where/WhereCore/Tests/ReportReaderTests.swift index 6572fd3d..b0ff7179 100644 --- a/Where/WhereCore/Tests/ReportReaderTests.swift +++ b/Where/WhereCore/Tests/ReportReaderTests.swift @@ -41,6 +41,40 @@ struct ReportReaderTests { #expect(report.totals == [.california: 1, .newYork: 1]) } + @Test func yearReportAppliesDeviceRecordingCutoffs() async throws { + let (reader, store) = try Self.makeReader() + let deviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + try await store.perform { + try await store.add(sample: LocationSample( + timestamp: WhereCoreTestSupport.iso("2026-01-10T12:00:00-08:00"), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 0, + source: .gpsVisit, + recordingDeviceID: deviceID, + )) + try await store.add(sample: LocationSample( + timestamp: WhereCoreTestSupport.iso("2026-01-12T12:00:00-08:00"), + coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), + horizontalAccuracy: 0, + source: .gpsVisit, + recordingDeviceID: deviceID, + )) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, + deviceID: deviceID, + effectiveAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"), + isEnabled: false, + )) + } + + let report = try await reader.yearReport(for: 2026) + + #expect(report.days.count == 1) + #expect(report.totals == [.california: 1]) + } + @Test func manualDaysReturnsOnlyTheRequestedYear() async throws { let (reader, store) = try Self.makeReader() try await store.perform { diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 964f5457..5fd87a8f 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -96,6 +96,42 @@ struct SwiftDataStoreTests { #expect(await !firstPing(stream, within: .milliseconds(200))) } + @Test func recordingDeviceAndPolicyRoundTripWithoutDuplicateLogicalRows() async throws { + let store = try SwiftDataStore.inMemory() + let deviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) + let date = Date(timeIntervalSinceReferenceDate: 100) + let device = RecordingDevice( + id: deviceID, + systemName: "iPad", + nickname: "Home iPad", + kind: .tablet, + registeredAt: date, + lastSeenAt: date, + archivedAt: nil, + lastAppliedPolicyChangeID: policyID, + status: .off, + ) + let policy = RecordingPolicyChange( + id: policyID, + deviceID: deviceID, + effectiveAt: date, + isEnabled: false, + ) + + try await store.perform { + try await store.setRecordingDevice(device) + try await store.setRecordingDevice(device) + try await store.addRecordingPolicyChange(policy) + try await store.addRecordingPolicyChange(policy) + } + + #expect(try await store.recordingDevices() == [device]) + #expect(try await store.recordingPolicyChanges() == [policy]) + } + /// A remote import (simulated via a scripted source) re-pings the same /// `changes()` fan-out a local commit does, so observers can't tell a sync /// from another device apart from a local write — one read path. diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index fa127e1e..6263ccea 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -57,6 +57,7 @@ struct WhereServicesTests { let services = try await WhereServices.make( store: store, locationSource: ScriptedLocationSource(), + currentDevice: .preview, aggregator: Self.makeAggregator(), reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), @@ -728,10 +729,29 @@ struct WhereServicesTests { in: Self.pacificCalendar, regions: [.california], ) + let deviceID = CurrentRecordingDevice.preview.id + let policyID = try #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")) try await store.perform { try await store.add(sample: seedSample) try await store.write(evidence: Self.backupEvidence, blob: Self.backupBlob) try await store.setManualDay(seedDay) + try await store.setRecordingDevice(RecordingDevice( + id: deviceID, + systemName: "iPhone", + nickname: nil, + kind: .phone, + registeredAt: seedSample.timestamp, + lastSeenAt: seedSample.timestamp, + archivedAt: nil, + lastAppliedPolicyChangeID: policyID, + status: .recording, + )) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: policyID, + deviceID: deviceID, + effectiveAt: seedSample.timestamp, + isEnabled: true, + )) } try await store.perform { try await store.clearAll() } @@ -739,6 +759,8 @@ struct WhereServicesTests { #expect(try await store.allSamples().isEmpty) #expect(try await store.allEvidence().isEmpty) #expect(try await store.allManualDays().isEmpty) + #expect(try await store.recordingDevices().isEmpty) + #expect(try await store.recordingPolicyChanges().isEmpty) } // MARK: - Logging reminders @@ -1367,6 +1389,22 @@ private actor ToggleFailingStore: WhereStore { try await backing.allSamples() } + func recordingDevices() async throws -> [RecordingDevice] { + try await backing.recordingDevices() + } + + func setRecordingDevice(_ device: RecordingDevice) async throws { + try await backing.setRecordingDevice(device) + } + + func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { + try await backing.recordingPolicyChanges() + } + + func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { + try await backing.addRecordingPolicyChange(change) + } + func write(evidence: Evidence, blob: Data?) async throws { try await backing.write(evidence: evidence, blob: blob) } diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 7afd946e..b02821a8 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -4,7 +4,7 @@ WhereUI is the SwiftUI layer of the Where feature: the screens, the shared components and widget views, and the `@Observable` view models that orchestrate `WhereCore` for them (`WhereModel`, the `WhereSession` coordinator, and the scoped `YearReportModel` / `ResolveModel` / -`BackupModel` / `RemindersSettingsModel`). Layering, localization, preview, +`BackupModel` / `RemindersSettingsModel` / `DevicesSettingsModel`). Layering, localization, preview, and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) — read that and the root [`AGENTS.md`](../../AGENTS.md) first. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index cfa1d586..220b7b23 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -62,13 +62,14 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's `endSession()`, `resetPreferences()`). - **`WhereSession`** — the always-on coordinator: tracking + location authorization state and the intents that drive them (`requestPermission()`, - `startTracking()` / `stopTracking()`, `refreshWidgetSnapshot()`). It holds no - presentation state of its own. + per-device recording changes, `startTracking()` / `stopTracking()`, + `refreshWidgetSnapshot()`). It holds no presentation state of its own. - **Scope-tiered models** — scene-scoped **`YearReportModel`** (the selected year's `YearReport`, its `LoadState`, and the manual-day edit intents), plus view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** - (export/import), and **`RemindersSettingsModel`** (notification prefs). Each - orchestrates `WhereServices`; none reimplements Core rules. + (export/import), **`RemindersSettingsModel`** (notification prefs), and + **`DevicesSettingsModel`** (synced installation names, policy, status, and + archival). Each orchestrates `WhereServices`; none reimplements Core rules. ### Reusable views & styling @@ -87,6 +88,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's picker (segmented map/list) and per-region color/emoji/icon customization, backed by `PrimaryRegionSelectionModel`. Reused by onboarding and the Settings `RegionsSettingsView` editor. +- **`DevicesSettingsView`** — Settings’ per-installation automatic-recording + controls. It distinguishes desired policy from acknowledged physical state, + labels the current installation, permits synced nicknames, and archives only + remote devices while preserving their history. - **Widget views** — the shared renderers the **WhereWidgets** extension draws with: `TodayWidgetView`, `YearTotalsWidgetView`, and the accessory family (`TodayInlineAccessoryView`, `TodayCircularAccessoryView`, diff --git a/Where/WhereUI/SnapshotTests/DevicesSettingsViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/DevicesSettingsViewSnapshotTests.swift new file mode 100644 index 00000000..d916ab97 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/DevicesSettingsViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct DevicesSettingsViewSnapshotTests { + @Test func devices() async { + await assertSnapshots(of: DevicesSettingsView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png new file mode 100644 index 00000000..a16c8d66 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09a12fb64def83240d8a3b1cbbfc2a55858f977e72950608aa055d2b45c28b27 +size 324417 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png new file mode 100644 index 00000000..0af61264 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59a6b267ce592ab9356f90d1ffb0906e7e20efb0fa337585daaa3ef05882d45e +size 597111 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png new file mode 100644 index 00000000..1c10088e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04b0b6cf1451ad9630df71766d12266d358c40ff57ddece1f8867db82ac33015 +size 513002 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png new file mode 100644 index 00000000..37d13eb3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49513eca25b556d6dd0f9275ff90f327628d06714c5503976275d99b4bf71d8d +size 322596 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png new file mode 100644 index 00000000..469f2b98 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:464f6e96d7ac8d5012d4c95ca9052e506dade4f4959812ca3e6ce5e6e33e9a0e +size 334169 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png new file mode 100644 index 00000000..bcbd4585 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddeaab4b0a4394bf94028afd91c8ab552b357fe2a79b2978f079d5a036b909cd +size 194032 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png new file mode 100644 index 00000000..ac8e6ea9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd7d236fe010e337254848e25a4a131d714f3b3ac5121f08bea377fa69c3e4f3 +size 427149 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png new file mode 100644 index 00000000..a3681ccd --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0dd307c74e6f5dc1ec772ecc507f2ef5a86daefb304148b96732608e920d571 +size 225100 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png new file mode 100644 index 00000000..9873de0b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:170fcbcab16a05f4c6aeed9b22ad69a8f95dd97ecaba5f43b58f3d6ab7e8b1f3 +size 193974 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png new file mode 100644 index 00000000..1c1e2b2a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:085b4071ea092f287de76235dbf4fcc098307c89af1b1e6638b1e51a1d9f330c +size 197879 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png index 80c3bc7f..7fe26c32 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a4b7de340cb9e6efa065ab7a42b92a729f95b43dd779dc52c66afa6c8ccd5b10 -size 555343 +oid sha256:28aa85046aac8fc67a899d7dafae6ad9b0e4797978607b98ea6a5d325588d72f +size 554768 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_ax5.png index 0cb8375d..f97a5932 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f4fac0fc3de277c0f916373e6632e736d4517f6d20ad82e6dab900071931f15 -size 443099 +oid sha256:56f7624a69ed6fc663c97a47b5c83355a391dd2345de5bff0ca6029a7af72527 +size 444562 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png index c310a69b..ab91df7e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f68b3e0546d2940a0d45067b8a6d54908c7f47b04efd74b6156a5306359e41cb -size 232386 +oid sha256:dc1ecd82c0f9e6d7a20894ecbb4f10e2f4f753f37ef2a0aafad028c5eb30ff27 +size 230280 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png index d660a6c1..d3a67f08 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2592f6cdc44017d2c76228566b0eded57ce8a299006d3977760d82128259f9a1 -size 416443 +oid sha256:731da1feea6c00745a2a15f210c48894a738e385c216fc798f20456ca00bf1d4 +size 419847 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_ax5.png index 643a3879..4fbfbb75 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78a668217041fcb4e90ad6766a9f86f4f9f580b26c05169730cf3787b5e9a4bf -size 279027 +oid sha256:138b281912746f4a89b0ae54cf9a7785b2775585d97b136bc13083434bd77e0a +size 279288 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png index 69a5fbc3..f79bc892 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:75f2ebbb3904dfa29cddb285f6d0f2b60e4971b948bdef87baae4a2e9a2b933c -size 221907 +oid sha256:b4fd5d80ec09a7c7bde1021c2fe5796b81ede2dc6d65656f93221cf3dbabbdc9 +size 222594 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png index 31f0a864..773bb373 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe553dd669e74c924ebee2bcecdb24119018b44a29d54b445b08cba74c384d14 -size 249355 +oid sha256:3d28bf82364b9168b339eb9b97a3a00c18f1b6d4a6be150bf930c37e9142cd66 +size 219532 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png index 419ce328..760b1dc7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7ef710986474e5bf35073d6d0eaf85b9539194a7d23fbd1882c8ff440ac95d4 -size 233575 +oid sha256:6eb5d75f3487193f7e00dbaae8e755031b41b24345bbc3d3f3b0e60bf8be615b +size 230211 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png index 564b1daf..0f5c10ee 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:50abca073ac4b54970cc7390ae5f8aeb62a88ab0e46dc869c41912a70055d70f -size 240670 +oid sha256:08102b36a5229a8ededc69254e49dbd5617d3e6201cb0e25a1f7d27ec27fe9ed +size 242062 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png index 416ea032..1ad6d839 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8ac665a90276a8f84203344be46838263c524bf59dc8629853a4f8c081ff94cf -size 265977 +oid sha256:43b807e1206cc97aede390a76823fb490782a9bfa3e1725e13aa7889b50ddd44 +size 254460 diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift index 69ed6a49..d9b14479 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift @@ -88,7 +88,7 @@ AddEvidenceView.flyoverData, LoggedDaysView.flyoverData, RegionsSettingsView.flyoverData, - LocationSettingsView.flyoverData, + DevicesSettingsView.flyoverData, AlertsSettingsView.flyoverData, AppearanceSettingsView.flyoverData, AppIconView.flyoverData, diff --git a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift new file mode 100644 index 00000000..60068d3b --- /dev/null +++ b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift @@ -0,0 +1,41 @@ +import Foundation +import UIKit +import WhereCore + +/// Builds the local installation identity at the app composition boundary. +/// +/// The first available `identifierForVendor` (or a generated fallback before +/// first unlock) is persisted immediately. Every later launch reuses that +/// choice, so a pre-unlock headless wake cannot register one identity and the +/// foreground launch silently switch to another. +@MainActor +enum CurrentRecordingDeviceProvider { + private enum Key: String { + case recordingDeviceID = "where.recordingDeviceID" + } + + static func current(defaults: UserDefaults) -> CurrentRecordingDevice { + let device = UIDevice.current + let id: UUID + if let stored = defaults.string(forKey: Key.recordingDeviceID.rawValue) + .flatMap(UUID.init(uuidString:)) + { + id = stored + } else { + id = device.identifierForVendor ?? UUID() + defaults.set(id.uuidString, forKey: Key.recordingDeviceID.rawValue) + } + + let kind: RecordingDeviceKind = switch device.userInterfaceIdiom { + case .phone: .phone + case .pad: .tablet + case .unspecified, .tv, .carPlay, .mac, .vision: .other + @unknown default: .other + } + return CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: id), + systemName: device.model, + kind: kind, + ) + } +} diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 66184d3e..d8c83ee3 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -266,6 +266,7 @@ public final class WhereBootstrap: WhereScopeAssembling { public func makeServices() async throws -> WhereServices { let source = locationSource ?? CoreLocationSource() locationSource = nil + let currentDevice = CurrentRecordingDeviceProvider.current(defaults: .standard) do { let store = try await Task.detached(priority: .userInitiated) { try SwiftDataStore.make() @@ -273,6 +274,7 @@ public final class WhereBootstrap: WhereScopeAssembling { let services = try await WhereServices.make( store: store, locationSource: source, + currentDevice: currentDevice, // The real world's seams, named here because this is the only // place that wants them: the demo scope builds the same stack // out of no-ops, and every test and preview gets no-ops by diff --git a/Where/WhereUI/Sources/Logging/WhereSessionLog.swift b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift index d9850d5c..6c4f39b2 100644 --- a/Where/WhereUI/Sources/Logging/WhereSessionLog.swift +++ b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift @@ -24,6 +24,7 @@ enum WhereSessionLog: LogEvent { case permissionGranted(status: String) case trackingEnabled case stoppedBackgroundTracking + case recordingReconcileFailed(description: String) case remindersUnauthorized case summaryUnauthorized case issueAlertsUnauthorized @@ -35,7 +36,8 @@ enum WhereSessionLog: LogEvent { var level: LogLevel { switch self { case .whenInUseOnly, .locationAccessDenied, .remindersUnauthorized, - .summaryUnauthorized, .issueAlertsUnauthorized, .regionStylesLoadFailed: + .summaryUnauthorized, .issueAlertsUnauthorized, .regionStylesLoadFailed, + .recordingReconcileFailed: .warning case .backgroundTrackingStarted, .backgroundTrackingStopped, .permissionGranted, .trackingEnabled, .stoppedBackgroundTracking, .erasedSession: @@ -59,6 +61,8 @@ enum WhereSessionLog: LogEvent { "Tracking enabled with background authorization" case .stoppedBackgroundTracking: "Stopped background tracking" + case let .recordingReconcileFailed(description): + "Failed to reconcile device recording policy: \(description)" case .remindersUnauthorized: "Logging reminders enabled but notifications not authorized" case .summaryUnauthorized: diff --git a/Where/WhereUI/Sources/Model/WhereScope.swift b/Where/WhereUI/Sources/Model/WhereScope.swift index 9d6d58b6..9ad09a80 100644 --- a/Where/WhereUI/Sources/Model/WhereScope.swift +++ b/Where/WhereUI/Sources/Model/WhereScope.swift @@ -206,6 +206,7 @@ public final class WhereScope { let services = try await WhereServices.make( store: store, locationSource: locationSource, + currentDevice: .preview, aggregator: aggregator, // Authorized, like the location source is: the demo presents a user // who has granted everything, so the alerts screen shows its real diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index ce406547..495d6c06 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -46,6 +46,12 @@ public final class WhereSession { /// (authorization + the user's intent), not just the last button tap. public private(set) var isTracking = false + /// Stable installation identity used by the Devices settings screen to mark + /// the current row and prevent archiving it. + public var currentRecordingDeviceID: RecordingDeviceID { + services.recording.currentDevice.id + } + /// The latest known location authorization status, kept live via /// `LocationIngestor.authorizationUpdates()`. public private(set) var authorizationStatus: LocationAuthorizationStatus = .notDetermined @@ -295,6 +301,11 @@ public final class WhereSession { for await _ in services.dataChangeUpdates() { guard let self else { break } await seedRegionStyles() + // A CloudKit policy change for this installation arrives through + // the same store signal. Reconcile it even if the Devices screen + // is not open, so a left-behind device physically stops as soon + // as it receives the command. + await reconcileTracking() } } } @@ -304,14 +315,24 @@ public final class WhereSession { /// launch step (see `WhereLaunch.plan(for:)`). func reconcileTracking() async { let wasTracking = isTracking - if wantsTracking, authorizationStatus.allowsBackgroundTracking { - await services.ingestor.start() - isTracking = true - if !wasTracking { Self.logger { .backgroundTrackingStarted } } - } else { - await services.ingestor.stop() - isTracking = false - if wasTracking { Self.logger { .backgroundTrackingStopped } } + do { + let configuration = try await services.recording.reconcile( + initialEnabled: wantsTracking, + authorization: authorizationStatus, + ) + // Keep the legacy local preference as the migration seed/fallback, + // but synced policy is authoritative once the device exists. + wantsTracking = configuration.isEnabled + isTracking = configuration.device.status == .recording + if isTracking, !wasTracking { + Self.logger { .backgroundTrackingStarted } + } else if !isTracking, wasTracking { + Self.logger { .backgroundTrackingStopped } + } + } catch { + Self.logger(attachments: [.error(error, name: "recording-reconcile-error")]) { + .recordingReconcileFailed(description: error.localizedDescription) + } } } @@ -353,25 +374,95 @@ public final class WhereSession { /// When-In-Use is granted the indicator guides the user to Settings; on a /// hard denial the Settings alert is surfaced. public func startTracking() async { - wantsTracking = true do { - try await services.ingestor.requestPermission() - permissionDenied = false + _ = try await setRecordingEnabled(true, for: currentRecordingDeviceID) } catch { - permissionDenied = true + Self.logger(attachments: [.error(error, name: "recording-enable-error")]) { + .recordingReconcileFailed(description: error.localizedDescription) + } } - await syncAuthorization() - await reconcileTracking() - if authorizationStatus.allowsBackgroundTracking { + } + + public func stopTracking() async { + do { + _ = try await setRecordingEnabled(false, for: currentRecordingDeviceID) + } catch { + Self.logger(attachments: [.error(error, name: "recording-disable-error")]) { + .recordingReconcileFailed(description: error.localizedDescription) + } + } + } + + /// Current synced device list, registering this installation on first use. + public func recordingDevices() async throws -> [RecordingDeviceConfiguration] { + try await services.recording.devices(initialEnabled: wantsTracking) + } + + /// Set automatic recording for any installation. The current device also + /// runs the permission flow and updates the session's live tracking mirror. + @discardableResult + public func setRecordingEnabled( + _ enabled: Bool, + for deviceID: RecordingDeviceID, + ) async throws -> [RecordingDeviceConfiguration] { + var devices = try await services.recording.setEnabled( + enabled, + for: deviceID, + initialEnabled: wantsTracking, + ) + guard deviceID == currentRecordingDeviceID else { return devices } + + var permissionRequestFailed = false + if enabled { + do { + try await services.ingestor.requestPermission() + } catch { + permissionRequestFailed = true + } + await syncAuthorization() + _ = try await services.recording.reconcile( + initialEnabled: wantsTracking, + authorization: authorizationStatus, + ) + // The permission prompt is an actor suspension point. Re-read after + // it because a later Off action may have won while the prompt was + // visible; reconciliation honors that latest policy rather than + // appending another On event. + devices = try await services.recording.devices(initialEnabled: wantsTracking) + } + + guard let current = devices.first(where: { $0.id == deviceID }) else { + return devices + } + wantsTracking = current.isEnabled + isTracking = current.device.status == .recording + permissionDenied = current.isEnabled && permissionRequestFailed + if current.isEnabled, isTracking { Self.logger { .trackingEnabled } + } else if !current.isEnabled { + Self.logger { .stoppedBackgroundTracking } } + return devices } - public func stopTracking() async { - wantsTracking = false - await services.ingestor.stop() - isTracking = false - Self.logger { .stoppedBackgroundTracking } + public func renameRecordingDevice( + _ deviceID: RecordingDeviceID, + to nickname: String, + ) async throws -> [RecordingDeviceConfiguration] { + try await services.recording.rename( + deviceID, + to: nickname, + initialEnabled: wantsTracking, + ) + } + + public func archiveRecordingDevice( + _ deviceID: RecordingDeviceID, + ) async throws -> [RecordingDeviceConfiguration] { + try await services.recording.archive( + deviceID, + initialEnabled: wantsTracking, + ) } /// Push the persisted reminder intent to the reminder reconciler and warn if @@ -452,20 +543,29 @@ public final class WhereSession { /// so the reset step parks the launcher in `.failed` rather than silently /// half-erasing. public func eraseSession() async throws { - try await services.reset() - isTracking = false - Self.logger { .erasedSession } - } + let authorizationObserver = authorizationTask + let dataObserver = regionStyleTask + authorizationTask = nil + regionStyleTask = nil + authorizationObserver?.cancel() + dataObserver?.cancel() + await authorizationObserver?.value + await dataObserver?.value - /// Drives the background-tracking `Toggle`. Reads the live `isTracking` - /// state; assigning kicks off the matching async start/stop so the view can - /// bind straight to it (`$session.trackingEnabled`) instead of building a - /// closure-based `Binding`. `isTracking` stays the single source of truth. - public var trackingEnabled: Bool { - get { isTracking } - set { - Task { newValue ? await startTracking() : await stopTracking() } + do { + try await services.reset() + } catch { + // A failed reset deliberately retains this session so the user can + // retry. Restore its live observers along with Core's operation + // gate rather than leaving the surviving UI stale. + isTracking = false + await reconcileTracking() + observeAuthorizationChanges() + observeRegionStyleChanges() + throw error } + isTracking = false + Self.logger { .erasedSession } } } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index c1acc0e0..12c5b8b5 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -298,6 +298,11 @@ public struct OnboardingView: View { gate.fail(error) return } + // This is also the initial synced policy for a newly registered + // installation. “Not Now” must therefore record false explicitly; + // leaving the old default true would make the next launch start + // recording despite the user's choice. + scope.preferences.wantsTracking = enableLocation if enableLocation { await enableTracking(in: scope) } diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 0d965182..adfd283a 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -115,6 +115,56 @@ WhereSession(services: previewServices(), preferences: previewPreferences()) } + /// Current + left-behind device rows for the Devices screen. The iPad's + /// off policy is intentionally unacknowledged so previews pin the + /// cross-device "waiting" state as well as the current happy path. + public static func recordingDeviceConfigurations() -> [RecordingDeviceConfiguration] { + let currentPolicyID = UUID( + uuidString: "10000000-0000-0000-0000-000000000001", + )! + let remoteAppliedPolicyID = UUID( + uuidString: "20000000-0000-0000-0000-000000000001", + )! + let remoteLatestPolicyID = UUID( + uuidString: "20000000-0000-0000-0000-000000000002", + )! + let remoteID = RecordingDeviceID( + rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + ) + return [ + RecordingDeviceConfiguration( + device: RecordingDevice( + id: CurrentRecordingDevice.preview.id, + systemName: "iPhone", + nickname: "My iPhone", + kind: .phone, + registeredAt: referenceNow.addingTimeInterval(-90 * 24 * 60 * 60), + lastSeenAt: referenceNow, + archivedAt: nil, + lastAppliedPolicyChangeID: currentPolicyID, + status: .recording, + ), + isEnabled: true, + latestPolicyChangeID: currentPolicyID, + ), + RecordingDeviceConfiguration( + device: RecordingDevice( + id: remoteID, + systemName: "iPad", + nickname: "Home iPad", + kind: .tablet, + registeredAt: referenceNow.addingTimeInterval(-60 * 24 * 60 * 60), + lastSeenAt: referenceNow.addingTimeInterval(-2 * 24 * 60 * 60), + archivedAt: nil, + lastAppliedPolicyChangeID: remoteAppliedPolicyID, + status: .recording, + ), + isEnabled: false, + latestPolicyChangeID: remoteLatestPolicyID, + ), + ] + } + // MARK: - Settings models (reminders / backup sub-screens) /// A reminders/summary editing model over in-memory services, for the diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 1ef0d3fc..9ab55a32 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -333,6 +333,17 @@ } } }, + "common.retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" + } + } + } + }, "common.save" : { "extractionState" : "manual", "localizations" : { @@ -3667,6 +3678,226 @@ } } }, + "settings.devices.archive" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archive Device" + } + } + } + }, + "settings.devices.archive.confirm.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic recording will be turned off from now on and this device will be hidden. Its existing history is kept." + } + } + } + }, + "settings.devices.archive.confirm.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archive this device?" + } + } + } + }, + "settings.devices.automaticRecording" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic Recording" + } + } + } + }, + "settings.devices.current.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device applies changes immediately. Always location access is required for background recording." + } + } + } + }, + "settings.devices.error.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn’t Update Devices" + } + } + } + }, + "settings.devices.grant" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grant location access" + } + } + } + }, + "settings.devices.keywords.name" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "device, name, nickname, iphone, ipad" + } + } + } + }, + "settings.devices.keywords.recording" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "location, gps, tracking, background, automatic, device, travel" + } + } + } + }, + "settings.devices.lastActive" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last Active" + } + } + } + }, + "settings.devices.loadFailed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devices Unavailable" + } + } + } + }, + "settings.devices.name" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device Name" + } + } + } + }, + "settings.devices.remote.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turning recording off hides new locations from the cutoff immediately. The device physically stops when it next syncs." + } + } + } + }, + "settings.devices.status" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + } + } + }, + "settings.devices.status.off" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Off" + } + } + } + }, + "settings.devices.status.pending" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for Device" + } + } + } + }, + "settings.devices.status.permissionRequired" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location Access Needed" + } + } + } + }, + "settings.devices.status.recording" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recording" + } + } + } + }, + "settings.devices.thisDevice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This Device" + } + } + } + }, + "settings.devices.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devices" + } + } + } + }, "settings.eraseYear.title" : { "extractionState" : "manual", "localizations" : { @@ -3988,17 +4219,6 @@ } } }, - "settings.keywords.tracking" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "location, gps, tracking, background, permission" - } - } - } - }, "settings.keywords.year" : { "extractionState" : "manual", "localizations" : { @@ -4010,50 +4230,6 @@ } } }, - "settings.location.footer" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Where watches for visits and big moves to figure out which region you're in. It needs Always access and a little patience." - } - } - } - }, - "settings.location.grant" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Grant location access" - } - } - } - }, - "settings.location.header" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Location" - } - } - } - }, - "settings.location.toggle" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Track in the background" - } - } - } - }, "settings.loggedDays.row" : { "comment" : "Row title for the hand-logged-days screen in the Settings Data group.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift new file mode 100644 index 00000000..25c9aef9 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift @@ -0,0 +1,63 @@ +import Foundation +import Observation +import WhereCore + +/// Editable presentation state for one synced recording device. +@MainActor +@Observable +final class DeviceSettingsRowModel: Identifiable { + let id: RecordingDeviceID + let systemName: String + let kind: RecordingDeviceKind + let isCurrent: Bool + + var nickname: String + private(set) var confirmedNickname: String + var isEnabled: Bool + private(set) var confirmedIsEnabled: Bool + var status: RecordingDeviceStatus + var lastSeenAt: Date + var isPending: Bool + var isBusy = false + + init(configuration: RecordingDeviceConfiguration, isCurrent: Bool) { + id = configuration.id + systemName = configuration.device.systemName + kind = configuration.device.kind + self.isCurrent = isCurrent + let nickname = configuration.device.nickname ?? "" + self.nickname = nickname + confirmedNickname = nickname + isEnabled = configuration.isEnabled + confirmedIsEnabled = configuration.isEnabled + status = configuration.device.status + lastSeenAt = configuration.device.lastSeenAt + isPending = configuration.isPending + } + + var displayName: String { + let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? systemName : trimmed + } + + var systemImage: String { + switch kind { + case .phone: "iphone" + case .tablet: "ipad" + case .other: "apple.logo" + } + } + + func update(from configuration: RecordingDeviceConfiguration) { + let updatedNickname = configuration.device.nickname ?? "" + if nickname == confirmedNickname { + nickname = updatedNickname + } + confirmedNickname = updatedNickname + isEnabled = configuration.isEnabled + confirmedIsEnabled = configuration.isEnabled + status = configuration.device.status + lastSeenAt = configuration.device.lastSeenAt + isPending = configuration.isPending + } +} diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift new file mode 100644 index 00000000..dd1a799f --- /dev/null +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift @@ -0,0 +1,180 @@ +import SwiftUI +import WhereCore + +/// Form section for one device. It binds directly to the row model and sends +/// async effects through the owning Devices model. +struct DeviceSettingsSection: View { + let model: DevicesSettingsModel + @Bindable var row: DeviceSettingsRowModel + + @Environment(WhereSession.self) private var session + @Environment(\.openURL) private var openURL + @State private var isConfirmingArchive = false + + var body: some View { + Section { + Toggle( + String(localized: .settingsDevicesAutomaticRecording), + isOn: $row.isEnabled, + ) + .settingsRow(DevicesSettingsView.Item.automaticRecording) + .disabled(row.isBusy) + .onChange(of: row.isEnabled) { oldValue, newValue in + guard oldValue != newValue else { return } + Task { + await model.setEnabled( + newValue, + row: row, + ) + } + } + + TextField(String(localized: .settingsDevicesName), text: $row.nickname) + .settingsRow(DevicesSettingsView.Item.deviceName) + .disabled(row.isBusy) + .onSubmit { + Task { await model.rename(row) } + } + + LabeledContent(String(localized: .settingsDevicesStatus)) { + Label(statusTitle, systemImage: statusSymbol) + .foregroundStyle(statusStyle) + } + + LabeledContent(String(localized: .settingsDevicesLastActive)) { + Text( + row.lastSeenAt, + format: .dateTime + .month(.abbreviated) + .day() + .year() + .hour() + .minute(), + ) + .foregroundStyle(.secondary) + } + + if row.isCurrent { + LocationStatusRow( + status: session.authorizationStatus, + isTracking: session.isTracking, + ) + + if showGrantButton { + Button { + Task { await model.requestPermission() } + } label: { + Label( + String(localized: .settingsDevicesGrant), + systemImage: "location.magnifyingglass", + ) + } + } + + if showOpenSettingsButton { + Button { + openSystemSettings(openURL) + } label: { + Label( + String(localized: .settingsPermissionAlertOpenSettings), + systemImage: "gear", + ) + } + } + } else { + Button( + String(localized: .settingsDevicesArchive), + systemImage: "archivebox", + role: .destructive, + ) { + isConfirmingArchive = true + } + .disabled(row.isBusy) + .confirmationDialog( + String(localized: .settingsDevicesArchiveConfirmTitle), + isPresented: $isConfirmingArchive, + titleVisibility: .visible, + ) { + Button(String(localized: .settingsDevicesArchive), role: .destructive) { + Task { await model.archive(row) } + } + } message: { + Text(String(localized: .settingsDevicesArchiveConfirmMessage)) + } + } + } header: { + Label { + HStack { + Text(row.displayName) + if row.isCurrent { + Text(String(localized: .settingsDevicesThisDevice)) + .foregroundStyle(.secondary) + } + } + } icon: { + Image(systemName: row.systemImage) + } + } footer: { + if row.isCurrent { + Text(String(localized: .settingsDevicesCurrentFooter)) + } else { + Text(String(localized: .settingsDevicesRemoteFooter)) + } + } + } + + private var statusTitle: String { + if row.isPending { + return String(localized: .settingsDevicesStatusPending) + } + switch row.status { + case .recording: return String(localized: .settingsDevicesStatusRecording) + case .off: return String(localized: .settingsDevicesStatusOff) + case .permissionRequired: + return String(localized: .settingsDevicesStatusPermissionRequired) + } + } + + private var statusSymbol: String { + if row.isPending { return "clock.arrow.trianglehead.counterclockwise.rotate.90" } + return switch row.status { + case .recording: "location.fill" + case .off: "location.slash" + case .permissionRequired: "exclamationmark.triangle" + } + } + + private var statusStyle: HierarchicalShapeStyle { + row.status == .recording && !row.isPending ? .primary : .secondary + } + + private var showGrantButton: Bool { + guard row.isEnabled else { return false } + return switch session.authorizationStatus { + case .notDetermined, .whenInUse: true + case .restricted, .denied, .always: false + } + } + + private var showOpenSettingsButton: Bool { + guard row.isEnabled else { return false } + return switch session.authorizationStatus { + case .denied, .restricted, .whenInUse: true + case .notDetermined, .always: false + } + } +} + +#if DEBUG + #Preview { + let session = PreviewSupport.loadedSession() + let model = DevicesSettingsModel( + session: session, + configurations: PreviewSupport.recordingDeviceConfigurations(), + ) + Form { + DeviceSettingsSection(model: model, row: model.rows[0]) + } + .environment(session) + } +#endif diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift new file mode 100644 index 00000000..71ec0fcb --- /dev/null +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift @@ -0,0 +1,141 @@ +import Foundation +import Observation +import WhereCore + +/// View-scoped Devices settings state. All mutations await the serialized Core +/// controller and restore the last confirmed value when a write fails. +@MainActor +@Observable +final class DevicesSettingsModel { + enum LoadState { + case idle + case loading + case loaded + case failed(String) + } + + private let session: WhereSession + private(set) var state: LoadState = .idle + private(set) var rows: [DeviceSettingsRowModel] = [] + var errorMessage: String? + + var isShowingError: Bool { + get { errorMessage != nil } + set { + if !newValue { errorMessage = nil } + } + } + + init(session: WhereSession) { + self.session = session + } + + #if DEBUG + init( + session: WhereSession, + configurations: [RecordingDeviceConfiguration], + ) { + self.session = session + state = .loaded + apply(configurations) + } + #endif + + /// Load once, then stay current with local commits and CloudKit imports + /// until the owning view disappears and SwiftUI cancels the task. + func run() async { + await load(showLoading: true) + for await _ in session.services.dataChangeUpdates() { + if Task.isCancelled { return } + await load(showLoading: false) + } + } + + func retry() async { + await load(showLoading: true) + } + + func setEnabled( + _ enabled: Bool, + row: DeviceSettingsRowModel, + ) async { + guard !row.isBusy, enabled != row.confirmedIsEnabled else { return } + row.isBusy = true + defer { row.isBusy = false } + do { + let configurations = try await session.setRecordingEnabled(enabled, for: row.id) + apply(configurations) + } catch { + row.isEnabled = row.confirmedIsEnabled + surface(error) + } + } + + func rename(_ row: DeviceSettingsRowModel) async { + guard !row.isBusy else { return } + row.isBusy = true + defer { row.isBusy = false } + do { + let nickname = row.nickname.trimmingCharacters(in: .whitespacesAndNewlines) + let configurations = try await session.renameRecordingDevice( + row.id, + to: nickname, + ) + row.nickname = nickname + apply(configurations) + } catch { + row.nickname = row.confirmedNickname + surface(error) + await load(showLoading: false) + } + } + + func archive(_ row: DeviceSettingsRowModel) async { + guard !row.isCurrent, !row.isBusy else { return } + row.isBusy = true + defer { row.isBusy = false } + do { + let configurations = try await session.archiveRecordingDevice(row.id) + apply(configurations) + } catch { + surface(error) + } + } + + func requestPermission() async { + await session.requestPermission() + await load(showLoading: false) + } + + private func load(showLoading: Bool) async { + if showLoading { state = .loading } + do { + try await apply(session.recordingDevices()) + state = .loaded + } catch { + if rows.isEmpty { + state = .failed(error.localizedDescription) + } else { + surface(error) + } + } + } + + private func apply(_ configurations: [RecordingDeviceConfiguration]) { + let existing = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) + rows = configurations.map { configuration in + if let row = existing[configuration.id] { + row.update(from: configuration) + return row + } + return DeviceSettingsRowModel( + configuration: configuration, + isCurrent: configuration.id == session.currentRecordingDeviceID, + ) + } + } + + private func surface(_ error: any Error) { + errorMessage = error.localizedDescription + } +} diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift new file mode 100644 index 00000000..ef6ba39d --- /dev/null +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift @@ -0,0 +1,166 @@ +import SnapshotKit +import SwiftUI +import WhereCore + +/// Synced device-management screen. Each installation has its own automatic +/// recording intent, editable nickname, acknowledgement state, and last check-in. +struct DevicesSettingsView: View { + var focus: SettingsFocus? + + @Environment(WhereSession.self) private var session + @Environment(\.openURL) private var openURL + @State private var model: DevicesSettingsModel + private let loadsLiveData: Bool + + init(session: WhereSession, focus: SettingsFocus? = nil) { + self.focus = focus + _model = State(initialValue: DevicesSettingsModel(session: session)) + loadsLiveData = true + } + + #if DEBUG + init( + session: WhereSession, + configurations: [RecordingDeviceConfiguration], + focus: SettingsFocus? = nil, + ) { + self.focus = focus + _model = State( + initialValue: DevicesSettingsModel( + session: session, + configurations: configurations, + ), + ) + loadsLiveData = false + } + #endif + + var body: some View { + @Bindable var session = session + @Bindable var model = model + SettingsFocusScope(focus: focus) { + Form { + switch model.state { + case .idle, .loading: + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + case let .failed(message): + Section { + ContentUnavailableView( + String(localized: .settingsDevicesLoadFailed), + systemImage: "exclamationmark.icloud", + description: Text(message), + ) + Button(String(localized: .commonRetry)) { + Task { await model.retry() } + } + } + case .loaded: + ForEach(model.rows) { row in + DeviceSettingsSection(model: model, row: row) + } + } + } + } + .navigationTitle(String(localized: .settingsDevicesTitle)) + .navigationBarTitleDisplayMode(.inline) + .task { + guard loadsLiveData else { return } + await model.run() + } + .alert( + String(localized: .settingsDevicesErrorTitle), + isPresented: $model.isShowingError, + presenting: model.errorMessage, + ) { _ in + Button(String(localized: .commonOk), role: .cancel) {} + } message: { message in + Text(message) + } + .alert( + String(localized: .settingsPermissionAlertTitle), + isPresented: $session.permissionDenied, + ) { + Button(String(localized: .settingsPermissionAlertOpenSettings)) { + openSystemSettings(openURL) + } + Button(String(localized: .settingsPermissionAlertNotNow), role: .cancel) {} + } message: { + Text(String(localized: .settingsPermissionAlertMessage)) + } + } +} + +extension DevicesSettingsView: SettingsSection { + static var destination: SettingsDestination { + .devices + } + + enum Item: SettingsItem { + case automaticRecording + case deviceName + + var title: String { + switch self { + case .automaticRecording: + String(localized: .settingsDevicesAutomaticRecording) + case .deviceName: + String(localized: .settingsDevicesName) + } + } + + var keywords: [String] { + switch self { + case .automaticRecording: + splitKeywords(String(localized: .settingsDevicesKeywordsRecording)) + case .deviceName: + splitKeywords(String(localized: .settingsDevicesKeywordsName)) + } + } + } +} + +#if DEBUG + extension DevicesSettingsView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + let session = PreviewSupport.loadedSession() + whereSnapshot( + name: "Default", + configurations: .screenDefaults, + onReadyToSnapshot: { await session.start() }, + ) { + NavigationStack { + DevicesSettingsView( + session: session, + configurations: PreviewSupport.recordingDeviceConfigurations(), + ) + } + .environment(session) + .task { await session.start() } + } + } + } + + #Preview { + DevicesSettingsView.snapshotPreviews + } +#endif + +#if DEBUG + extension DevicesSettingsView: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.hosted( + DevicesSettingsView.self, + title: "Devices", + ) { world in + DevicesSettingsView( + session: world.session, + configurations: PreviewSupport.recordingDeviceConfigurations(), + ) + } + } +#endif diff --git a/Where/WhereUI/Sources/Settings/LocationSettingsView.swift b/Where/WhereUI/Sources/Settings/LocationSettingsView.swift deleted file mode 100644 index 4c01a13a..00000000 --- a/Where/WhereUI/Sources/Settings/LocationSettingsView.swift +++ /dev/null @@ -1,137 +0,0 @@ -import SwiftUI -import WhereCore - -/// Settings drill-in for location permission and background tracking: the live -/// status row, the tracking toggle, and the grant / open-Settings affordances -/// that depend on the current authorization. -struct LocationSettingsView: View { - var focus: SettingsFocus? - - @Environment(WhereSession.self) private var session - @Environment(\.openURL) private var openURL - - var body: some View { - @Bindable var session = session - SettingsFocusScope(focus: focus) { - Form { - Section { - LocationStatusRow( - status: session.authorizationStatus, - isTracking: session.isTracking, - ) - - Toggle(isOn: $session.trackingEnabled) { - Label( - String(localized: .settingsLocationToggle), - systemImage: "location.fill", - ) - } - .settingsRow(Item.tracking) - - if showGrantButton { - Button { - Task { await session.requestPermission() } - } label: { - Label( - String(localized: .settingsLocationGrant), - systemImage: "location.magnifyingglass", - ) - } - } - - if showOpenSettingsButton { - Button { - openSystemSettings(openURL) - } label: { - Label( - String(localized: .settingsPermissionAlertOpenSettings), - systemImage: "gear", - ) - } - } - } header: { - Text(String(localized: .settingsLocationHeader)) - } footer: { - Text(String(localized: .settingsLocationFooter)) - } - } - } - .navigationTitle(String(localized: .settingsLocationHeader)) - .navigationBarTitleDisplayMode(.inline) - // `session.permissionDenied` is only ever raised by the Grant button / - // tracking toggle on this screen (an external Settings-app toggle flows - // through the authorization observer, which never sets it), so the alert - // belongs here rather than on the always-mounted settings root. - .alert( - String(localized: .settingsPermissionAlertTitle), - isPresented: $session.permissionDenied, - ) { - Button(String(localized: .settingsPermissionAlertOpenSettings)) { - openSystemSettings(openURL) - } - Button(String(localized: .settingsPermissionAlertNotNow), role: .cancel) {} - } message: { - Text(String(localized: .settingsPermissionAlertMessage)) - } - } - - /// Re-requesting only helps before the user has made a final decision. - private var showGrantButton: Bool { - switch session.authorizationStatus { - case .notDetermined, .whenInUse: true - case .restricted, .denied, .always: false - } - } - - /// Once access is denied/restricted (or stuck at When-In-Use), the only way - /// forward is the Settings app. - private var showOpenSettingsButton: Bool { - switch session.authorizationStatus { - case .denied, .restricted, .whenInUse: true - case .notDetermined, .always: false - } - } -} - -extension LocationSettingsView: SettingsSection { - static var destination: SettingsDestination { - .location - } - - enum Item: SettingsItem { - case tracking - - var title: String { - switch self { - case .tracking: String(localized: .settingsLocationToggle) - } - } - - var keywords: [String] { - switch self { - case .tracking: splitKeywords(String(localized: .settingsKeywordsTracking)) - } - } - } -} - -#if DEBUG - #Preview { - NavigationStack { - LocationSettingsView() - .environment(PreviewSupport.loadedSession()) - } - .whereBroadwayRoot() - } -#endif - -#if DEBUG - extension LocationSettingsView: WhereFlyoverProviding { - static let flyoverData = WhereFlyoverData.hosted( - LocationSettingsView.self, - title: "Location Settings", - ) { _ in - LocationSettingsView() - } - } -#endif diff --git a/Where/WhereUI/Sources/Settings/SettingsRow.swift b/Where/WhereUI/Sources/Settings/SettingsRow.swift index 9beee66c..104ddd67 100644 --- a/Where/WhereUI/Sources/Settings/SettingsRow.swift +++ b/Where/WhereUI/Sources/Settings/SettingsRow.swift @@ -104,13 +104,15 @@ struct SettingsFocusScope: View { #if DEBUG #Preview { - SettingsFocusScope(focus: SettingsFocus(LocationSettingsView.Item.tracking)) { + SettingsFocusScope( + focus: SettingsFocus(DevicesSettingsView.Item.automaticRecording), + ) { List { Label( - String(localized: .settingsLocationToggle), + String(localized: .settingsDevicesAutomaticRecording), systemImage: "location.fill", ) - .settingsRow(LocationSettingsView.Item.tracking) + .settingsRow(DevicesSettingsView.Item.automaticRecording) } } .whereBroadwayRoot() diff --git a/Where/WhereUI/Sources/Settings/SettingsSearch.swift b/Where/WhereUI/Sources/Settings/SettingsSearch.swift index 0aefb113..6c3d55c1 100644 --- a/Where/WhereUI/Sources/Settings/SettingsSearch.swift +++ b/Where/WhereUI/Sources/Settings/SettingsSearch.swift @@ -7,7 +7,7 @@ import SwiftUI enum SettingsDestination: Hashable, CaseIterable { case attachments case loggedDays - case location + case devices case regions case alerts case appearance @@ -21,7 +21,7 @@ enum SettingsDestination: Hashable, CaseIterable { switch self { case .attachments: String(localized: .settingsAttachmentsRow) case .loggedDays: String(localized: .settingsLoggedDaysRow) - case .location: String(localized: .settingsLocationHeader) + case .devices: String(localized: .settingsDevicesTitle) case .regions: String(localized: .settingsRegionsSection) case .alerts: String(localized: .settingsAlertsGroup) case .appearance: String(localized: .settingsAppearanceGroup) @@ -36,7 +36,7 @@ enum SettingsDestination: Hashable, CaseIterable { switch self { case .attachments: "paperclip" case .loggedDays: "calendar.badge.plus" - case .location: "location.fill" + case .devices: "iphone.and.arrow.forward" case .regions: "map.fill" case .alerts: "bell.badge" case .appearance: "paintbrush.fill" @@ -53,7 +53,7 @@ enum SettingsDestination: Hashable, CaseIterable { switch self { case .attachments: .indigo case .loggedDays: .mint - case .location: .blue + case .devices: .blue case .regions: .green case .alerts: .red case .appearance: .purple @@ -72,7 +72,7 @@ enum SettingsDestination: Hashable, CaseIterable { var isAvailableInDemoMode: Bool { switch self { case .data, .appearance: false - case .attachments, .loggedDays, .location, .regions, .alerts, .year, .about: true + case .attachments, .loggedDays, .devices, .regions, .alerts, .year, .about: true } } @@ -82,7 +82,7 @@ enum SettingsDestination: Hashable, CaseIterable { var isSheet: Bool { switch self { case .regions: true - case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .data, .about: + case .attachments, .loggedDays, .devices, .alerts, .appearance, .year, .data, .about: false } } @@ -105,7 +105,7 @@ enum SettingsListSection: CaseIterable { var destinations: [SettingsDestination] { switch self { case .userData: [.attachments, .loggedDays, .regions] - case .tracking: [.location] + case .tracking: [.devices] case .notifications: [.alerts] case .display: [.appearance, .year] case .storage: [.data] @@ -115,7 +115,7 @@ enum SettingsListSection: CaseIterable { } /// A per-screen setting identity. Conformers are small, screen-local enums (e.g. -/// `LocationSettingsView.Item`) that also carry their own localized search text, +/// `DevicesSettingsView.Item`) that also carry their own localized search text, /// so the search index is *derived* from the cases and can't drift from them. protocol SettingsItem: Hashable, CaseIterable { /// The setting's localized name, matched by search and shown in results. @@ -209,7 +209,7 @@ enum SettingsCatalog { static let results: [SettingsSearchResult] = EvidenceListView.searchResults + LoggedDaysView.searchResults - + LocationSettingsView.searchResults + + DevicesSettingsView.searchResults + RegionsSettingsView.searchResults + AlertsSettingsView.searchResults + AppearanceSettingsView.searchResults diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 5926012f..ae77b3c5 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -5,15 +5,15 @@ import WhereCore /// Settings tab: an iOS-Settings-style top-level list of icon rows that drill /// into grouped sub-screens — a Data group at the top (attachments, logged days, -/// regions), then location, alerts, appearance, report year, data management, +/// regions), then devices, alerts, appearance, report year, data management, /// and About — plus a search field that filters individual settings and /// deep-links to the screen — and the row — containing each. /// /// The top level owns nothing but navigation; behavior lives in the sub-screens -/// (`LocationSettingsView`, `AlertsSettingsView`, …). The scene's report model and +/// (`DevicesSettingsView`, `AlertsSettingsView`, …). The scene's report model and /// the two view-scoped editing models (backup, reminders) are owned here and -/// handed down; the `WhereSession` coordinator (location) and `WhereModel` (reset) -/// come from the environment via the sub-screens. +/// handed down; the `WhereSession` coordinator (recording/location) and +/// `WhereModel` (reset) come from the environment via the sub-screens. struct SettingsView: View { let report: YearReportModel @State private var backup: BackupModel @@ -157,7 +157,7 @@ struct SettingsView: View { switch destination { case .regions: showRegions = true - case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .data, .about: + case .attachments, .loggedDays, .devices, .alerts, .appearance, .year, .data, .about: assertionFailure("\(destination) is a push destination, not a sheet") } } @@ -183,7 +183,7 @@ struct SettingsView: View { /// for groups without a meaningful one-line summary. private func subtitle(for destination: SettingsDestination) -> String? { switch destination { - case .location: + case .devices: LocationStatusRow.statusTitle( status: session.authorizationStatus, isTracking: session.isTracking, @@ -220,8 +220,8 @@ struct SettingsView: View { EvidenceListView(report: report) case .loggedDays: LoggedDaysView(report: report) - case .location: - LocationSettingsView(focus: route.focus) + case .devices: + DevicesSettingsView(session: session, focus: route.focus) case .regions: // Regions is presented as a sheet (`isSheet`), so it's never // routed here; this arm only keeps the switch exhaustive. @@ -289,7 +289,7 @@ struct SettingsView: View { .push(to: EvidenceListView.flyoverID), .push(to: LoggedDaysView.flyoverID), .modal(to: RegionsSettingsView.flyoverID), - .push(to: LocationSettingsView.flyoverID), + .push(to: DevicesSettingsView.flyoverID), .push(to: AlertsSettingsView.flyoverID), .push(to: AppearanceSettingsView.flyoverID), .push(to: VisibleYearSettingsView.flyoverID), diff --git a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift new file mode 100644 index 00000000..f3b55a29 --- /dev/null +++ b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import WhereUI + +@MainActor +struct CurrentRecordingDeviceProviderTests { + @Test func persistsOneInstallationIdentity() throws { + let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = CurrentRecordingDeviceProvider.current(defaults: defaults) + let second = CurrentRecordingDeviceProvider.current(defaults: defaults) + + #expect(first == second) + #expect(defaults.dictionaryRepresentation().values.contains { + ($0 as? String) == first.id.rawValue.uuidString + }) + #expect(first.systemName.isEmpty == false) + } +} diff --git a/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift new file mode 100644 index 00000000..a4c800ce --- /dev/null +++ b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct DeviceSettingsRowModelTests { + private static let id = RecordingDeviceID( + rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + ) + private static let policyID = UUID( + uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + )! + private static let date = Date(timeIntervalSinceReferenceDate: 100) + + @Test func presentsNicknameKindAndAcknowledgement() { + let row = DeviceSettingsRowModel( + configuration: configuration( + nickname: "Home iPad", + status: .recording, + appliedPolicyID: Self.policyID, + ), + isCurrent: false, + ) + + #expect(row.displayName == "Home iPad") + #expect(row.systemImage == "ipad") + #expect(row.isPending == false) + } + + @Test func updateKeepsEditableObjectIdentityAndAppliesRemoteState() { + let row = DeviceSettingsRowModel( + configuration: configuration( + nickname: nil, + status: .recording, + appliedPolicyID: Self.policyID, + ), + isCurrent: false, + ) + row.update(from: configuration( + nickname: "Desk", + status: .off, + appliedPolicyID: nil, + )) + + #expect(row.id == Self.id) + #expect(row.displayName == "Desk") + #expect(row.confirmedNickname == "Desk") + #expect(row.status == .off) + #expect(row.isPending) + #expect(row.confirmedIsEnabled == false) + } + + @Test func syncedRefreshDoesNotOverwriteAnUnsavedNickname() { + let row = DeviceSettingsRowModel( + configuration: configuration( + nickname: "Home", + status: .off, + appliedPolicyID: Self.policyID, + ), + isCurrent: false, + ) + row.nickname = "Home iPad" + + row.update(from: configuration( + nickname: "Synced elsewhere", + status: .off, + appliedPolicyID: Self.policyID, + )) + + #expect(row.nickname == "Home iPad") + #expect(row.confirmedNickname == "Synced elsewhere") + } + + private func configuration( + nickname: String?, + status: RecordingDeviceStatus, + appliedPolicyID: UUID?, + ) -> RecordingDeviceConfiguration { + RecordingDeviceConfiguration( + device: RecordingDevice( + id: Self.id, + systemName: "iPad", + nickname: nickname, + kind: .tablet, + registeredAt: Self.date, + lastSeenAt: Self.date, + archivedAt: nil, + lastAppliedPolicyChangeID: appliedPolicyID, + status: status, + ), + isEnabled: status != .off, + latestPolicyChangeID: Self.policyID, + ) + } +} diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift new file mode 100644 index 00000000..38975aea --- /dev/null +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +@MainActor +struct DevicesSettingsModelTests { + private static let now = Date(timeIntervalSinceReferenceDate: 1000) + + private func makeSubject() throws -> ( + model: DevicesSettingsModel, + session: WhereSession, + store: SwiftDataStore + ) { + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + currentDevice: .preview, + now: { Self.now }, + ) + let preferences = makePreferences() + preferences.wantsTracking = true + let session = WhereSession(services: services, preferences: preferences) + return (DevicesSettingsModel(session: session), session, store) + } + + @Test func loadsTheCurrentDeviceAndAwaitsAToggle() async throws { + let subject = try makeSubject() + await subject.session.start() + await subject.model.retry() + let row = try #require(subject.model.rows.first) + #expect(row.isCurrent) + #expect(row.isEnabled) + #expect(row.status == .recording) + + row.isEnabled = false + await subject.model.setEnabled(false, row: row) + + #expect(row.isEnabled == false) + #expect(row.status == .off) + #expect(row.isPending == false) + #expect(subject.session.isTracking == false) + #expect(subject.session.preferences.wantsTracking == false) + } + + @Test func renamesAndArchivesARemoteDevice() async throws { + let subject = try makeSubject() + await subject.session.start() + let remoteID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + try await subject.store.perform { + try await subject.store.setRecordingDevice(RecordingDevice( + id: remoteID, + systemName: "iPad", + nickname: nil, + kind: .tablet, + registeredAt: Self.now, + lastSeenAt: Self.now, + archivedAt: nil, + lastAppliedPolicyChangeID: nil, + status: .off, + )) + } + await subject.model.retry() + let remote = try #require(subject.model.rows.first(where: { $0.id == remoteID })) + + remote.nickname = "Home iPad" + await subject.model.rename(remote) + #expect(remote.displayName == "Home iPad") + #expect(try await subject.store.recordingDevices() + .first(where: { $0.id == remoteID })?.nickname == "Home iPad") + + await subject.model.archive(remote) + #expect(subject.model.rows.contains(where: { $0.id == remoteID }) == false) + #expect(try await subject.store.recordingDevices() + .first(where: { $0.id == remoteID })?.archivedAt == Self.now) + } +} diff --git a/Where/WhereUI/Tests/LocationSettingsViewTests.swift b/Where/WhereUI/Tests/LocationSettingsViewTests.swift deleted file mode 100644 index da4cc478..00000000 --- a/Where/WhereUI/Tests/LocationSettingsViewTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import SwiftUI -import TestHostSupport -import Testing -@testable import WhereUI - -@MainActor -struct LocationSettingsViewTests { - @Test func hostsWithASession() throws { - let rootView = NavigationStack { LocationSettingsView() } - .environment(PreviewSupport.loadedSession()) - try show(UIHostingController(rootView: rootView)) { hosted in - #expect(hosted.view != nil) - } - } -} diff --git a/Where/WhereUI/Tests/SettingsSearchTests.swift b/Where/WhereUI/Tests/SettingsSearchTests.swift index 647d23ac..8276564d 100644 --- a/Where/WhereUI/Tests/SettingsSearchTests.swift +++ b/Where/WhereUI/Tests/SettingsSearchTests.swift @@ -41,11 +41,11 @@ struct SettingsSearchTests { } @Test func matchesOnKeyword() { - // "gps" is a keyword for both the location-tracking and data-resolution + // "gps" is a keyword for both the device-recording and data-resolution // settings, but not part of either title. let results = SettingsCatalog.results(matching: "gps") let destinations = Set(results.map(\.destination)) - #expect(destinations.contains(.location)) + #expect(destinations.contains(.devices)) #expect(destinations.contains(.alerts)) } @@ -64,6 +64,6 @@ struct SettingsSearchTests { } @Test func groupRouteHasNoFocus() { - #expect(SettingsRoute(.location).focus == nil) + #expect(SettingsRoute(.devices).focus == nil) } } diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 2eb7ff1f..71e5f151 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -89,6 +89,22 @@ actor TestStore: WhereStore { try await backing.allSamples() } + func recordingDevices() async throws -> [RecordingDevice] { + try await backing.recordingDevices() + } + + func setRecordingDevice(_ device: RecordingDevice) async throws { + try await backing.setRecordingDevice(device) + } + + func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { + try await backing.recordingPolicyChanges() + } + + func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { + try await backing.addRecordingPolicyChange(change) + } + func write(evidence: Evidence, blob: Data?) async throws { try await backing.write(evidence: evidence, blob: blob) } diff --git a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift index 6ef4f8f8..05574175 100644 --- a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift +++ b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift @@ -28,6 +28,8 @@ struct SwiftDataInspectorWiringTests { "SDEvidence", "SDLocationSample", "SDManualDay", + "SDRecordingDevice", + "SDRecordingPolicyChange", "SDTrackedRegion", ]) } diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index 2ae75274..d112b8b5 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -87,6 +87,41 @@ struct WhereSessionTrackingTests { #expect(!relaunched.isTracking) } + @Test func offWinsWhileAnEarlierEnableWaitsForPermission() async throws { + let source = SuspendedPermissionLocationSource() + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: source, + ) + let preferences = makePreferences() + preferences.wantsTracking = false + let session = WhereSession(services: services, preferences: preferences) + + let enabling = Task { + try await session.setRecordingEnabled( + true, + for: session.currentRecordingDeviceID, + ) + } + await waitUntil { source.isAwaitingPermission } + + _ = try await session.setRecordingEnabled( + false, + for: session.currentRecordingDeviceID, + ) + source.resolvePermission(as: .always) + _ = try await enabling.value + + let current = try #require( + try await session.recordingDevices() + .first(where: { $0.id == session.currentRecordingDeviceID }), + ) + #expect(current.isEnabled == false) + #expect(current.device.status == .off) + #expect(session.isTracking == false) + #expect(preferences.wantsTracking == false) + } + @Test func grantingLaterStartsTrackingViaLiveUpdates() async throws { let (session, source) = try makeSession( status: .notDetermined, @@ -186,3 +221,47 @@ struct WhereSessionTrackingTests { #expect(await predicate(), "condition was not met before timeout") } } + +/// Permission seam that parks until the test resolves it, matching the +/// suspension point of Core Location's real system prompt. +private final class SuspendedPermissionLocationSource: LocationSource, @unchecked Sendable { + let sampleStream = AsyncStream { _ in } + + var authorizationUpdates: AsyncStream { + AsyncStream { _ in } + } + + private let lock = NSLock() + private var status = LocationAuthorizationStatus.notDetermined + private var permissionContinuation: CheckedContinuation? + + var isAwaitingPermission: Bool { + lock.withLock { permissionContinuation != nil } + } + + func start() async {} + func stop() async {} + + func requestCurrentLocation() async -> LocationSample? { + nil + } + + func currentAuthorization() async -> LocationAuthorizationStatus { + lock.withLock { status } + } + + func requestPermission() async throws { + await withCheckedContinuation { continuation in + lock.withLock { permissionContinuation = continuation } + } + } + + func resolvePermission(as status: LocationAuthorizationStatus) { + let continuation = lock.withLock { + self.status = status + defer { permissionContinuation = nil } + return permissionContinuation + } + continuation?.resume() + } +} From 1e5aea082360b9fe9032dfe78faf73c8a00204fc Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 31 Jul 2026 07:49:50 -0700 Subject: [PATCH 02/31] Fix device status row layout --- .../devices.Default_iPad.png | 4 ++-- .../devices.Default_iPad_accessibility.png | 4 ++-- .../devices.Default_iPad_ax5.png | 4 ++-- .../devices.Default_iPad_contrast.png | 4 ++-- .../devices.Default_iPad_dark.png | 4 ++-- .../devices.Default_iPhone.png | 4 ++-- .../devices.Default_iPhone_accessibility.png | 4 ++-- .../devices.Default_iPhone_ax5.png | 4 ++-- .../devices.Default_iPhone_contrast.png | 4 ++-- .../devices.Default_iPhone_dark.png | 4 ++-- .../WhereUI/Sources/Settings/DeviceSettingsSection.swift | 8 ++++++-- 11 files changed, 26 insertions(+), 22 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png index a16c8d66..b853cb01 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09a12fb64def83240d8a3b1cbbfc2a55858f977e72950608aa055d2b45c28b27 -size 324417 +oid sha256:730a55edcdc21b7ae32a8f32aa55df7621d50853cd6edc5b719a405843c37654 +size 349730 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png index 0af61264..c05f9bb3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59a6b267ce592ab9356f90d1ffb0906e7e20efb0fa337585daaa3ef05882d45e -size 597111 +oid sha256:b426449b2324628421f359fe8c69e1389e5dc61ba913d1aa5fe5d5d13b212097 +size 663464 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png index 1c10088e..2564ed93 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04b0b6cf1451ad9630df71766d12266d358c40ff57ddece1f8867db82ac33015 -size 513002 +oid sha256:4ba3aaf3475b3e7e709e1fa4f708fe424cc5533d012c9ef85c3f260f5df4d7cf +size 513684 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png index 37d13eb3..db8be004 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49513eca25b556d6dd0f9275ff90f327628d06714c5503976275d99b4bf71d8d -size 322596 +oid sha256:d12452c0cfb8e88c223fe4245f89f5975c611e6c4ca7c0c9d2d2b1d84e2f0bc1 +size 350719 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png index 469f2b98..ff8adf14 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:464f6e96d7ac8d5012d4c95ca9052e506dade4f4959812ca3e6ce5e6e33e9a0e -size 334169 +oid sha256:4e5f880dca469d55b2398cd5260754737604cba4e986071117852bda0a6327b4 +size 356171 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png index bcbd4585..c7560f97 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ddeaab4b0a4394bf94028afd91c8ab552b357fe2a79b2978f079d5a036b909cd -size 194032 +oid sha256:5e00b4ac92bbfbb28db2a7a8056c649d6a946abc92795cc9f48b52ec9d0cfbd9 +size 233745 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png index ac8e6ea9..7a115a0e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cd7d236fe010e337254848e25a4a131d714f3b3ac5121f08bea377fa69c3e4f3 -size 427149 +oid sha256:2e5afca7c7c18e20cce69d270937e88e1b471e9c2cf1a23a1d3b4100506baf6e +size 511087 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png index a3681ccd..79f6c07b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0dd307c74e6f5dc1ec772ecc507f2ef5a86daefb304148b96732608e920d571 -size 225100 +oid sha256:27c6b628896c1df322a1fa135ef114900129ab52b0b127ae14bfa43e5dbf8565 +size 225666 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png index 9873de0b..0b531c32 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:170fcbcab16a05f4c6aeed9b22ad69a8f95dd97ecaba5f43b58f3d6ab7e8b1f3 -size 193974 +oid sha256:61dab2e91aa3bce0bf44450d06498d2ef35b4a13081eccd3d86736b531a9a1c5 +size 236378 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png index 1c1e2b2a..7d016afb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:085b4071ea092f287de76235dbf4fcc098307c89af1b1e6638b1e51a1d9f330c -size 197879 +oid sha256:ecb1c21c5007e8d944c361de70618e6aab0df328efc25cc1a0434bbafcdd2509 +size 237815 diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift index dd1a799f..2f91c5cd 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift @@ -37,8 +37,12 @@ struct DeviceSettingsSection: View { } LabeledContent(String(localized: .settingsDevicesStatus)) { - Label(statusTitle, systemImage: statusSymbol) - .foregroundStyle(statusStyle) + HStack { + Image(systemName: statusSymbol) + .accessibilityHidden(true) + Text(statusTitle) + } + .foregroundStyle(statusStyle) } LabeledContent(String(localized: .settingsDevicesLastActive)) { From fffa664b95d8d08fbc3400ff8ffbb861f4594e73 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 31 Jul 2026 07:50:18 -0700 Subject: [PATCH 03/31] Commit and push completed work eagerly --- AGENTS.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d10d162..2207e00d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -599,18 +599,21 @@ machine's last successful run". tests](#running-tests) for which tier a change calls for. - **Multi-step work lands one commit per step**, so history stays bisectable and can land piecewise — including pure-groundwork steps, which say so in the body. -- **Commit when asked, or when working through a plan.** If it's unclear whether - a commit is wanted, make the change and ask rather than committing silently. +- **Commit completed work eagerly.** Once a coherent change is verified, commit + it without waiting for a separate request; never hand back a finished task + with task-related changes left local and uncommitted. Honor an explicit + request to keep work uncommitted. ### GitHub - Use the `gh` CLI for all GitHub interaction — PRs, issues, checks, releases, review comments. - **Open PRs ready-for-review, not draft.** -- **Keep an open PR current:** push each commit as it lands, and refresh the - title/body once the branch outgrows them — describing the end state rather - than a changelog of the conversation, and folding into any human edits rather - than overwriting them. A branch with no PR waits for the user before pushing. +- **Push each commit as it lands.** Keep an open PR current immediately; when a + branch has no PR, push it too unless the user explicitly asks to keep it + local. Refresh an open PR's title/body once the branch outgrows them — + describing the end state rather than a changelog of the conversation, and + folding into any human edits rather than overwriting them. - **Don't act on review comments the user hasn't pointed you at.** Summarize what's there and ask which to take on; reading them to write that summary is expected. When a commit resolves one, reply to it naming the commit. Anything From 496d0196ac9a25282d3c72d6c5f87c91a254222b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 13:43:24 -0700 Subject: [PATCH 04/31] Enable CloudKit push notifications --- Project.swift | 4 ++++ Where/Where/AGENTS.md | 6 +++--- Where/Where/README.md | 11 ++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Project.swift b/Project.swift index 31fecea3..213cd082 100644 --- a/Project.swift +++ b/Project.swift @@ -47,6 +47,10 @@ let whereAppGroupEntitlements: Entitlements = .dictionary([ /// entitlement above: they write the shared local store and let the app's /// CloudKit-backed container publish those changes when it next opens. let whereAppEntitlements: Entitlements = .dictionary([ + // Xcode replaces this development placeholder with the environment from + // the selected provisioning profile. Keeping the entitlement in the + // target is what makes automatic signing request Push Notifications. + "aps-environment": .string("development"), "com.apple.security.application-groups": .array([.string("group.com.stuff.where")]), "com.apple.developer.icloud-container-identifiers": .array([ .string("iCloud.com.stuff.where"), diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index f0c6b435..fd1267d1 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -48,9 +48,9 @@ layering, and the domain rules this target merely starts up. intents stack derives from whatever scope the launch resolves, in the `onServicesReady` hook. - **Only the app owns the CloudKit capability.** Keep its App Group, CloudKit - container (`iCloud.com.stuff.where`), and remote-notification background mode - together in `Project.swift`; widgets and the share extension stay App - Group-only and never open a CloudKit container. + container (`iCloud.com.stuff.where`), Push Notifications entitlement, and + remote-notification background mode together in `Project.swift`; widgets and + the share extension stay App Group-only and never open a CloudKit container. - **Nothing here may assume the user has a store.** `didFinishLaunching` starts the ambient log sources and drives the launch; anything wanting the user's data waits for `.ready` and checks what it got — the Spotlight indexing after diff --git a/Where/Where/README.md b/Where/Where/README.md index f97f9385..8aee2ba0 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -44,11 +44,12 @@ team — see [`Where/AGENTS.md`](../AGENTS.md#installing-to-a-device)). ## CloudKit rollout and device validation -The app target owns `iCloud.com.stuff.where` plus the remote-notification -background mode. Widgets and the share extension intentionally have only the -App Group entitlement: they write/read local shared artifacts, while the app's -single SwiftData container owns CloudKit mirroring. Debug uses `.localOnly`; -exercise sync with a Release-signed build. +The app target owns `iCloud.com.stuff.where`, the Push Notifications +entitlement, and the remote-notification background mode. Widgets and the share +extension intentionally have only the App Group entitlement: they write/read +local shared artifacts, while the app's single SwiftData container owns +CloudKit mirroring. Debug uses `.localOnly`; exercise sync with a Release-signed +build. Before shipping a schema change: From 5a01f35b37fc9f350ef5662826f12ed806c62c00 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 13:47:40 -0700 Subject: [PATCH 05/31] Keep recording device IDs local --- .../CurrentRecordingDeviceProvider.swift | 97 ++++++++++++++++--- .../WhereUI/Sources/Launch/WhereLaunch.swift | 2 +- .../CurrentRecordingDeviceProviderTests.swift | 95 ++++++++++++++++-- 3 files changed, 169 insertions(+), 25 deletions(-) diff --git a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift index 60068d3b..c4a92692 100644 --- a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift +++ b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift @@ -5,37 +5,104 @@ import WhereCore /// Builds the local installation identity at the app composition boundary. /// /// The first available `identifierForVendor` (or a generated fallback before -/// first unlock) is persisted immediately. Every later launch reuses that -/// choice, so a pre-unlock headless wake cannot register one identity and the -/// foreground launch silently switch to another. +/// first unlock) is persisted immediately in a device-local, non-backed-up +/// file. Every later launch reuses that choice, so a pre-unlock headless wake +/// cannot register one identity and the foreground launch silently switch to +/// another, while restoring a backup onto a second device cannot clone the +/// first installation's identity. @MainActor enum CurrentRecordingDeviceProvider { private enum Key: String { + /// Pre-file-storage builds kept the identity here. `UserDefaults` is + /// backed up, so this key is migration input only and is removed after + /// the device-local file is created. case recordingDeviceID = "where.recordingDeviceID" } - static func current(defaults: UserDefaults) -> CurrentRecordingDevice { - let device = UIDevice.current - let id: UUID - if let stored = defaults.string(forKey: Key.recordingDeviceID.rawValue) - .flatMap(UUID.init(uuidString:)) - { - id = stored - } else { - id = device.identifierForVendor ?? UUID() - defaults.set(id.uuidString, forKey: Key.recordingDeviceID.rawValue) - } + private static let identityFileName = "recording-device-id" + static func current() throws -> CurrentRecordingDevice { + let device = UIDevice.current let kind: RecordingDeviceKind = switch device.userInterfaceIdiom { case .phone: .phone case .pad: .tablet case .unspecified, .tv, .carPlay, .mac, .vision: .other @unknown default: .other } + let directory = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true, + ) + return try current( + identityFileURL: directory.appending(path: identityFileName), + legacyDefaults: .standard, + vendorID: device.identifierForVendor, + systemName: device.model, + kind: kind, + ) + } + + /// Explicit-dependency factory used by the production composition method + /// above and by tests that exercise backup restoration and pre-unlock + /// identity resolution without touching the app sandbox. + static func current( + identityFileURL: URL, + legacyDefaults: UserDefaults, + vendorID: UUID?, + systemName: String, + kind: RecordingDeviceKind, + ) throws -> CurrentRecordingDevice { + let id = try identity( + at: identityFileURL, + legacyDefaults: legacyDefaults, + vendorID: vendorID, + ) return CurrentRecordingDevice( id: RecordingDeviceID(rawValue: id), - systemName: device.model, + systemName: systemName, kind: kind, ) } + + private static func identity( + at fileURL: URL, + legacyDefaults: UserDefaults, + vendorID: UUID?, + ) throws -> UUID { + if FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) { + let data = try Data(contentsOf: fileURL) + guard let value = String(data: data, encoding: .utf8) + .flatMap(UUID.init(uuidString:)) + else { + throw CocoaError( + .fileReadCorruptFile, + userInfo: [NSFilePathErrorKey: fileURL.path(percentEncoded: false)], + ) + } + return value + } + + // On the original device the vendor id matches the legacy preference; + // after a restore it does not. Making the current vendor id authoritative + // preserves the former and rotates the latter. Before first unlock there + // is no vendor id, so mint the fallback directly in non-backed-up storage. + let value = vendorID ?? UUID() + + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + try Data(value.uuidString.utf8).write( + to: fileURL, + options: [.atomic, .noFileProtection], + ) + var persistedURL = fileURL + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try persistedURL.setResourceValues(resourceValues) + legacyDefaults.removeObject(forKey: Key.recordingDeviceID.rawValue) + return value + } } diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index d8c83ee3..714eaea2 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -266,8 +266,8 @@ public final class WhereBootstrap: WhereScopeAssembling { public func makeServices() async throws -> WhereServices { let source = locationSource ?? CoreLocationSource() locationSource = nil - let currentDevice = CurrentRecordingDeviceProvider.current(defaults: .standard) do { + let currentDevice = try CurrentRecordingDeviceProvider.current() let store = try await Task.detached(priority: .userInitiated) { try SwiftDataStore.make() }.value diff --git a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift index f3b55a29..f7c22dcd 100644 --- a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift +++ b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift @@ -1,21 +1,98 @@ import Foundation import Testing +import WhereCore @testable import WhereUI @MainActor struct CurrentRecordingDeviceProviderTests { - @Test func persistsOneInstallationIdentity() throws { + private static let vendorID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + private static let restoredID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + + @Test func persistsOneDeviceLocalInstallationIdentity() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + + let first = try current(fixture: fixture, vendorID: Self.vendorID) + let second = try current(fixture: fixture, vendorID: Self.vendorID) + + #expect(first == second) + #expect(first.id.rawValue == Self.vendorID) + #expect(first.systemName == "iPhone") + #expect( + try fixture.identityFileURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) + .isExcludedFromBackup == true, + ) + } + + @Test func migratesAUserDefaultThatMatchesThisDevice() throws { + let fixture = try makeFixture(legacyID: Self.vendorID) + defer { fixture.cleanup() } + + let current = try current(fixture: fixture, vendorID: Self.vendorID) + + #expect(current.id.rawValue == Self.vendorID) + #expect(fixture.defaults.string(forKey: "where.recordingDeviceID") == nil) + } + + @Test func rejectsARestoredUserDefaultFromAnotherDevice() throws { + let fixture = try makeFixture(legacyID: Self.restoredID) + defer { fixture.cleanup() } + + let current = try current(fixture: fixture, vendorID: Self.vendorID) + + #expect(current.id.rawValue == Self.vendorID) + #expect(current.id.rawValue != Self.restoredID) + } + + @Test func preUnlockFallbackRemainsStableAfterUnlock() throws { + let fixture = try makeFixture(legacyID: Self.restoredID) + defer { fixture.cleanup() } + + let beforeUnlock = try current(fixture: fixture, vendorID: nil) + let afterUnlock = try current(fixture: fixture, vendorID: Self.vendorID) + + #expect(beforeUnlock == afterUnlock) + #expect(beforeUnlock.id.rawValue != Self.restoredID) + } + + private func current( + fixture: Fixture, + vendorID: UUID?, + ) throws -> CurrentRecordingDevice { + try CurrentRecordingDeviceProvider.current( + identityFileURL: fixture.identityFileURL, + legacyDefaults: fixture.defaults, + vendorID: vendorID, + systemName: "iPhone", + kind: .phone, + ) + } + + private func makeFixture(legacyID: UUID? = nil) throws -> Fixture { let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } + if let legacyID { + defaults.set(legacyID.uuidString, forKey: "where.recordingDeviceID") + } + let directory = FileManager.default.temporaryDirectory + .appending(path: suiteName, directoryHint: .isDirectory) + return Fixture( + directory: directory, + identityFileURL: directory.appending(path: "recording-device-id"), + defaults: defaults, + suiteName: suiteName, + ) + } - let first = CurrentRecordingDeviceProvider.current(defaults: defaults) - let second = CurrentRecordingDeviceProvider.current(defaults: defaults) + private struct Fixture { + let directory: URL + let identityFileURL: URL + let defaults: UserDefaults + let suiteName: String - #expect(first == second) - #expect(defaults.dictionaryRepresentation().values.contains { - ($0 as? String) == first.id.rawValue.uuidString - }) - #expect(first.systemName.isEmpty == false) + func cleanup() { + try? FileManager.default.removeItem(at: directory) + defaults.removePersistentDomain(forName: suiteName) + } } } From 4803b0ba50c0c5889ae1a5ae8085e788fb525fe6 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 13:52:14 -0700 Subject: [PATCH 06/31] Subscribe before loading recording devices --- .../Settings/DevicesSettingsModel.swift | 4 +- .../Tests/DevicesSettingsModelTests.swift | 64 +++++++++++++++++++ Where/WhereUI/Tests/Support/TestStore.swift | 40 +++++++++++- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift index 71ec0fcb..e6ceb74c 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift @@ -44,9 +44,9 @@ final class DevicesSettingsModel { /// Load once, then stay current with local commits and CloudKit imports /// until the owning view disappears and SwiftUI cancels the task. func run() async { + let updates = session.services.dataChangeUpdates() await load(showLoading: true) - for await _ in session.services.dataChangeUpdates() { - if Task.isCancelled { return } + for await _ in updates { await load(showLoading: false) } } diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift index 38975aea..63243795 100644 --- a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -25,6 +25,22 @@ struct DevicesSettingsModelTests { return (DevicesSettingsModel(session: session), session, store) } + private func makeSubject(store: any WhereStore) -> ( + model: DevicesSettingsModel, + session: WhereSession + ) { + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + currentDevice: .preview, + now: { Self.now }, + ) + let preferences = makePreferences() + preferences.wantsTracking = true + let session = WhereSession(services: services, preferences: preferences) + return (DevicesSettingsModel(session: session), session) + } + @Test func loadsTheCurrentDeviceAndAwaitsAToggle() async throws { let subject = try makeSubject() await subject.session.start() @@ -77,4 +93,52 @@ struct DevicesSettingsModelTests { #expect(try await subject.store.recordingDevices() .first(where: { $0.id == remoteID })?.archivedAt == Self.now) } + + @Test func observesADeviceAddedDuringInitialLoad() async throws { + let store = try TestStore() + let subject = makeSubject(store: store) + await subject.session.start() + await store.gateRecordingDevices(afterCalls: 1) + + let runTask = Task { await subject.model.run() } + await store.awaitRecordingDevicesGate() + + let remoteID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), + ) + try await store.perform { + try await store.setRecordingDevice(RecordingDevice( + id: remoteID, + systemName: "iPad", + nickname: nil, + kind: .tablet, + registeredAt: Self.now, + lastSeenAt: Self.now, + archivedAt: nil, + lastAppliedPolicyChangeID: nil, + status: .off, + )) + } + await store.releaseRecordingDevicesGate() + + await waitUntil { + subject.model.rows.contains(where: { $0.id == remoteID }) + } + runTask.cancel() + await runTask.value + + #expect(subject.model.rows.contains(where: { $0.id == remoteID })) + } + + private func waitUntil( + timeout: Duration = .seconds(2), + _ predicate: () -> Bool, + ) async { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if predicate() { return } + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(predicate(), "condition was not met before timeout") + } } diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 71e5f151..80ebe676 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -9,11 +9,13 @@ struct ManualSaveFailure: Error, Equatable {} struct SampleReadFailure: Error, Equatable {} /// Test `WhereStore` that forwards to an in-memory `SwiftDataStore` but adds -/// two hooks the view-model tests need: +/// hooks the view-model tests need: /// /// - `enableFirstSamplesGate()` suspends the first `samples(in:)` call until /// the test releases it, so two `refresh()`es can be forced to complete out /// of order (the stale-year race). +/// - `gateRecordingDevices(afterCalls:)` suspends a selected device read after +/// capturing its result, so a committed change can race an initial load. /// - `failManualDays()` makes `setManualDay` throw, so manual-entry error /// handling is exercisable without a real persistence fault. /// @@ -26,6 +28,11 @@ actor TestStore: WhereStore { private var gate: CheckedContinuation? private var arrival: CheckedContinuation? + private var recordingDeviceCallsBeforeGate: Int? + private var recordingDevicesGateReached = false + private var recordingDevicesGate: CheckedContinuation? + private var recordingDevicesArrival: CheckedContinuation? + private var shouldFailManualDay = false private var shouldFailSamples = false @@ -50,6 +57,23 @@ actor TestStore: WhereStore { gate = nil } + /// Gates the device read after `calls` earlier reads have completed. + func gateRecordingDevices(afterCalls calls: Int) { + precondition(calls >= 0) + recordingDeviceCallsBeforeGate = calls + recordingDevicesGateReached = false + } + + func awaitRecordingDevicesGate() async { + guard !recordingDevicesGateReached else { return } + await withCheckedContinuation { recordingDevicesArrival = $0 } + } + + func releaseRecordingDevicesGate() { + recordingDevicesGate?.resume() + recordingDevicesGate = nil + } + func failManualDays() { shouldFailManualDay = true } @@ -90,7 +114,19 @@ actor TestStore: WhereStore { } func recordingDevices() async throws -> [RecordingDevice] { - try await backing.recordingDevices() + let devices = try await backing.recordingDevices() + guard let calls = recordingDeviceCallsBeforeGate else { return devices } + guard calls == 0 else { + recordingDeviceCallsBeforeGate = calls - 1 + return devices + } + + recordingDeviceCallsBeforeGate = nil + recordingDevicesGateReached = true + recordingDevicesArrival?.resume() + recordingDevicesArrival = nil + await withCheckedContinuation { recordingDevicesGate = $0 } + return devices } func setRecordingDevice(_ device: RecordingDevice) async throws { From 68df1479258bcc8acad6593c79ef5de9c2c0caaf Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 20:17:07 -0700 Subject: [PATCH 07/31] Test remote recording imports end to end --- .../Persistence/StoreRemoteChangeSource.swift | 6 +- .../Sources/Persistence/SwiftDataStore.swift | 36 +++++- .../WhereUI/Sources/Model/WhereSession.swift | 4 +- .../Tests/DevicesSettingsModelTests.swift | 60 +++++++++ .../Tests/WhereSessionTrackingTests.swift | 118 ++++++++++++++++++ 5 files changed, 217 insertions(+), 7 deletions(-) diff --git a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift index 1a4f149d..ce924daf 100644 --- a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift +++ b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift @@ -86,7 +86,7 @@ final class PersistentStoreRemoteChangeSource: StoreRemoteChangeSource, @uncheck let remoteChanges: AsyncStream private let continuation: AsyncStream.Continuation - init() { + public init() { var cont: AsyncStream.Continuation! remoteChanges = AsyncStream { cont = $0 } continuation = cont @@ -94,11 +94,11 @@ final class PersistentStoreRemoteChangeSource: StoreRemoteChangeSource, @uncheck /// Simulate a remote import: a store observing this source re-pings its /// `changes()` fan-out. Named for the `continuation.yield()` it makes. - func yield() { + public func yield() { continuation.yield() } - func finish() { + public func finish() { continuation.finish() } } diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index a2ff0bcc..13fdda77 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -354,6 +354,34 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func perform( _ block: @Sendable () async throws -> T, + ) async throws -> T { + try await perform(sendsChange: true, block) + } + + #if DEBUG + /// Test seam for the data half of a remote import: commit recording + /// values without emitting the local-write `changes()` ping. Tests pair + /// this with `ScriptedStoreRemoteChangeSource.yield()` so observers can + /// only refresh through the production remote-import path. + @_spi(Testing) + public func simulateRemoteRecordingImport( + devices: [RecordingDevice], + policyChanges: [RecordingPolicyChange], + ) async throws { + try await perform(sendsChange: false) { + for device in devices { + try await self.setRecordingDevice(device) + } + for policyChange in policyChanges { + try await self.addRecordingPolicyChange(policyChange) + } + } + } + #endif + + private func perform( + sendsChange: Bool, + _ block: @Sendable () async throws -> T, ) async throws -> T { // Genuine nested call on this task: a write transaction is already in // flight for this store. Reuse its peer so nested writes coalesce into @@ -395,8 +423,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { try peer.save() // Committed: ping `changes()` subscribers so they re-read. Only the // outermost `perform` reaches here (nested calls returned above - // without saving), so a transaction pings exactly once. - changeBroadcaster.send() + // without saving), so a transaction pings exactly once. The DEBUG + // remote-import seam suppresses this local ping; its scripted + // source emits the corresponding remote one separately. + if sendsChange { + changeBroadcaster.send() + } return result } } diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 13f9d350..c57d3855 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -293,9 +293,9 @@ public final class WhereSession { /// another device reloads ``regionStyles``. Idempotent. func observeRegionStyleChanges() { guard regionStyleTask == nil else { return } - let services = services + let updates = services.dataChangeUpdates() regionStyleTask = Task { @MainActor [weak self] in - for await _ in services.dataChangeUpdates() { + for await _ in updates { guard let self else { break } await seedRegionStyles() // A CloudKit policy change for this installation arrives through diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift index 63243795..bc2ae591 100644 --- a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -94,6 +94,66 @@ struct DevicesSettingsModelTests { .first(where: { $0.id == remoteID })?.archivedAt == Self.now) } + @Test func refreshesADeviceImportedFromAnotherDevice() async throws { + let remoteChanges = ScriptedStoreRemoteChangeSource() + let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) + let subject = makeSubject(store: store) + await subject.session.start() + + let runTask = Task { await subject.model.run() } + await waitUntil { + subject.model.rows.contains(where: \.isCurrent) + } + + let remoteID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")), + ) + let policyID = try #require( + UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"), + ) + try await store.simulateRemoteRecordingImport( + devices: [ + RecordingDevice( + id: remoteID, + systemName: "iPad", + nickname: "Home iPad", + kind: .tablet, + registeredAt: Self.now, + lastSeenAt: Self.now, + archivedAt: nil, + lastAppliedPolicyChangeID: policyID, + status: .off, + ), + ], + policyChanges: [ + RecordingPolicyChange( + id: policyID, + deviceID: remoteID, + effectiveAt: Self.now, + isEnabled: false, + ), + ], + ) + + // The imported rows alone are intentionally silent: this assertion + // prevents a normal local `perform` ping from making the test pass. + #expect(subject.model.rows.contains(where: { $0.id == remoteID }) == false) + + remoteChanges.yield() + + await waitUntil { + subject.model.rows.contains(where: { $0.id == remoteID }) + } + runTask.cancel() + await runTask.value + + let remote = try #require(subject.model.rows.first(where: { $0.id == remoteID })) + #expect(remote.displayName == "Home iPad") + #expect(remote.isEnabled == false) + #expect(remote.status == .off) + #expect(remote.isPending == false) + } + @Test func observesADeviceAddedDuringInitialLoad() async throws { let store = try TestStore() let subject = makeSubject(store: store) diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index d112b8b5..a44ba71b 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -138,6 +138,61 @@ struct WhereSessionTrackingTests { #expect(session.isTracking) } + @Test func remoteOffPolicyStopsThisDeviceAndAcknowledgesIt() async throws { + let remoteChanges = ScriptedStoreRemoteChangeSource() + let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) + let source = TrackingLocationSource() + let now = Date(timeIntervalSinceReferenceDate: 1000) + let services = WhereServices( + store: store, + locationSource: source, + currentDevice: .preview, + now: { now }, + ) + let preferences = makePreferences() + preferences.wantsTracking = true + let session = WhereSession(services: services, preferences: preferences) + await session.start() + + #expect(session.isTracking) + #expect(source.isMonitoring) + + let policyID = try #require( + UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"), + ) + try await store.simulateRemoteRecordingImport( + devices: [], + policyChanges: [ + RecordingPolicyChange( + id: policyID, + deviceID: session.currentRecordingDeviceID, + effectiveAt: now.addingTimeInterval(1), + isEnabled: false, + ), + ], + ) + + // Saving the imported row is not enough; the session must be responding + // to the store's remote-import notification path. + #expect(session.isTracking) + #expect(source.isMonitoring) + + remoteChanges.yield() + + await waitUntil { + session.isTracking == false && source.isMonitoring == false + } + let current = try #require( + try await store.recordingDevices() + .first(where: { $0.id == session.currentRecordingDeviceID }), + ) + #expect(source.startCount == 1) + #expect(source.stopCount == 1) + #expect(preferences.wantsTracking == false) + #expect(current.status == .off) + #expect(current.lastAppliedPolicyChangeID == policyID) + } + @Test func foregroundLogsTodayWhenWantedAndAuthorized() async throws { // When-In-Use is enough for a foreground fix — and the only way such a // user gets any data, since passive background tracking needs Always. @@ -222,6 +277,69 @@ struct WhereSessionTrackingTests { } } +/// Location source whose counters prove reconciliation reached the physical +/// monitoring seam rather than only changing `WhereSession.isTracking`. +private final class TrackingLocationSource: LocationSource, @unchecked Sendable { + let sampleStream: AsyncStream + + var authorizationUpdates: AsyncStream { + AsyncStream { $0.finish() } + } + + private let sampleContinuation: AsyncStream.Continuation + private let lock = NSLock() + private var _isMonitoring = false + private var _startCount = 0 + private var _stopCount = 0 + + init() { + (sampleStream, sampleContinuation) = AsyncStream.makeStream( + of: LocationSample.self, + bufferingPolicy: .bufferingNewest(1), + ) + } + + deinit { + sampleContinuation.finish() + } + + var isMonitoring: Bool { + lock.withLock { _isMonitoring } + } + + var startCount: Int { + lock.withLock { _startCount } + } + + var stopCount: Int { + lock.withLock { _stopCount } + } + + func start() async { + lock.withLock { + _isMonitoring = true + _startCount += 1 + } + } + + func stop() async { + lock.withLock { + _isMonitoring = false + _stopCount += 1 + } + } + + func requestCurrentLocation() async -> LocationSample? { + nil + } + + func currentAuthorization() async -> LocationAuthorizationStatus { + .always + } + + func requestPermission() async throws {} +} + /// Permission seam that parks until the test resolves it, matching the /// suspension point of Core Location's real system prompt. private final class SuspendedPermissionLocationSource: LocationSource, @unchecked Sendable { From 77a4e5d87f39e33198fde4f624a2767399e24e7c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 14:21:24 -0700 Subject: [PATCH 08/31] Confirm per-device recording during onboarding --- Where/AGENTS.md | 7 +- Where/Where/README.md | 5 + Where/WhereCore/AGENTS.md | 6 +- Where/WhereCore/README.md | 7 +- .../Sources/Devices/RecordingDevice.swift | 10 ++ .../Preferences/WherePreferences.swift | 10 ++ .../Tests/RecordingDeviceTests.swift | 13 ++ .../Tests/WherePreferencesTests.swift | 17 +++ Where/WhereUI/README.md | 15 +- ...onboarding.PhoneRecordingChoice_iPhone.png | 3 + .../onboarding.TabletRecordingChoice_iPad.png | 3 + .../CurrentRecordingDeviceProvider.swift | 23 ++- .../Sources/Launch/WhereLaunchSteps.swift | 16 +- Where/WhereUI/Sources/Model/WhereModel.swift | 19 ++- Where/WhereUI/Sources/Model/WhereScope.swift | 1 + .../Sources/Onboarding/OnboardingView.swift | 142 ++++++++++++++---- .../Sources/Preview/PreviewSupport.swift | 1 + .../Sources/Resources/Localizable.xcstrings | 60 +++++--- Where/WhereUI/Sources/RootView.swift | 6 +- .../Settings/DeviceSettingsRowModel.swift | 16 +- .../CurrentRecordingDeviceProviderTests.swift | 7 + Where/WhereUI/Tests/DemoModeTests.swift | 1 + Where/WhereUI/Tests/OnboardingTests.swift | 20 ++- Where/WhereUI/Tests/WhereLaunchTests.swift | 21 +++ Where/WhereUI/Tests/WhereResetTests.swift | 11 +- 25 files changed, 351 insertions(+), 89 deletions(-) create mode 100644 Where/WhereCore/Tests/RecordingDeviceTests.swift create mode 100644 Where/WhereCore/Tests/WherePreferencesTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.TabletRecordingChoice_iPad.png diff --git a/Where/AGENTS.md b/Where/AGENTS.md index c32ee905..cd7a479c 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -73,6 +73,8 @@ Rules the code enforces and agents must preserve: through `LocationHistoryReader`. A synced cutoff hides later raw samples immediately while the target device is still pending, and raw/legacy/manual history is never deleted or hidden without an attributable device policy. + Seed the first policy only from that installation's confirmed onboarding + choice: phone recommends On; tablet/other recommend Off. - **Manual entries carry a `ManualEntryAudit`**; `DayJournal`'s write methods take an explicit `audit:` (no default). An additive backfill can't downgrade an authoritative row's regions, but the newer audit always wins. @@ -138,8 +140,9 @@ slow. `WhereResetTests.loggingOutReleasesTheScopeBeforeTheNextLoginOpensOne`. `WhereFlyoverWorldTests.buildsASeededSiblingWithoutActivatingIt`. - **The onboarding gate declares `modes: .all`,** not the `.foreground` - default: parking a headless launch is the point. A background wake needs the - permission this flow asks for, so `isNeeded` is false by then. + default: parking a headless launch is the point. It also parks an already + onboarded installation once when its device-local recording choice is + unconfirmed; that surface skips straight to the final choice page. - **A gate carries no value,** so a choice made *at* it reaches `resolve-scope` through `WhereModel` — the one step that reads model state rather than the trunk. diff --git a/Where/Where/README.md b/Where/Where/README.md index a06dd704..dcc4dc49 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -93,3 +93,8 @@ Before shipping a schema change: 6. Archive the non-current device and verify it is hidden without losing older report history. Export and replace-import a backup and verify device names, raw samples, policy history, and archived state round-trip. + +On a fresh install, onboarding recommends automatic recording On for an iPhone +and Off for an iPad/other device, then requires the user to confirm. Existing +installations created before that choice was introduced revisit only the final +recording page once; enabling is the only path that asks for location access. diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 452147f2..8de58914 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -93,8 +93,10 @@ internal shape. - **`DeviceRecordingController` owns automatic-recording policy and physical GPS state.** Keep policy events append-only, serialize mutations across awaits, stamp every ingested GPS sample with the current installation id, - and apply `LocationHistoryReader` to every user-facing projection. Backups - alone read the lossless raw samples and full policy/device tables. + seed the first policy from the installation's explicitly confirmed + preference, and apply `LocationHistoryReader` to every user-facing + projection. Backups alone read the lossless raw samples and full + policy/device tables. - **Tracked regions live in the store, not preferences** — one `SDTrackedRegion` row per region so cross-device edits merge; read as a `Set` defaulting to the four. `RegionAttribution` derives the attributor diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 4d828a51..af70b100 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -118,9 +118,10 @@ one it belongs to rather than to a god-object: `ZIPFoundation`). - **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over a selectable look-back `RecentActivityWindow`. -- **`WherePreferences`** — persisted user intent (onboarding, tracking intent, - reminder / summary schedules) behind a `KeyValueStore`. The store has no - default: production names `UserDefaults.standard` and everything else names +- **`WherePreferences`** — persisted user intent (onboarding, the device-local + recording-choice confirmation and tracking intent, reminder / summary + schedules) behind a `KeyValueStore`. The store has no default: production + names `UserDefaults.standard` and everything else names `InMemoryKeyValueStore()`, so no test or preview can reach the host's real defaults by saying nothing. - **`BuildInfo`** + **`AppAttribution`** — what Settings > About says about the diff --git a/Where/WhereCore/Sources/Devices/RecordingDevice.swift b/Where/WhereCore/Sources/Devices/RecordingDevice.swift index ea966462..187d4d88 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDevice.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDevice.swift @@ -6,6 +6,16 @@ public enum RecordingDeviceKind: String, Codable, Sendable, Hashable { case phone case tablet case other + + /// Safe first-run recommendation for automatic recording. A phone usually + /// travels with its owner; tablets and other devices are commonly left + /// behind and must be opted in explicitly. + public var recommendsAutomaticRecording: Bool { + switch self { + case .phone: true + case .tablet, .other: false + } + } } /// The last effective recording state acknowledged by a device. diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index ddaf769b..20ff9b2e 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -29,6 +29,15 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.hasOnboarded.rawValue) } } + /// Whether this installation has explicitly confirmed its initial + /// automatic-recording choice. Device-local rather than CloudKit-synced: + /// every installation must make its own decision before it registers a + /// synced recording policy. + public var hasConfirmedRecordingChoice: Bool { + get { store.bool(forKey: Keys.hasConfirmedRecordingChoice.rawValue) } + set { store.set(newValue, forKey: Keys.hasConfirmedRecordingChoice.rawValue) } + } + /// Persisted intent to track in the background. Defaults to `true` so that, /// once the user grants Always, tracking resumes automatically every launch. public var wantsTracking: Bool { @@ -115,6 +124,7 @@ public final class WherePreferences { /// sync — adding a case is all it takes to have it reset. private enum Keys: String, CaseIterable { case hasOnboarded = "where.hasOnboarded" + case hasConfirmedRecordingChoice = "where.hasConfirmedRecordingChoice" case wantsTracking = "where.wantsBackgroundTracking" case remindersEnabled = "where.remindersEnabled" case reminderHour = "where.reminderHour" diff --git a/Where/WhereCore/Tests/RecordingDeviceTests.swift b/Where/WhereCore/Tests/RecordingDeviceTests.swift new file mode 100644 index 00000000..7344984c --- /dev/null +++ b/Where/WhereCore/Tests/RecordingDeviceTests.swift @@ -0,0 +1,13 @@ +import Testing +@testable import WhereCore + +struct RecordingDeviceTests { + @Test func phoneRecommendsAutomaticRecording() { + #expect(RecordingDeviceKind.phone.recommendsAutomaticRecording) + } + + @Test(arguments: [RecordingDeviceKind.tablet, .other]) + func devicesCommonlyLeftBehindRecommendRecordingOff(kind: RecordingDeviceKind) { + #expect(kind.recommendsAutomaticRecording == false) + } +} diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift new file mode 100644 index 00000000..8fcc92c2 --- /dev/null +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -0,0 +1,17 @@ +import Testing +@testable import WhereCore + +struct WherePreferencesTests { + @Test func recordingChoiceConfirmationIsPersistedAndReset() { + let store = InMemoryKeyValueStore() + let preferences = WherePreferences(store: store) + #expect(preferences.hasConfirmedRecordingChoice == false) + + preferences.hasConfirmedRecordingChoice = true + let relaunched = WherePreferences(store: store) + #expect(relaunched.hasConfirmedRecordingChoice) + + relaunched.reset() + #expect(relaunched.hasConfirmedRecordingChoice == false) + } +} diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index b7644d1a..2bd6c374 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -64,8 +64,9 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's `LogHistoryPruner` (a 100-day window *and* a 50k-event ceiling, so the store is bounded however heavily the device logs). - **`WhereModel`** — app-level state that outlives any one scope: the - onboarding flag, the active `WhereScope`, the owned `WhereSession`, and the - lifecycle intents (`activate(scope:)`, `startSession(scope:)` — which + onboarding and per-device recording-confirmation flags, the active + `WhereScope`, the owned `WhereSession`, and the lifecycle intents + (`activate(scope:)`, `startSession(scope:)` — which *returns* the session the launch's `start-session` step threads onward — `endSession()`, `resetPreferences()`). - **`WhereSession`** — the always-on coordinator: tracking + location @@ -85,9 +86,13 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's `OnboardingGate` and handed its `LifecycleGateHandle`. The gate roots the trunk, so there is no session (and no open store) behind it: a paged intro, then picking up to five primary US regions (map or searchable list) and - giving each a look, then the location-permission ask. Finishing logs in to - the real scope — the app's one store open — and commits the picks as the - tracked-region set + appearances before resolving the gate. The intro also + giving each a look, then verifying this installation's automatic-recording + choice. Phones recommend On; tablets/other devices recommend Off, and only + an enabled confirmation requests location permission. An existing + installation without the new confirmation skips straight to that final + page. Finishing logs in to the real scope — the app's one store open — and + commits the picks as the tracked-region set + appearances before resolving + the gate. The intro also offers **Restore from a backup**, which opens the store, imports a backup (`.replace`), and skips the manual pick/customize steps straight to the location ask; and **Explore a demo**, which builds a throwaway in-memory diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone.png new file mode 100644 index 00000000..621ff391 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8e7a3554d84139aaabcf43bb6bd208ff3008e88640711f291df02e9ec12ce30 +size 532933 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.TabletRecordingChoice_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.TabletRecordingChoice_iPad.png new file mode 100644 index 00000000..166b2421 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.TabletRecordingChoice_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71c12f8d34b8be34ad6122304d6c06e7c51bb541a5d9ad43001bc323b6d1f445 +size 1019732 diff --git a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift index c4a92692..7e0475fc 100644 --- a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift +++ b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift @@ -21,14 +21,16 @@ enum CurrentRecordingDeviceProvider { private static let identityFileName = "recording-device-id" + /// Hardware family available before the scope/store is opened, so + /// onboarding can recommend a safe initial recording choice without + /// creating this installation's durable identity yet. + static var currentKind: RecordingDeviceKind { + kind(for: UIDevice.current.userInterfaceIdiom) + } + static func current() throws -> CurrentRecordingDevice { let device = UIDevice.current - let kind: RecordingDeviceKind = switch device.userInterfaceIdiom { - case .phone: .phone - case .pad: .tablet - case .unspecified, .tv, .carPlay, .mac, .vision: .other - @unknown default: .other - } + let kind = kind(for: device.userInterfaceIdiom) let directory = try FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, @@ -44,6 +46,15 @@ enum CurrentRecordingDeviceProvider { ) } + static func kind(for idiom: UIUserInterfaceIdiom) -> RecordingDeviceKind { + switch idiom { + case .phone: .phone + case .pad: .tablet + case .unspecified, .tv, .carPlay, .mac, .vision: .other + @unknown default: .other + } + } + /// Explicit-dependency factory used by the production composition method /// above and by tests that exercise backup restoration and pre-unlock /// identity resolution without touching the app sandbox. diff --git a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift index b1a1e689..6e097338 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift @@ -22,16 +22,17 @@ import WhereCore // Each step also declares a span `budget` (see `BudgetedLaunchStep`), and // the plans compose them `.measured()` so every run is one Periscope span. -/// First-run onboarding. Rooted at the trunk's head so that an install whose -/// user hasn't chosen yet builds nothing: no store is opened, no CloudKit is -/// contacted, and no session exists behind this. +/// First-run onboarding and the one-time per-installation recording choice. +/// Rooted at the trunk's head so that an install whose user hasn't chosen yet +/// builds nothing: no store is opened, no CloudKit is contacted, and no session +/// exists behind this. /// /// Unlike most gates it applies to **all** launch reasons rather than the /// foreground-only default. Parking a headless launch is the point here — the /// alternative is opening the user's store for a launch they can't see and may -/// never have consented to — and it costs nothing: a genuine background wake -/// can only happen once location monitoring is running, which requires the -/// permission this flow asks for, by which point `isNeeded` is false. +/// never have consented to. An existing installation upgrading from before the +/// per-device choice may still receive a background wake; parking it safely +/// defers opening the store until the user verifies the choice in foreground. struct OnboardingGate: LifecycleGate { let model: WhereModel @@ -42,7 +43,8 @@ struct OnboardingGate: LifecycleGate { // An active scope means the choice has already been made — by // onboarding just now, or by a preview/test injecting one — so don't // ask again even though `hasOnboarded` may not be written yet. - model.activeScope == nil && !model.hasOnboarded + model.activeScope == nil + && (!model.hasOnboarded || !model.hasConfirmedRecordingChoice) } } diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index af25b92a..19bb02c6 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -158,13 +158,28 @@ public final class WhereModel { set { preferences.hasOnboarded = newValue } } - /// Mark first-run onboarding complete. Called by `OnboardingView` once the - /// user finishes the intro (after the permission prompt resolves). + /// Whether this installation has confirmed the recording recommendation. + /// Existing installations that predate the choice keep onboarding complete + /// but revisit its final page once to make this device-specific decision. + public private(set) var hasConfirmedRecordingChoice: Bool { + get { preferences.hasConfirmedRecordingChoice } + set { preferences.hasConfirmedRecordingChoice = newValue } + } + + /// Mark first-run onboarding and this installation's recording choice + /// complete. Called after the optional permission prompt resolves. public func completeOnboarding() { hasOnboarded = true + hasConfirmedRecordingChoice = true Self.logger { .onboardingCompleted } } + /// Persist the one-time recording confirmation for an installation that + /// completed the rest of onboarding before per-device controls existed. + public func confirmRecordingChoice() { + hasConfirmedRecordingChoice = true + } + public static var currentYear: Int { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current diff --git a/Where/WhereUI/Sources/Model/WhereScope.swift b/Where/WhereUI/Sources/Model/WhereScope.swift index 77795b67..8ce1f8e1 100644 --- a/Where/WhereUI/Sources/Model/WhereScope.swift +++ b/Where/WhereUI/Sources/Model/WhereScope.swift @@ -255,6 +255,7 @@ public final class WhereScope { // demo's own preferences: the user's real ones are untouched, which is // what makes quitting mid-demo return to onboarding. preferences.hasOnboarded = true + preferences.hasConfirmedRecordingChoice = true preferences.wantsTracking = true let scope = WhereScope( diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 12c5b8b5..8d6008f8 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -7,9 +7,9 @@ import WhereCore /// First-run onboarding, run as the launch's opening gate. A short paged /// intro to the passport concept, then picking the primary regions you spend -/// time in and giving each a look, then the background-location permission -/// request — the natural place to ask for Always, rather than burying it in -/// Settings. +/// time in and giving each a look, then confirming whether this device should +/// record automatically. Enabling it requests background-location permission +/// here, rather than burying that decision in Settings. /// /// Nothing exists behind this screen yet: the gate roots the trunk, so the /// store is unopened and there is no session. Onboarding is what brings the @@ -26,6 +26,7 @@ public struct OnboardingView: View { @Environment(WhereModel.self) private var model @Environment(\.stylesheet) private var stylesheet private let gate: LifecycleGateHandle + private let deviceKind: RecordingDeviceKind /// The ordered onboarding phases. An explicit state machine (rather than /// loose flags) so only one screen is ever showing and the transitions are @@ -40,6 +41,7 @@ public struct OnboardingView: View { @State private var phase: Phase = .intro @State private var page = 0 @State private var selection = PrimaryRegionSelectionModel() + @State private var recordingEnabled: Bool @State private var isFinishing = false /// What the intro is doing, and how it went — see ``OnboardingIntroState``. @@ -59,7 +61,27 @@ public struct OnboardingView: View { private static let logger = WhereLog.session(OnboardingViewLog.self) public init(gate: LifecycleGateHandle) { + self.init( + gate: gate, + deviceKind: CurrentRecordingDeviceProvider.currentKind, + startsAtRecordingChoice: false, + ) + } + + /// Internal composition/test initializer. A returning installation that + /// predates the per-device choice skips the first-run pages and verifies the + /// recommendation directly; snapshots inject the device kind explicitly. + init( + gate: LifecycleGateHandle, + deviceKind: RecordingDeviceKind, + startsAtRecordingChoice: Bool, + ) { self.gate = gate + self.deviceKind = deviceKind + _phase = State(initialValue: startsAtRecordingChoice ? .location : .intro) + _recordingEnabled = State( + initialValue: deviceKind.recommendsAutomaticRecording, + ) } private let pages = OnboardingPage.all @@ -237,15 +259,15 @@ public struct OnboardingView: View { private var location: some View { VStack(spacing: stylesheet.spacing.xxxLarge) { Spacer(minLength: 0) - Image(systemName: "location.fill.viewfinder") + Image(systemName: deviceKind.systemImage) .font(stylesheet.typography.onboardingIcon) .foregroundStyle(Color.accentColor) .accessibilityHidden(true) VStack(spacing: stylesheet.spacing.large) { - Text(String(localized: .onboardingLocationTitle)) + Text(recordingTitle) .font(.largeTitle.bold()) .multilineTextAlignment(.center) - Text(String(localized: .onboardingLocationDescription)) + Text(String(localized: .onboardingRecordingDescription)) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -253,22 +275,28 @@ public struct OnboardingView: View { Spacer(minLength: 0) VStack(spacing: stylesheet.spacing.large) { + VStack(alignment: .leading, spacing: stylesheet.spacing.small) { + Toggle( + String(localized: .settingsDevicesAutomaticRecording), + isOn: $recordingEnabled, + ) + Text(recordingRecommendation) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + Button { - // Request Always-location right here so the system prompt - // maps 1:1 to the tap; the launch's tracking-reconcile step - // picks up whatever was granted. - finish(enableLocation: true) + // Request Always-location only after the user confirms an + // enabled choice; the launch's reconcile step picks up + // whatever the system grants. + finish(enableLocation: recordingEnabled) } label: { - Text(String(localized: .onboardingEnableLocation)) + Text(String(localized: .onboardingContinue)) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) - - Button(String(localized: .onboardingNotNow)) { - finish(enableLocation: false) - } - .controlSize(.large) } .disabled(isFinishing) } @@ -276,10 +304,26 @@ public struct OnboardingView: View { .padding(.bottom, stylesheet.spacing.xxxLarge) } + private var recordingTitle: LocalizedStringResource { + switch deviceKind { + case .phone: .onboardingRecordingPhoneTitle + case .tablet: .onboardingRecordingTabletTitle + case .other: .onboardingRecordingOtherTitle + } + } + + private var recordingRecommendation: LocalizedStringResource { + if deviceKind.recommendsAutomaticRecording { + .onboardingRecordingRecommendationOn + } else { + .onboardingRecordingRecommendationOff + } + } + /// Log in to the user's real world (opening the store, if the restore path /// hasn't already), commit the picked regions + appearances, optionally - /// request location, then persist `hasOnboarded` and resolve the gate so - /// the launch continues. + /// request location when enabled, then persist onboarding + the confirmed + /// per-device choice and resolve the gate so the launch continues. /// /// A store that won't open fails the gate rather than stranding the user /// on a dead intro: the runner lands on the failure surface, which is @@ -299,9 +343,9 @@ public struct OnboardingView: View { return } // This is also the initial synced policy for a newly registered - // installation. “Not Now” must therefore record false explicitly; - // leaving the old default true would make the next launch start - // recording despite the user's choice. + // installation. Persist the confirmed toggle explicitly so an Off + // recommendation cannot fall back to the old default-true intent + // on the next launch. scope.preferences.wantsTracking = enableLocation if enableLocation { await enableTracking(in: scope) @@ -324,7 +368,11 @@ public struct OnboardingView: View { } } } - model.completeOnboarding() + if model.hasOnboarded { + model.confirmRecordingChoice() + } else { + model.completeOnboarding() + } gate.complete() } } @@ -335,7 +383,6 @@ public struct OnboardingView: View { /// resolves, and they are what read the granted authorization back and /// actually start GPS. private func enableTracking(in scope: WhereScope) async { - scope.preferences.wantsTracking = true do { try await scope.services.ingestor.requestPermission() } catch { @@ -511,14 +558,47 @@ struct OnboardingPage: Identifiable { #if DEBUG extension OnboardingView: SnapshotProviding { public static var snapshots: [SnapshotCase] { - whereSnapshot(name: "Default", configurations: .screenDefaults) { - // `onboardingModel()` (not `loadedModel()`) so `hasOnboarded` is - // false and the capture lands on the intro phase. - OnboardingView( - gate: LifecycleGateHandle(id: LaunchStepID.onboarding, reason: .userForeground), - ) - .environment(PreviewSupport.onboardingModel()) - } + [ + whereSnapshot(name: "Default", configurations: .screenDefaults) { + // `onboardingModel()` (not `loadedModel()`) so `hasOnboarded` + // is false and the capture lands on the intro phase. + OnboardingView( + gate: LifecycleGateHandle( + id: LaunchStepID.onboarding, + reason: .userForeground, + ), + ) + .environment(PreviewSupport.onboardingModel()) + }, + whereSnapshot( + name: "PhoneRecordingChoice", + configurations: SnapshotConfiguration.combinations(devices: [.iPhone]), + ) { + OnboardingView( + gate: LifecycleGateHandle( + id: LaunchStepID.onboarding, + reason: .userForeground, + ), + deviceKind: .phone, + startsAtRecordingChoice: true, + ) + .environment(PreviewSupport.onboardingModel()) + }, + whereSnapshot( + name: "TabletRecordingChoice", + configurations: SnapshotConfiguration.combinations(devices: [.iPad]), + ) { + OnboardingView( + gate: LifecycleGateHandle( + id: LaunchStepID.onboarding, + reason: .userForeground, + ), + deviceKind: .tablet, + startsAtRecordingChoice: true, + ) + .environment(PreviewSupport.onboardingModel()) + }, + ] } } diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index adfd283a..77dd820a 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -567,6 +567,7 @@ public static func loadedModel() -> WhereModel { let preferences = previewPreferences() preferences.hasOnboarded = true + preferences.hasConfirmedRecordingChoice = true return WhereModel( services: previewServices(), report: sampleReport(), diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 1783eacb..66e92204 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -1607,87 +1607,105 @@ } } }, - "onboarding.enableLocation" : { - "comment" : "Button text to prompt the user to enable location services.", + "onboarding.next" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next" + } + } + } + }, + "onboarding.privacy.description" : { + "comment" : "Description of the privacy policy of the app.", "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Enable Location" + "value" : "Your location stays on your device and in your own iCloud. Turn on background location to start your passport." } } } }, - "onboarding.location.description" : { + "onboarding.privacy.title" : { + "comment" : "Title of the privacy section of the onboarding flow.", "extractionState" : "manual", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Where uses background location to log the regions you pass through. You can change this anytime in Settings." + "value" : "Private by design" } } } }, - "onboarding.location.title" : { + "onboarding.recording.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Turn on location" + "value" : "Where should record automatically only on devices you usually carry. You can change this later in Devices." } } } }, - "onboarding.next" : { + "onboarding.recording.other.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Next" + "value" : "Record on this device?" } } } }, - "onboarding.notNow" : { - "comment" : "Button title for skipping the onboarding flow.", + "onboarding.recording.phone.title" : { "extractionState" : "manual", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Not Now" + "value" : "Record on this iPhone?" } } } }, - "onboarding.privacy.description" : { - "comment" : "Description of the privacy policy of the app.", + "onboarding.recording.recommendation.off" : { "extractionState" : "manual", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Your location stays on your device and in your own iCloud. Turn on background location to start your passport." + "value" : "Off is recommended for devices you may leave behind." } } } }, - "onboarding.privacy.title" : { - "comment" : "Title of the privacy section of the onboarding flow.", + "onboarding.recording.recommendation.on" : { "extractionState" : "manual", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Private by design" + "value" : "On is recommended because your iPhone usually travels with you." + } + } + } + }, + "onboarding.recording.tablet.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Record on this iPad?" } } } diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 7a27f568..906064a6 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -103,7 +103,11 @@ public struct RootView: View { // open store) behind it yet — onboarding builds the scope // it commits regions with, through the model. GateView(for: OnboardingGate.self) { handle, _ in - OnboardingView(gate: handle) + OnboardingView( + gate: handle, + deviceKind: CurrentRecordingDeviceProvider.currentKind, + startsAtRecordingChoice: model.hasOnboarded, + ) } }, ) { session in diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift index 25c9aef9..16669b54 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift @@ -41,11 +41,7 @@ final class DeviceSettingsRowModel: Identifiable { } var systemImage: String { - switch kind { - case .phone: "iphone" - case .tablet: "ipad" - case .other: "apple.logo" - } + kind.systemImage } func update(from configuration: RecordingDeviceConfiguration) { @@ -61,3 +57,13 @@ final class DeviceSettingsRowModel: Identifiable { isPending = configuration.isPending } } + +extension RecordingDeviceKind { + var systemImage: String { + switch self { + case .phone: "iphone" + case .tablet: "ipad" + case .other: "apple.logo" + } + } +} diff --git a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift index f7c22dcd..0b155cac 100644 --- a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift +++ b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import UIKit import WhereCore @testable import WhereUI @@ -8,6 +9,12 @@ struct CurrentRecordingDeviceProviderTests { private static let vendorID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! private static let restoredID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + @Test func mapsInterfaceIdiomsToRecordingKinds() { + #expect(CurrentRecordingDeviceProvider.kind(for: .phone) == .phone) + #expect(CurrentRecordingDeviceProvider.kind(for: .pad) == .tablet) + #expect(CurrentRecordingDeviceProvider.kind(for: .mac) == .other) + } + @Test func persistsOneDeviceLocalInstallationIdentity() throws { let fixture = try makeFixture() defer { fixture.cleanup() } diff --git a/Where/WhereUI/Tests/DemoModeTests.swift b/Where/WhereUI/Tests/DemoModeTests.swift index 31520e21..61801156 100644 --- a/Where/WhereUI/Tests/DemoModeTests.swift +++ b/Where/WhereUI/Tests/DemoModeTests.swift @@ -67,6 +67,7 @@ struct DemoModeTests { // Onboarded and tracking, so the demo opens on the logged-in app. #expect(scope.preferences.hasOnboarded) + #expect(scope.preferences.hasConfirmedRecordingChoice) #expect(scope.preferences.wantsTracking) // Its log store is in memory, like everything else it owns — held but diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index 89bb0fdd..4fe6f42a 100644 --- a/Where/WhereUI/Tests/OnboardingTests.swift +++ b/Where/WhereUI/Tests/OnboardingTests.swift @@ -10,7 +10,8 @@ struct OnboardingModelTests { makeBootstrap: { UnusedBootstrap() }, logSystem: .isolated(), ) - #expect(!model.hasOnboarded) + #expect(model.hasOnboarded == false) + #expect(model.hasConfirmedRecordingChoice == false) } @Test func completeOnboardingPersists() { @@ -22,6 +23,7 @@ struct OnboardingModelTests { ) model.completeOnboarding() #expect(model.hasOnboarded) + #expect(model.hasConfirmedRecordingChoice) // A fresh model over the same preferences sees onboarding as done. let relaunched = WhereModel( @@ -30,5 +32,21 @@ struct OnboardingModelTests { logSystem: .isolated(), ) #expect(relaunched.hasOnboarded) + #expect(relaunched.hasConfirmedRecordingChoice) + } + + @Test func recordingChoiceCanBeConfirmedWithoutRepeatingOnboarding() { + let preferences = makePreferences() + preferences.hasOnboarded = true + let model = WhereModel( + preferences: preferences, + makeBootstrap: { UnusedBootstrap() }, + logSystem: .isolated(), + ) + + model.confirmRecordingChoice() + + #expect(model.hasOnboarded) + #expect(model.hasConfirmedRecordingChoice) } } diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index ee6f1726..b45789b8 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -286,12 +286,33 @@ struct WhereLaunchTests { #expect(bootstrap.makeServicesCount == 1) } + @Test func existingInstallationParksForItsRecordingChoiceBeforeOpening() async throws { + let preferences = makePreferences() + preferences.hasOnboarded = true + let (model, bootstrap) = try makeLoggedOutModel(preferences: preferences) + let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) + let task = Task { @MainActor in await launcher.run() } + + try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } + #expect(model.hasOnboarded) + #expect(model.hasConfirmedRecordingChoice == false) + #expect(bootstrap.makeServicesCount == 0) + + model.confirmRecordingChoice() + launcher.phase.gateHandle?.complete() + await task.value + + #expect(launcher.phase.isReady) + #expect(bootstrap.makeServicesCount == 1) + } + @Test func aStoreThatCannotOpenFailsTheLaunch() async { // Lazy creation moved the store open behind the gate, but an // unopenable store must still park the runner in `.failed` rather than // reading as a launch that simply never finished. let preferences = makePreferences() preferences.hasOnboarded = true + preferences.hasConfirmedRecordingChoice = true let model = WhereModel( preferences: preferences, makeBootstrap: { FailingBootstrap() }, diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 7026365f..31ed876b 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -90,12 +90,14 @@ struct WhereResetTests { preferences.remindersEnabled = false preferences.summaryEnabled = false #expect(model.hasOnboarded) + #expect(model.hasConfirmedRecordingChoice) model.resetPreferences() // Removing the keys lets the default-valued getters report first-install // state again: onboarding returns and reminders/summary default back on. - #expect(!model.hasOnboarded) + #expect(model.hasOnboarded == false) + #expect(model.hasConfirmedRecordingChoice == false) #expect(preferences.remindersEnabled) #expect(preferences.summaryEnabled) } @@ -266,7 +268,8 @@ struct WhereResetTests { // preferences cleared, and the app logged out — parked at the gate with // no session, since the relaunch rebuilds one only once the user has // chosen a world again. - #expect(!model.hasOnboarded) + #expect(model.hasOnboarded == false) + #expect(model.hasConfirmedRecordingChoice == false) #expect(model.session == nil) #expect(launcher.phase.gateHandle != nil) // The erase quiesced GPS before wiping, so the torn-down session is no @@ -314,7 +317,8 @@ struct WhereResetTests { // reopened, reminder/summary schedules defaulted back on rather than // the off state above. #expect(model.session == nil) - #expect(!model.hasOnboarded) + #expect(model.hasOnboarded == false) + #expect(model.hasConfirmedRecordingChoice == false) #expect(preferences.remindersEnabled) #expect(preferences.summaryEnabled) @@ -346,6 +350,7 @@ struct WhereResetTests { await launcher.teardown(failing, input: session) #expect(launcher.phase.failed(at: LaunchStepID.eraseData)) #expect(model.hasOnboarded) // reset-preferences never ran + #expect(model.hasConfirmedRecordingChoice) } } From 3b5dfaca728cb8023e2c02bccfbd52bb0738449a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 03:49:57 -0700 Subject: [PATCH 09/31] Redesign multi-device recording authority Replace mutable device state with epoch-scoped causal records, fail-closed ingestion, and durable import recovery. Add confirmed onboarding defaults, the Devices settings UI, remote-change reconciliation, and backup v7 coverage. --- AGENTS.md | 4 +- Where/AGENTS.md | 17 +- Where/TODOs.md | 5 +- Where/Tools/Tests/upgrade_backup_test.rb | 276 ++++ Where/Tools/upgrade-backup.rb | 286 +++- Where/Where/AGENTS.md | 6 +- Where/Where/README.md | 15 +- .../Sources/RegularApplicationRuntime.swift | 46 +- Where/Where/Tests/WhereTests.swift | 14 + Where/WhereCore/AGENTS.md | 62 +- Where/WhereCore/README.md | 105 +- .../Sources/Backup/BackupArchive.swift | 44 +- .../Sources/Backup/BackupCoordinator.swift | 847 ++++++++++-- .../Sources/Backup/BackupImportRecovery.swift | 102 ++ .../Sources/Backup/BackupService.swift | 129 +- .../Devices/DeviceRecordingController.swift | 1017 +++++++++++--- .../InstallationRecordingContext.swift | 107 ++ .../InstallationRecordingContextStoring.swift | 57 + .../Devices/LocationHistoryReader.swift | 19 +- .../RecordingConfigurationBroadcaster.swift | 41 + .../Sources/Devices/RecordingDevice.swift | 94 +- .../Devices/RecordingDeviceCheckIn.swift | 97 ++ .../RecordingDeviceConfiguration.swift | 67 +- .../RecordingDeviceMetadataChange.swift | 117 ++ .../Devices/RecordingDeviceProfile.swift | 31 + .../Devices/RecordingDeviceRuntimeState.swift | 15 + .../Devices/RecordingPersistenceError.swift | 46 + .../Devices/RecordingPolicyChange.swift | 426 +++++- .../Devices/RecordingPolicyFilter.swift | 36 +- .../Devices/RecordingPolicyResolution.swift | 6 + .../Devices/ResolvedRecordingPolicy.swift | 21 + .../Sources/Journal/DayJournal.swift | 73 +- .../Sources/Location/LocationIngestor.swift | 316 +++-- .../Sources/Location/LocationOutbox.swift | 401 +++++- .../DeviceRecordingControllerLog.swift | 25 + .../Sources/Logging/LocationOutboxLog.swift | 18 +- .../Sources/Logging/SwiftDataStoreLog.swift | 16 +- .../RemoteDataChangeReconciler.swift | 26 + .../Persistence/StoreRemoteChangeSource.swift | 168 ++- .../Sources/Persistence/SwiftDataStore.swift | 1201 ++++++++++++++--- .../Sources/Persistence/WhereDataEpoch.swift | 268 ++++ .../Sources/Persistence/WhereStore.swift | 125 +- .../Preferences/WherePreferences.swift | 26 +- .../WhereCore/Sources/RegionAttribution.swift | 47 +- .../Sources/Reporting/ReportReader.swift | 66 +- .../Sources/Resources/Localizable.xcstrings | 187 +++ .../Sources/WhereServices+Intents.swift | 31 +- Where/WhereCore/Sources/WhereServices.swift | 132 +- .../Sources/Widgets/WidgetDataReader.swift | 56 +- .../Widgets/WidgetSnapshotPublisher.swift | 15 + .../Tests/BackupCoordinatorTests.swift | 538 +++++++- .../WhereCore/Tests/BackupServiceTests.swift | 342 ++++- Where/WhereCore/Tests/DayJournalTests.swift | 2 + .../DeviceRecordingControllerTests.swift | 921 +++++++++++-- .../Tests/DismissedIssueStoreTests.swift | 8 +- .../InstallationRecordingContextTests.swift | 47 + .../Tests/LocationIngestorTests.swift | 511 ++++++- .../WhereCore/Tests/LocationOutboxTests.swift | 251 +++- ...cordingConfigurationBroadcasterTests.swift | 41 + .../RecordingDeviceMetadataChangeTests.swift | 53 + .../Tests/RecordingPolicyChangeTests.swift | 371 +++++ .../Tests/RecordingPolicyFilterTests.swift | 218 ++- .../Tests/RegionAttributionTests.swift | 2 + .../RemoteDataChangeReconcilerTests.swift | 38 + Where/WhereCore/Tests/ReportReaderTests.swift | 18 +- .../Tests/StoreRemoteChangeSourceTests.swift | 102 +- .../WhereCore/Tests/SwiftDataStoreTests.swift | 998 +++++++++++++- .../Tests/TrackedRegionStoreTests.swift | 10 +- .../Tests/WhereCoreTestSupport.swift | 66 + Where/WhereCore/Tests/WhereCoreTests.swift | 41 +- .../WhereCore/Tests/WhereDataEpochTests.swift | 253 ++++ .../Tests/WherePreferencesTests.swift | 26 +- .../WhereCore/Tests/WhereServicesTests.swift | 536 +++++++- .../Tests/WidgetSnapshotPublisherTests.swift | 53 + Where/WhereUI/AGENTS.md | 17 + Where/WhereUI/README.md | 37 +- ...ereLifecycleFailureViewSnapshotTests.swift | 10 + .../devices.Default_iPad.png | 4 +- .../devices.Default_iPad_accessibility.png | 4 +- .../devices.Default_iPad_ax5.png | 4 +- .../devices.Default_iPad_contrast.png | 4 +- .../devices.Default_iPad_dark.png | 4 +- .../devices.Default_iPhone.png | 4 +- .../devices.Default_iPhone_accessibility.png | 4 +- .../devices.Default_iPhone_ax5.png | 4 +- .../devices.Default_iPhone_contrast.png | 4 +- .../devices.Default_iPhone_dark.png | 4 +- ...onboarding.PhoneRecordingChoice_iPhone.png | 4 +- ...arding.PhoneRecordingChoice_iPhone_ax5.png | 3 + .../onboarding.TabletRecordingChoice_iPad.png | 4 +- .../seededEntryState.WhereFlyover_iPad.png | 4 +- ...eFailure.CommittedImportCleanup_iPhone.png | 3 + ...ure.CommittedImportCleanup_iPhone_dark.png | 3 + ...cleFailure.CommittedImportSetup_iPhone.png | 3 + ...ilure.CommittedImportSetup_iPhone_dark.png | 3 + ...ycleFailure.CommittedResetCleanup_iPad.png | 3 + ...mmittedResetCleanup_iPad_accessibility.png | 3 + ...Failure.CommittedResetCleanup_iPad_ax5.png | 3 + ...re.CommittedResetCleanup_iPad_contrast.png | 3 + ...ailure.CommittedResetCleanup_iPad_dark.png | 3 + ...leFailure.CommittedResetCleanup_iPhone.png | 3 + ...ittedResetCleanup_iPhone_accessibility.png | 3 + ...ilure.CommittedResetCleanup_iPhone_ax5.png | 3 + ....CommittedResetCleanup_iPhone_contrast.png | 3 + ...lure.CommittedResetCleanup_iPhone_dark.png | 3 + .../Flyover/WhereFlyoverCatalog.swift | 1 + .../CurrentRecordingDeviceProvider.swift | 119 -- ...oryInstallationRecordingContextStore.swift | 78 ++ .../InstallationRecordingContextStore.swift | 696 ++++++++++ .../WhereUI/Sources/Launch/WhereLaunch.swift | 40 +- .../Sources/Launch/WhereLaunchSteps.swift | 76 +- .../Launch/WhereLifecycleFailureView.swift | 149 ++ .../Sources/Logging/BackupModelLog.swift | 5 +- .../Sources/Logging/OnboardingViewLog.swift | 32 +- Where/WhereUI/Sources/Model/WhereModel.swift | 183 ++- Where/WhereUI/Sources/Model/WhereScope.swift | 13 +- .../WhereUI/Sources/Model/WhereSession.swift | 170 ++- .../OnboardingRestoreSelection.swift | 118 ++ .../Sources/Onboarding/OnboardingView.swift | 389 ++++-- .../Sources/Preview/PreviewSupport.swift | 19 +- .../Sources/Resources/Localizable.xcstrings | 163 ++- Where/WhereUI/Sources/RootView.swift | 18 +- .../Sources/Settings/BackupModel.swift | 211 ++- .../Settings/BackupSettingsSection.swift | 92 +- .../Settings/DeviceSettingsRowModel.swift | 285 +++- .../Settings/DeviceSettingsSection.swift | 112 +- .../Settings/DevicesSettingsModel.swift | 293 +++- .../Settings/DevicesSettingsView.swift | 30 +- .../Sources/Settings/SettingsRow.swift | 24 +- .../WhereUI/Sources/Shared/WhereFormat.swift | 32 + Where/WhereUI/Tests/BackupModelTests.swift | 103 +- .../CurrentRecordingDeviceProviderTests.swift | 105 -- .../Tests/DemoModeEnvironmentTests.swift | 3 +- Where/WhereUI/Tests/DemoModeTests.swift | 21 +- .../Tests/DeviceSettingsRowModelTests.swift | 49 +- .../Tests/DevicesSettingsModelTests.swift | 582 +++++++- ...stallationRecordingContextStoreTests.swift | 69 + ...stallationRecordingContextStoreTests.swift | 405 ++++++ .../OnboardingRestoreSelectionTests.swift | 60 + Where/WhereUI/Tests/OnboardingTests.swift | 80 +- .../TestInstallationRecordingContext.swift | 9 + Where/WhereUI/Tests/Support/TestStore.swift | 116 +- .../Tests/WhereFlyoverWorldTests.swift | 1 - Where/WhereUI/Tests/WhereFormatTests.swift | 18 + Where/WhereUI/Tests/WhereLaunchTests.swift | 312 ++++- .../WhereLifecycleFailureViewTests.swift | 79 ++ Where/WhereUI/Tests/WhereModelTests.swift | 9 +- Where/WhereUI/Tests/WhereResetTests.swift | 186 ++- .../Tests/WhereSessionTrackingTests.swift | 71 +- Where/install | 22 +- test | 6 +- 151 files changed, 16560 insertions(+), 2053 deletions(-) create mode 100644 Where/Tools/Tests/upgrade_backup_test.rb create mode 100644 Where/WhereCore/Sources/Backup/BackupImportRecovery.swift create mode 100644 Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift create mode 100644 Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingConfigurationBroadcaster.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceProfile.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceRuntimeState.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingPolicyResolution.swift create mode 100644 Where/WhereCore/Sources/Devices/ResolvedRecordingPolicy.swift create mode 100644 Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift create mode 100644 Where/WhereCore/Sources/Persistence/RemoteDataChangeReconciler.swift create mode 100644 Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift create mode 100644 Where/WhereCore/Tests/InstallationRecordingContextTests.swift create mode 100644 Where/WhereCore/Tests/RecordingConfigurationBroadcasterTests.swift create mode 100644 Where/WhereCore/Tests/RecordingDeviceMetadataChangeTests.swift create mode 100644 Where/WhereCore/Tests/RecordingPolicyChangeTests.swift create mode 100644 Where/WhereCore/Tests/RemoteDataChangeReconcilerTests.swift create mode 100644 Where/WhereCore/Tests/WhereDataEpochTests.swift create mode 100644 Where/WhereUI/SnapshotTests/WhereLifecycleFailureViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportCleanup_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportCleanup_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportSetup_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportSetup_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_dark.png delete mode 100644 Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift create mode 100644 Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift create mode 100644 Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift create mode 100644 Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift create mode 100644 Where/WhereUI/Sources/Onboarding/OnboardingRestoreSelection.swift delete mode 100644 Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift create mode 100644 Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift create mode 100644 Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift create mode 100644 Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift create mode 100644 Where/WhereUI/Tests/Support/TestInstallationRecordingContext.swift create mode 100644 Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift diff --git a/AGENTS.md b/AGENTS.md index 986b4b10..7a4a7877 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -527,7 +527,9 @@ flag is needed there. ## Running tests **Use [`./test`](test)** — the only way to run tests. Never hand-roll `tuist -test` or `xcodebuild`. **Validate in proportion to risk:** run +test` or `xcodebuild`. It runs the host-side backup-upgrader regression before +selecting an iOS bundle, so tool-only changes remain covered by the same entry +point. **Validate in proportion to risk:** run `./swiftformat --lint` when the changed files are in its scope, and run the narrowest applicable `./test` tier for code, build, tooling, or behavior changes. Pure documentation or comment-only changes may skip checks that diff --git a/Where/AGENTS.md b/Where/AGENTS.md index cd7a479c..1697764b 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -71,10 +71,13 @@ Rules the code enforces and agents must preserve: every automatic GPS sample with its `RecordingDeviceID`; write enable/disable events with an effective timestamp; route every user-facing sample read through `LocationHistoryReader`. A synced cutoff hides later raw samples - immediately while the target device is still pending, and raw/legacy/manual - history is never deleted or hidden without an attributable device policy. - Seed the first policy only from that installation's confirmed onboarding - choice: phone recommends On; tablet/other recommend Off. + immediately while the target device is still pending; device-stamped samples + fail closed until an effective On exists; archive is a state in that same + multi-parent causal policy DAG, and legacy/manual history remains visible. + Persist immutable profiles, nickname events, target-owned check-ins, and + desired-policy events separately. Keep the confirmed choice and + immutable first profile/policy IDs and timestamps beside the backup-excluded + installation identity; phone recommends On, while tablet/other recommends Off. - **Manual entries carry a `ManualEntryAudit`**; `DayJournal`'s write methods take an explicit `audit:` (no default). An additive backfill can't downgrade an authoritative row's regions, but the newer audit always wins. @@ -140,9 +143,9 @@ slow. `WhereResetTests.loggingOutReleasesTheScopeBeforeTheNextLoginOpensOne`. `WhereFlyoverWorldTests.buildsASeededSiblingWithoutActivatingIt`. - **The onboarding gate declares `modes: .all`,** not the `.foreground` - default: parking a headless launch is the point. It also parks an already - onboarded installation once when its device-local recording choice is - unconfirmed; that surface skips straight to the final choice page. + default: parking a headless launch is the point. Keep recording confirmation + in the backup-excluded installation sidecar, so restoring backed-up + `hasOnboarded` onto another device parks at the final choice page. - **A gate carries no value,** so a choice made *at* it reaches `resolve-scope` through `WhereModel` — the one step that reads model state rather than the trunk. diff --git a/Where/TODOs.md b/Where/TODOs.md index 34580c8b..e98cc97b 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -17,11 +17,12 @@ The item format and the placement rule live in the root - fix(WhereCore): Nothing gets recorded on a day with no movement — presumably because background updates ride on GPS. Any way to guarantee a daily boot outside of GPS? (human) ## P0s (Must do) -- fix(WhereCore) [needs-design]: `DailySummaryReconciler.reconcile()` is absent from the post-day-change fan-out. Its only caller is `configure` (`DailySummaryReconciler.swift:45`), and `DayJournal.reconcileAfterDayChange()` (`:63`) fans out to issue state and widgets only — so the daily notification body stays stale until a foreground re-`configure`. Add it to the fan-out (the GPS ingest hook, `reconcileAfterDayChange()`, backup `onImport`), or document the foreground-only policy. (audit 2026-07-26) +- fix(WhereCore) [needs-design]: `DailySummaryReconciler.reconcile()` is absent from the post-day-change fan-out. Its local-write callers still fan out to issue state and widgets only, so the daily notification body stays stale until a foreground re-`configure` (backup and remote imports now use the full composition-root reconcile). Add it to the GPS ingest hook and `DayJournal.reconcileAfterDayChange()`, or document the foreground-only policy. (audit 2026-07-26; PR #160 narrowed scope) - test(WhereCore) [quick-win]: Mutate data and assert the summary notification body updates without a re-`configure`. (audit 2026-07-26) - perf(WhereCore) [needs-design]: Performance pass — how often is the app booting? Can we only do it on changes of, say, 1 km or more? (human) ## P1s (Should do) +- fix(WhereCore) [needs-design]: Replace cross-device wall-clock recording cutoffs with a target-applied or server-time boundary. Causal revisions converge which policy wins, but `RecordingPolicyFilter` still compares sample timestamps against `effectiveAt` authored on the issuing device, so substantial clock skew can hide pre-disable samples or expose post-disable samples. Preserve the immediate remote-history cutoff while making its boundary independent of peer clock agreement. (`RecordingPolicyChange.swift`, `RecordingPolicyFilter.swift`; PR #160 review) - refactor(WhereCore) [needs-design]: Scope diagnostic emission so Flyover's unactivated sibling demo world cannot write its activity through the process-global `WhereLog` / `Periscope.shared` facade into the active real scope's durable diagnostic store. `WhereFlyoverWorld.build()` correctly gives the sibling a private `Periscope` and never starts its sink, but static `WhereLog` channels still bypass that injection; carry the scope's logging system through services/models or add a task-/environment-scoped routing context before treating Flyover's diagnostic activity as isolated. Domain data, preferences, widgets, notifications, and location remain in memory/no-op already. (`WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift`, `WhereCore/Sources/Logging/WhereLog.swift`; agent 2026-07-29) - fix(WhereUI) [quick-win]: `CalendarDay.displayDate` resolves through `Calendar.current` (`DateRangeFormatting.swift:33`), so every day label that flows through it — relabel, logged days, resolution details, the region drill-in — renders a wrong date on a non-Gregorian device: `startOfDay(in:)` interprets the day's Gregorian Y-M-D as *that* calendar's components, so a Buddhist-era device resolves 2026-07-26 to a date ~543 years off. `DateRangeFormatting.abbreviated` (`:6`, `:19`) and `PresenceTimeline.stints` (`PresenceTimeline.swift:37`) also *default* to `.current`, and `PresenceTimelineList` (`:12`) doesn't pass `report.calendar`. Take an explicit calendar (Gregorian + current time zone) in the helper and thread the report's calendar from the call sites. The `where.gregorian_calendar` Bumper rule that should catch this is blind to the implicit-member form — filed in the root [`TODOs.md`](../TODOs.md). (audit 2026-07-26) - fix(WhereCore) [needs-design]: `WhereServices.setPrimaryRegions(_:)` (`:285`) commits atomically but skips `DayJournal.reconcileAfterDayChange()` — widgets/reminders/summary don't refresh until foreground/configure. Route picker commits through the unified fan-out, or document the intentional deferral. (audit 2026-07-26) @@ -61,6 +62,8 @@ The item format and the placement rule live in the root - fix(WhereUI): broken-snapshots: `locations.Loaded_iPad.png` bakes in raw inflection markup — the Elsewhere card's subtitle renders literally as `^[3 region](inflect: true)`. This is the `locations.elsewhere.subtitle` P1 filed above, now pinned as a reference; recorded here so the image isn't mistaken for correct output, and so that reference is re-recorded when the fix lands. (pr#101 review) ## P2s (Nice to have) +- perf(WhereCore) [needs-design]: Measure and bound the append-only recording-policy and device-metadata timelines. Compaction must preserve the current causal winner, historical cutoff semantics, backup round trips, and enough audit history to diagnose cross-device commands; do not delete events merely because a newer one exists. (`DeviceRecordingController.swift`, `RecordingPolicyFilter.swift`; PR #160 review) +- feat(Where): Consider the user-assigned device-name entitlement and matching provisioning-profile support so the Devices screen can offer a better initial label than the generic hardware family. Keep the current generic name until the entitlement is intentionally provisioned; never silently depend on an entitlement absent from developer signing. (`FileInstallationRecordingContextStore`; PR #160 review) - feat(WhereUI) [needs-design]: Give the app a branded launch screen. `UILaunchScreen` is an empty dictionary (`Project.swift`), so the pre-main frame is plain white. Measured from a fresh-install simulator recording, a first run reads as ~1.7s of white → ~0.25s of the dark `LaunchSplashView` → the light onboarding screen, so the splash registers as a quarter-second dark blip between two light screens rather than as the app opening. A launch screen matching the splash's background + icon would make that continuous. Note this is the right layer to fix it at: the splash's own `minimumSplashDuration` hold deliberately gates only the `.ready` reveal, not a gate transition like onboarding, so lengthening the hold would just delay interactive UI. (agent) - refactor(WhereUI) [needs-design]: Make the scene-scoped model wiring compiler-checked rather than an `@Environment` lookup that fails silently. `WhereSession` (the always-on coordinator) is read from the environment, so a screen mounted without a parent injecting it resolves to a runtime fallback/precondition instead of a compile error. The scoped models (`YearReportModel`, `ResolveModel`, `BackupModel`, `RemindersSettingsModel`) are already constructor-injected; explore threading the coordinator the same way (or a non-defaulting typed `EnvironmentKey`) so a broken wiring can't build. Follow-up from the `WhereSession` split. (agent) - refactor(WhereUI) [needs-design]: Split `YearReportModel` further. Post-split it still fuses several roles for the selected year: the loaded report + everything derived from it (ranking, missing days, calendar inputs, tracked-day count), the Resolve badge *count*, the day-write intents (`setManualDay(s)`, `overrideDay`, `clearManualDay`, `clearSelectedYear`), and the Elsewhere drill-in reads (`days(in:)`, `locations(in:)`, `representativeCoordinates()`). The read-only presentation state and the write-intent/drill-in surface could be separate collaborators so a view only holds what it uses. Follow-up from the `WhereSession` split. (agent) diff --git a/Where/Tools/Tests/upgrade_backup_test.rb b/Where/Tools/Tests/upgrade_backup_test.rb new file mode 100644 index 00000000..f66f023e --- /dev/null +++ b/Where/Tools/Tests/upgrade_backup_test.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../upgrade-backup" + +class UpgradeBackupTest < Minitest::Test + DEVICE_ID = "store://devices/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + + def test_v3_final_archive_state_out_ranks_its_later_monotonic_off_cutoff + manifest = { + "formatVersion" => 3, + "samples" => [], + "evidence" => [], + "manualDays" => [], + "dismissedIssues" => [], + "trackedRegions" => [], + "primaryRegions" => [], + "assets" => [], + "recordingDevices" => [ + { + "id" => DEVICE_ID, + "systemName" => "iPad", + "kind" => "tablet", + "registeredAt" => 100.0, + "lastSeenAt" => 200.0, + "archivedAt" => 200.0, + "lastAppliedPolicyChangeID" => "22222222-2222-2222-2222-222222222222", + "status" => "off", + "nickname" => nil, + }, + ], + "recordingPolicyChanges" => [ + { + "id" => "11111111-1111-1111-1111-111111111111", + "deviceID" => DEVICE_ID, + "effectiveAt" => 100.0, + "isEnabled" => true, + }, + { + "id" => "22222222-2222-2222-2222-222222222222", + "deviceID" => DEVICE_ID, + # The v3 writer advanced equal/backward cutoffs by one microsecond. + "effectiveAt" => 200.000001, + "isEnabled" => false, + }, + ], + } + + upgraded = upgrade_manifest(manifest) + policies = upgraded.fetch("recordingPolicyChanges") + + assert_equal [0, 1, 2], policies.map { |policy| policy.fetch("revision") } + assert_equal [ + [], + [policies[0].fetch("id")], + [policies[1].fetch("id")], + ], policies.map { |policy| policy.fetch("parentIDs") } + refute policies.any? { |policy| policy.key?("parentID") } + assert_equal "archived", policies.last.fetch("state") + assert_equal "archive", policies.last.fetch("reason") + end + + def test_v3_active_device_expands_into_current_tables_idempotently + policy_id = "11111111-1111-1111-1111-111111111111" + manifest = base_manifest(3).merge( + "recordingDevices" => [ + { + "id" => DEVICE_ID, + "systemName" => "iPhone", + "kind" => "phone", + "registeredAt" => 100.0, + "lastSeenAt" => 200.0, + "archivedAt" => nil, + "lastAppliedPolicyChangeID" => policy_id, + "status" => "recording", + "nickname" => "Travel phone", + }, + ], + "recordingPolicyChanges" => [ + { + "id" => policy_id, + "deviceID" => DEVICE_ID, + "effectiveAt" => 100.0, + "isEnabled" => true, + }, + ], + ) + + upgraded = upgrade_manifest(deep_copy(manifest)) + + assert_equal CURRENT_FORMAT_VERSION, upgraded.fetch("formatVersion") + refute upgraded.key?("recordingDevices") + assert_equal [ + { + "id" => DEVICE_ID, + "systemName" => "iPhone", + "kind" => "phone", + "registeredAt" => 100.0, + "registrationEpochID" => { "rawValue" => INITIAL_DATA_EPOCH_ID }, + }, + ], upgraded.fetch("recordingDeviceProfiles") + + metadata = upgraded.fetch("recordingDeviceMetadataChanges") + assert_equal 1, metadata.length + assert_equal DEVICE_ID, metadata.first.fetch("deviceID") + assert_equal "nickname", metadata.first.fetch("field") + assert_equal 0, metadata.first.fetch("revision") + assert_equal 200.0, metadata.first.fetch("changedAt") + assert_equal DEVICE_ID, metadata.first.fetch("changedByDeviceID") + assert_equal "Travel phone", metadata.first.fetch("nickname") + + assert_equal [ + { + "deviceID" => DEVICE_ID, + "revision" => 0, + "lastSeenAt" => 200.0, + "appliedAt" => 200.0, + "lastAppliedPolicyChangeID" => policy_id, + "status" => "recording", + }, + ], upgraded.fetch("recordingDeviceCheckIns") + + policy = upgraded.fetch("recordingPolicyChanges").fetch(0) + assert_equal policy_id, policy.fetch("id") + assert_equal DEVICE_ID, policy.fetch("deviceID") + assert_equal [], policy.fetch("parentIDs") + refute policy.key?("parentID") + assert_equal 0, policy.fetch("revision") + assert_equal 100.0, policy.fetch("issuedAt") + assert_equal DEVICE_ID, policy.fetch("issuedByDeviceID") + assert_equal 100.0, policy.fetch("effectiveAt") + assert_equal "on", policy.fetch("state") + assert_equal "initialRegistration", policy.fetch("reason") + + assert_equal upgraded, upgrade_manifest(deep_copy(upgraded)) + end + + def test_v4_preserves_independent_tables_and_links_flat_policy_revisions + root_id = "11111111-1111-1111-1111-111111111111" + on_id = "22222222-2222-2222-2222-222222222222" + off_id = "33333333-3333-3333-3333-333333333333" + descendant_id = "44444444-4444-4444-4444-444444444444" + profile = { + "id" => DEVICE_ID, + "systemName" => "iPad", + "kind" => "tablet", + "registeredAt" => 100.0, + } + metadata = [ + { + "id" => "aaaaaaaa-1111-1111-1111-111111111111", + "deviceID" => DEVICE_ID, + "field" => "nickname", + "revision" => 0, + "changedAt" => 110.0, + "changedByDeviceID" => DEVICE_ID, + "nickname" => "Kitchen iPad", + }, + ] + check_ins = [ + { + "deviceID" => DEVICE_ID, + "revision" => 7, + "lastSeenAt" => 140.0, + "appliedAt" => 140.0, + "lastAppliedPolicyChangeID" => off_id, + "status" => "off", + }, + ] + policies = [ + policy(root_id, revision: 0, state: "off", effective_at: 100.0), + policy(on_id, revision: 1, state: "on", effective_at: 120.0), + policy(off_id, revision: 1, state: "off", effective_at: 121.0), + policy(descendant_id, revision: 2, state: "on", effective_at: 130.0), + ] + manifest = base_manifest(4).merge( + "recordingDeviceProfiles" => [profile], + "recordingDeviceMetadataChanges" => metadata, + "recordingDeviceCheckIns" => check_ins, + "recordingPolicyChanges" => policies, + ) + + upgraded = upgrade_manifest(deep_copy(manifest)) + upgraded_policies = upgraded.fetch("recordingPolicyChanges") + + assert_equal CURRENT_FORMAT_VERSION, upgraded.fetch("formatVersion") + assert_equal [profile.merge( + "registrationEpochID" => { "rawValue" => INITIAL_DATA_EPOCH_ID }, + )], upgraded.fetch("recordingDeviceProfiles") + assert_equal metadata, upgraded.fetch("recordingDeviceMetadataChanges") + assert_equal check_ins, upgraded.fetch("recordingDeviceCheckIns") + assert_equal policies, upgraded_policies.map { |entry| entry.reject { |key| key == "parentIDs" } } + assert_equal [], upgraded_policies[0].fetch("parentIDs") + assert_equal [root_id], upgraded_policies[1].fetch("parentIDs") + assert_equal [root_id], upgraded_policies[2].fetch("parentIDs") + # The old flat resolver preferred Off at the concurrent revision, so the + # formerly ambiguous revision-2 event is attached to that deterministic winner. + assert_equal [off_id], upgraded_policies[3].fetch("parentIDs") + refute upgraded_policies.any? { |entry| entry.key?("parentID") } + end + + def test_v6_converts_nullable_scalar_policy_parents_to_parent_sets + root_id = "11111111-1111-1111-1111-111111111111" + child_id = "22222222-2222-2222-2222-222222222222" + policies = [ + policy(root_id, revision: 0, state: "off", effective_at: 100.0).merge( + "parentID" => nil, + ), + policy(child_id, revision: 1, state: "on", effective_at: 120.0).merge( + "parentID" => root_id, + ), + ] + manifest = base_manifest(6).merge("recordingPolicyChanges" => policies) + + upgraded = upgrade_manifest(deep_copy(manifest)) + upgraded_policies = upgraded.fetch("recordingPolicyChanges") + + assert_equal CURRENT_FORMAT_VERSION, upgraded.fetch("formatVersion") + assert_equal [[], [root_id]], upgraded_policies.map { |entry| entry.fetch("parentIDs") } + refute upgraded_policies.any? { |entry| entry.key?("parentID") } + assert_equal upgraded, upgrade_manifest(deep_copy(upgraded)) + end + + def test_v7_preserves_a_sorted_multi_parent_set + parent_ids = [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + ] + join = policy( + "33333333-3333-3333-3333-333333333333", + revision: 2, + state: "off", + effective_at: 130.0, + ).merge("parentIDs" => parent_ids) + manifest = base_manifest(7).merge("recordingPolicyChanges" => [join]) + + upgraded = upgrade_manifest(deep_copy(manifest)) + + assert_equal CURRENT_FORMAT_VERSION, upgraded.fetch("formatVersion") + assert_equal parent_ids, upgraded.fetch("recordingPolicyChanges").fetch(0).fetch("parentIDs") + refute upgraded.fetch("recordingPolicyChanges").fetch(0).key?("parentID") + end + + private + + def base_manifest(version) + { + "formatVersion" => version, + "exportedAt" => 0.0, + "samples" => [], + "evidence" => [], + "manualDays" => [], + "dismissedIssues" => [], + "trackedRegions" => [], + "primaryRegions" => [], + "assets" => [], + } + end + + def policy(id, revision:, state:, effective_at:) + { + "id" => id, + "deviceID" => DEVICE_ID, + "revision" => revision, + "issuedAt" => effective_at, + "issuedByDeviceID" => DEVICE_ID, + "effectiveAt" => effective_at, + "state" => state, + "reason" => revision.zero? ? "initialRegistration" : "userCommand", + } + end + + def deep_copy(value) + JSON.parse(JSON.generate(value)) + end +end diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 985c9916..0a78471d 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -21,13 +21,24 @@ # - Dismissals: converts `{ "key": "borderDrift:2026-04-01", ... }` to # `{ "id": "store://issues/borderDrift?day=2026-04-01", ... }`, parsing the # old joined key and recovering any legacy epoch value to a calendar day. +# - Recording devices: splits the v3 aggregate rows into immutable profiles, +# append-only nickname metadata, target-owned check-ins, and causally ordered +# complete-authority policy events. Interim archive metadata is folded into +# that policy stream, boolean policy values become explicit state/reason, +# and pre-v6 flat revisions are parent-linked into a deterministic causal +# branch while retaining concurrent losing events for audit/cleanup; v6's +# scalar `parentID` becomes v7's sorted `parentIDs` set so current commands +# can causally join every observed concurrent head. +# v1-v4 profiles are stamped as registrations from the initial logical data +# epoch; v4's nickname/check-in rows remain untouched and its flat policy +# revisions gain parent links without being reordered or renumbered. # - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes # `primaryRegions` from the tracked ids (null appearance, listed order) when # absent, adds empty device/policy tables, stamps legacy samples with null -# device provenance, and sets `formatVersion` to 3 (the current version). +# device provenance, and sets `formatVersion` to 7 (the current version). # -# Idempotent: re-running on an already-upgraded archive is a no-op (it only -# touches legacy `date` / `key` fields and unmapped region ids). +# Idempotent: re-running on an already-upgraded archive is a no-op; every legacy +# date, id, and recording-authority transform converges on one stable value. # # Usage (from the repo root): # ruby Where/Tools/upgrade-backup.rb INPUT.zip [OUTPUT.zip] @@ -39,9 +50,12 @@ require "fileutils" require "time" require "set" +require "digest" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 3 +CURRENT_FORMAT_VERSION = 7 +INITIAL_DATA_EPOCH_ID = "00000000-0000-0000-0000-0000000000E0" +SUPPORTED_SOURCE_FORMAT_VERSIONS = (1..CURRENT_FORMAT_VERSION).freeze # Former enum-case region ids -> current catalog ids. `canada` / `other` are # unchanged but listed so an already-current id passes through untouched. @@ -163,7 +177,267 @@ def upgrade_dismissals!(manifest) end end +def deterministic_uuid(seed) + hex = Digest::SHA256.hexdigest(seed)[0, 32] + hex[12] = "5" + hex[16] = ((hex[16].to_i(16) & 0x3) | 0x8).to_s(16) + [hex[0, 8], hex[8, 4], hex[12, 4], hex[16, 4], hex[20, 12]].join("-") +end + +def instant_seconds(value) + value.is_a?(Numeric) ? value.to_f : Time.iso8601(value).to_f +rescue ArgumentError + 0.0 +end + +def policy_sort_key(change) + [instant_seconds(change.fetch("effectiveAt")), change.fetch("id")] +end + +def normalize_policy!(change) + change["issuedAt"] ||= change.fetch("effectiveAt") + # The v3 wire format did not retain the author. The target installation is + # the only deterministic attribution available during an out-of-band upgrade. + change["issuedByDeviceID"] ||= change.fetch("deviceID") + + unless change.key?("state") + enabled = change.delete("isEnabled") do + die "recording policy #{change.fetch('id').inspect} has neither state nor isEnabled" + end + change["state"] = enabled ? "on" : "off" + end + change.delete("isEnabled") + change["reason"] ||= if change.fetch("state") == "archived" + "archive" + elsif change.fetch("revision", 0).zero? && change.fetch("issuedByDeviceID") == change.fetch("deviceID") + "initialRegistration" + else + "userCommand" + end +end + +def policy_conflict_key(change) + reason_priority = case change.fetch("reason") + when "backupReplace" then 1 + when "accountReset" then 2 + else 0 + end + state_priority = case change.fetch("state") + when "on" then 0 + when "off" then 1 + when "archived" then 2 + else die "unknown recording policy state #{change.fetch('state').inspect}" + end + [reason_priority, state_priority, change.fetch("id")] +end + +# Pre-v6 archives had only flat revisions, so the actual parent of a command written after a +# concurrent fork is unknowable. Link every event at the next revision to the same deterministic +# winner the old reader treated as current. This preserves the old archive's resolved authority; +# future writes will carry their exact observed parent and cannot cross branches. +def upgrade_policy_parents!(manifest, source_version) + return if source_version >= 6 + + Array(manifest["recordingPolicyChanges"]) + .group_by { |change| change.fetch("deviceID") } + .each_value do |changes| + by_revision = changes.group_by { |change| change.fetch("revision") } + revisions = by_revision.keys.sort + unless revisions.first == 0 && revisions.each_cons(2).all? { |before, after| after == before + 1 } + die "recording policy revisions must begin at zero without gaps" + end + + parent = nil + revisions.each do |revision| + siblings = by_revision.fetch(revision) + siblings.each do |change| + if revision.zero? + change.delete("parentID") + else + change["parentID"] = parent.fetch("id") + end + end + parent = siblings.max_by { |change| policy_conflict_key(change) } + end + end +end + +# v6 could name only one causal parent. The v7 wire shape always carries the full sorted parent +# frontier; legacy events can only contribute an empty root set or one already-canonical parent. +def upgrade_policy_parent_sets!(manifest, source_version) + return if source_version >= 7 + + Array(manifest["recordingPolicyChanges"]).each do |change| + parent_id = change.delete("parentID") + change["parentIDs"] = parent_id.nil? ? [] : [parent_id] + end +end + +def archive_policy_change(metadata) + metadata_id = metadata.fetch("id") + { + "id" => deterministic_uuid( + ["recording-policy-from-archive-metadata", metadata_id].join(":"), + ), + "deviceID" => metadata.fetch("deviceID"), + "issuedAt" => metadata.fetch("changedAt"), + "issuedByDeviceID" => metadata.fetch("changedByDeviceID"), + "effectiveAt" => metadata.fetch("changedAt"), + "archiveValue" => metadata.fetch("isArchived"), + } +end + +def upgrade_pre_v4_recording_devices!(manifest) + legacy_devices = Array(manifest.delete("recordingDevices")) + + manifest["recordingDeviceProfiles"] ||= legacy_devices.map do |device| + { + "id" => device.fetch("id"), + "systemName" => device.fetch("systemName"), + "kind" => device.fetch("kind"), + "registeredAt" => device.fetch("registeredAt"), + } + end + + manifest["recordingDeviceMetadataChanges"] ||= legacy_devices.filter_map do |device| + device_id = device.fetch("id") + next if device["nickname"].nil? + + changed_at = device["lastSeenAt"] || device.fetch("registeredAt") + nickname = device.fetch("nickname") + seed = ["recording-device-metadata", device_id, "nickname", nickname, changed_at] + .map(&:to_json).join(":") + { + "id" => deterministic_uuid(seed), + "deviceID" => device_id, + "field" => "nickname", + "revision" => 0, + "changedAt" => changed_at, + "changedByDeviceID" => device_id, + "nickname" => nickname, + } + end + + manifest["recordingDeviceCheckIns"] ||= legacy_devices.filter_map do |device| + policy_id = device["lastAppliedPolicyChangeID"] + status = device.fetch("status") + next if policy_id.nil? || status == "unknown" + + last_seen_at = device["lastSeenAt"] || device.fetch("registeredAt") + { + "deviceID" => device.fetch("id"), + "revision" => 0, + "lastSeenAt" => last_seen_at, + "appliedAt" => last_seen_at, + "lastAppliedPolicyChangeID" => policy_id, + "status" => status, + } + end + + policies = Array(manifest["recordingPolicyChanges"]) + policies.group_by { |change| change.fetch("deviceID") }.each_value do |changes| + changes.sort_by { |change| policy_sort_key(change) }.each_with_index do |change, revision| + change["revision"] ||= revision + normalize_policy!(change) + end + end + + metadata = Array(manifest["recordingDeviceMetadataChanges"]) + archive_metadata, nickname_metadata = metadata.partition { |change| change["field"] == "archive" } + manifest["recordingDeviceMetadataChanges"] = nickname_metadata + + legacy_archive_metadata = legacy_devices.filter_map do |device| + next if device["archivedAt"].nil? + + changed_at = device.fetch("archivedAt") + seed = ["recording-device-metadata", device.fetch("id"), "archive", true, changed_at] + .map(&:to_json).join(":") + { + "id" => deterministic_uuid(seed), + "deviceID" => device.fetch("id"), + "changedAt" => changed_at, + "changedByDeviceID" => device.fetch("id"), + "isArchived" => true, + } + end + + archive_commands = archive_metadata.map { |change| archive_policy_change(change) } + # `recordingDevices.archivedAt` is the v3 aggregate's final state, not merely another event + # ordered by wall clock. The paired Off cutoff can legitimately be later than `archivedAt` + # because the old writer forced effective timestamps to increase monotonically. Preserve the + # aggregate's final archived state by ordering this synthesized authority after every ordinary + # policy event for the device. + final_archive_commands = legacy_archive_metadata.map do |change| + archive_policy_change(change) + end + + # The old archive-event and desired-policy streams had no shared causal revision. Merge those + # events by effective instant, placing archive commands after ordinary policy commands at the + # same instant. The aggregate's final `archivedAt` marker is the exception handled above: it + # must remain final regardless of its paired Off cutoff. An unarchive resumes the most recent + # non-archive desired state instead of silently enabling recording. + merged = (policies.map { |change| [change, false, false] } + + archive_commands.map { |change| [change, true, false] } + + final_archive_commands.map { |change| [change, true, true] }) + .group_by { |change, _archive, _final_archive| change.fetch("deviceID") } + .flat_map do |_device_id, entries| + last_non_archive_state = "off" + entries.sort_by do |change, archive, final_archive| + [ + final_archive ? 1 : 0, + instant_seconds(change.fetch("effectiveAt")), + archive ? 1 : 0, + change.fetch("id"), + ] + end.each_with_index.map do |(change, archive, _final_archive), revision| + if archive + archived = change.delete("archiveValue") + change["state"] = archived ? "archived" : last_non_archive_state + change["reason"] = archived ? "archive" : "userCommand" + elsif change.fetch("state") != "archived" + last_non_archive_state = change.fetch("state") + end + change["revision"] = revision + change + end + end + manifest["recordingPolicyChanges"] = merged +end + +def upgrade_recording_devices!(manifest, source_version) + if source_version < 4 + upgrade_pre_v4_recording_devices!(manifest) + else + # v4 already has independent causal tables. In particular, do not sort or + # renumber its policy events by wall clock: offline writers can legitimately + # produce multiple events at one revision, and their revision is authority. + manifest.delete("recordingDevices") + manifest["recordingDeviceProfiles"] ||= [] + manifest["recordingDeviceMetadataChanges"] ||= [] + manifest["recordingDeviceCheckIns"] ||= [] + manifest["recordingPolicyChanges"] ||= [] + end + + Array(manifest["recordingDeviceProfiles"]).each do |profile| + profile["registrationEpochID"] ||= { "rawValue" => INITIAL_DATA_EPOCH_ID } + end + upgrade_policy_parents!(manifest, source_version) + upgrade_policy_parent_sets!(manifest, source_version) +end + +def source_format_version(manifest) + version = manifest["formatVersion"] + unless version.is_a?(Integer) + die "manifest formatVersion must be an integer" + end + unless SUPPORTED_SOURCE_FORMAT_VERSIONS.cover?(version) + die "unsupported manifest formatVersion #{version}; expected 1-#{CURRENT_FORMAT_VERSION}" + end + version +end + def upgrade_manifest(manifest) + source_version = source_format_version(manifest) warnings = [] upgrade_evidence!(manifest, warnings) upgrade_manual_days!(manifest, warnings) @@ -182,8 +456,8 @@ def upgrade_manifest(manifest) Array(manifest["samples"]).each do |sample| sample["recordingDeviceID"] = nil unless sample.key?("recordingDeviceID") end - manifest["recordingDevices"] ||= [] manifest["recordingPolicyChanges"] ||= [] + upgrade_recording_devices!(manifest, source_version) manifest["formatVersion"] = CURRENT_FORMAT_VERSION warnings.uniq.each { |message| warn "warning: #{message}" } manifest @@ -238,4 +512,4 @@ def sort_deep(value) end end -main(ARGV) +main(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index 8c0d1bf2..db856514 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -49,7 +49,8 @@ layering, and the domain rules this target merely starts up. the `LifecycleRunner` (whose synchronous `initializePrerequisites` installs the `CLLocationManager` in time to receive the queued event) and hands it to `RootView` through `WhereApp`. Don't move this wiring into a view. -- **The regular runtime owns exactly one of each shared thing** — one `WhereModel`, one +- **The regular runtime owns exactly one of each shared thing** — one + `FileInstallationRecordingContextStore`, one `WhereModel`, one `IntentServices`, one launcher — created here and injected down, per [Composition](../../AGENTS.md#composition-create-once-inject-down). The launch's `resolve-scope` step is the process's only store open and runs @@ -60,6 +61,9 @@ layering, and the domain rules this target merely starts up. container (`iCloud.com.stuff.where`), Push Notifications entitlement, and remote-notification background mode together in `Project.swift`; widgets and the share extension stay App Group-only and never open a CloudKit container. +- **Choose the regular runtime's store explicitly.** Release uses `.cloudKit`; + Debug uses `.localOnly` unless built with `WHERE_CLOUDKIT_VALIDATION` + (`./Where/install --cloudkit`); the choice must survive every process relaunch. - **Nothing here may assume the user has a store.** `didFinishLaunching` starts the ambient log sources and drives the launch; anything wanting the user's data waits for `.ready` and checks what it got — the Spotlight indexing after diff --git a/Where/Where/README.md b/Where/Where/README.md index dcc4dc49..e6b4f6aa 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -73,12 +73,16 @@ entitlement, and the remote-notification background mode. Widgets and the share extension intentionally have only the App Group entitlement: they write/read local shared artifacts, while the app's single SwiftData container owns CloudKit mirroring. Debug uses `.localOnly`; exercise sync with a Release-signed -build. +build or use `./Where/install --cloudkit`. Release always selects `.cloudKit`. +The installer compiles the validation choice into that Debug app, so manual, +background, and CloudKit-push relaunches keep using CloudKit until another build +is installed without `--cloudkit`. Before shipping a schema change: -1. Install a Release build against the Development CloudKit environment and - open the store so SwiftData initializes the additive schema. +1. Run `./Where/install --cloudkit` (or install a Release build) against the + Development CloudKit environment and open the store so SwiftData initializes + the additive schema. 2. Inspect the new fields/record types in CloudKit Console, then deploy that schema to Production before distributing the build. 3. On two devices signed into the same iCloud account, open Settings → Devices @@ -91,8 +95,9 @@ Before shipping a schema change: and the waiting state clears on the carried device. Re-enable it and verify new locations appear again. 6. Archive the non-current device and verify it is hidden without losing older - report history. Export and replace-import a backup and verify device names, - raw samples, policy history, and archived state round-trip. + report history. Export and replace-import a backup; verify history and names + round-trip, archived imported devices stay hidden, file-absent devices stay + retired, and every visible device is Off until explicitly re-enabled. On a fresh install, onboarding recommends automatic recording On for an iPhone and Off for an iPad/other device, then requires the user to confirm. Existing diff --git a/Where/Where/Sources/RegularApplicationRuntime.swift b/Where/Where/Sources/RegularApplicationRuntime.swift index c6812a56..f418c3df 100644 --- a/Where/Where/Sources/RegularApplicationRuntime.swift +++ b/Where/Where/Sources/RegularApplicationRuntime.swift @@ -14,25 +14,59 @@ import WhereUI /// runner that make up the shipping application. @MainActor final class RegularApplicationRuntime: WhereApplicationRuntime { - let model = WhereModel( - preferences: WherePreferences(store: UserDefaults.standard), - makeBootstrap: { WhereBootstrap() }, - logSystem: .shared, - ) + let model: WhereModel let intentServices = IntentServices() private(set) var launcher: LifecycleRunner! #if DEBUG + /// Compiled into Debug device builds created by `Where/install --cloudkit`, so every + /// foreground, background, and CloudKit-push relaunch uses the same store mode. + static let isCloudKitValidationBuild: Bool = { + #if WHERE_CLOUDKIT_VALIDATION + true + #else + false + #endif + }() + private let inspectorModeController: InspectorModeController? init(inspectorModeController: InspectorModeController? = nil) { self.inspectorModeController = inspectorModeController + model = Self.makeModel(storeStorage: Self.storeStorage( + forCloudKitValidationBuild: Self.isCloudKitValidationBuild, + )) + } + + static func storeStorage( + forCloudKitValidationBuild validatesCloudKit: Bool, + ) -> SwiftDataStore.Storage { + validatesCloudKit ? .cloudKit : .localOnly } #else - init() {} + init() { + model = Self.makeModel(storeStorage: .cloudKit) + } #endif + private static func makeModel(storeStorage: SwiftDataStore.Storage) -> WhereModel { + let installationContextStore = FileInstallationRecordingContextStore() + let locationOutbox = FileLocationOutbox.applicationSupport() + return WhereModel( + preferences: WherePreferences(store: UserDefaults.standard), + installationContextStore: installationContextStore, + makeBootstrap: { + WhereBootstrap( + installationContextStore: $0, + storeStorage: storeStorage, + locationOutbox: locationOutbox, + ) + }, + logSystem: .shared, + ) + } + func didFinishLaunching( application _: UIApplication, options _: [UIApplication.LaunchOptionsKey: Any]?, diff --git a/Where/Where/Tests/WhereTests.swift b/Where/Where/Tests/WhereTests.swift index 944e1e98..7c8b3e44 100644 --- a/Where/Where/Tests/WhereTests.swift +++ b/Where/Where/Tests/WhereTests.swift @@ -25,6 +25,20 @@ struct WhereAppTests { } #if DEBUG + @Test func ordinaryDebugBuildUsesLocalOnlyStorageAcrossRelaunches() { + #expect( + RegularApplicationRuntime.storeStorage(forCloudKitValidationBuild: false) + == .localOnly, + ) + } + + @Test func cloudKitValidationBuildUsesCloudKitStorageAcrossRelaunches() { + #expect( + RegularApplicationRuntime.storeStorage(forCloudKitValidationBuild: true) + == .cloudKit, + ) + } + @Test func selectingInspectorConstructsOnlyInspectorRuntime() throws { let fixture = try ModeFixture() defer { fixture.cleanup() } diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 8de58914..701f4362 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -27,11 +27,17 @@ internal shape. collaborator it belongs to. - **`WhereStore` is a value-type boundary.** Everything crossing it is a value, never a SwiftData record; every mutation runs inside - `perform { … }` (the production store traps otherwise), and each committed - transaction pings `changes()`. Never expose its `ModelContainer` through + `perform { … }` (the production store traps otherwise), stale-decision writes + use `perform(expectedDataEpochID:)`, and multi-table reads use `readSnapshot`; + guard: `SwiftDataStoreTests.readSnapshotRejectsCommitBeforeNotification`. Each + committed transaction pings `changes()`. Never expose its `ModelContainer` through `WhereServices`; the separate DEBUG Inspector runtime uses `SwiftDataStore.makeContainer`, `inspectorModelTypes`, and `inspectorStoreURL` as its schema/storage adapter. +- **Resolve destructive generations as a multi-parent causal DAG.** A rotation names every real + maximal head; two unjoined reset heads resolve to a deterministic empty UUIDv8 synthetic epoch + until the next rotation joins them, and persisted epoch events must never use that reserved + namespace (`WhereDataEpoch.resolve(in:)`). - **Each process opens its on-disk store once and injects it** — the app's launch opens it; the App Intents stack shares it via `WhereServices.forIntents(sharingStoreOf:)`. A second container over the @@ -42,14 +48,21 @@ internal shape. `trackedRegions()` — picking scopes GPS attribution *and* carries each region's `RegionAppearance` + pick order. `RegionAppearance` is data (WhereCore); the token→`Color` mapping is presentation (WhereUI). -- **Backups mirror the persisted model — keep them lossless.** Any persisted - change is reflected end-to-end: add it to `BackupArchive`, write it in - `BackupService.makeArchiveFile`, read it back in - `BackupCoordinator.importBackup` for **both** `.replace` and `.merge`, and - add a round-trip test (`BackupServiceTests` / `BackupCoordinatorTests`). - The archive is strict synthesized `Codable` — no in-code legacy decode; a - shape change bumps `BackupArchive.currentFormatVersion` and extends - [`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb) instead. +- **Export backups from one `readSnapshot` and keep restorable user data + lossless.** Add persisted user-data shapes end-to-end and cover both import + strategies, but export no target-owned recording check-ins and ignore any in + an imported archive (`BackupServiceTests` / `BackupCoordinatorTests`). +- **Backup import never changes live recording authority.** Merge reasserts each pre-import + state (including the destructive epoch's implicit archive, defaulting every new imported + device Off); Replace rotates to a child epoch and appends an Off/archive barrier to every + imported device before discarding the local outbox (`BackupCoordinatorTests`). +- **Gate import recovery with a two-phase sidecar plus an atomic store receipt.** Never clear a + committed onboarding marker before its independent terminal completion tombstone + (`BackupCoordinatorTests` / `WhereLaunchTests`). +- **Keep the backup archive strict synthesized `Codable`.** A shape change bumps + `BackupArchive.currentFormatVersion` and extends + [`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb); never add an + in-code legacy decode fallback. - **A logical day is a `CalendarDay`, not a `Date`.** `CalendarDay` (Y-M-D) is the timezone-independent identity every stored user record and day comparison keys on; persisting a `Date` makes a day drift across time-zone @@ -66,7 +79,9 @@ internal shape. with `StoreURL`; families without a dedicated identity type get theirs from `WhereStoreID`. Used to stamp Periscope `LogEvent.externalID`s. - **No in-app data migration or legacy recovery.** `SD….toValue()` reads only - the current shape and drops (fault-logs) a row it can't place. The one-time + the current shape and fault-logs a row it can't place; incomplete epoch or + policy authority throws and fails closed instead of dropping into a benign + state. The one-time reshape path is backup **export → transform ([`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb)) → replace-import**. Deliberate pre-release; the durable successor @@ -74,14 +89,14 @@ internal shape. - **Writes await their side effects.** `DayJournal` commits, then awaits the reminder reconcile + widget publish in sequence, so a reader on the next `changes()` ping never observes a half-applied write. -- **Filter persistent-store remote-change notifications by the Where store - URL.** Never let another store in the process (notably Periscope) ping - `WhereStore.changes()`; guard: `StoreRemoteChangeSourceTests`. +- **Filter persistent-store remote-change notifications by the Where store URL + and the store instance's transaction author.** Never let Periscope or Where's + own local saves enter `remoteChanges()`; guard: `StoreRemoteChangeSourceTests`. - **Post-write reconciliation is defined once.** Every write and import routes through `DayJournal.reconcileAfterDayChange()` (or its widget-less subset `reconcileIssueState()`) — never copy the fan-out into a new write path. Cross-collaborator hooks take a single closure wired at the - composition root (`BackupCoordinator.onImport`). + composition root (`BackupCoordinator.ImportLifecycle.didCommit`). - **Detectors read aggregated input; the speed-based one needs raw fixes.** `DataIssueInput.daySamples` carries per-day GPS fixes only (`.gpsVisit` / `.gpsSignificantChange`, sorted) — manual and evidence-implied samples are @@ -92,11 +107,18 @@ internal shape. `LocationIngestor.captureTodayIfNeeded(now:)`. - **`DeviceRecordingController` owns automatic-recording policy and physical GPS state.** Keep policy events append-only, serialize mutations across - awaits, stamp every ingested GPS sample with the current installation id, - seed the first policy from the installation's explicitly confirmed - preference, and apply `LocationHistoryReader` to every user-facing - projection. Backups alone read the lossless raw samples and full - policy/device tables. + awaits, fail closed when authority or acknowledgement is unavailable, stamp + every ingested GPS sample with the current installation id, seed the first + policy from `InstallationRecordingContext`'s explicitly confirmed choice plus + its stable profile/policy IDs and timestamps, and apply `LocationHistoryReader` + to every user-facing projection. Require an effective On event for every + device-stamped sample, and represent On, Off, and archive in one multi-parent causal authority + DAG. Make each command name every observed maximal head, resolve concurrent heads + safety-first, and derive cleanup/reset floors from the independent destructive frontier. + Persist immutable profiles, nickname events, target-owned check-ins, and policy events separately. + Stamp every durable location-outbox entry with its authorizing data epoch and + never replay it into another generation; backups alone read lossless raw + samples and policy/device timelines, excluding non-restorable check-ins. - **Tracked regions live in the store, not preferences** — one `SDTrackedRegion` row per region so cross-device edits merge; read as a `Set` defaulting to the four. `RegionAttribution` derives the attributor diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index af70b100..65e3bde0 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -23,10 +23,18 @@ one it belongs to rather than to a god-object: - **`WhereStore`** — the value-type persistence boundary (a protocol; nothing crossing it is a SwiftData record). Mutations run inside `perform { … }` (one - atomic transaction) and `changes()` emits once per commit and on a CloudKit - remote import for the Where store URL, excluding other process stores such as - Periscope. `SwiftDataStore.make()` is the production, CloudKit-backed - implementation; `SwiftDataStore.inMemory()` backs tests and previews. Each + atomic transaction); callers whose decision was made against a particular + data epoch use `perform(expectedDataEpochID:)`, and multi-table reads use + `readSnapshot { … }` so a Reset or Replace cannot split one operation across + generations; a persistent-history boundary invalidates any external commit + crossing a snapshot even when its remote-change notification arrives later. + `changes()` emits once per local commit and external import for the Where store + URL, excluding other stores such as Periscope. `remoteChanges()` uses + persistent-history transaction authors to emit only the external-import subset, + so headless notifications and widgets rebuild without duplicating local work. + `SwiftDataStore.make(storage:)` opens an explicitly selected + CloudKit, local-only, or in-memory store; `SwiftDataStore.inMemory()` is the + convenience used by tests and previews. Each process opens its on-disk store **once** and injects it where it's needed — in the app, the launch's `resolve-scope` step opens it and the App Intents stack shares it via `WhereServices.forIntents(sharingStoreOf:)` — so two @@ -36,9 +44,16 @@ one it belongs to rather than to a god-object: which surface and persist each region's picked `RegionAppearance` — color token, emoji, SF Symbol — and pick order alongside the synced rows) — one row per region, defaulting to the four until the user chooses in the onboarding / - Settings region picker. It also stores one `RecordingDevice` profile per - installation plus the append-only `RecordingPolicyChange` timeline used to - control automatic recording across devices. + Settings region picker. Recording identity and authority are split into + immutable profiles, append-only nickname and policy events, and target-owned + check-ins rather than one mutable device row. +- **`WhereDataEpoch`** — the account-wide logical generation that keeps late + uploads from an offline device from repopulating data after Reset or Replace. + Each destructive operation appends one immutable node naming every real + maximal epoch it observed. Reset wins a concurrent Replace; multiple unjoined + resets resolve to a deterministic empty UUIDv8 synthetic generation, so neither + reset branch's rows can reappear before another operation causally joins them. Persisted + event ids remain UUIDv4; UUIDv8 is reserved for resolver-derived generations. - **`RegionAttribution`** — a live `RegionAttributing` built from the tracked regions that rebuilds on `changes()` (a local edit or a remote import), so the app + App Intents process attribute against the same synced set. Assemble @@ -87,15 +102,25 @@ one it belongs to rather than to a god-object: - **`LocationIngestor`** — monitoring, the persist-with-retry queue, and authorization; after each committed sample it reconciles the badge/reminders and republishes the widget snapshot. Every automatic sample is stamped with - the current installation's `RecordingDeviceID`. + the current installation's `RecordingDeviceID`. Every durable retry entry + also carries the data epoch that authorized it, so a pre-reset fix can be + discarded but never written into the replacement generation. - **`DeviceRecordingController`** — serializes per-device enable/disable - policy with the current installation's physical `LocationIngestor`. A remote - disable is effective at its timestamp as soon as it syncs; the target device - later acknowledges that event after it has stopped. + policy with the current installation's physical `LocationIngestor`. Immutable + profiles, nickname events, target-owned check-ins, and complete-authority + desired-policy events sync independently so one writer cannot roll another + field backward. Each policy command names every maximal event it observed; + concurrent unjoined heads resolve to the most restrictive authority, while a + later command joins them with one identity. + A remote disable or archive affects history at its timestamp + as soon as it syncs, and the target device acknowledges only after privacy-critical + cleanup is durable. - **`LocationHistoryReader`** — the shared policy-aware read boundary used by reports, widgets, recent activity, and foreground capture checks. It filters - GPS samples during disabled intervals while keeping raw storage, backups, - legacy samples without provenance, and user-asserted samples lossless. + GPS samples during disabled/archived intervals while keeping raw storage, + backups, legacy samples without provenance, and user-asserted samples + lossless. A device-stamped sample remains invisible until its matching + effective On policy arrives, so partial CloudKit delivery fails closed. ### Detection, notifications & the rest @@ -114,16 +139,36 @@ one it belongs to rather than to a god-object: `DataIssueAlertReconciler` ("issues to resolve"). - **`WidgetSnapshotPublisher`** — republishes the App Group snapshot the widgets read, with a freshness policy. -- **`BackupCoordinator`** — whole-database export / import (a ZIP archive, via - `ZIPFoundation`). +- **`BackupCoordinator`** — ZIP export/import via `ZIPFoundation`. Export pins + tables and evidence blobs to one epoch-consistent snapshot. Merge preserves + queued locations and reasserts each device's pre-import recording authority + after the imported timeline (including an existing profile's implicit Archived + state after a destructive epoch; only a newly seen policy defaults Off). Replace writes + the archive into a new child epoch, retains the global device-profile ledger, + and appends an Off/archive barrier to every imported policy timeline—even when + its profile has not synced yet—and gives a profile-only import an Off root before + pending fixes are discarded. A prepared marker in the backup-excluded installation + sidecar pairs with a receipt committed in the same store transaction as the archive; + recreated services can therefore distinguish rollback from commit and gate further + imports until cleanup succeeds. Onboarding acknowledgement records an independent terminal + sidecar tombstone before clearing recovery, so a cold launch can repair a preference write + that did not reach disk without blocking later Settings imports. + Check-ins are deliberately neither exported nor restored: an archive cannot + prove that the target installation applied authority and cleared its local + outbox. - **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over a selectable look-back `RecentActivityWindow`. -- **`WherePreferences`** — persisted user intent (onboarding, the device-local - recording-choice confirmation and tracking intent, reminder / summary - schedules) behind a `KeyValueStore`. The store has no default: production - names `UserDefaults.standard` and everything else names +- **`InstallationRecordingContext`** — the device-local installation identity, + explicitly confirmed initial choice, and stable IDs/timestamps for recreating + its immutable first device profile and recording policy idempotently. + `InstallationRecordingContextStoring` keeps the persistence adapter outside + the domain value. +- **`WherePreferences`** — persisted user intent (onboarding and reminder / + summary schedules) behind a `KeyValueStore`. The store has no + default: production names `UserDefaults.standard` and everything else names `InMemoryKeyValueStore()`, so no test or preview can reach the host's real - defaults by saying nothing. + defaults by saying nothing. Recording confirmation is deliberately absent: + it lives beside the non-backed-up installation identity instead. - **`BuildInfo`** + **`AppAttribution`** — what Settings > About says about the bundle it is running in. `BuildInfo.current(bundle:)` reads the marketing version, build number, the commit the app was built from, and how the Swift @@ -166,8 +211,9 @@ import WhereCore // previews use the synchronous `@_spi(Testing)` `init` instead (an explicit // attributor, default four) via `@_spi(Testing) import WhereCore`. let services = try await WhereServices.make( - store: try SwiftDataStore.make(), // production; use .inMemory() in tests + store: try SwiftDataStore.make(storage: .cloudKit), locationSource: CoreLocationSource(), + installationContext: installationContext, // resolved once by the app composition root ) // Read a year, aggregated with the injected calendar + region attribution. @@ -191,9 +237,11 @@ funnels through `WhereStore.perform` (or the remote-import path) and pings `changes()`. Readers (the UI's session, the issue scanner) re-derive purely off that ping, so nothing goes stale behind a write it didn't initiate; and because writes await their own side effects, a reader on the next ping sees a -fully-applied change. `WhereServices.reset()` is the one inherently -cross-collaborator operation — it quiesces GPS ingestion *before* wiping the -store so the retry queue can't repopulate it mid-erase. +fully-applied change. Epoch-pinned snapshots keep a multi-table projection in +one generation, while expected-epoch writes reject work whose assumptions went +stale across a suspension. `WhereServices.reset()` is the one inherently +cross-collaborator operation — it reversibly pauses ingestion, atomically +rotates to a Reset child epoch, and discards the retry queue only after commit. ## Contracts & limitations @@ -207,6 +255,15 @@ store so the retry queue can't repopulate it mid-erase. being online before reports become correct: once the policy event syncs, samples at or after its effective timestamp are excluded. Its row remains "waiting" until the target installation physically stops and acknowledges it. + The sample gate stays closed until that check-in and any destructive-backlog + cleanup are durable; an incomplete multi-parent policy DAG also fails closed. + The cutoff currently uses the issuing device's wall clock; substantial + cross-device clock skew can shift the historical boundary even though the causal + DAG still converges the current desired state correctly. +- **Destructive operations are logical generations.** Old rows may remain in + CloudKit as sync/audit history, but ordinary reads select only the resolved + epoch. Concurrent unjoined resets select a synthetic empty generation; an + incomplete causal epoch DAG fails closed instead of mixing old and new state. - **Failures surface.** Store methods are `async throws`; errors are logged via `WhereLog` and left observable — never swallowed into an empty default. - **Foundation Models may be unavailable.** `RecentActivitySummarizer` reports a diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 0b0eb970..54af0ce1 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -6,22 +6,29 @@ import RegionKit /// `.zip`; evidence blob bytes live alongside it under `assets/` and are /// linked back to their records by `BackupAssetEntry`. /// -/// The arrays mirror the SwiftData tables exactly (`SDLocationSample` / +/// The arrays represent the persisted collections (`SDLocationSample` / /// `SDEvidence` / `SDManualDay` / `SDDismissedIssue` / `SDTrackedRegion`) via -/// their value-type representations, plus `SDRecordingDevice` / -/// `SDRecordingPolicyChange`, so an export captures everything and an import -/// can upsert it back row-for-row. +/// their value-type representations, plus the split recording profile / nickname / +/// policy rows. The check-in collection remains in the versioned shape for compatibility, but +/// exports leave it empty and imports ignore it: a backup cannot restore a target installation's +/// live proof that policy was applied and its local outbox was cleared. public struct BackupArchive: Codable, Sendable, Hashable { /// Bumped whenever the archive's on-disk shape changes in a way older /// readers can't understand, so an importer can refuse a file it doesn't /// know how to read instead of silently dropping data (see /// `BackupService.readArchive`, which rejects any other version). /// - /// v3 adds sample device provenance plus the synced recording-device and - /// append-only policy tables. There's no in-app decode fallback for an older - /// archive — it is reshaped out of band by `Tools/upgrade-backup.rb`, - /// matching the module's no-migration-on-read rule (see `AGENTS.md`). - public static let currentFormatVersion = 3 + /// v3 added sample device provenance plus the first recording-device shape. + /// v4 split that shape into immutable profiles, append-only nickname metadata, + /// target-owned check-ins, and append-only complete-authority policy events. v5 adds the + /// logical data epoch in which each immutable profile registered. v6 adds causal parent + /// metadata and state-preserving Merge barriers. v7 expands that metadata to a sorted parent + /// set so one semantic command can causally join every observed concurrent head. There's no + /// in-app decode + /// fallback for an older archive — it is reshaped out of band by + /// `Tools/upgrade-backup.rb`, matching the module's no-migration-on-read rule (see + /// `AGENTS.md`). + public static let currentFormatVersion = 7 public let formatVersion: Int public let exportedAt: Date @@ -40,8 +47,13 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// brings back the *look*, not just the region set. Import restores from /// this; `trackedRegions` is the derived id list. public let primaryRegions: [PrimaryRegion] - /// Every synced device profile, including archived devices. - public let recordingDevices: [RecordingDevice] + /// Immutable installation profiles. + public let recordingDeviceProfiles: [RecordingDeviceProfile] + /// Full append-only nickname history. + public let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] + /// Compatibility field for target-owned acknowledgements. New exports leave it empty and + /// imports never apply it as live authority. + public let recordingDeviceCheckIns: [RecordingDeviceCheckIn] /// The full append-only policy timeline for every device. public let recordingPolicyChanges: [RecordingPolicyChange] /// One entry per evidence record that has blob bytes in the archive. @@ -57,8 +69,10 @@ public struct BackupArchive: Codable, Sendable, Hashable { dismissedIssues: [DismissedIssue], trackedRegions: [Region], primaryRegions: [PrimaryRegion], - recordingDevices: [RecordingDevice] = [], - recordingPolicyChanges: [RecordingPolicyChange] = [], + recordingDeviceProfiles: [RecordingDeviceProfile], + recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], + recordingDeviceCheckIns: [RecordingDeviceCheckIn], + recordingPolicyChanges: [RecordingPolicyChange], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -69,7 +83,9 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.dismissedIssues = dismissedIssues self.trackedRegions = trackedRegions self.primaryRegions = primaryRegions - self.recordingDevices = recordingDevices + self.recordingDeviceProfiles = recordingDeviceProfiles + self.recordingDeviceMetadataChanges = recordingDeviceMetadataChanges + self.recordingDeviceCheckIns = recordingDeviceCheckIns self.recordingPolicyChanges = recordingPolicyChanges self.assets = assets } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 18c94267..d6f91df3 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -2,27 +2,24 @@ import Foundation import PeriscopeCore import RegionKit -/// Owns backup export/import over the `BackupService` and the store, running a -/// caller-supplied `onImport` hook after an import lands new data. -/// -/// An import rewrites day data, so the same badge / notification / widget -/// reconcile a `DayJournal` day change runs has to follow it. Rather than reach -/// into all those collaborators (a leaky abstraction), the coordinator takes one -/// `onImport` closure and the composition root points it at the shared fan-out — -/// so the reconcile stays defined in a single place. +/// Owns backup export/import over the `BackupService` and the store. Its lifecycle seam lets the +/// composition root revoke recording before the transaction, restore the old authority after a +/// rollback, and reconcile all derived state after a commit. /// /// Public so its `ImportStrategy` / `ImportSummary` types stay nameable from the /// UI directly through `WhereServices.backup`; construction stays in-module via /// the internal `init`. public actor BackupCoordinator { /// How an imported backup combines with whatever is already on the device. - public enum ImportStrategy: Sendable { + public enum ImportStrategy: Sendable, Hashable { /// Upsert the imported rows into the existing data (by `id` for /// samples/evidence, by day key for manual days), leaving anything not - /// present in the file untouched. + /// present in the file untouched. Recording authority is snapshotted before the write + /// and reasserted after every imported policy timeline; a newly seen device defaults Off. case merge - /// Erase the whole store first so the device ends up mirroring the file - /// exactly. + /// Replace synced user history and settings with the file. Recording-device identities + /// remain append-only, and every imported policy receives a newer destructive barrier so + /// restoring an archive can never silently start GPS on a device whose profile syncs later. case replace } @@ -55,15 +52,51 @@ public actor BackupCoordinator { } } + /// Whether a committed import still needs its privacy-critical post-commit cleanup retried. + public enum ImportRecoveryState: Sendable, Hashable { + case ready + case cleanupRequired(ImportSummary) + case onboardingAcknowledgementRequired(ImportSummary) + } + + private enum ImportRecoveryPhase { + case ready + case importing(UUID) + case recoveryRequired(DurableImportRecovery) + case retrying(DurableImportRecovery) + + var recovery: DurableImportRecovery? { + switch self { + case .ready, .importing: nil + case let .recoveryRequired(recovery), let .retrying(recovery): recovery + } + } + } + + /// Cross-collaborator work around an import transaction. Once the store commits, a + /// `didCommit` failure is retained as an explicitly recoverable partial success rather than + /// reported as though the transaction rolled back. + struct ImportLifecycle { + let prepare: @Sendable (ImportStrategy) async throws -> Void + /// May throw only for privacy-critical cleanup after the data commit. The coordinator + /// wraps that as an explicitly committed partial-success error and never runs rollback. + let didCommit: @Sendable (ImportStrategy) async throws -> Void + let didRollBack: @Sendable (ImportStrategy) async -> Void + } + private let store: any WhereStore private let backupService = BackupService() - /// Invoked once after an import successfully commits. The composition root - /// wires it to the same post-day-change reconcile a journal write runs - /// (drop the issue-scan cache, reconcile the app-icon badge + issues - /// notification, republish the widget snapshot). - private let onImport: @Sendable () async -> Void + private let importLifecycle: ImportLifecycle + private let importRecoveryPersistence: ImportRecoveryPersistence + private let currentDeviceID: RecordingDeviceID + private let now: @Sendable () -> Date private static let logger = WhereLog.backup(BackupCoordinatorLog.self) + private var importRecoveryPhase = ImportRecoveryPhase.ready + private var hasHydratedImportRecovery = false + private var isHydratingImportRecovery = false + private var importRecoveryHydrationWaiters: [CheckedContinuation] = [] + /// Staging directory of the most recent export. Each archive lands in its /// own temporary directory; the share sheet copies the file it needs out of /// ours and gives no dismissal hook to clean up after, so we purge the @@ -74,10 +107,16 @@ public actor BackupCoordinator { init( store: any WhereStore, - onImport: @escaping @Sendable () async -> Void, + currentDeviceID: RecordingDeviceID, + now: @escaping @Sendable () -> Date, + importLifecycle: ImportLifecycle, + importRecoveryPersistence: ImportRecoveryPersistence, ) { self.store = store - self.onImport = onImport + self.importLifecycle = importLifecycle + self.importRecoveryPersistence = importRecoveryPersistence + self.currentDeviceID = currentDeviceID + self.now = now } /// Fraction of the export the evidence-blob load accounts for. The load is @@ -114,35 +153,43 @@ public actor BackupCoordinator { ) async throws -> URL { purgePreviousExport() - let tables = try await Self.logger.measure(.exportReads) { - // The user's primary regions with their picked looks + order (the - // resolved default set when they haven't chosen yet). - try await ExportTables( - samples: store.allSamples(), - evidence: store.allEvidence(), - manualDays: store.allManualDays(), - dismissedIssues: store.allDismissedIssues(), - primaryRegions: store.primaryRegions(), - recordingDevices: store.recordingDevices(), - recordingPolicyChanges: store.recordingPolicyChanges(), - ) - } - let evidence = tables.evidence - var blobs: [UUID: Data] = [:] - try await Self.logger.measure(.exportBlobLoad) { - var lastPercent = -1 - for (index, item) in evidence.enumerated() { - if let blob = try await store.evidenceBlob(for: item.id) { - blobs[item.id] = blob + let snapshot = try await store.readSnapshot { + let tables = try await Self.logger.measure(.exportReads) { + // The user's primary regions with their picked looks + order (the + // resolved default set when they haven't chosen yet). Check-ins are deliberately + // excluded: they are live proofs about a target's local outbox, not restorable + // user data. + try await ExportTables( + samples: store.allSamples(), + evidence: store.allEvidence(), + manualDays: store.allManualDays(), + dismissedIssues: store.allDismissedIssues(), + primaryRegions: store.primaryRegions(), + recordingDeviceProfiles: store.recordingDeviceProfiles(), + recordingDeviceMetadataChanges: store.recordingDeviceMetadataChanges(), + recordingDeviceCheckIns: [], + recordingPolicyChanges: store.recordingPolicyChanges(), + ) + } + let evidence = tables.evidence + var blobs: [UUID: Data] = [:] + try await Self.logger.measure(.exportBlobLoad) { + var lastPercent = -1 + for (index, item) in evidence.enumerated() { + if let blob = try await store.evidenceBlob(for: item.id) { + blobs[item.id] = blob + } + let fraction = Double(index + 1) / Double(evidence.count) + * Self.exportBlobLoadFraction + let percent = Int(fraction * 100) + guard percent != lastPercent else { continue } + lastPercent = percent + onProgress(fraction) } - let fraction = Double(index + 1) / Double(evidence.count) - * Self.exportBlobLoadFraction - let percent = Int(fraction * 100) - guard percent != lastPercent else { continue } - lastPercent = percent - onProgress(fraction) } + return ExportSnapshot(tables: tables, blobs: blobs) } + let tables = snapshot.tables let backupService = backupService let url = try await Task.detached(priority: .utility) { try backupService.makeArchiveFile( @@ -153,9 +200,11 @@ public actor BackupCoordinator { // The bare ids ride alongside the primary regions for older readers. trackedRegions: tables.primaryRegions.map(\.region), primaryRegions: tables.primaryRegions, - recordingDevices: tables.recordingDevices, + recordingDeviceProfiles: tables.recordingDeviceProfiles, + recordingDeviceMetadataChanges: tables.recordingDeviceMetadataChanges, + recordingDeviceCheckIns: tables.recordingDeviceCheckIns, recordingPolicyChanges: tables.recordingPolicyChanges, - blobs: blobs, + blobs: snapshot.blobs, ) }.value onProgress(1) @@ -172,10 +221,17 @@ public actor BackupCoordinator { let manualDays: [DayPresence] let dismissedIssues: [DismissedIssue] let primaryRegions: [PrimaryRegion] - let recordingDevices: [RecordingDevice] + let recordingDeviceProfiles: [RecordingDeviceProfile] + let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] + let recordingDeviceCheckIns: [RecordingDeviceCheckIn] let recordingPolicyChanges: [RecordingPolicyChange] } + private struct ExportSnapshot { + let tables: ExportTables + let blobs: [UUID: Data] + } + /// Delete the most recent export's staging directory now, rather than /// lazily on the next export. For a caller that's finished offering the /// archive — e.g. a UI that times out its "share" affordance — so the temp @@ -200,10 +256,10 @@ public actor BackupCoordinator { } /// Read a backup `.zip` and write its contents back into the store inside a - /// single transaction. `.replace` wipes the store first; `.merge` relies on - /// the store's upsert semantics. Tracked regions round-trip too: `.replace` - /// restores the archive's set exactly, `.merge` unions it into the current - /// set. Returns counts of what was imported. + /// single transaction. `.replace` wipes user history/settings first while retaining the + /// append-only device ledger; `.merge` relies on the store's upsert semantics. Tracked + /// regions round-trip too: `.replace` restores the archive's set exactly, `.merge` unions it + /// into the current set. Returns counts of what was imported. /// /// `onProgress` is invoked with a fraction in `0...1` as rows are written, /// throttled to whole-percent changes so a large import doesn't flood the @@ -212,11 +268,117 @@ public actor BackupCoordinator { public func importBackup( from url: URL, strategy: ImportStrategy, + purpose: ImportPurpose, onProgress: @Sendable (Double) -> Void = { _ in }, ) async throws -> ImportSummary { - try await Self.logger.measure(.importBackup) { - try await performImport(from: url, strategy: strategy, onProgress: onProgress) + try await hydrateImportRecovery() + let operationID = UUID() + switch importRecoveryPhase { + case .ready: + importRecoveryPhase = .importing(operationID) + case .importing: + throw RecordingPersistenceError.recordingRewriteInProgress + case let .recoveryRequired(recovery), let .retrying(recovery): + throw ImportRecoveryRequiredError(summary: recovery.details.summary) + } + defer { + if case let .importing(activeOperationID) = importRecoveryPhase, + activeOperationID == operationID + { + importRecoveryPhase = .ready + } + } + return try await Self.logger.measure(.importBackup) { + try await performImport( + from: url, + strategy: strategy, + purpose: purpose, + transactionID: operationID, + onProgress: onProgress, + ) + } + } + + /// Current recovery gate for backup UI. The coordinator owns this state so recreating a + /// presentation model cannot accidentally reopen imports after a committed cleanup failure. + public func importRecoveryState() async throws -> ImportRecoveryState { + try await hydrateImportRecovery() + guard let recovery = importRecoveryPhase.recovery else { return .ready } + switch recovery { + case let .committed(details, cleanupCompleted, onboardingAcknowledged) + where cleanupCompleted + && details.purpose == .onboarding + && !onboardingAcknowledged: + return .onboardingAcknowledgementRequired(details.summary) + case .prepared, .committed: + return .cleanupRequired(recovery.details.summary) + } + } + + /// Retry only the post-commit cleanup for the last committed import. The imported rows are + /// never applied a second time, and the gate clears only after cleanup and reconciliation + /// complete successfully. + public func retryImportCleanup() async throws { + try await hydrateImportRecovery() + let recovery: DurableImportRecovery + switch importRecoveryPhase { + case .ready: + return + case .importing: + throw RecordingPersistenceError.recordingRewriteInProgress + case let .recoveryRequired(value): + recovery = value + importRecoveryPhase = .retrying(value) + case let .retrying(value): + throw ImportRecoveryRequiredError(summary: value.details.summary) + } + do { + try await recoverCommittedImport(recovery) + } catch { + if case .retrying = importRecoveryPhase { + importRecoveryPhase = .recoveryRequired(recovery) + } + throw CommittedImportCleanupError( + strategy: recovery.details.strategy, + summary: recovery.details.summary, + underlying: error, + ) + } + } + + /// Record terminal onboarding authority, then clear a committed marker after `WhereModel` + /// has written its preference. The sidecar tombstone repairs that preference after a crash. + public func acknowledgeOnboardingImport() async throws { + try await hydrateImportRecovery() + guard let recovery = importRecoveryPhase.recovery else { return } + guard case let .committed( + details, + cleanupCompleted, + onboardingAcknowledged, + ) = recovery, + details.purpose == .onboarding + else { + throw ImportRecoveryRequiredError(summary: recovery.details.summary) } + let acknowledged = DurableImportRecovery.committed( + details, + cleanupCompleted: cleanupCompleted, + onboardingAcknowledged: true, + ) + // UserDefaults may acknowledge its setter before the bytes reach disk. Persist an + // independent, backup-excluded authority before marking recovery acknowledged or clearing + // it, so every later path that observes `onboardingAcknowledged` is safe to finish cleanup. + try await importRecoveryPersistence.recordOnboardingCompletion(.init( + transactionID: details.transactionID, + )) + if !onboardingAcknowledged { + try await importRecoveryPersistence.save(acknowledged) + importRecoveryPhase = .recoveryRequired(acknowledged) + } + guard cleanupCompleted else { return } + try await removeReceipt(for: details) + try await importRecoveryPersistence.save(nil) + importRecoveryPhase = .ready } /// `importBackup`'s body, split out for the same reason as @@ -224,8 +386,11 @@ public actor BackupCoordinator { private func performImport( from url: URL, strategy: ImportStrategy, + purpose: ImportPurpose, + transactionID: UUID, onProgress: @Sendable (Double) -> Void, ) async throws -> ImportSummary { + let expectedEpochID = try await (store.dataEpoch()).id // Files handed over by the document picker are security-scoped; we must // bracket the read with start/stop access or `Data(contentsOf:)` fails // with a permissions error. @@ -238,86 +403,526 @@ public actor BackupCoordinator { }.value let archive = result.archive let blobs = result.blobs + let importedDeviceIDs = Set(archive.recordingPolicyChanges.map(\.deviceID)) + .union(archive.recordingDeviceProfiles.map(\.id)) + let summary = ImportSummary( + sampleCount: archive.samples.count, + evidenceCount: archive.evidence.count, + manualDayCount: archive.manualDays.count, + dismissedIssueCount: archive.dismissedIssues.count, + trackedRegionCount: archive.primaryRegions.count, + recordingDeviceCount: archive.recordingDeviceProfiles.count, + recordingPolicyChangeCount: archive.recordingPolicyChanges.count, + ) + let recoveryDetails = ImportRecoveryDetails( + transactionID: transactionID, + strategy: strategy, + summary: summary, + purpose: purpose, + ) let total = archive.samples.count + archive.evidence.count + archive.manualDays.count + archive.dismissedIssues.count - + archive.recordingDevices.count + archive.recordingPolicyChanges.count + + archive.recordingDeviceProfiles.count + + archive.recordingDeviceMetadataChanges.count + + archive.recordingDeviceCheckIns.count + + archive.recordingPolicyChanges.count - try await Self.logger.measure(.importWrite) { - try await store.perform { - if strategy == .replace { - try await store.clearAll() - } - // `completed`/`report` are local to this `@Sendable` block, so - // the running count never crosses the actor boundary; only the - // throttled fraction is handed to `onProgress`. - var completed = 0 - var lastPercent = -1 - func report() { - completed += 1 - guard total > 0 else { return } - let percent = Int(Double(completed) / Double(total) * 100) - guard percent != lastPercent else { return } - lastPercent = percent - onProgress(Double(completed) / Double(total)) - } - for sample in archive.samples { - try await store.add(sample: sample) - report() - } - for item in archive.evidence { - try await store.write(evidence: item, blob: blobs[item.id]) - report() + // Decode and validate before touching live authority. Once the archive is known-good, + // close ingestion before either merge or replace: both can change this installation's + // policy while a streamed sample is otherwise able to cross the transaction boundary. + let preparedRecovery = DurableImportRecovery.prepared(recoveryDetails) + try await importRecoveryPersistence.save(preparedRecovery) + do { + try await importLifecycle.prepare(strategy) + } catch { + do { + try await importRecoveryPersistence.save(nil) + } catch let persistenceError { + importRecoveryPhase = .recoveryRequired(preparedRecovery) + throw ImportRecoveryResolutionError( + summary: summary, + underlying: persistenceError, + ) + } + throw error + } + let importDate = now() + do { + try await Self.logger.measure(.importWrite) { + try await store.perform(expectedDataEpochID: expectedEpochID) { + let mergeAuthority = if strategy == .merge { + try await Self.snapshotAuthority( + for: importedDeviceIDs, + in: store, + ) + } else { + [RecordingDeviceID: RecordingPolicyState]() + } + let replacementEpoch: WhereDataEpoch? = if strategy == .replace { + try await store.rotateDataEpoch( + reason: .backupReplace, + changedBy: currentDeviceID, + at: importDate, + ) + } else { + nil + } + // `completed`/`report` are local to this `@Sendable` block, so + // the running count never crosses the actor boundary; only the + // throttled fraction is handed to `onProgress`. + var completed = 0 + var lastPercent = -1 + func report() { + completed += 1 + guard total > 0 else { return } + let percent = Int(Double(completed) / Double(total) * 100) + guard percent != lastPercent else { return } + lastPercent = percent + onProgress(Double(completed) / Double(total)) + } + for sample in archive.samples { + try await store.add(sample: sample) + report() + } + for item in archive.evidence { + try await store.write(evidence: item, blob: blobs[item.id]) + report() + } + for day in archive.manualDays { + try await store.setManualDay(day) + report() + } + for dismissal in archive.dismissedIssues { + try await store.restoreDismissedIssue(dismissal) + report() + } + for profile in archive.recordingDeviceProfiles { + try await store.addRecordingDeviceProfile(profile) + report() + } + for metadataChange in archive.recordingDeviceMetadataChanges { + try await store.addRecordingDeviceMetadataChange(metadataChange) + report() + } + // A check-in is a target installation's proof that it applied policy and + // cleared its own raw outbox. A backup cannot make that proof on its behalf; + // consume progress for legacy archives but never restore it as live authority. + for _ in archive.recordingDeviceCheckIns { + report() + } + for change in archive.recordingPolicyChanges { + try await store.addRecordingPolicyChange(change) + report() + } + if let replacementEpoch { + try await Self.appendReplacementSafetyBarriers( + to: store, + epoch: replacementEpoch, + importedDeviceIDs: importedDeviceIDs, + issuedBy: currentDeviceID, + ) + } else { + try await Self.appendMergeSafetyBarriers( + to: store, + preserving: mergeAuthority, + importedDeviceIDs: importedDeviceIDs, + issuedBy: currentDeviceID, + issuedAt: importDate, + ) + } + // Primary regions (with their picked looks) round-trip like any + // other data. On `.replace` the store was cleared above, so write + // the archive's set exactly; on `.merge` union it into the current + // set (reading the *resolved* current set first so a device on the + // implicit default four doesn't collapse to just the imported + // ones), with the archive's appearance winning on overlap. + // `setPrimaryRegions` is a whole-set replace, so a merge builds + // the full merged list. A handful of rows, so they're not folded + // into the progress total. + let archivePrimary = archive.primaryRegions + let regionsToWrite: [PrimaryRegion] = if strategy == .merge { + try await Self.merge(archivePrimary, into: store.primaryRegions()) + } else { + archivePrimary + } + try await store.setPrimaryRegions(regionsToWrite) + try await store.addBackupImportReceipt( + id: transactionID, + installationID: currentDeviceID, + ) } - for day in archive.manualDays { - try await store.setManualDay(day) - report() + } + } catch { + // `SwiftDataStore.perform` can throw after its peer save when a concurrent remote + // epoch supersedes the transaction. The receipt distinguishes that physical commit + // from a true rollback; never reapply an archive whose rows already landed. + let receipt: BackupImportReceipt? + do { + receipt = try await store.backupImportReceipt( + id: transactionID, + installationID: currentDeviceID, + ) + } catch let receiptError { + importRecoveryPhase = .recoveryRequired(preparedRecovery) + throw ImportRecoveryResolutionError( + summary: summary, + underlying: receiptError, + ) + } + guard receipt != nil else { + await importLifecycle.didRollBack(strategy) + do { + try await importRecoveryPersistence.save(nil) + } catch let persistenceError { + importRecoveryPhase = .recoveryRequired(preparedRecovery) + throw ImportRecoveryResolutionError( + summary: summary, + underlying: persistenceError, + ) } - for dismissal in archive.dismissedIssues { - try await store.restoreDismissedIssue(dismissal) - report() + throw error + } + do { + try await finishCommittedImport(recoveryDetails) + } catch { + throw CommittedImportCleanupError( + strategy: strategy, + summary: summary, + underlying: error, + ) + } + throw CommittedImportSupersededError(summary: summary, underlying: error) + } + + do { + try await finishCommittedImport(recoveryDetails) + } catch { + throw CommittedImportCleanupError( + strategy: strategy, + summary: summary, + underlying: error, + ) + } + + return summary + } + + /// Persist the irreversible boundary before cleanup, then advance monotonically through + /// cleanup completion, receipt removal, and (for Settings) sidecar acknowledgement. + private func finishCommittedImport(_ details: ImportRecoveryDetails) async throws { + let cleanupPending = DurableImportRecovery.committed( + details, + cleanupCompleted: false, + onboardingAcknowledged: details.purpose == .settings, + ) + do { + try await importRecoveryPersistence.save(cleanupPending) + } catch { + importRecoveryPhase = .recoveryRequired(.prepared(details)) + throw error + } + importRecoveryPhase = .recoveryRequired(cleanupPending) + try await recoverCommittedImport(cleanupPending) + } + + /// Resume a durable import from any safe restart point. Every transition is persisted before + /// deleting the receipt that proves the store save, so a crash cannot turn a committed import + /// back into an apparent rollback. + private func recoverCommittedImport(_ recovery: DurableImportRecovery) async throws { + let cleanupPending: DurableImportRecovery + switch recovery { + case let .prepared(details): + let receipt = try await store.backupImportReceipt( + id: details.transactionID, + installationID: currentDeviceID, + ) + guard receipt != nil else { + await importLifecycle.didRollBack(details.strategy) + try await importRecoveryPersistence.save(nil) + importRecoveryPhase = .ready + return } - for device in archive.recordingDevices { - try await store.setRecordingDevice(device) - report() + cleanupPending = .committed( + details, + cleanupCompleted: false, + onboardingAcknowledged: details.purpose == .settings, + ) + do { + try await importRecoveryPersistence.save(cleanupPending) + } catch { + importRecoveryPhase = .recoveryRequired(recovery) + throw error } - for change in archive.recordingPolicyChanges { - try await store.addRecordingPolicyChange(change) - report() + importRecoveryPhase = .recoveryRequired(cleanupPending) + case .committed: + cleanupPending = recovery + } + + let completed: DurableImportRecovery + switch cleanupPending { + case .prepared: + preconditionFailure("Prepared recovery must be resolved before cleanup.") + case let .committed(details, cleanupCompleted, onboardingAcknowledged): + if cleanupCompleted { + completed = cleanupPending + } else { + do { + try await importLifecycle.didCommit(details.strategy) + } catch { + importRecoveryPhase = .recoveryRequired(cleanupPending) + throw error + } + completed = .committed( + details, + cleanupCompleted: true, + onboardingAcknowledged: onboardingAcknowledged, + ) + do { + try await importRecoveryPersistence.save(completed) + } catch { + importRecoveryPhase = .recoveryRequired(cleanupPending) + throw error + } + importRecoveryPhase = .recoveryRequired(completed) } - // Primary regions (with their picked looks) round-trip like any - // other data. On `.replace` the store was cleared above, so write - // the archive's set exactly; on `.merge` union it into the current - // set (reading the *resolved* current set first so a device on the - // implicit default four doesn't collapse to just the imported - // ones), with the archive's appearance winning on overlap. - // `setPrimaryRegions` is a whole-set replace, so a merge builds - // the full merged list. A handful of rows, so they're not folded - // into the progress total. - let archivePrimary = archive.primaryRegions - let regionsToWrite: [PrimaryRegion] = if strategy == .merge { - try await Self.merge(archivePrimary, into: store.primaryRegions()) + } + + let details = completed.details + do { + try await removeReceipt(for: details) + } catch { + importRecoveryPhase = .recoveryRequired(completed) + throw error + } + let onboardingAcknowledged: Bool + switch completed { + case .prepared: + preconditionFailure("A completed recovery cannot be prepared.") + case let .committed(_, _, acknowledged): + onboardingAcknowledged = acknowledged + } + if details.purpose == .settings || onboardingAcknowledged { + do { + try await importRecoveryPersistence.save(nil) + } catch { + importRecoveryPhase = .recoveryRequired(completed) + throw error + } + importRecoveryPhase = .ready + } else { + importRecoveryPhase = .recoveryRequired(completed) + } + } + + private func removeReceipt(for details: ImportRecoveryDetails) async throws { + guard try await store.backupImportReceipt( + id: details.transactionID, + installationID: currentDeviceID, + ) != nil else { return } + try await store.perform { + try await self.store.removeBackupImportReceipt( + id: details.transactionID, + installationID: self.currentDeviceID, + ) + } + } + + /// Load the sidecar exactly once per coordinator lifetime, serializing concurrent first + /// callers. A prepared marker is resolved against its installation-scoped store receipt. + private func hydrateImportRecovery() async throws { + if hasHydratedImportRecovery { return } + if isHydratingImportRecovery { + await withCheckedContinuation { importRecoveryHydrationWaiters.append($0) } + return try await hydrateImportRecovery() + } + isHydratingImportRecovery = true + defer { + isHydratingImportRecovery = false + let waiters = importRecoveryHydrationWaiters + importRecoveryHydrationWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + guard let recovery = try await importRecoveryPersistence.load() else { + importRecoveryPhase = .ready + hasHydratedImportRecovery = true + return + } + switch recovery { + case let .prepared(details): + let receipt = try await store.backupImportReceipt( + id: details.transactionID, + installationID: currentDeviceID, + ) + if receipt == nil { + await importLifecycle.didRollBack(details.strategy) + try await importRecoveryPersistence.save(nil) + importRecoveryPhase = .ready } else { - archivePrimary + let committed = DurableImportRecovery.committed( + details, + cleanupCompleted: false, + onboardingAcknowledged: details.purpose == .settings, + ) + try await importRecoveryPersistence.save(committed) + importRecoveryPhase = .recoveryRequired(committed) } - try await store.setPrimaryRegions(regionsToWrite) + case .committed: + importRecoveryPhase = .recoveryRequired(recovery) + } + hasHydratedImportRecovery = true + } + + /// A previous import committed but has not completed its privacy-critical cleanup. Applying + /// another archive would erase the strategy and summary needed to finish that recovery. + public struct ImportRecoveryRequiredError: LocalizedError, Sendable, Hashable { + public let summary: ImportSummary + + public var errorDescription: String? { + String(localized: .backupErrorRecoveryRequired) + } + } + + /// The sidecar exists but the coordinator could not determine or persist its next safe phase. + public struct ImportRecoveryResolutionError: LocalizedError, @unchecked Sendable { + public let summary: ImportSummary + public let underlying: any Error + + public var errorDescription: String? { + String(localized: .backupErrorRecoveryRequired) + } + } + + /// The import physically committed, but a newer destructive epoch became authoritative before + /// the call returned. The receipt prevents an automatic reapply into that newer generation. + public struct CommittedImportSupersededError: LocalizedError, @unchecked Sendable { + public let summary: ImportSummary + public let underlying: any Error + + public var errorDescription: String? { + String(localized: .backupErrorRecoveryRequired) + } + } + + /// The archive rows committed, but pending raw locations could not be removed safely. This + /// is intentionally distinct from an import failure: callers must not retry as though the + /// store rolled back, and recording remains paused until cleanup succeeds. + public struct CommittedImportCleanupError: LocalizedError, @unchecked Sendable { + public let strategy: ImportStrategy + public let summary: ImportSummary + public let underlying: any Error + + public var errorDescription: String? { + switch strategy { + case .merge: + String(localized: .backupErrorCommittedCleanupMerge) + case .replace: + String(localized: .backupErrorCommittedCleanupReplace) } } - // An import rewrites day data, so the badge / notification / widget - // reconcile a day change runs has to follow it — these headless - // reconcilers don't observe `store.changes()`, so without this the - // home-screen badge and the issues alert stay stuck at their pre-import - // values. The composition root supplies the shared fan-out. - await onImport() + } - return ImportSummary( - sampleCount: archive.samples.count, - evidenceCount: archive.evidence.count, - manualDayCount: archive.manualDays.count, - dismissedIssueCount: archive.dismissedIssues.count, - trackedRegionCount: archive.primaryRegions.count, - recordingDeviceCount: archive.recordingDevices.count, - recordingPolicyChangeCount: archive.recordingPolicyChanges.count, - ) + /// Snapshot the authority that a merge must preserve. This runs inside the import transaction, + /// so epoch, profiles, and policies describe one indivisible pre-import state. After a + /// destructive generation, an existing profile with no current policy has the controller's + /// implicit archived authority; only a genuinely new policy-only id defaults Off. + private static func snapshotAuthority( + for deviceIDs: Set, + in store: any WhereStore, + ) async throws -> [RecordingDeviceID: RecordingPolicyState] { + let epoch = try await store.dataEpoch() + let profiles = try await store.recordingDeviceProfiles() + let policies = try await store.recordingPolicyChanges() + let existingProfileIDs = Set(profiles.map(\.id)) + var states: [RecordingDeviceID: RecordingPolicyState] = [:] + for deviceID in deviceIDs { + let history = policies.filter { $0.deviceID == deviceID } + guard history.isEmpty || RecordingPolicyChange.formValidPersistedTimelines(history) + else { + throw RecordingPersistenceError.incompletePolicyHistory(deviceID) + } + if let state = RecordingPolicyChange.canonicalHead(in: history)?.state { + states[deviceID] = state + } else if epoch.isDestructive, existingProfileIDs.contains(deviceID) { + states[deviceID] = .archived + } else { + states[deviceID] = .off + } + } + return states + } + + /// Merge restores historical policy without letting the archive change live authority. Each + /// imported timeline receives one command joining every observed head and reasserting the + /// pre-import state; a profile without policy receives a safe root. + private static func appendMergeSafetyBarriers( + to store: any WhereStore, + preserving states: [RecordingDeviceID: RecordingPolicyState], + importedDeviceIDs: Set, + issuedBy deviceID: RecordingDeviceID, + issuedAt: Date, + ) async throws { + let policies = try await store.recordingPolicyChanges() + for importedDeviceID in importedDeviceIDs.sorted(by: deviceIDIsOrderedBefore) { + let history = policies.filter { $0.deviceID == importedDeviceID } + guard history.isEmpty || RecordingPolicyChange.formValidPersistedTimelines(history) + else { + throw RecordingPersistenceError.incompletePolicyHistory(importedDeviceID) + } + try await store.addRecordingPolicyChange(RecordingPolicyChange.appendingCommand( + to: history, + deviceID: importedDeviceID, + issuedAt: issuedAt, + issuedByDeviceID: deviceID, + effectiveAt: issuedAt, + state: states[importedDeviceID] ?? .off, + reason: .backupMerge, + )) + } + } + + /// Replace restores history but never restores recording consent. Every imported policy + /// timeline receives one destructive command joining every observed head; a profile-only + /// device receives an Off root. Active devices become Off and archived devices stay archived. + private static func appendReplacementSafetyBarriers( + to store: any WhereStore, + epoch: WhereDataEpoch, + importedDeviceIDs: Set, + issuedBy deviceID: RecordingDeviceID, + ) async throws { + let policies = try await store.recordingPolicyChanges() + for importedDeviceID in importedDeviceIDs.sorted(by: deviceIDIsOrderedBefore) { + let history = policies.filter { $0.deviceID == importedDeviceID } + guard history.isEmpty || RecordingPolicyChange.formValidPersistedTimelines(history) + else { + throw RecordingPersistenceError.incompletePolicyHistory(importedDeviceID) + } + let replacementState: RecordingPolicyState = if RecordingPolicyChange.canonicalHead( + in: history, + )?.state == .archived { + .archived + } else { + .off + } + try await store.addRecordingPolicyChange(RecordingPolicyChange.appendingCommand( + to: history, + deviceID: importedDeviceID, + issuedAt: epoch.changedAt, + issuedByDeviceID: deviceID, + effectiveAt: epoch.changedAt, + state: replacementState, + reason: .backupReplace, + )) + } + } + + private static func deviceIDIsOrderedBefore( + _ lhs: RecordingDeviceID, + _ rhs: RecordingDeviceID, + ) -> Bool { + lhs.storeURL.absoluteString < rhs.storeURL.absoluteString } /// Union `archive` primary regions into `current` for a `.merge` import: diff --git a/Where/WhereCore/Sources/Backup/BackupImportRecovery.swift b/Where/WhereCore/Sources/Backup/BackupImportRecovery.swift new file mode 100644 index 00000000..3283e2d9 --- /dev/null +++ b/Where/WhereCore/Sources/Backup/BackupImportRecovery.swift @@ -0,0 +1,102 @@ +import Foundation + +extension BackupCoordinator { + /// Why an archive is being imported. Onboarding imports retain their durable commit marker + /// until the backed-up onboarding preference has been written and explicitly acknowledged. + public enum ImportPurpose: Sendable, Hashable { + case onboarding + case settings + } + + /// Immutable identity and result of one import attempt, persisted before the store write. + public struct ImportRecoveryDetails: Sendable, Hashable { + public let transactionID: UUID + public let strategy: ImportStrategy + public let summary: ImportSummary + public let purpose: ImportPurpose + + public init( + transactionID: UUID, + strategy: ImportStrategy, + summary: ImportSummary, + purpose: ImportPurpose, + ) { + self.transactionID = transactionID + self.strategy = strategy + self.summary = summary + self.purpose = purpose + } + } + + /// Durable half of the two-phase import protocol. + /// + /// A prepared marker is written before the store transaction. The transaction inserts a + /// matching receipt atomically with imported rows, allowing a new process to distinguish a + /// rolled-back attempt from a committed one. Once promoted to committed, this marker remains + /// authoritative even after the receipt is deleted. + public enum DurableImportRecovery: Sendable, Hashable { + case prepared(ImportRecoveryDetails) + case committed( + ImportRecoveryDetails, + cleanupCompleted: Bool, + onboardingAcknowledged: Bool, + ) + + public var details: ImportRecoveryDetails { + switch self { + case let .prepared(details), let .committed(details, _, _): details + } + } + } + + /// Async persistence seam for the device-local, backup-excluded installation sidecar's active + /// recovery and terminal onboarding proof. Production bridges this to + /// `InstallationRecordingContextStoring`; tests can share an in-memory implementation across + /// recreated coordinators. + public struct ImportRecoveryPersistence: Sendable { + let load: @Sendable () async throws -> DurableImportRecovery? + let save: @Sendable (DurableImportRecovery?) async throws -> Void + let recordOnboardingCompletion: @Sendable (OnboardingImportCompletion) async throws -> Void + + public init( + load: @escaping @Sendable () async throws -> DurableImportRecovery?, + save: @escaping @Sendable (DurableImportRecovery?) async throws -> Void, + recordOnboardingCompletion: @escaping @Sendable ( + OnboardingImportCompletion, + ) async throws -> Void, + ) { + self.load = load + self.save = save + self.recordOnboardingCompletion = recordOnboardingCompletion + } + + public static let none = ImportRecoveryPersistence( + load: { nil }, + save: { _ in }, + recordOnboardingCompletion: { _ in }, + ) + } + + /// Terminal device-local proof that an onboarding import was accepted by the app layer. + /// It is independent of active recovery so clearing a finished marker or starting a later + /// Settings import cannot make Restore eligible again. + public struct OnboardingImportCompletion: Sendable, Hashable { + public let transactionID: UUID + + public init(transactionID: UUID) { + self.transactionID = transactionID + } + } +} + +/// Store receipt committed atomically with one backup import. +/// +/// The device-local sidecar supplies the token to query, so a receipt synced from another +/// installation cannot create recovery work here. A receipt is stamped with the transaction's +/// epoch, but remains discoverable after that epoch is superseded: the rows may be inert, yet the +/// receipt still proves the physical save happened and prevents an automatic reapply. +public struct BackupImportReceipt: Identifiable, Sendable, Hashable { + public let id: UUID + public let installationID: RecordingDeviceID + public let dataEpochID: WhereDataEpochID +} diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 91dc8555..3a744b96 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -15,6 +15,12 @@ import ZIPFoundation /// SwiftData. `BackupCoordinator` owns reading the store and committing an /// import transaction; this type only marshals bytes to and from the zip. public struct BackupService: Sendable { + /// Header decoded before the strict current archive shape, so an older manifest reports its + /// format version instead of failing first on a field introduced by a later format. + private struct FormatEnvelope: Decodable { + let formatVersion: Int + } + /// Decoded contents of a backup archive: the manifest plus the evidence /// blob bytes, keyed by evidence id so the importer can pair them with /// the matching `Evidence` metadata. @@ -37,6 +43,9 @@ public struct BackupService: Sendable { /// The manifest declares a `formatVersion` this build can't read (it /// must match `BackupArchive.currentFormatVersion` exactly). case unsupportedFormatVersion(Int) + /// Recording rows decoded structurally but violate persisted invariants (for example a + /// negative causal revision or an `.unknown` check-in). + case invalidRecordingData public var errorDescription: String? { switch self { @@ -44,6 +53,8 @@ public struct BackupService: Sendable { String(localized: .backupErrorManifestMissing) case let .unsupportedFormatVersion(version): String(localized: .backupErrorUnsupportedFormatVersion(version)) + case .invalidRecordingData: + String(localized: .backupErrorInvalidRecordingData) } } } @@ -54,16 +65,39 @@ public struct BackupService: Sendable { public init() {} - private static func makeEncoder() -> JSONEncoder { + static func makeEncoder() -> JSONEncoder { let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 + // ISO8601's Foundation encoder drops sub-second precision. Policy + // changes deliberately use that precision to preserve the order of + // rapid local actions, so encode the underlying instant losslessly. + encoder.dateEncodingStrategy = .secondsSince1970 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] return encoder } - private static func makeDecoder() -> JSONDecoder { + static func makeDecoder() -> JSONDecoder { let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + if let seconds = try? container.decode(Double.self) { + return Date(timeIntervalSince1970: seconds) + } + + let value = try container.decode(String.self) + if let date = try? Date( + value, + strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true), + ) { + return date + } + if let date = try? Date(value, strategy: Date.ISO8601FormatStyle()) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected a Unix timestamp or ISO8601 date.", + ) + } return decoder } @@ -82,12 +116,19 @@ public struct BackupService: Sendable { dismissedIssues: [DismissedIssue] = [], trackedRegions: [Region] = [], primaryRegions: [PrimaryRegion] = [], - recordingDevices: [RecordingDevice] = [], - recordingPolicyChanges: [RecordingPolicyChange] = [], + recordingDeviceProfiles: [RecordingDeviceProfile], + recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], + recordingDeviceCheckIns: [RecordingDeviceCheckIn], + recordingPolicyChanges: [RecordingPolicyChange], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, ) throws -> URL { + try Self.validateRecordingData( + metadataChanges: recordingDeviceMetadataChanges, + checkIns: recordingDeviceCheckIns, + policyChanges: recordingPolicyChanges, + ) let fileManager = FileManager.default let workRoot = fileManager.temporaryDirectory .appendingPathComponent("where-backup-\(UUID().uuidString)", isDirectory: true) @@ -118,7 +159,9 @@ public struct BackupService: Sendable { dismissedIssues: dismissedIssues, trackedRegions: trackedRegions, primaryRegions: primaryRegions, - recordingDevices: recordingDevices, + recordingDeviceProfiles: recordingDeviceProfiles, + recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, + recordingDeviceCheckIns: recordingDeviceCheckIns, recordingPolicyChanges: recordingPolicyChanges, assets: assetEntries, ) @@ -181,29 +224,75 @@ public struct BackupService: Sendable { throw BackupError.manifestMissing } let archive = try Self.logger.measure(.decodeManifest) { - let manifestData = try Data(contentsOf: manifestURL) - return try Self.makeDecoder().decode(BackupArchive.self, from: manifestData) - } - guard archive.formatVersion == BackupArchive.currentFormatVersion else { - throw BackupError.unsupportedFormatVersion(archive.formatVersion) + try Self.decodeManifest(Data(contentsOf: manifestURL)) } + try Self.validateRecordingData(archive) + + let blobs = try Self.loadAssets(archive.assets, from: extractDir) + return ReadResult(archive: archive, blobs: blobs) + } + /// Load every blob the manifest explicitly declares. Evidence without an asset entry remains + /// intentionally metadata-only; an entry whose file is absent or unreadable is a corrupt + /// archive and must throw before `BackupCoordinator` pauses recording or mutates the store. + static func loadAssets( + _ entries: [BackupAssetEntry], + from extractDirectory: URL, + ) throws -> [UUID: Data] { var blobs: [UUID: Data] = [:] - Self.logger.measure(.loadAssets) { - for entry in archive.assets { + try Self.logger.measure(.loadAssets) { + for entry in entries { // Drain the per-read bridging scratch each iteration so walking a // large asset set doesn't accumulate transient temporaries (the // decoded blobs themselves are retained in `blobs`). - autoreleasepool { - let assetURL = extractDir.appendingPathComponent(entry.filename) - guard let data = try? Data(contentsOf: assetURL) else { + try autoreleasepool { + let assetURL = extractDirectory.appendingPathComponent(entry.filename) + do { + blobs[entry.evidenceId] = try Data(contentsOf: assetURL) + } catch { Self.logger { .assetMissing(evidenceID: entry.evidenceId.uuidString) } - return + throw error } - blobs[entry.evidenceId] = data } } } - return ReadResult(archive: archive, blobs: blobs) + return blobs + } + + static func decodeManifest(_ data: Data) throws -> BackupArchive { + let decoder = makeDecoder() + let envelope = try decoder.decode(FormatEnvelope.self, from: data) + guard envelope.formatVersion == BackupArchive.currentFormatVersion else { + throw BackupError.unsupportedFormatVersion(envelope.formatVersion) + } + return try decoder.decode(BackupArchive.self, from: data) + } + + /// Validate invariants that synthesized `Decodable` cannot route through the public + /// initializers. Kept separate so malformed input is rejected before the import transaction, + /// rather than being committed and silently disappearing from later materialized reads. + static func validateRecordingData(_ archive: BackupArchive) throws { + try validateRecordingData( + metadataChanges: archive.recordingDeviceMetadataChanges, + checkIns: archive.recordingDeviceCheckIns, + policyChanges: archive.recordingPolicyChanges, + ) + } + + private static func validateRecordingData( + metadataChanges: [RecordingDeviceMetadataChange], + checkIns: [RecordingDeviceCheckIn], + policyChanges: [RecordingPolicyChange], + ) throws { + guard metadataChanges.allSatisfy({ $0.revision >= 0 }), + checkIns.allSatisfy({ + $0.revision >= 0 && $0.status != .unknown + }), + RecordingPolicyChange.formValidPersistedTimelines( + policyChanges, + ) + else { + throw BackupError.invalidRecordingData + } } } diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index 3bf4c410..3756489b 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -1,313 +1,970 @@ import Foundation -/// Serializes the synced recording policy with this device's physical GPS -/// lifecycle. +/// Owns recording-device registration, desired-policy commands, and this installation's +/// physical GPS reconciliation. /// -/// Policy writes take effect historically at their timestamp immediately on -/// every device that has synced them. The target device later acknowledges the -/// latest event after it has started or stopped its local `LocationIngestor`. +/// The controller deliberately persists three independently owned device records: an immutable +/// profile created by the installation, append-only nickname events authored from any device, +/// and a target-owned check-in. Desired authority (On, Off, or archived) is one append-only event +/// stream. +/// Keeping those writers apart prevents CloudKit's last-writer-wins merge from rolling unrelated +/// fields backward. +/// +/// Registration is one explicit lifecycle operation. Reads and later commands never accept or +/// infer an initial preference, so synced policy is the only authority after registration. A +/// focused store observer compares the current installation's effective authority and check-in. +/// Unrelated sample or region writes do not repeatedly reconcile GPS except when a heartbeat +/// is due. public actor DeviceRecordingController { private let store: any WhereStore private let ingestor: LocationIngestor public nonisolated let currentDevice: CurrentRecordingDevice private let now: @Sendable () -> Date + private let onPolicyChanged: @Sendable () async -> Void + private let registeredAt: Date + private let initialRecordingChoice: InstallationRecordingContext.InitialRecordingChoice + private let configurationBroadcaster = RecordingConfigurationBroadcaster() - /// Reentrancy-safe gate: each public mutation/reconcile holds it across - /// awaits, so a rapid toggle cannot let an older start finish after a newer - /// stop. Actor isolation alone is insufficient because actors are reentrant. + /// Reentrancy-safe gate held across store and physical-ingestor awaits. Actor isolation alone + /// is insufficient because another command can enter while an actor method is suspended. private var isExclusive = false private var waiters: [CheckedContinuation] = [] private var acceptsOperations = true + /// Whether this stack has entered the recording lifecycle. A scope created only to restore + /// onboarding data has not: backup completion must not invent authority that did not exist + /// before the reversible import pause. + private var recordingLifecycleStarted = false + /// Snapshot consumed by the matching resume path after a reversible pause. + private var shouldResumeAuthorityAfterPause = false + /// Prevents two reset/import lifecycles from interleaving across their actor awaits. + private var isRewritePaused = false + + private var policyObservationTask: Task? + /// Exact policy event most recently applied and acknowledged on this installation. + private var lastAppliedCurrentPolicyID: UUID? + /// Retained after fail-closed reconciliation so any later store ping retries it. + private var needsPolicyReconciliation = false + private var nextRuntimeSequence: UInt64 = 0 + private var latestRuntimeUpdate: RecordingDeviceRuntimeUpdate? + + private static let checkInInterval: TimeInterval = 15 * 60 + + private static let logger = WhereLog.root(DeviceRecordingControllerLog.self) + + /// Epoch-pinned recording tables used to make one authority decision. A reset/Replace that + /// lands while these tables are loading makes the snapshot throw instead of combining old + /// policy with new-epoch check-ins. + private struct StoreSnapshot { + let epoch: WhereDataEpoch + let profiles: [RecordingDeviceProfile] + let metadataChanges: [RecordingDeviceMetadataChange] + let checkIns: [RecordingDeviceCheckIn] + let policyChanges: [RecordingPolicyChange] + } init( store: any WhereStore, ingestor: LocationIngestor, - currentDevice: CurrentRecordingDevice, + installationContext: InstallationRecordingContext, now: @escaping @Sendable () -> Date, + onPolicyChanged: @escaping @Sendable () async -> Void, ) { self.store = store self.ingestor = ingestor - self.currentDevice = currentDevice + guard let initialRecordingChoice = installationContext.initialRecordingChoice else { + preconditionFailure("Recording services require a confirmed installation context.") + } + currentDevice = installationContext.currentDevice + registeredAt = installationContext.registeredAt + self.initialRecordingChoice = initialRecordingChoice self.now = now + self.onPolicyChanged = onPolicyChanged + } + + deinit { + policyObservationTask?.cancel() + configurationBroadcaster.finishAll() + } + + /// Applied current-installation states, emitted only after acknowledgement is durable. + public nonisolated func runtimeUpdates() + -> AsyncStream + { + configurationBroadcaster.subscribe() + } + + /// Latest controller-ordered runtime state, for a caller that needs to synchronize after an + /// awaited command without racing a newer emission already queued on the async stream. + public func currentRuntimeUpdate() -> RecordingDeviceRuntimeUpdate? { + latestRuntimeUpdate + } + + /// Start the focused policy observer. Safe to call repeatedly from lifecycle setup. + public func startMonitoringPolicyChanges() { + recordingLifecycleStarted = true + guard policyObservationTask == nil else { return } + let updates = store.changes() + policyObservationTask = Task { [weak self] in + for await _ in updates { + guard let self else { break } + await applyObservedPolicyChange() + } + } + } + + /// Register this installation and its confirmed initial choice exactly once, then apply it. + /// `initialPolicyChangeID` comes from the non-backed-up installation context, making a retry + /// idempotent even if profile and policy records are observed at different times. + @discardableResult + public func register( + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + await beginExclusive() + defer { endExclusive() } + try requireActive() + recordingLifecycleStarted = true + do { + try await registerLocked( + initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialEnabled: initialRecordingChoice.isEnabled, + ) + let reconciliation = try await reconcileLocked(authorization: authorization) + lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID + needsPolicyReconciliation = false + return reconciliation + } catch { + needsPolicyReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + throw error + } + } + + /// Register the immutable first choice, then apply the user's current onboarding selection + /// before opening physical recording authority. This differs only on a retry after the first + /// choice was already persisted: the immutable event stays intact and the new selection is a + /// causal follow-up command, instead of silently snapping the UI back to the earlier choice. + @discardableResult + public func registerForOnboarding( + desiredEnabled: Bool, + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + await beginExclusive() + defer { endExclusive() } + try requireActive() + recordingLifecycleStarted = true + do { + try await registerLocked( + initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialEnabled: initialRecordingChoice.isEnabled, + ) + let snapshot = try await storeSnapshot() + let timeline = Self.policyTimeline( + for: currentDevice.id, + in: snapshot.policyChanges, + ) + guard Self.hasCompleteRevisionHistory(timeline), + let latestPolicy = RecordingPolicyChange.canonicalHead(in: timeline) + else { + throw RecordingPersistenceError.currentDevicePolicyUnknown(currentDevice.id) + } + let commandDate = now() + let desiredState: RecordingPolicyState = desiredEnabled ? .on : .off + let policyChange: RecordingPolicyChange? = if latestPolicy.state == desiredState { + nil + } else { + try RecordingPolicyChange.appendingCommand( + to: timeline, + deviceID: currentDevice.id, + issuedAt: commandDate, + issuedByDeviceID: currentDevice.id, + effectiveAt: Self.nextEffectiveDate( + proposed: max(commandDate, snapshot.epoch.changedAt), + after: latestPolicy, + ), + state: desiredState, + reason: .userCommand, + ) + } + if let policyChange { + try await store.perform(expectedDataEpochID: snapshot.epoch.id) { + try await self.store.addRecordingPolicyChange(policyChange) + } + // Historical visibility changed as soon as the authority event committed. Do + // not make derived reconciliation depend on a later physical/check-in success. + await onPolicyChanged() + } + + let reconciliation = try await reconcileLocked(authorization: authorization) + lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID + needsPolicyReconciliation = false + return reconciliation + } catch { + needsPolicyReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + throw error + } } - /// Register this installation if needed, migrate its initial desired state - /// from local preferences, then make physical monitoring match the latest - /// synced policy and authorization. + /// Apply the latest synced policy to this installation. Failure is fail-closed: GPS is + /// stopped before the error is surfaced, so stale local preference can never authorize a fix. @discardableResult public func reconcile( - initialEnabled: Bool, authorization: LocationAuthorizationStatus, ) async throws -> RecordingDeviceConfiguration { await beginExclusive() defer { endExclusive() } try requireActive() - return try await reconcileLocked( - initialEnabled: initialEnabled, - authorization: authorization, - ) + recordingLifecycleStarted = true + do { + let reconciliation = try await reconcileLocked(authorization: authorization) + lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID + needsPolicyReconciliation = false + return reconciliation + } catch { + needsPolicyReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + throw error + } } - /// Active device configurations, current device first and then by most - /// recent check-in. - public func devices(initialEnabled: Bool) async throws -> [RecordingDeviceConfiguration] { + /// Pure read of active device configurations. A profile whose policy has not arrived yet is + /// returned with `.unknown` policy rather than fabricated as enabled. + public func devices() async throws -> [RecordingDeviceConfiguration] { await beginExclusive() defer { endExclusive() } try requireActive() - try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) return try await configurationsLocked(includeArchived: false) } - /// Append a desired-state change. For this installation, reconcile and - /// acknowledge it before returning. A remote installation will show pending - /// until that device receives and applies the CloudKit row. + /// Append a desired-state command. A command for this installation is physically reconciled + /// and acknowledged before returning; a remote command remains pending until its target syncs. @discardableResult public func setEnabled( _ enabled: Bool, for deviceID: RecordingDeviceID, - initialEnabled: Bool, ) async throws -> [RecordingDeviceConfiguration] { await beginExclusive() defer { endExclusive() } try requireActive() - try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) - let device = try await store.recordingDevices().first(where: { $0.id == deviceID }) - let changes = try await store.recordingPolicyChanges() - let change = RecordingPolicyChange( - id: UUID(), - deviceID: deviceID, - effectiveAt: Self.nextEffectiveDate( - proposed: now(), - after: Self.latestPolicy(for: deviceID, in: changes), - ), - isEnabled: enabled, - ) - try await store.perform { - try await store.addRecordingPolicyChange(change) - if enabled, let device, device.archivedAt != nil { - try await store.setRecordingDevice(device.unarchived()) + let snapshot = try await storeSnapshot() + guard snapshot.profiles.contains(where: { $0.id == deviceID }) else { + throw RecordingPersistenceError.deviceNotFound(deviceID) + } + let epoch = snapshot.epoch + let timeline = Self.policyTimeline(for: deviceID, in: snapshot.policyChanges) + guard Self.hasCompleteRevisionHistory(timeline), + let latestPolicy = Self.effectivePolicy( + for: deviceID, + epoch: epoch, + timeline: timeline, + ) + else { + throw RecordingPersistenceError.devicePolicyUnknown(deviceID) + } + let issuedAt = now() + let desiredState: RecordingPolicyState = enabled ? .on : .off + let causalHead = RecordingPolicyChange.canonicalHead(in: timeline) + let policyChange: RecordingPolicyChange? = if latestPolicy.state == desiredState { + nil + } else { + try RecordingPolicyChange.appendingCommand( + to: timeline, + deviceID: deviceID, + issuedAt: issuedAt, + issuedByDeviceID: currentDevice.id, + effectiveAt: Self.nextEffectiveDate( + proposed: max(issuedAt, epoch.changedAt), + after: causalHead, + ), + state: desiredState, + reason: .userCommand, + ) + } + + guard let policyChange else { + if deviceID == currentDevice.id { + if !enabled { + await ingestor.revokeRecordingAuthorization() + } + try await reconcileCurrentAfterCommandLocked() } + return try await configurationsLocked(includeArchived: false) + } + + try await store.perform(expectedDataEpochID: epoch.id) { + try await self.store.addRecordingPolicyChange(policyChange) + } + // Close local physical authority before any potentially slow derived-data rebuild. The + // durable cutoff already hides history, but raw fixes must not continue entering the + // store/outbox after the user turns this installation Off. + if deviceID == currentDevice.id, !enabled { + await ingestor.revokeRecordingAuthorization() } + // The cutoff is already durable even if physical acknowledgement below fails. + await onPolicyChanged() if deviceID == currentDevice.id { - let authorization = await ingestor.authorizationStatus() - _ = try await reconcileLocked( - initialEnabled: initialEnabled, - authorization: authorization, - ) + try await reconcileCurrentAfterCommandLocked() } return try await configurationsLocked(includeArchived: false) } - /// Change the synced, user-editable nickname. Empty/whitespace-only text - /// clears the nickname and falls back to the generic system label. + /// Append a user-editable nickname change. Empty or whitespace-only input clears it. public func rename( _ deviceID: RecordingDeviceID, to nickname: String, - initialEnabled: Bool, ) async throws -> [RecordingDeviceConfiguration] { await beginExclusive() defer { endExclusive() } try requireActive() - try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) - guard let device = try await store.recordingDevices().first(where: { $0.id == deviceID }) - else { return try await configurationsLocked(includeArchived: false) } - + let snapshot = try await storeSnapshot() + guard snapshot.profiles.contains(where: { $0.id == deviceID }) else { + throw RecordingPersistenceError.deviceNotFound(deviceID) + } + let changes = snapshot.metadataChanges + let latest = Self.latestMetadata(for: deviceID, field: .nickname, in: changes) let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) - let renamed = device.renamed(trimmed.isEmpty ? nil : trimmed) - guard renamed != device else { + let resolvedNickname = trimmed.isEmpty ? nil : trimmed + guard latest?.nickname != resolvedNickname else { return try await configurationsLocked(includeArchived: false) } - try await store.perform { - try await store.setRecordingDevice(renamed) + let change = try RecordingDeviceMetadataChange( + id: UUID(), + deviceID: deviceID, + revision: Self.nextRevision(after: latest?.revision, for: deviceID), + changedAt: now(), + changedByDeviceID: currentDevice.id, + nickname: resolvedNickname, + ) + try await store.perform(expectedDataEpochID: snapshot.epoch.id) { + try await self.store.addRecordingDeviceMetadataChange(change) } return try await configurationsLocked(includeArchived: false) } - /// Hide a non-current stale device and append an off cutoff atomically. - /// Policy history and raw samples remain available to backups. + /// Hide a non-current device and append an Off policy atomically. History and raw samples + /// remain in the event log and backups. public func archive( _ deviceID: RecordingDeviceID, - initialEnabled: Bool, ) async throws -> [RecordingDeviceConfiguration] { precondition(deviceID != currentDevice.id, "The current device cannot archive itself.") await beginExclusive() defer { endExclusive() } try requireActive() - try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) - guard let device = try await store.recordingDevices().first(where: { $0.id == deviceID }) - else { return try await configurationsLocked(includeArchived: false) } + let snapshot = try await storeSnapshot() + guard snapshot.profiles.contains(where: { $0.id == deviceID }) else { + throw RecordingPersistenceError.deviceNotFound(deviceID) + } let date = now() - let changes = try await store.recordingPolicyChanges() - let change = RecordingPolicyChange( - id: UUID(), - deviceID: deviceID, - effectiveAt: Self.nextEffectiveDate( - proposed: date, - after: Self.latestPolicy(for: deviceID, in: changes), - ), - isEnabled: false, - ) - try await store.perform { - try await store.addRecordingPolicyChange(change) - try await store.setRecordingDevice(device.archived(at: date)) + let epoch = snapshot.epoch + let timeline = Self.policyTimeline(for: deviceID, in: snapshot.policyChanges) + guard Self.hasCompleteRevisionHistory(timeline), + let latestPolicy = Self.effectivePolicy( + for: deviceID, + epoch: epoch, + timeline: timeline, + ) + else { + throw RecordingPersistenceError.devicePolicyUnknown(deviceID) + } + let causalHead = RecordingPolicyChange.canonicalHead(in: timeline) + let policyChange: RecordingPolicyChange? = if latestPolicy.state != .archived { + try RecordingPolicyChange.appendingCommand( + to: timeline, + deviceID: deviceID, + issuedAt: date, + issuedByDeviceID: currentDevice.id, + effectiveAt: Self.nextEffectiveDate( + proposed: max(date, epoch.changedAt), + after: causalHead, + ), + state: .archived, + reason: .archive, + ) + } else { + nil + } + guard let policyChange else { + return try await configurationsLocked(includeArchived: false) + } + try await store.perform(expectedDataEpochID: epoch.id) { + try await self.store.addRecordingPolicyChange(policyChange) } + await onPolicyChanged() return try await configurationsLocked(includeArchived: false) } - /// Permanently close this stack's policy/write gate and quiesce GPS before - /// reset wipes the store. A queued observer reconciliation resumes behind - /// this gate, sees the closed state, and cannot recreate the just-erased - /// current-device rows. - func quiesce() async { + /// Reversibly close this stack around a backup import or reset transaction. Pending samples + /// remain owned by the installation until the destructive operation actually commits. + func pause() async throws { await beginExclusive() defer { endExclusive() } + guard !isRewritePaused else { + throw RecordingPersistenceError.recordingRewriteInProgress + } + isRewritePaused = true + shouldResumeAuthorityAfterPause = shouldResumeAuthorityAfterPause + || recordingLifecycleStarted + recordingLifecycleStarted = false acceptsOperations = false - await ingestor.quiesce() + policyObservationTask?.cancel() + policyObservationTask = nil + await ingestor.pause() } - /// A failed reset retains the session, so reopen its operation gate. The - /// next lifecycle reconciliation decides whether GPS should resume. + /// Reopen a stack retained after a failed reset. func resumeAfterFailedReset() async { await beginExclusive() defer { endExclusive() } + await resumeLocked() + } + + /// Reopen the old authority after a backup-import transaction rolls back. + func resumeAfterImportRollback() async { + await beginExclusive() + defer { endExclusive() } + await resumeLocked() + } + + private func resumeLocked() async { acceptsOperations = true + isRewritePaused = false + let shouldResumeAuthority = shouldResumeAuthorityAfterPause + shouldResumeAuthorityAfterPause = false + guard shouldResumeAuthority else { return } + startMonitoringPolicyChanges() + do { + try await registerLocked( + initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialEnabled: initialRecordingChoice.isEnabled, + ) + let authorization = await ingestor.authorizationStatus() + let reconciliation = try await reconcileLocked(authorization: authorization) + lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID + needsPolicyReconciliation = false + } catch { + needsPolicyReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + Self.logger(attachments: [.error(error, name: "rollback-recovery-error")]) { + .rollbackRecoveryFailed(description: error.localizedDescription) + } + } } - private func reconcileLocked( + /// Reactivate after a committed backup import, restore this installation's fixed + /// registration, and apply imported authority. Import data is already committed at this + /// point. Privacy-critical sidecar cleanup throws so the coordinator can report committed + /// partial success; later physical recovery remains fail-closed and logged for retry. + func resumeAfterImport(discardPendingSamples: Bool) async throws { + await beginExclusive() + acceptsOperations = true + let shouldResumeAuthority = shouldResumeAuthorityAfterPause + if discardPendingSamples { + do { + try await ingestor.discardRetryBacklog() + } catch { + // The import is already committed. Keep the old installation context and + // recording stack paused so a retry can remove the same sidecar safely. + isRewritePaused = false + acceptsOperations = false + needsPolicyReconciliation = true + publishRuntimeState(.unavailable) + endExclusive() + throw error + } + } + shouldResumeAuthorityAfterPause = false + isRewritePaused = false + guard shouldResumeAuthority else { + endExclusive() + return + } + startMonitoringPolicyChanges() + do { + try await registerLocked( + initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialEnabled: initialRecordingChoice.isEnabled, + ) + let authorization = await ingestor.authorizationStatus() + let reconciliation = try await reconcileLocked(authorization: authorization) + lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID + needsPolicyReconciliation = false + endExclusive() + } catch { + needsPolicyReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + endExclusive() + Self.logger(attachments: [.error(error, name: "import-recovery-error")]) { + .importRecoveryFailed(description: error.localizedDescription) + } + } + } + + /// Finish a committed reset without reopening this installation's authority. A failed + /// sidecar cleanup leaves the old installation fail-closed; the destructive data epoch makes + /// a later retry clear the same backlog before acknowledgement. + func finishReset() async throws { + await beginExclusive() + do { + try await ingestor.discardRetryBacklog() + } catch { + isRewritePaused = false + needsPolicyReconciliation = true + publishRuntimeState(.unavailable) + endExclusive() + throw error + } + shouldResumeAuthorityAfterPause = false + isRewritePaused = false + endExclusive() + } + + private func registerLocked( + initialPolicyChangeID: UUID, initialEnabled: Bool, + ) async throws { + let snapshot = try await storeSnapshot() + let epoch = snapshot.epoch + let existingProfile = snapshot.profiles.first(where: { $0.id == currentDevice.id }) + let profile = expectedProfile( + registrationEpochID: existingProfile?.registrationEpochID ?? epoch.id, + ) + let ownsInitialPolicyInThisEpoch = existingProfile == nil + || existingProfile?.registrationEpochID == epoch.id + let initialPolicy = expectedInitialPolicy( + id: initialPolicyChangeID, + isEnabled: initialEnabled, + in: epoch, + ) + let existingInitialPolicy = snapshot.policyChanges + .first(where: { $0.id == initialPolicyChangeID }) + let needsProfileWrite = existingProfile != profile + let needsInitialPolicyWrite = ownsInitialPolicyInThisEpoch + && existingInitialPolicy != initialPolicy + guard needsProfileWrite || needsInitialPolicyWrite else { return } + + // The add APIs validate identical immutable retries and reject conflicting payloads. + // An installation first seen in this epoch also retries its immutable initial policy. + // An existing profile entering a newer destructive epoch must not replay that old first + // choice: the epoch's fail-closed default remains authoritative until a new command. + try await store.perform(expectedDataEpochID: epoch.id) { + try await self.store.addRecordingDeviceProfile(profile) + if ownsInitialPolicyInThisEpoch { + try await self.store.addRecordingPolicyChange(initialPolicy) + } + } + } + + private func reconcileCurrentAfterCommandLocked() async throws { + do { + let authorization = await ingestor.authorizationStatus() + let reconciliation = try await reconcileLocked(authorization: authorization) + lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID + needsPolicyReconciliation = false + } catch { + needsPolicyReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + throw error + } + } + + private func reconcileLocked( authorization: LocationAuthorizationStatus, ) async throws -> RecordingDeviceConfiguration { - try await ensureCurrentDeviceLocked(initialEnabled: initialEnabled) - let policies = try await store.recordingPolicyChanges() - guard let latest = Self.latestPolicy(for: currentDevice.id, in: policies) else { - preconditionFailure( - "Current recording device was registered without an initial policy.", + let snapshot = try await storeSnapshot() + guard let profile = snapshot.profiles.first(where: { $0.id == currentDevice.id }) else { + throw RecordingPersistenceError.currentDeviceNotRegistered(currentDevice.id) + } + let epoch = snapshot.epoch + let policies = snapshot.policyChanges + let timeline = Self.policyTimeline(for: currentDevice.id, in: policies) + guard Self.hasCompleteRevisionHistory(timeline) else { + throw RecordingPersistenceError.incompletePolicyHistory(currentDevice.id) + } + guard let latest = Self.effectivePolicy( + for: currentDevice.id, + epoch: epoch, + timeline: timeline, + ) else { + throw RecordingPersistenceError.currentDevicePolicyUnknown(currentDevice.id) + } + let nickname = Self.latestMetadata( + for: currentDevice.id, + field: .nickname, + in: snapshot.metadataChanges, + ) + + let existing = snapshot.checkIns + .first(where: { $0.deviceID == currentDevice.id }) + let requiredCleanupToken: RecordingPolicyCleanupToken? = if epoch.isDestructive { + RecordingPolicyCleanupToken(rawValue: epoch.id.rawValue) + } else { + Self.destructiveCleanupToken( + for: currentDevice.id, + in: policies, ) } - let status: RecordingDeviceStatus - if latest.isEnabled, authorization.allowsBackgroundTracking { - await ingestor.start() - status = .recording + // Close the sample gate before computing or acknowledging authority. In particular, + // `authorizeRecording` restores and drains the durable outbox, so it cannot run until + // the check-in proving this policy was applied has committed. + await ingestor.revokeRecordingAuthorization() + if existing?.lastDiscardedPolicyFrontierToken != requiredCleanupToken, + requiredCleanupToken != nil + { + try await ingestor.discardRetryBacklog() + } + if latest.isEnabled { + try await ingestor.prepareRetryBacklog() + } + + let status: RecordingDeviceStatus = if latest.isEnabled { + authorization.allowsBackgroundTracking ? .recording : .permissionRequired } else { - await ingestor.stop() - status = latest.isEnabled ? .permissionRequired : .off + .off } - guard let device = try await store.recordingDevices() - .first(where: { $0.id == currentDevice.id }) - else { - preconditionFailure("Current recording device disappeared during reconciliation.") - } - let checkIn = now() - let needsAcknowledgement = device.lastAppliedPolicyChangeID != latest.id - || device.status != status - let needsPeriodicCheckIn = checkIn.timeIntervalSince(device.lastSeenAt) >= 15 * 60 - let acknowledged = if needsAcknowledgement || needsPeriodicCheckIn { - device.acknowledging( - policyChangeID: latest.id, + let checkInDate = now() + let needsAcknowledgement = existing?.lastAppliedPolicyChangeID != latest.id + || existing?.lastDiscardedPolicyFrontierToken != requiredCleanupToken + || existing?.status != status + let needsPeriodicCheckIn = existing.map { + checkInDate.timeIntervalSince($0.lastSeenAt) >= Self.checkInInterval + } ?? true + let checkIn: RecordingDeviceCheckIn + if needsAcknowledgement || needsPeriodicCheckIn { + checkIn = try RecordingDeviceCheckIn( + deviceID: currentDevice.id, + revision: Self.nextRevision( + after: existing?.revision, + for: currentDevice.id, + ), + lastSeenAt: checkInDate, + appliedAt: needsAcknowledgement ? checkInDate : + (existing?.appliedAt ?? checkInDate), + lastAppliedPolicyChangeID: latest.id, + lastDiscardedPolicyFrontierToken: requiredCleanupToken, status: status, - at: checkIn, ) + try await store.perform(expectedDataEpochID: epoch.id) { + try await self.store.setRecordingDeviceCheckIn(checkIn) + } + } else if let existing { + checkIn = existing } else { - device + preconditionFailure("A required recording check-in was not created.") } - if acknowledged != device { - try await store.perform { - try await store.setRecordingDevice(acknowledged) + + // Only a durable acknowledgement opens physical authority. This ordering also prevents + // an outbox drain from committing samples when the check-in write fails. + if latest.isEnabled { + if authorization.allowsBackgroundTracking { + try await ingestor.start( + effectiveAt: latest.effectiveAt, + dataEpochID: epoch.id, + ) + } else { + // Keep foreground fill-in fixes authorized for When-In-Use while pausing + // background monitoring. + try await ingestor.authorizeRecording( + effectiveAt: latest.effectiveAt, + dataEpochID: epoch.id, + ) + await ingestor.stop() } } - return RecordingDeviceConfiguration( - device: acknowledged, - isEnabled: latest.isEnabled, - latestPolicyChangeID: latest.id, - ) - } - - private func ensureCurrentDeviceLocked(initialEnabled: Bool) async throws { - let devices = try await store.recordingDevices() - let policies = try await store.recordingPolicyChanges() - let existing = devices.first(where: { $0.id == currentDevice.id }) - let latest = Self.latestPolicy(for: currentDevice.id, in: policies) - guard existing == nil || latest == nil else { return } - let date = now() - let profile = existing ?? RecordingDevice( - id: currentDevice.id, - systemName: currentDevice.systemName, - nickname: nil, - kind: currentDevice.kind, - registeredAt: date, - lastSeenAt: date, - archivedAt: nil, - lastAppliedPolicyChangeID: nil, - status: .off, - ) - let initialChange = latest ?? RecordingPolicyChange( - id: UUID(), - deviceID: currentDevice.id, - effectiveAt: date, - isEnabled: initialEnabled, + let configuration = RecordingDeviceConfiguration( + device: RecordingDevice( + profile: profile, + nicknameChange: nickname, + checkIn: checkIn, + policyChange: latest, + ), + policyChange: latest, + requiredCleanupToken: requiredCleanupToken, ) - try await store.perform { - if existing == nil { - try await store.setRecordingDevice(profile) - } - if latest == nil { - try await store.addRecordingPolicyChange(initialChange) - } - } + publishRuntimeState(.applied(configuration)) + return configuration } private func configurationsLocked( includeArchived: Bool, ) async throws -> [RecordingDeviceConfiguration] { - async let devices = store.recordingDevices() - async let policies = store.recordingPolicyChanges() - let (resolvedDevices, resolvedPolicies) = try await (devices, policies) - return resolvedDevices - .filter { - includeArchived || $0.archivedAt == nil || $0.id == currentDevice.id - } - .map { device in - let latest = Self.latestPolicy(for: device.id, in: resolvedPolicies) - return RecordingDeviceConfiguration( - device: device, - isEnabled: latest?.isEnabled ?? true, - latestPolicyChangeID: latest?.id, + try await store.readSnapshot { + async let devices = store.recordingDevices() + async let policies = store.recordingPolicyChanges() + async let epoch = store.dataEpoch() + let (resolvedDevices, resolvedPolicies, resolvedEpoch) = try await ( + devices, + policies, + epoch, + ) + return resolvedDevices + .map { device in + let timeline = Self.policyTimeline(for: device.id, in: resolvedPolicies) + guard Self.hasCompleteRevisionHistory(timeline), + let latest = Self.effectivePolicy( + for: device.id, + epoch: resolvedEpoch, + timeline: timeline, + ) + else { + return RecordingDeviceConfiguration(device: device, policy: .unknown) + } + return RecordingDeviceConfiguration( + device: device, + policyChange: latest, + requiredCleanupToken: resolvedEpoch.isDestructive + ? RecordingPolicyCleanupToken(rawValue: resolvedEpoch.id.rawValue) + : Self.destructiveCleanupToken( + for: device.id, + in: resolvedPolicies, + ), + ) + } + .filter { + includeArchived || !$0.isArchived || $0.id == currentDevice.id + } + .sorted { lhs, rhs in + if lhs.id == currentDevice.id { return true } + if rhs.id == currentDevice.id { return false } + if lhs.device.lastSeenAt != rhs.device.lastSeenAt { + return lhs.device.lastSeenAt > rhs.device.lastSeenAt + } + return lhs.id.storeURL.absoluteString < rhs.id.storeURL.absoluteString + } + } + } + + private func applyObservedPolicyChange() async { + await beginExclusive() + guard acceptsOperations else { + endExclusive() + return + } + do { + let snapshot = try await storeSnapshot() + let epoch = snapshot.epoch + let policies = snapshot.policyChanges + let checkIns = snapshot.checkIns + let existingProfile = snapshot.profiles.first { $0.id == currentDevice.id } + let hasExpectedProfile = existingProfile.map { + $0 == expectedProfile(registrationEpochID: $0.registrationEpochID) + } ?? false + let requiresInitialPolicy = existingProfile?.registrationEpochID == epoch.id + let hasExpectedInitialPolicy = !requiresInitialPolicy || policies + .contains(expectedInitialPolicy( + id: initialRecordingChoice.policyChangeID, + isEnabled: initialRecordingChoice.isEnabled, + in: epoch, + )) + let timeline = Self.policyTimeline(for: currentDevice.id, in: policies) + let latestCurrentPolicyID = Self.effectivePolicy( + for: currentDevice.id, + epoch: epoch, + timeline: timeline, + )?.id + let requiredCleanupToken: RecordingPolicyCleanupToken? = epoch.isDestructive + ? RecordingPolicyCleanupToken(rawValue: epoch.id.rawValue) + : Self.destructiveCleanupToken( + for: currentDevice.id, + in: policies, + ) + let acknowledgedCurrentPolicyID = checkIns.first(where: { + $0.deviceID == currentDevice.id + })?.lastAppliedPolicyChangeID + let currentCheckIn = checkIns.first { $0.deviceID == currentDevice.id } + let acknowledgedCleanupToken = currentCheckIn? + .lastDiscardedPolicyFrontierToken + let heartbeatDue = currentCheckIn.map { + now().timeIntervalSince($0.lastSeenAt) >= Self.checkInInterval + } ?? true + let shouldReconcile = needsPolicyReconciliation + || !hasExpectedProfile + || !hasExpectedInitialPolicy + || latestCurrentPolicyID != lastAppliedCurrentPolicyID + || acknowledgedCurrentPolicyID != latestCurrentPolicyID + || acknowledgedCleanupToken != requiredCleanupToken + || heartbeatDue + if shouldReconcile { + try await registerLocked( + initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialEnabled: initialRecordingChoice.isEnabled, ) + let authorization = await ingestor.authorizationStatus() + let reconciliation = try await reconcileLocked(authorization: authorization) + lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID + needsPolicyReconciliation = false } - .sorted { lhs, rhs in - if lhs.id == currentDevice.id { return true } - if rhs.id == currentDevice.id { return false } - if lhs.device.lastSeenAt != rhs.device.lastSeenAt { - return lhs.device.lastSeenAt > rhs.device.lastSeenAt - } - return lhs.id.storeURL.absoluteString < rhs.id.storeURL.absoluteString + endExclusive() + } catch { + needsPolicyReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + endExclusive() + Self.logger(attachments: [.error(error, name: "policy-observation-error")]) { + .policyObservationFailed(description: error.localizedDescription) } + } + } + + private func storeSnapshot() async throws -> StoreSnapshot { + try await store.readSnapshot { + async let epoch = store.dataEpoch() + async let profiles = store.recordingDeviceProfiles() + async let metadataChanges = store.recordingDeviceMetadataChanges() + async let checkIns = store.recordingDeviceCheckIns() + async let policyChanges = store.recordingPolicyChanges() + let values = try await ( + epoch, + profiles, + metadataChanges, + checkIns, + policyChanges, + ) + return StoreSnapshot( + epoch: values.0, + profiles: values.1, + metadataChanges: values.2, + checkIns: values.3, + policyChanges: values.4, + ) + } } - private static func latestPolicy( + private static func policyTimeline( for deviceID: RecordingDeviceID, in changes: [RecordingPolicyChange], - ) -> RecordingPolicyChange? { + ) -> [RecordingPolicyChange] { changes .filter { $0.deviceID == deviceID } - .max { RecordingPolicyChange.isOrderedBefore($0, $1) } + .sorted(by: RecordingPolicyChange.isOrderedBefore) + } + + /// A destructive epoch is a universal fail-closed authority event. Devices absent from the + /// issuer's CloudKit snapshot therefore still resolve archived when their old profile arrives + /// later; a new per-device command at revision zero can explicitly reopen them. + private static func effectivePolicy( + for deviceID: RecordingDeviceID, + epoch: WhereDataEpoch, + timeline: [RecordingPolicyChange], + ) -> RecordingPolicyChange? { + if let latest = RecordingPolicyChange.canonicalHead(in: timeline) { return latest } + guard epoch.isDestructive, let issuer = epoch.changedByDeviceID else { return nil } + let reason: RecordingPolicyReason = switch epoch.reason { + case .initial: preconditionFailure("The initial epoch is not destructive.") + case .accountReset: .accountReset + case .backupReplace: .backupReplace + } + return RecordingPolicyChange( + id: epoch.id.rawValue, + deviceID: deviceID, + parentIDs: [], + revision: 0, + issuedAt: epoch.changedAt, + issuedByDeviceID: issuer, + effectiveAt: epoch.changedAt, + state: .archived, + reason: reason, + ) + } + + private static func destructiveCleanupToken( + for deviceID: RecordingDeviceID, + in changes: [RecordingPolicyChange], + ) -> RecordingPolicyCleanupToken? { + RecordingPolicyChange.destructiveCleanupToken( + in: changes.filter { $0.deviceID == deviceID }, + ) + } + + /// A higher revision arriving before one of its predecessors, or a malformed reason/state + /// pair, is not authority. Waiting for a valid complete timeline prevents a later On from + /// opening and draining the outbox before an intervening destructive barrier arrives. + private static func hasCompleteRevisionHistory( + _ timeline: [RecordingPolicyChange], + ) -> Bool { + RecordingPolicyChange.formValidPersistedTimelines(timeline) + } + + private func expectedProfile( + registrationEpochID: WhereDataEpochID, + ) -> RecordingDeviceProfile { + RecordingDeviceProfile( + id: currentDevice.id, + systemName: currentDevice.systemName, + kind: currentDevice.kind, + registeredAt: registeredAt, + registrationEpochID: registrationEpochID, + ) + } + + private func expectedInitialPolicy( + id: UUID, + isEnabled: Bool, + in epoch: WhereDataEpoch, + ) -> RecordingPolicyChange { + RecordingPolicyChange( + id: id, + deviceID: currentDevice.id, + parentIDs: [], + revision: 0, + issuedAt: initialRecordingChoice.confirmedAt, + issuedByDeviceID: currentDevice.id, + effectiveAt: max(initialRecordingChoice.confirmedAt, epoch.changedAt), + state: isEnabled ? .on : .off, + reason: .initialRegistration, + ) + } + + private static func latestMetadata( + for deviceID: RecordingDeviceID, + field: RecordingDeviceMetadataField, + in changes: [RecordingDeviceMetadataChange], + ) -> RecordingDeviceMetadataChange? { + changes + .filter { $0.deviceID == deviceID && $0.field == field } + .max(by: RecordingDeviceMetadataChange.isOrderedBefore) + } + + private static func nextRevision( + after revision: Int64?, + for deviceID: RecordingDeviceID, + ) throws -> Int64 { + guard let revision else { return 0 } + let (next, overflow) = revision.addingReportingOverflow(1) + guard !overflow else { + throw RecordingPersistenceError.revisionExhausted(deviceID) + } + return next + } + + private func publishRuntimeState(_ state: RecordingDeviceRuntimeState) { + let update = RecordingDeviceRuntimeUpdate(sequence: nextRuntimeSequence, state: state) + let (next, overflow) = nextRuntimeSequence.addingReportingOverflow(1) + precondition(overflow == false, "Recording runtime sequence exhausted UInt64.") + nextRuntimeSequence = next + latestRuntimeUpdate = update + configurationBroadcaster.send(update) } - /// Preserve the local order of rapid actions even when the injected clock - /// returns the same instant for both. UUID ordering remains the convergent - /// tie-break for genuinely concurrent changes written on different devices. + /// Keep historical cutoffs monotonic for a writer whose wall clock moves backward. Causal + /// ordering is carried separately by `revision`, so equal cutoffs need no timestamp mutation. private static func nextEffectiveDate( proposed: Date, after latest: RecordingPolicyChange?, ) -> Date { - guard let latest, proposed <= latest.effectiveAt else { return proposed } - return latest.effectiveAt.addingTimeInterval(0.000_001) + guard let latest else { return proposed } + return max(proposed, latest.effectiveAt) } private func requireActive() throws { diff --git a/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift new file mode 100644 index 00000000..e651cf7c --- /dev/null +++ b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Device-local state that gives one installation a stable recording identity. +/// +/// The whole value is persisted outside backed-up preferences. A restored device +/// therefore gets a new identity and must confirm its own initial recording +/// choice, while repeated launches of the same installation reuse both the +/// identity and the complete immutable payload inputs for its first synced +/// device profile and policy event. +public struct InstallationRecordingContext: Sendable, Hashable { + /// The explicitly confirmed first policy for this installation, including + /// the timestamp reused whenever its immutable event must be recreated. + public struct InitialRecordingChoice: Sendable, Hashable { + public let isEnabled: Bool + public let policyChangeID: UUID + public let confirmedAt: Date + + public init( + isEnabled: Bool, + policyChangeID: UUID, + confirmedAt: Date, + ) { + self.isEnabled = isEnabled + self.policyChangeID = policyChangeID + self.confirmedAt = confirmedAt + } + } + + public let currentDevice: CurrentRecordingDevice + /// Stable creation time for this installation's immutable device profile. + public let registeredAt: Date + public let initialRecordingChoice: InitialRecordingChoice? + + public init( + currentDevice: CurrentRecordingDevice, + registeredAt: Date, + initialRecordingChoice: InitialRecordingChoice?, + ) { + self.currentDevice = currentDevice + self.registeredAt = registeredAt + self.initialRecordingChoice = initialRecordingChoice + } + + /// The safe default shown until this installation confirms a choice. + public var recommendedRecordingEnabled: Bool { + currentDevice.kind.recommendsAutomaticRecording + } + + /// Return the confirmed form of a newly proposed context, freezing every + /// value needed to recreate the first policy event byte-for-byte. + public func confirmingInitialRecording( + isEnabled: Bool, + policyChangeID: UUID, + confirmedAt: Date, + ) -> InstallationRecordingContext { + precondition( + initialRecordingChoice == nil, + "An installation's initial recording choice can only be confirmed once.", + ) + return InstallationRecordingContext( + currentDevice: currentDevice, + registeredAt: registeredAt, + initialRecordingChoice: InitialRecordingChoice( + isEnabled: isEnabled, + policyChangeID: policyChangeID, + confirmedAt: confirmedAt, + ), + ) + } + + /// The throwaway identity used by demo mode. It is intentionally distinct + /// from test fixtures and never belongs to the real installation sidecar. + public static let demo = InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID( + rawValue: UUID(uuidString: "00000000-0000-0000-0000-0000000000D0")!, + ), + systemName: "Demo iPhone", + kind: .phone, + ), + registeredAt: Date(timeIntervalSinceReferenceDate: 0), + initialRecordingChoice: InitialRecordingChoice( + isEnabled: true, + policyChangeID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!, + confirmedAt: Date(timeIntervalSinceReferenceDate: 1), + ), + ) + + /// Deterministic context for tests and previews that do not care which + /// installation is current. + @_spi(Testing) + public static let testing = InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID( + rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + ), + systemName: "iPhone", + kind: .phone, + ), + registeredAt: Date(timeIntervalSinceReferenceDate: 0), + initialRecordingChoice: InitialRecordingChoice( + isEnabled: true, + policyChangeID: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + confirmedAt: Date(timeIntervalSinceReferenceDate: 1), + ), + ) +} diff --git a/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift b/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift new file mode 100644 index 00000000..f735eff8 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift @@ -0,0 +1,57 @@ +/// Persistence boundary for the device-local installation context. +/// +/// The app supplies a file-backed implementation at its composition root. +/// Tests and previews inject an in-memory implementation, so domain consumers +/// and views never reach for `FileManager`, `UIDevice`, or `UserDefaults`. +@MainActor +public protocol InstallationRecordingContextStoring: AnyObject { + /// Context used to render onboarding before any real store is opened. + var onboardingContext: InstallationRecordingContext { get } + + /// Resolve this installation's context. Repeated calls return the same + /// value for the lifetime of the store object. + func resolve() throws -> InstallationRecordingContext + + /// Persist the first explicit choice and its immutable event time beside + /// the installation identity and immutable profile time. Later calls return + /// that frozen choice; subsequent intent changes belong in the synced policy stream. + func confirmInitialRecording(isEnabled: Bool) throws -> InstallationRecordingContext + + /// Durable two-phase state for an import started by this installation. Kept beside the + /// identity so a recreated service layer cannot forget a committed cleanup or onboarding + /// acknowledgement boundary. + var backupImportRecovery: BackupCoordinator.DurableImportRecovery? { get } + + /// Atomically replace the import-recovery marker without changing the installation identity. + func setBackupImportRecovery( + _ recovery: BackupCoordinator.DurableImportRecovery?, + ) throws + + /// Terminal proof that this installation completed onboarding through an imported archive. + /// Kept independently from active recovery so later Settings imports cannot erase it. + var onboardingImportCompletion: BackupCoordinator.OnboardingImportCompletion? { get } + + /// Persist the completion proof before an acknowledged onboarding recovery marker is cleared. + func recordOnboardingImportCompletion( + _ completion: BackupCoordinator.OnboardingImportCompletion, + ) throws + + /// Forget the logical installation as part of erase-and-reset. + func reset() throws +} + +extension InstallationRecordingContextStoring { + /// Bridge this main-actor sidecar to the coordinator's async persistence seam without + /// exposing the adapter's filesystem details to Core. + public var backupImportRecoveryPersistence: BackupCoordinator.ImportRecoveryPersistence { + BackupCoordinator.ImportRecoveryPersistence( + load: { @MainActor [self] in backupImportRecovery }, + save: { @MainActor [self] recovery in + try setBackupImportRecovery(recovery) + }, + recordOnboardingCompletion: { @MainActor [self] completion in + try recordOnboardingImportCompletion(completion) + }, + ) + } +} diff --git a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift index 5f020b04..442c38a7 100644 --- a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift +++ b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift @@ -11,12 +11,17 @@ public struct LocationHistoryReader: Sendable { } public func samples(in interval: DateInterval) async throws -> [LocationSample] { - async let samples = store.samples(in: interval) - async let policyChanges = store.recordingPolicyChanges() - let (resolvedSamples, resolvedPolicyChanges) = try await (samples, policyChanges) - return RecordingPolicyFilter.visibleSamples( - resolvedSamples, - policyChanges: resolvedPolicyChanges, - ) + try await store.readSnapshot { + async let samples = store.samples(in: interval) + async let policyChanges = store.recordingPolicyChanges() + let (resolvedSamples, resolvedPolicyChanges) = try await ( + samples, + policyChanges, + ) + return RecordingPolicyFilter.visibleSamples( + resolvedSamples, + policyChanges: resolvedPolicyChanges, + ) + } } } diff --git a/Where/WhereCore/Sources/Devices/RecordingConfigurationBroadcaster.swift b/Where/WhereCore/Sources/Devices/RecordingConfigurationBroadcaster.swift new file mode 100644 index 00000000..ece7fe3f --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingConfigurationBroadcaster.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Fans applied current-installation recording state to independent coordinator subscribers. +/// +/// The controller publishes only after its target-owned check-in commits. Presentation can +/// therefore mirror physical GPS state without observing every unrelated store transaction or +/// independently re-running the domain reconciliation policy. +final class RecordingConfigurationBroadcaster: @unchecked Sendable { + private let lock = NSLock() + private var subscribers: + [UUID: AsyncStream.Continuation] = [:] + + func send(_ update: RecordingDeviceRuntimeUpdate) { + let continuations = lock.withLock { Array(subscribers.values) } + for continuation in continuations { + continuation.yield(update) + } + } + + func subscribe() -> AsyncStream { + let id = UUID() + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + lock.withLock { subscribers[id] = continuation } + continuation.onTermination = { [weak self] _ in + guard let self else { return } + lock.withLock { _ = subscribers.removeValue(forKey: id) } + } + } + } + + func finishAll() { + let continuations = lock.withLock { + let values = Array(subscribers.values) + subscribers.removeAll() + return values + } + for continuation in continuations { + continuation.finish() + } + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDevice.swift b/Where/WhereCore/Sources/Devices/RecordingDevice.swift index 187d4d88..cc5a7764 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDevice.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDevice.swift @@ -20,16 +20,19 @@ public enum RecordingDeviceKind: String, Codable, Sendable, Hashable { /// The last effective recording state acknowledged by a device. public enum RecordingDeviceStatus: String, Codable, Sendable, Hashable { + /// The profile arrived before this installation's first check-in. + case unknown case recording case off case permissionRequired } -/// Synced profile for one device that can contribute automatic locations. +/// Read model for one device assembled from independently synced records. /// -/// `nickname` is user-editable and synced. `systemName` is only a generic -/// hardware label such as “iPhone” or “iPad”; Where deliberately does not ask -/// for the user-assigned-device-name entitlement. +/// The immutable profile, append-only nickname timeline, desired-authority timeline, and +/// target-owned check-in have +/// deliberately separate persistence rows. This aggregate is never written back wholesale: +/// doing so would let CloudKit's last writer overwrite fields owned by another device. public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { public let id: RecordingDeviceID public let systemName: String @@ -39,6 +42,13 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { public let lastSeenAt: Date public let archivedAt: Date? public let lastAppliedPolicyChangeID: UUID? + /// Stable storage field; see `RecordingDeviceCheckIn.lastDiscardedPolicyChangeID`. + public let lastDiscardedPolicyChangeID: UUID? + + var lastDiscardedPolicyFrontierToken: RecordingPolicyCleanupToken? { + lastDiscardedPolicyChangeID.map(RecordingPolicyCleanupToken.init(rawValue:)) + } + public let status: RecordingDeviceStatus public init( @@ -60,6 +70,7 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { self.lastSeenAt = lastSeenAt self.archivedAt = archivedAt self.lastAppliedPolicyChangeID = lastAppliedPolicyChangeID + lastDiscardedPolicyChangeID = nil self.status = status } @@ -68,64 +79,22 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { return if let trimmed, !trimmed.isEmpty { trimmed } else { systemName } } - func renamed(_ nickname: String?) -> RecordingDevice { - RecordingDevice( - id: id, - systemName: systemName, - nickname: nickname, - kind: kind, - registeredAt: registeredAt, - lastSeenAt: lastSeenAt, - archivedAt: archivedAt, - lastAppliedPolicyChangeID: lastAppliedPolicyChangeID, - status: status, - ) - } - - func archived(at date: Date) -> RecordingDevice { - RecordingDevice( - id: id, - systemName: systemName, - nickname: nickname, - kind: kind, - registeredAt: registeredAt, - lastSeenAt: lastSeenAt, - archivedAt: date, - lastAppliedPolicyChangeID: lastAppliedPolicyChangeID, - status: status, - ) - } - - func unarchived() -> RecordingDevice { - RecordingDevice( - id: id, - systemName: systemName, - nickname: nickname, - kind: kind, - registeredAt: registeredAt, - lastSeenAt: lastSeenAt, - archivedAt: nil, - lastAppliedPolicyChangeID: lastAppliedPolicyChangeID, - status: status, - ) - } - - func acknowledging( - policyChangeID: UUID, - status: RecordingDeviceStatus, - at date: Date, - ) -> RecordingDevice { - RecordingDevice( - id: id, - systemName: systemName, - nickname: nickname, - kind: kind, - registeredAt: registeredAt, - lastSeenAt: date, - archivedAt: archivedAt, - lastAppliedPolicyChangeID: policyChangeID, - status: status, - ) + init( + profile: RecordingDeviceProfile, + nicknameChange: RecordingDeviceMetadataChange?, + checkIn: RecordingDeviceCheckIn?, + policyChange: RecordingPolicyChange?, + ) { + id = profile.id + systemName = profile.systemName + nickname = nicknameChange?.nickname + kind = profile.kind + registeredAt = profile.registeredAt + lastSeenAt = checkIn?.lastSeenAt ?? profile.registeredAt + archivedAt = policyChange?.isArchived == true ? policyChange?.effectiveAt : nil + lastAppliedPolicyChangeID = checkIn?.lastAppliedPolicyChangeID + lastDiscardedPolicyChangeID = checkIn?.lastDiscardedPolicyChangeID + status = checkIn?.status ?? .unknown } } @@ -144,6 +113,7 @@ public struct CurrentRecordingDevice: Sendable, Hashable { /// Deterministic identity for tests and previews that do not care which /// installation is current. + @_spi(Testing) public static let preview = CurrentRecordingDevice( id: RecordingDeviceID( rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift new file mode 100644 index 00000000..ab48d968 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift @@ -0,0 +1,97 @@ +import Foundation + +/// Stable proof key for the destructive policy frontier whose outbox cleanup completed. +/// +/// A single barrier uses its event id; concurrent barriers use a deterministic digest of every +/// frontier event id, so this is deliberately not modeled as one policy-change identity. +struct RecordingPolicyCleanupToken: RawRepresentable, Hashable { + let rawValue: UUID +} + +/// Latest policy acknowledgement and activity heartbeat written by one installation. +/// +/// The target installation is the sole live writer for its check-in. Keeping this row apart +/// from user-editable metadata prevents a local acknowledgement from reverting a remote rename +/// or recording authority, and vice versa. +public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable { + public var id: RecordingDeviceID { + deviceID + } + + public let deviceID: RecordingDeviceID + /// Monotonic sequence written only by the target installation. + public let revision: Int64 + public let lastSeenAt: Date + public let appliedAt: Date + public let lastAppliedPolicyChangeID: UUID + /// Persisted UUID backing the destructive-frontier cleanup proof. It is an event id for a + /// singleton frontier and a deterministic digest for concurrent barriers. The legacy storage + /// name remains stable; domain code uses ``lastDiscardedPolicyFrontierToken``. + public let lastDiscardedPolicyChangeID: UUID? + + var lastDiscardedPolicyFrontierToken: RecordingPolicyCleanupToken? { + lastDiscardedPolicyChangeID.map(RecordingPolicyCleanupToken.init(rawValue:)) + } + + public let status: RecordingDeviceStatus + + public init( + deviceID: RecordingDeviceID, + revision: Int64, + lastSeenAt: Date, + appliedAt: Date, + lastAppliedPolicyChangeID: UUID, + status: RecordingDeviceStatus, + ) { + precondition(revision >= 0, "A recording-device check-in revision cannot be negative.") + precondition(status != .unknown, "A persisted device check-in must have a known status.") + self.deviceID = deviceID + self.revision = revision + self.lastSeenAt = lastSeenAt + self.appliedAt = appliedAt + self.lastAppliedPolicyChangeID = lastAppliedPolicyChangeID + lastDiscardedPolicyChangeID = nil + self.status = status + } + + init( + deviceID: RecordingDeviceID, + revision: Int64, + lastSeenAt: Date, + appliedAt: Date, + lastAppliedPolicyChangeID: UUID, + lastDiscardedPolicyFrontierToken: RecordingPolicyCleanupToken?, + status: RecordingDeviceStatus, + ) { + precondition(revision >= 0, "A recording-device check-in revision cannot be negative.") + precondition(status != .unknown, "A persisted device check-in must have a known status.") + self.deviceID = deviceID + self.revision = revision + self.lastSeenAt = lastSeenAt + self.appliedAt = appliedAt + self.lastAppliedPolicyChangeID = lastAppliedPolicyChangeID + lastDiscardedPolicyChangeID = lastDiscardedPolicyFrontierToken?.rawValue + self.status = status + } + + static func isOlder(_ lhs: RecordingDeviceCheckIn, than rhs: RecordingDeviceCheckIn) -> Bool { + if lhs.revision != rhs.revision { + return lhs.revision < rhs.revision + } + if lhs.lastAppliedPolicyChangeID != rhs.lastAppliedPolicyChangeID { + return lhs.lastAppliedPolicyChangeID.uuidString + < rhs.lastAppliedPolicyChangeID.uuidString + } + if lhs.lastDiscardedPolicyChangeID != rhs.lastDiscardedPolicyChangeID { + return (lhs.lastDiscardedPolicyChangeID?.uuidString ?? "") + < (rhs.lastDiscardedPolicyChangeID?.uuidString ?? "") + } + if lhs.appliedAt != rhs.appliedAt { + return lhs.appliedAt < rhs.appliedAt + } + if lhs.lastSeenAt != rhs.lastSeenAt { + return lhs.lastSeenAt < rhs.lastSeenAt + } + return lhs.status.rawValue < rhs.status.rawValue + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift index c2cbc6c7..4e610a95 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift @@ -1,28 +1,75 @@ import Foundation -/// One row shown by device-management UI: the synced profile plus its latest -/// desired policy and whether that policy has been acknowledged by the device. +/// One row shown by device-management UI: the assembled profile plus an honest +/// policy resolution that can represent staggered CloudKit delivery. public struct RecordingDeviceConfiguration: Identifiable, Sendable, Hashable { public let device: RecordingDevice - public let isEnabled: Bool - public let latestPolicyChangeID: UUID? + public let policy: RecordingPolicyResolution public var id: RecordingDeviceID { device.id } + public var isEnabled: Bool? { + guard case let .resolved(policy) = policy else { return nil } + return policy.isEnabled + } + + public var latestPolicyChangeID: UUID? { + guard case let .resolved(policy) = policy else { return nil } + return policy.changeID + } + + public var isArchived: Bool { + guard case let .resolved(policy) = policy else { return false } + return policy.isArchived + } + public var isPending: Bool { - guard let latestPolicyChangeID else { return false } - return device.lastAppliedPolicyChangeID != latestPolicyChangeID + switch policy { + case .unknown: true + case let .resolved(policy): policy.isAcknowledged == false + } } public init( device: RecordingDevice, - isEnabled: Bool, - latestPolicyChangeID: UUID?, + policy: RecordingPolicyResolution, ) { self.device = device - self.isEnabled = isEnabled - self.latestPolicyChangeID = latestPolicyChangeID + self.policy = policy + } + + /// Convenience for callers assembling a configuration from a known policy. + public init(device: RecordingDevice, policyChange: RecordingPolicyChange) { + self.init( + device: device, + policyChange: policyChange, + requiredCleanupToken: nil, + ) + } + + init( + device: RecordingDevice, + policyChange: RecordingPolicyChange, + requiredCleanupToken: RecordingPolicyCleanupToken?, + ) { + let isEffectivelyEnabled = policyChange.isEnabled + let acknowledgedStatus = if isEffectivelyEnabled { + device.status == .recording || device.status == .permissionRequired + } else { + device.status == .off + } + self.init( + device: device, + policy: .resolved(ResolvedRecordingPolicy( + isEnabled: isEffectivelyEnabled, + isArchived: policyChange.isArchived, + changeID: policyChange.id, + isAcknowledged: device.lastAppliedPolicyChangeID == policyChange.id + && device.lastDiscardedPolicyFrontierToken == requiredCleanupToken + && acknowledgedStatus, + )), + ) } } diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift new file mode 100644 index 00000000..6760ffa2 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift @@ -0,0 +1,117 @@ +import Foundation + +/// User-editable device-profile field changed by an append-only metadata event. +public enum RecordingDeviceMetadataField: String, Codable, Sendable, Hashable { + case nickname +} + +/// Append-only nickname edit for one recording installation. +/// +/// Recording authority, including archive, deliberately does not live here: On, Off, and +/// archived are mutually exclusive states in the single ``RecordingPolicyChange`` stream. +public struct RecordingDeviceMetadataChange: Identifiable, Codable, Sendable, Hashable { + public let id: UUID + public let deviceID: RecordingDeviceID + public let revision: Int64 + public let changedAt: Date + public let changedByDeviceID: RecordingDeviceID + /// New nickname; `nil` explicitly clears it. + public let nickname: String? + + public var field: RecordingDeviceMetadataField { + .nickname + } + + public init( + id: UUID, + deviceID: RecordingDeviceID, + revision: Int64, + changedAt: Date, + changedByDeviceID: RecordingDeviceID, + nickname: String?, + ) { + precondition(revision >= 0, "A recording-device metadata revision cannot be negative.") + self.id = id + self.deviceID = deviceID + self.revision = revision + self.changedAt = changedAt + self.changedByDeviceID = changedByDeviceID + self.nickname = nickname + } + + static func isOrderedBefore( + _ lhs: RecordingDeviceMetadataChange, + _ rhs: RecordingDeviceMetadataChange, + ) -> Bool { + if lhs.revision != rhs.revision { + return lhs.revision < rhs.revision + } + return lhs.id.uuidString < rhs.id.uuidString + } + + /// Stable winner when CloudKit supplies conflicting values for one immutable event id. + /// Local writes reject this state, but reads must still converge on every device. + static func isCanonicalBefore( + _ lhs: RecordingDeviceMetadataChange, + _ rhs: RecordingDeviceMetadataChange, + ) -> Bool { + if lhs.deviceID != rhs.deviceID { + return lhs.deviceID.storeURL.absoluteString < rhs.deviceID.storeURL.absoluteString + } + if lhs.revision != rhs.revision { return lhs.revision < rhs.revision } + if lhs.changedAt != rhs.changedAt { return lhs.changedAt < rhs.changedAt } + if lhs.changedByDeviceID != rhs.changedByDeviceID { + return lhs.changedByDeviceID.storeURL.absoluteString + < rhs.changedByDeviceID.storeURL.absoluteString + } + switch (lhs.nickname, rhs.nickname) { + case (nil, .some): return true + case (.some, nil): return false + case let (.some(lhsNickname), .some(rhsNickname)): + return lhsNickname < rhsNickname + case (nil, nil): return false + } + } + + private enum CodingKeys: String, CodingKey { + case id + case deviceID + case field + case revision + case changedAt + case changedByDeviceID + case nickname + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + deviceID = try container.decode(RecordingDeviceID.self, forKey: .deviceID) + revision = try container.decode(Int64.self, forKey: .revision) + guard revision >= 0 else { + throw DecodingError.dataCorruptedError( + forKey: .revision, + in: container, + debugDescription: "A recording-device metadata revision cannot be negative.", + ) + } + changedAt = try container.decode(Date.self, forKey: .changedAt) + changedByDeviceID = try container.decode( + RecordingDeviceID.self, + forKey: .changedByDeviceID, + ) + _ = try container.decode(RecordingDeviceMetadataField.self, forKey: .field) + nickname = try container.decodeIfPresent(String.self, forKey: .nickname) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(deviceID, forKey: .deviceID) + try container.encode(field, forKey: .field) + try container.encode(revision, forKey: .revision) + try container.encode(changedAt, forKey: .changedAt) + try container.encode(changedByDeviceID, forKey: .changedByDeviceID) + try container.encodeIfPresent(nickname, forKey: .nickname) + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceProfile.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceProfile.swift new file mode 100644 index 00000000..0091bac4 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceProfile.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Immutable synced identity for one installation that can contribute automatic locations. +/// +/// Only the installation itself creates this value. User-editable labels, archive state, +/// and the installation's acknowledgement heartbeat live in separate records so CloudKit +/// never has two devices overwriting unrelated fields on one row. +public struct RecordingDeviceProfile: Identifiable, Codable, Sendable, Hashable { + public let id: RecordingDeviceID + public let systemName: String + public let kind: RecordingDeviceKind + public let registeredAt: Date + /// Logical account generation in which this installation first registered. A profile is + /// global and survives later rotations; this origin lets the target distinguish an + /// interrupted first registration from an old installation entering a new epoch. + public let registrationEpochID: WhereDataEpochID + + public init( + id: RecordingDeviceID, + systemName: String, + kind: RecordingDeviceKind, + registeredAt: Date, + registrationEpochID: WhereDataEpochID, + ) { + self.id = id + self.systemName = systemName + self.kind = kind + self.registeredAt = registeredAt + self.registrationEpochID = registrationEpochID + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceRuntimeState.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceRuntimeState.swift new file mode 100644 index 00000000..ed44b86f --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceRuntimeState.swift @@ -0,0 +1,15 @@ +/// Honest physical state of automatic recording on the current installation. +public enum RecordingDeviceRuntimeState: Sendable, Hashable { + /// Desired policy, physical monitoring, and durable acknowledgement agree. + case applied(RecordingDeviceConfiguration) + /// Core stopped monitoring because it could not prove or persist the applicable policy. + case unavailable +} + +/// One controller-ordered runtime emission. The process-local sequence lets presentation merge +/// direct command results with the async stream without an older suspended caller overwriting a +/// newer CloudKit-driven state. +public struct RecordingDeviceRuntimeUpdate: Sendable, Hashable { + public let sequence: UInt64 + public let state: RecordingDeviceRuntimeState +} diff --git a/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift b/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift new file mode 100644 index 00000000..a16c4c40 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Honest failures from the append-only recording persistence boundary. +public enum RecordingPersistenceError: Error, LocalizedError, Sendable, Hashable { + case conflictingImmutableRecord(id: UUID) + case deviceNotFound(RecordingDeviceID) + case devicePolicyUnknown(RecordingDeviceID) + case currentDeviceNotRegistered(RecordingDeviceID) + case currentDevicePolicyUnknown(RecordingDeviceID) + case incompletePolicyHistory(RecordingDeviceID) + case corruptRecordingPolicyHistory + case revisionExhausted(RecordingDeviceID) + case incompleteDataEpochHistory + case dataEpochRevisionExhausted + case dataEpochChanged + case recordingRewriteInProgress + + public var errorDescription: String? { + switch self { + case .conflictingImmutableRecord: + String(localized: .recordingErrorConflictingImmutableRecord) + case .deviceNotFound: + String(localized: .recordingErrorDeviceNotFound) + case .devicePolicyUnknown: + String(localized: .recordingErrorDevicePolicyUnknown) + case .currentDeviceNotRegistered: + String(localized: .recordingErrorCurrentDeviceNotRegistered) + case .currentDevicePolicyUnknown: + String(localized: .recordingErrorCurrentDevicePolicyUnknown) + case .incompletePolicyHistory: + String(localized: .recordingErrorIncompletePolicyHistory) + case .corruptRecordingPolicyHistory: + String(localized: .recordingErrorCorruptPolicyHistory) + case .revisionExhausted: + String(localized: .recordingErrorRevisionExhausted) + case .incompleteDataEpochHistory: + String(localized: .recordingErrorIncompleteDataEpochHistory) + case .dataEpochRevisionExhausted: + String(localized: .recordingErrorDataEpochRevisionExhausted) + case .dataEpochChanged: + String(localized: .recordingErrorDataEpochChanged) + case .recordingRewriteInProgress: + String(localized: .recordingErrorRewriteInProgress) + } + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift index 4d3238c0..201f8d54 100644 --- a/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift +++ b/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift @@ -1,39 +1,445 @@ +import CryptoKit import Foundation +/// Complete desired authority for one recording installation. +/// +/// Archive belongs here rather than in the editable profile metadata stream: turning a device +/// Off and hiding it is one causal command, so CloudKit can never deliver independent halves +/// that later undo a newer re-enable. +public enum RecordingPolicyState: String, Codable, Sendable, Hashable { + case on + case off + case archived + + public var isEnabled: Bool { + self == .on + } + + public var isArchived: Bool { + self == .archived + } +} + +/// Why a recording-authority event was appended. +public enum RecordingPolicyReason: String, Codable, Sendable, Hashable { + case initialRegistration + case userCommand + case archive + case backupMerge + case accountReset + case backupReplace + + /// Destructive account operations must discard a target's unsynced retry backlog so an + /// offline device cannot repopulate data the user explicitly erased or replaced. + var discardsPendingSamples: Bool { + switch self { + case .accountReset, .backupReplace: true + case .initialRegistration, .userCommand, .archive, .backupMerge: false + } + } +} + /// Append-only change to automatic recording policy for one device. /// -/// The timestamp is the effective historical cutoff. A device that has not -/// received the CloudKit change may briefly keep producing raw samples, but -/// every report filters those samples from this instant onward. +/// `parentIDs` and `revision` form a causal DAG without comparing clocks from different devices. +/// One command names every maximal event its writer observed, so it is a semantic join rather +/// than one physical child per branch. `effectiveAt` remains the historical cutoff applied to +/// samples, while `issuedAt` and `issuedByDeviceID` retain an auditable account of the command. public struct RecordingPolicyChange: Identifiable, Codable, Sendable, Hashable { public let id: UUID public let deviceID: RecordingDeviceID + public let parentIDs: [UUID] + public let revision: Int64 + public let issuedAt: Date + public let issuedByDeviceID: RecordingDeviceID public let effectiveAt: Date - public let isEnabled: Bool + public let state: RecordingPolicyState + public let reason: RecordingPolicyReason + + public var isEnabled: Bool { + state.isEnabled + } + + public var isArchived: Bool { + state.isArchived + } public init( id: UUID, deviceID: RecordingDeviceID, + parentIDs: [UUID], + revision: Int64, + issuedAt: Date, + issuedByDeviceID: RecordingDeviceID, effectiveAt: Date, - isEnabled: Bool, + state: RecordingPolicyState, + reason: RecordingPolicyReason, ) { + precondition(revision >= 0, "A recording-policy revision cannot be negative.") + precondition( + (revision == 0) == parentIDs.isEmpty, + "Only a recording-policy root may omit its parents.", + ) + let canonicalParentIDs = parentIDs.sorted { $0.uuidString < $1.uuidString } + precondition( + Set(canonicalParentIDs).count == canonicalParentIDs.count, + "A recording-policy command cannot name the same parent twice.", + ) + precondition( + canonicalParentIDs.contains(id) == false, + "A recording-policy command cannot parent itself.", + ) self.id = id self.deviceID = deviceID + self.parentIDs = canonicalParentIDs + self.revision = revision + self.issuedAt = issuedAt + self.issuedByDeviceID = issuedByDeviceID self.effectiveAt = effectiveAt - self.isEnabled = isEnabled + self.state = state + self.reason = reason } } extension RecordingPolicyChange { - /// Deterministic latest-wins ordering. UUID text breaks equal-timestamp - /// ties so devices that receive concurrent CloudKit rows converge. + private struct CausalGraph { + let byID: [UUID: RecordingPolicyChange] + let heads: [RecordingPolicyChange] + } + + /// Whether the persisted reason and complete-authority state describe a command the domain + /// can issue. Kept on the value so every wire/storage boundary can reject the same malformed + /// combinations without duplicating the matrix. + var hasValidReasonAndState: Bool { + switch (reason, state) { + case (.initialRegistration, .on), + (.initialRegistration, .off), + (.userCommand, .on), + (.userCommand, .off), + (.archive, .archived), + (.backupMerge, .on), + (.backupMerge, .off), + (.backupMerge, .archived), + (.accountReset, .off), + (.accountReset, .archived), + (.backupReplace, .off), + (.backupReplace, .archived): + true + case (.initialRegistration, .archived), + (.userCommand, .archived), + (.archive, .on), + (.archive, .off), + (.accountReset, .on), + (.backupReplace, .on): + false + } + } + + /// A complete persisted policy snapshot has at least one revision-zero root for every + /// device, and every later event names a unique, present parent set, advances one revision + /// beyond that set's maximum, and never moves its historical cutoff before any parent. + /// Multiple roots or unjoined heads remain valid: concurrent CloudKit writers can legitimately + /// produce them, and resolution compares every maximal causal head. + static func formValidPersistedTimelines(_ changes: [RecordingPolicyChange]) -> Bool { + guard changes.allSatisfy({ $0.revision >= 0 && $0.hasValidReasonAndState }) else { + return false + } + return Dictionary(grouping: changes, by: \.deviceID).values.allSatisfy { timeline in + canonicalTimeline(in: timeline) != nil + } + } + + /// Resolve the authoritative maximal head's ancestor DAG for one installation. + /// + /// Descendants supersede ancestors. Concurrent maximal heads remain eligible even when they + /// descend from a sibling that previously lost resolution; destructive/restrictive state + /// wins between those heads, followed by immutable identity. A local command names every + /// observed head so one post-convergence action can causally supersede all of them. The + /// returned order is deterministic and causal (revision first), with the selected + /// head last; historical evaluation resolves the eligible induced DAG directly rather than + /// treating this array as a single branch. + static func canonicalTimeline( + in changes: [RecordingPolicyChange], + ) -> [RecordingPolicyChange]? { + guard changes.isEmpty == false else { return [] } + guard let graph = causalGraph(in: changes), + let head = graph.heads.max(by: isPreferredBefore) + else { return nil } + + var ancestorIDs: Set = [head.id] + var pending = head.parentIDs + while let parentID = pending.popLast() { + guard let parent = graph.byID[parentID] else { return nil } + if ancestorIDs.insert(parent.id).inserted { + pending.append(contentsOf: parent.parentIDs) + } + } + return ancestorIDs + .compactMap { graph.byID[$0] } + .sorted(by: isOrderedBefore) + } + + static func canonicalHead( + in changes: [RecordingPolicyChange], + ) -> RecordingPolicyChange? { + canonicalTimeline(in: changes)?.last + } + + /// Resolve authority at one historical instant from the induced causal DAG. Effective times + /// are monotonic across every parent edge, so removing future commands leaves an + /// ancestor-complete graph whose concurrent heads use the same safety-first join as current + /// authority. + static func effectiveHead( + in changes: [RecordingPolicyChange], + at date: Date, + ) -> RecordingPolicyChange? { + guard causalGraph(in: changes) != nil else { return nil } + let eligible = changes.filter { $0.effectiveAt <= date } + guard eligible.isEmpty == false else { return nil } + return canonicalHead(in: eligible) + } + + /// Every currently maximal causal head, ordered by the same deterministic safety lattice as + /// resolution. An empty valid history has an empty frontier; malformed history returns nil. + static func maximalHeads( + in changes: [RecordingPolicyChange], + ) -> [RecordingPolicyChange]? { + guard changes.isEmpty == false else { return [] } + return causalGraph(in: changes)?.heads.sorted(by: isPreferredBefore) + } + + /// Create one semantic command naming every observed maximal head. After this node syncs, + /// every observed branch has been causally superseded while an unseen concurrent branch + /// remains eligible. + static func appendingCommand( + to changes: [RecordingPolicyChange], + deviceID: RecordingDeviceID, + issuedAt: Date, + issuedByDeviceID: RecordingDeviceID, + effectiveAt: Date, + state: RecordingPolicyState, + reason: RecordingPolicyReason, + ) throws -> RecordingPolicyChange { + guard let heads = maximalHeads(in: changes), + changes.allSatisfy({ $0.deviceID == deviceID }) + else { + throw RecordingPersistenceError.incompletePolicyHistory(deviceID) + } + let commandEffectiveAt = heads.reduce(effectiveAt) { partialResult, head in + max(partialResult, head.effectiveAt) + } + let maximumRevision = heads.map(\.revision).max() + let revision: Int64 + if let maximumRevision { + let (next, overflow) = maximumRevision.addingReportingOverflow(1) + guard overflow == false else { + throw RecordingPersistenceError.revisionExhausted(deviceID) + } + revision = next + } else { + revision = 0 + } + return RecordingPolicyChange( + id: UUID(), + deviceID: deviceID, + parentIDs: heads.map(\.id), + revision: revision, + issuedAt: issuedAt, + issuedByDeviceID: issuedByDeviceID, + effectiveAt: commandEffectiveAt, + state: state, + reason: reason, + ) + } + + /// Stable acknowledgement token for the causally maximal destructive frontier. A singleton + /// uses its event id; multiple concurrent barriers hash the entire sorted id set so delivery + /// of any newly relevant barrier changes the token and forces the target to clear its outbox + /// again before acknowledging authority. + static func destructiveCleanupToken( + in changes: [RecordingPolicyChange], + ) -> RecordingPolicyCleanupToken? { + guard let frontier = destructiveFrontier(in: changes), frontier.isEmpty == false else { + return nil + } + guard frontier.count > 1 else { + return RecordingPolicyCleanupToken(rawValue: frontier[0].id) + } + + var hasher = SHA256() + hasher.update(data: Data("com.stuff.where.recording-cleanup-frontier.v1".utf8)) + for change in frontier.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + hasher.update(data: Data("\n\(change.id.uuidString)".utf8)) + } + let digest = Array(hasher.finalize().prefix(16)) + return RecordingPolicyCleanupToken(rawValue: UUID(uuid: ( + digest[0], + digest[1], + digest[2], + digest[3], + digest[4], + digest[5], + digest[6], + digest[7], + digest[8], + digest[9], + digest[10], + digest[11], + digest[12], + digest[13], + digest[14], + digest[15], + ))) + } + + /// Conservative historical erase floor contributed by active account-reset barriers. A + /// later non-destructive On does not clear it; only a causally later destructive boundary + /// removes its ancestor from the destructive frontier. Concurrent reset floors join by the + /// latest effective cutoff. + static func activeAccountResetFloor( + in changes: [RecordingPolicyChange], + ) -> Date? { + destructiveFrontier(in: changes)? + .filter { $0.reason == .accountReset } + .map(\.effectiveAt) + .max() + } + + /// Deterministic causal ordering. At an equal revision, destructive cleanup wins first, + /// followed by the more restrictive state, so a concurrent On cannot defeat an + /// Off/archive/reset command; UUID text breaks the remaining tie. static func isOrderedBefore( _ lhs: RecordingPolicyChange, _ rhs: RecordingPolicyChange, ) -> Bool { - if lhs.effectiveAt != rhs.effectiveAt { - return lhs.effectiveAt < rhs.effectiveAt + if lhs.revision != rhs.revision { + return lhs.revision < rhs.revision + } + return isPreferredBefore(lhs, rhs) + } + + /// Orders competing roots or children of the same parent. Destructive cleanup wins first, + /// followed by the more restrictive state; immutable identity breaks the remaining tie. + private static func isPreferredBefore( + _ lhs: RecordingPolicyChange, + _ rhs: RecordingPolicyChange, + ) -> Bool { + if lhs.reason.conflictPriority != rhs.reason.conflictPriority { + return lhs.reason.conflictPriority < rhs.reason.conflictPriority + } + if lhs.state.conflictPriority != rhs.state.conflictPriority { + return lhs.state.conflictPriority < rhs.state.conflictPriority } return lhs.id.uuidString < rhs.id.uuidString } + + private static func causalGraph( + in changes: [RecordingPolicyChange], + ) -> CausalGraph? { + guard let deviceID = changes.first?.deviceID, + changes.allSatisfy({ + $0.deviceID == deviceID && $0.revision >= 0 && $0.hasValidReasonAndState + }) + else { return nil } + + let groupedByID = Dictionary(grouping: changes, by: \.id) + guard groupedByID.values.allSatisfy({ $0.count == 1 }) else { return nil } + let byID = groupedByID.compactMapValues(\.first) + var parentIDs = Set() + for change in changes { + let canonicalParentIDs = change.parentIDs.sorted { $0.uuidString < $1.uuidString } + if change.revision == 0 { + guard change.parentIDs.isEmpty, change.parentIDs == canonicalParentIDs else { + return nil + } + continue + } + guard change.parentIDs.isEmpty == false, + change.parentIDs == canonicalParentIDs, + Set(change.parentIDs).count == change.parentIDs.count, + change.parentIDs.contains(change.id) == false + else { return nil } + let parents = change.parentIDs.compactMap { byID[$0] } + guard parents.count == change.parentIDs.count, + parents.allSatisfy({ $0.deviceID == deviceID }), + let maximumRevision = parents.map(\.revision).max(), + maximumRevision < Int64.max, + change.revision == maximumRevision + 1, + parents.allSatisfy({ change.effectiveAt >= $0.effectiveAt }) + else { + return nil + } + parentIDs.formUnion(change.parentIDs) + } + let heads = changes.filter { parentIDs.contains($0.id) == false } + guard heads.isEmpty == false else { return nil } + return CausalGraph(byID: byID, heads: heads) + } + + /// Destructive boundaries remain active until another destructive event causally descends + /// from them. Non-destructive On/Off commands intentionally do not clear a reset's historical + /// erase floor. + private static func destructiveFrontier( + in changes: [RecordingPolicyChange], + ) -> [RecordingPolicyChange]? { + guard changes.isEmpty == false else { return [] } + guard let graph = causalGraph(in: changes) else { return nil } + let destructive = changes.filter(\.reason.discardsPendingSamples) + var superseded = Set() + for change in destructive { + var pending = change.parentIDs + var visited = Set() + while let id = pending.popLast(), let ancestor = graph.byID[id] { + guard visited.insert(id).inserted else { continue } + if ancestor.reason.discardsPendingSamples { + superseded.insert(ancestor.id) + } + pending.append(contentsOf: ancestor.parentIDs) + } + } + return destructive.filter { superseded.contains($0.id) == false } + } + + /// Stable winner when CloudKit supplies conflicting values for one immutable event id. + static func isCanonicalBefore( + _ lhs: RecordingPolicyChange, + _ rhs: RecordingPolicyChange, + ) -> Bool { + if lhs.deviceID != rhs.deviceID { + return lhs.deviceID.storeURL.absoluteString < rhs.deviceID.storeURL.absoluteString + } + if lhs.parentIDs != rhs.parentIDs { + return lhs.parentIDs.map(\.uuidString).joined(separator: ",") + < rhs.parentIDs.map(\.uuidString).joined(separator: ",") + } + if lhs.revision != rhs.revision { return lhs.revision < rhs.revision } + if lhs.issuedAt != rhs.issuedAt { return lhs.issuedAt < rhs.issuedAt } + if lhs.issuedByDeviceID != rhs.issuedByDeviceID { + return lhs.issuedByDeviceID.storeURL.absoluteString + < rhs.issuedByDeviceID.storeURL.absoluteString + } + if lhs.effectiveAt != rhs.effectiveAt { return lhs.effectiveAt < rhs.effectiveAt } + if lhs.state != rhs.state { return lhs.state.rawValue < rhs.state.rawValue } + return lhs.reason.rawValue < rhs.reason.rawValue + } +} + +extension RecordingPolicyState { + fileprivate var conflictPriority: Int { + switch self { + case .on: 0 + case .off: 1 + case .archived: 2 + } + } +} + +extension RecordingPolicyReason { + fileprivate var conflictPriority: Int { + switch self { + case .initialRegistration, .userCommand, .archive, .backupMerge: 0 + case .backupReplace: 1 + case .accountReset: 2 + } + } } diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift index aa20e20f..646d0b3d 100644 --- a/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift +++ b/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift @@ -10,20 +10,42 @@ public enum RecordingPolicyFilter { _ samples: [LocationSample], policyChanges: [RecordingPolicyChange], ) -> [LocationSample] { - let timelines = Dictionary(grouping: policyChanges, by: \.deviceID) - .mapValues { $0.sorted(by: RecordingPolicyChange.isOrderedBefore) } + let histories = Dictionary(grouping: policyChanges, by: \.deviceID) return samples.filter { sample in guard sample.source.isGPS, let deviceID = sample.recordingDeviceID else { return true } - guard let timeline = timelines[deviceID] else { - return true + // Device-stamped rows fail closed while CloudKit delivery is incomplete. The sample + // and its installation's policy are separate records, so briefly receiving only the + // sample must never make an unproven location visible. + guard let history = histories[deviceID], + RecordingPolicyChange.formValidPersistedTimelines(history), + RecordingPolicyChange.canonicalTimeline(in: history) != nil + else { + return false } - let latest = timeline.last { change in - change.effectiveAt <= sample.timestamp + // Account reset is a historical erase boundary, not merely an Off interval. A fix + // captured before reset but uploaded by an offline device later must stay erased. + // A subsequent backup replacement intentionally restores historical rows, so only + // the latest destructive boundary carries this reset floor. + // Resolve the causally maximal destructive frontier independently from current + // On/Off authority. A non-destructive re-enable does not clear a reset floor, while a + // later Replace must name that reset as a parent to retire it. Concurrent reset floors + // join conservatively at their latest cutoff. + if let resetFloor = RecordingPolicyChange.activeAccountResetFloor(in: history), + sample.timestamp <= resetFloor + { + return false } - return latest?.isEnabled ?? true + // Evaluate the induced causal DAG at the sample instant. Effective times are monotonic + // across every parent edge, so future commands can be removed without orphaning an + // eligible ancestor; concurrent heads still use the safety-first state join. + guard let latest = RecordingPolicyChange.effectiveHead( + in: history, + at: sample.timestamp, + ) else { return false } + return latest.isEnabled } } } diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyResolution.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyResolution.swift new file mode 100644 index 00000000..3cf23923 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingPolicyResolution.swift @@ -0,0 +1,6 @@ +/// Eventual-consistency state of a device's desired policy. +public enum RecordingPolicyResolution: Sendable, Hashable { + /// The profile has synced but no policy command has arrived yet. + case unknown + case resolved(ResolvedRecordingPolicy) +} diff --git a/Where/WhereCore/Sources/Devices/ResolvedRecordingPolicy.swift b/Where/WhereCore/Sources/Devices/ResolvedRecordingPolicy.swift new file mode 100644 index 00000000..fabf0b5a --- /dev/null +++ b/Where/WhereCore/Sources/Devices/ResolvedRecordingPolicy.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Resolved desired policy for one device. +public struct ResolvedRecordingPolicy: Sendable, Hashable { + public let isEnabled: Bool + public let isArchived: Bool + public let changeID: UUID + public let isAcknowledged: Bool + + public init( + isEnabled: Bool, + isArchived: Bool, + changeID: UUID, + isAcknowledged: Bool, + ) { + self.isEnabled = isEnabled + self.isArchived = isArchived + self.changeID = changeID + self.isAcknowledged = isAcknowledged + } +} diff --git a/Where/WhereCore/Sources/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index dfd63c04..9a5e9584 100644 --- a/Where/WhereCore/Sources/Journal/DayJournal.swift +++ b/Where/WhereCore/Sources/Journal/DayJournal.swift @@ -20,6 +20,8 @@ public actor DayJournal { /// data rather than racing the scanner's async store-change invalidation. private let issueScanner: DataIssueScanner private let widgets: WidgetSnapshotPublisher + private let currentDeviceID: RecordingDeviceID + private let now: @Sendable () -> Date private static let logger = WhereLog.root(DayJournalLog.self) @@ -30,6 +32,8 @@ public actor DayJournal { issueAlerts: DataIssueAlertReconciler, issueScanner: DataIssueScanner, widgets: WidgetSnapshotPublisher, + currentDeviceID: RecordingDeviceID, + now: @escaping @Sendable () -> Date, ) { self.store = store self.aggregator = aggregator @@ -37,6 +41,8 @@ public actor DayJournal { self.issueAlerts = issueAlerts self.issueScanner = issueScanner self.widgets = widgets + self.currentDeviceID = currentDeviceID + self.now = now } // MARK: - Post-write reconciliation @@ -59,9 +65,8 @@ public actor DayJournal { /// Full reconcile after a change to persisted day data (manual overlays, /// clears): recount issues / badge / notification, then republish the widget - /// snapshot. Every day-mutating write funnels through here so the fan-out - /// stays in one place — including the backup import, which the composition - /// root points at this method via `BackupCoordinator`'s `onImport` hook. + /// snapshot. Every local day-mutating write funnels through here; backup and + /// remote imports use the composition root's full derived-data fan-out. func reconcileAfterDayChange() async { await Self.logger.measure(.reconcileAfterDayChange, budget: .seconds(5)) { await reconcileIssueState() @@ -72,7 +77,10 @@ public actor DayJournal { // MARK: - Ingestion public func ingest(_ sample: LocationSample) async throws { - try await store.perform { try await store.add(sample: sample) } + let epochID = try await (store.dataEpoch()).id + try await store.perform(expectedDataEpochID: epochID) { + try await store.add(sample: sample) + } await widgets.publishAfterIngest(of: sample) } @@ -84,8 +92,9 @@ public actor DayJournal { /// sample, which is quadratic in the batch size. An empty batch is a no-op. public func ingest(_ samples: [LocationSample]) async throws { guard !samples.isEmpty else { return } + let epochID = try await (store.dataEpoch()).id try await Self.logger.measure(.ingestBatch, budget: .seconds(5)) { - try await store.perform { + try await store.perform(expectedDataEpochID: epochID) { for sample in samples { try await store.add(sample: sample) } @@ -97,7 +106,10 @@ public actor DayJournal { // MARK: - Retroactive entry public func addManualSample(_ sample: LocationSample) async throws { - try await store.perform { try await store.add(sample: sample) } + let epochID = try await (store.dataEpoch()).id + try await store.perform(expectedDataEpochID: epochID) { + try await store.add(sample: sample) + } await widgets.publish() } @@ -108,7 +120,10 @@ public actor DayJournal { ) async throws { let day = CalendarDay(from: date, in: aggregator.calendar) let presence = DayPresence(day: day, regions: regions, audit: audit) - try await store.perform { try await store.setManualDay(presence) } + let epochID = try await (store.dataEpoch()).id + try await store.perform(expectedDataEpochID: epochID) { + try await store.setManualDay(presence) + } await reconcileAfterDayChange() Self.logger { .addedManualDay(day: String(describing: day), regionCount: regions.count) } } @@ -125,7 +140,10 @@ public actor DayJournal { ) async throws { let day = CalendarDay(from: date, in: aggregator.calendar) let presence = DayPresence(day: day, regions: regions, isAuthoritative: true, audit: audit) - try await store.perform { try await store.setManualDay(presence) } + let epochID = try await (store.dataEpoch()).id + try await store.perform(expectedDataEpochID: epochID) { + try await store.setManualDay(presence) + } await reconcileAfterDayChange() Self.logger { .overrodeDay(day: String(describing: day), regionCount: regions.count) } } @@ -136,7 +154,10 @@ public actor DayJournal { /// simply lets the aggregator fall back to whatever GPS recorded. public func clearManualDay(date: Date) async throws { let day = CalendarDay(from: date, in: aggregator.calendar) - try await store.perform { try await store.clearManualDay(day) } + let epochID = try await (store.dataEpoch()).id + try await store.perform(expectedDataEpochID: epochID) { + try await store.clearManualDay(day) + } await reconcileAfterDayChange() Self.logger { .clearedManualDay(day: String(describing: day)) } } @@ -153,8 +174,9 @@ public actor DayJournal { public func clearManualDays(dates: [Date]) async throws { guard !dates.isEmpty else { return } let days = dates.map { CalendarDay(from: $0, in: aggregator.calendar) } + let epochID = try await (store.dataEpoch()).id try await Self.logger.measure(.clearManualDays, budget: .seconds(2)) { - try await store.perform { + try await store.perform(expectedDataEpochID: epochID) { for day in days { try await store.clearManualDay(day) } @@ -183,10 +205,11 @@ public actor DayJournal { let days = CalendarDay(from: start, in: calendar) .days(through: CalendarDay(from: end, in: calendar)) guard !days.isEmpty else { return } + let epochID = try await (store.dataEpoch()).id // One audit stamps every day in the range — it records the single act of // entry, not a per-day fact. try await Self.logger.measure(.backfillDays, budget: .seconds(2)) { - try await store.perform { + try await store.perform(expectedDataEpochID: epochID) { for day in days { try await store.setManualDay( DayPresence(day: day, regions: regions, audit: audit), @@ -205,8 +228,11 @@ public actor DayJournal { public func clearYear(_ year: Int) async throws { let interval = aggregator.yearInterval(year: year) let dayRange = CalendarDay.yearRange(year) + let epochID = try await (store.dataEpoch()).id try await Self.logger.measure(.clearYear, budget: .seconds(5)) { - try await store.perform { try await store.clear(in: interval, manualDays: dayRange) } + try await store.perform(expectedDataEpochID: epochID) { + try await store.clear(in: interval, manualDays: dayRange) + } } await reconcileAfterDayChange() Self.logger { .clearedYear(year: year) } @@ -219,7 +245,13 @@ public actor DayJournal { /// store immediately rather than relying on a later launch step. public func eraseAllData() async throws { try await Self.logger.measure(.eraseAllData, budget: .seconds(10)) { - try await store.perform { try await store.clearAll() } + try await store.perform { + _ = try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: currentDeviceID, + at: now(), + ) + } } await reconcileAfterDayChange() Self.logger { .erasedAllData } @@ -228,7 +260,10 @@ public actor DayJournal { // MARK: - Evidence public func addEvidence(_ evidence: Evidence, blob: Data? = nil) async throws { - try await store.perform { try await store.write(evidence: evidence, blob: blob) } + let epochID = try await (store.dataEpoch()).id + try await store.perform(expectedDataEpochID: epochID) { + try await store.write(evidence: evidence, blob: blob) + } Self.logger { .wroteEvidence(id: String(describing: evidence.id), hasBlob: blob != nil) } @@ -245,7 +280,10 @@ public actor DayJournal { // MARK: - Data resolution dismissals public func dismissIssue(id: DataIssueID) async throws { - try await store.perform { try await store.setIssueDismissed(true, id: id) } + let epochID = try await (store.dataEpoch()).id + try await store.perform(expectedDataEpochID: epochID) { + try await store.setIssueDismissed(true, id: id) + } // Dismissing removes the issue from the unresolved count, so the badge // and the "issues to resolve" notification both have to recount. No // widget publish: a dismissal doesn't change day data. @@ -253,7 +291,10 @@ public actor DayJournal { } public func restoreIssue(id: DataIssueID) async throws { - try await store.perform { try await store.setIssueDismissed(false, id: id) } + let epochID = try await (store.dataEpoch()).id + try await store.perform(expectedDataEpochID: epochID) { + try await store.setIssueDismissed(false, id: id) + } await reconcileIssueState() } } diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index b765f47d..4fabef72 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -34,7 +34,8 @@ public actor LocationIngestor { private let onPersisted: PostPersistHook /// Durable mirror of `retryQueue`, so a backlog survives the process dying /// mid-outage. Loaded once on the first `start()` and rewritten whenever the - /// queue changes; cleared by `quiesce()`. + /// queue changes; cleared only by `discardRetryBacklog()` (directly or via + /// `quiesce()`). private let outbox: any LocationOutbox private var ingestTask: Task? @@ -43,18 +44,18 @@ public actor LocationIngestor { /// if any. Tracked so overlapping foreground / launch triggers coalesce onto /// a single fix (single-flight) and so teardown can cancel it. Cleared when /// the work completes. This spans the (slow, up to ~10s) fix acquisition, so - /// `quiesce()` cancels it but does *not* await it — see `capturePersistTask`. + /// `pause()` cancels it but does *not* await it — see `capturePersistTask`. private var captureTask: Task? /// The capture's *persist* step, once a fix is in hand — separate from - /// `captureTask` (which also covers the slow fix) so `quiesce()` can await a + /// `captureTask` (which also covers the slow fix) so `pause()` can await a /// commit already in progress without stalling on a slow GPS fix. A single /// writer (capture is single-flight via `captureTask`), so it never clobbers /// the stream loop's `inFlightIngest` the way a shared slot would. private var capturePersistTask: Task? /// The persist the stream loop is currently awaiting, if any. Tracked so - /// `quiesce()` can wait for an in-flight write to commit before a teardown + /// `pause()` can wait for an in-flight write to commit before a teardown /// wipes the store — gating alone can't, since a persist that already /// started has an actor hop across `store.perform`. private var inFlightIngest: Task? @@ -64,16 +65,34 @@ public actor LocationIngestor { /// `stop()` pause (see `start()` for why). private var isMonitoring = false - /// Whether streamed samples are currently persisted. Shut by `quiesce()` so - /// a teardown can wipe the store without a late GPS event writing into it, - /// and re-opened by the next `start()`. - private var acceptsSamples = true + /// Whether the resolved device policy currently authorizes automatic samples. Closed before + /// registration, while policy is Off/unavailable, and during teardown. This is independent + /// of background monitoring: an enabled When-In-Use device may take a foreground fix while + /// monitoring remains paused. + private var acceptsSamples = false + /// Logical generation whose recording authority opened the sample gate. Every persist and + /// retry uses this as an expected-epoch token, so a remote reset cannot restamp an in-flight + /// old-authority sample into the new generation. + private var authorizedDataEpochID: WhereDataEpochID? + + /// Earliest timestamp a newly delivered live sample may carry for the current authority + /// window. Core Location can buffer callbacks before the stream consumer is installed; the + /// cutoff prevents those pre-consent / Off-period samples from becoming authorized merely + /// because they are consumed after recording turns On. + private var acceptsSamplesSince: Date? /// Samples whose persist call failed (e.g. transient SwiftData / CloudKit /// error). Drained before each new GPS save and on the next `start()` so a /// brief I/O outage doesn't silently drop measurements. Mirrored to `outbox` /// on every change so the backlog also survives a relaunch. - private var retryQueue: [LocationSample] = [] + private var retryQueue: [LocationOutboxEntry] = [] + + #if DEBUG + /// Test-only acknowledgement that the stream loop finished processing + /// an emitted sample. This lets rejection tests wait for consumption + /// itself rather than assume a scheduler delay was long enough. + private var testingConsumedSampleIDs: Set = [] + #endif /// Whether the durable backlog has been merged into `retryQueue` yet. Loaded /// exactly once (first `start()`); afterwards `retryQueue` is authoritative @@ -121,27 +140,55 @@ public actor LocationIngestor { /// underlying monitoring. Cancelling that task would terminate the /// single-consumer `AsyncStream`, so a later `start()` would iterate an /// already-finished stream and silently drop every subsequent sample. - public func start() async { - // Re-open the sample gate a prior `quiesce()` may have shut (e.g. the - // relaunch after a reset resumes ingestion here). - acceptsSamples = true + public func start(effectiveAt: Date, dataEpochID: WhereDataEpochID) async throws { + try await authorizeRecording(effectiveAt: effectiveAt, dataEpochID: dataEpochID) guard !isMonitoring else { return } isMonitoring = true await locationSource.start() Self.logger { .monitoringStarted } - // Seed the in-memory queue from the durable backlog once, so samples that - // failed to persist in a prior launch get retried now. - if !didLoadDurableBacklog { - didLoadDurableBacklog = true - let restored = await outbox.load() - if !restored.isEmpty { - Self.logger { .restoredBacklog(count: restored.count) } - } - retryQueue = restored.map { $0.recorded(by: recordingDeviceID) } + retryQueue + installIngestTaskIfNeeded() + } + + /// Authorize automatic foreground samples without requiring background monitoring. Used + /// when policy is On but authorization is only When-In-Use. Re-enabling also restores and + /// drains the durable backlog before accepting a new foreground fix. + public func authorizeRecording( + effectiveAt: Date, + dataEpochID: WhereDataEpochID, + ) async throws { + let currentEpochID: WhereDataEpochID + do { + currentEpochID = try await store.readSnapshot { try await (store.dataEpoch()).id } + } catch { + await closeRecordingAuthority() + throw error + } + guard currentEpochID == dataEpochID else { + await closeRecordingAuthority() + throw RecordingPersistenceError.dataEpochChanged + } + guard acceptsSamples == false || authorizedDataEpochID != dataEpochID else { return } + + // Changing authority is fail-closed. In particular, if a remote reset + // crosses backlog restoration/draining, the previous epoch must not + // remain authorized while the replacement attempt fails. + await closeRecordingAuthority() + try await prepareRetryBacklog() + let retained = retryQueue.filter { $0.dataEpochID == dataEpochID } + if retained.count != retryQueue.count { + retryQueue = retained + await outbox.save(retryQueue) } // Flush anything that failed to persist before this session started, // before we (re)attach the stream consumer. - let drainedDays = await drainRetryQueue() + let drainedDays = try await drainRetryQueue(expectedDataEpochID: dataEpochID) + let confirmedEpochID = try await store.readSnapshot { try await (store.dataEpoch()).id } + guard confirmedEpochID == dataEpochID else { + throw RecordingPersistenceError.dataEpochChanged + } + authorizedDataEpochID = dataEpochID + acceptsSamplesSince = effectiveAt + acceptsSamples = true await Self.logger.measure(.postPersist, budget: .seconds(2)) { await onPersisted(IngestOutcome( changedDays: drainedDays, @@ -149,6 +196,25 @@ public actor LocationIngestor { needsFullWidgetRebuild: !drainedDays.isEmpty, )) } + } + + /// Load the durable retry sidecar without opening sample authority or draining it. Recording + /// reconciliation calls this before persisting an acknowledgement, so an unreadable raw- + /// location file leaves the device honestly pending and Off instead of claiming Recording. + func prepareRetryBacklog() async throws { + guard !didLoadDurableBacklog else { return } + let restored = try await outbox.load() + if !restored.isEmpty { + Self.logger { .restoredBacklog(count: restored.count) } + } + // Rows written before device provenance existed intentionally remain unstamped and + // legacy-visible. Re-attributing them to this installation would make them depend on + // a policy event that did not exist when they were captured. + retryQueue = restored + retryQueue + didLoadDurableBacklog = true + } + + private func installIngestTaskIfNeeded() { guard ingestTask == nil else { return } let stream = locationSource.sampleStream ingestTask = Task { [weak self] in @@ -156,10 +222,53 @@ public actor LocationIngestor { if Task.isCancelled { break } guard let self else { break } await ingest(sample) + #if DEBUG + await recordTestingConsumption(of: sample.id) + #endif } } } + /// Revoke policy authority before acknowledging Off/unavailable. Late stream events and + /// one-shot fixes are rejected, fix acquisition is cancelled, and any persist that already + /// crossed the gate is awaited. The retry backlog is retained for a later re-enable; + /// transactional destructive flows call ``pause()`` and discard only after commit. + public func revokeRecordingAuthorization() async { + // Consume the source even for an installation whose first policy is Off. Otherwise the + // source's buffered callbacks can sit unobserved until a later On and cross the gate then. + installIngestTaskIfNeeded() + await closeRecordingAuthority() + await capturePersistTask?.value + await inFlightIngest?.value + } + + /// Close the in-memory authority gate immediately. Unlike + /// ``revokeRecordingAuthorization()``, this does not await in-flight + /// persistence tasks and is therefore safe to call from one of those tasks + /// when its expected epoch has just lost. + private func closeRecordingAuthority(ifAuthorizedFor epochID: WhereDataEpochID? = nil) async { + if let epochID, authorizedDataEpochID != epochID { return } + acceptsSamples = false + authorizedDataEpochID = nil + acceptsSamplesSince = nil + if isMonitoring { + isMonitoring = false + await locationSource.stop() + Self.logger { .monitoringStopped } + } + captureTask?.cancel() + } + + /// Reversibly pause ingestion while another operation temporarily owns the + /// store. This closes the recording-authority gate, stops monitoring, + /// cancels one-shot acquisition, and waits for persistence work that already + /// crossed the gate. Both the in-memory retry queue and its durable outbox + /// remain intact so a merge or rolled-back import can resume without losing + /// samples captured during an earlier persistence outage. + public func pause() async { + await revokeRecordingAuthorization() + } + /// Pause GPS ingestion by stopping the underlying location monitoring. /// Idempotent and safe to call from teardown paths that may run before any /// `start()`. The ingestion task is intentionally left running (see @@ -172,37 +281,28 @@ public actor LocationIngestor { Self.logger { .monitoringStopped } } - /// Stop ingestion and guarantee nothing else writes until the next - /// `start()`: stop monitoring, refuse further streamed samples, wait for any - /// persist already in flight to commit, then drop the retry backlog — both - /// the in-memory queue and its durable outbox. The app's reset/erase teardown - /// awaits this before wiping the store (see `WhereServices.reset`), so a late - /// GPS event can't repopulate it and a stale backlog can't re-drain into it on - /// the next `start()`. + /// Permanently discard samples awaiting persistence. The durable outbox is + /// cleared before the in-memory queue: if durable deletion fails, the live + /// process retains responsibility for retrying every sample. /// - /// Unlike `stop()` (a normal pause that keeps the backlog for when - /// monitoring resumes), `quiesce()` clears it — the store is about to be - /// erased, so those samples must not come back. - public func quiesce() async { - acceptsSamples = false - isMonitoring = false - await locationSource.stop() - // Cancel the one-shot capture's fix acquisition (best-effort — it isn't - // the store write, so we don't await it and never stall the erase on a - // slow GPS fix). If it was still acquiring, the `acceptsSamples` gate - // stops it persisting; if it had already begun a persist, that is tracked - // on `capturePersistTask` and awaited below. - captureTask?.cancel() - captureTask = nil - // Let an already-started persist — the stream loop's and the capture's, - // each on its own single-writer handle — settle before clearing the - // backlog, so nothing commits into the store the caller is about to wipe. - await capturePersistTask?.value - await inFlightIngest?.value + /// Call ``pause()`` first when the discard is part of a teardown, so no new + /// sample can enter the queue while the durable clear is suspended. + public func discardRetryBacklog() async throws { + try await outbox.clear() retryQueue.removeAll() - // Clear the durable mirror too; the store is about to be erased, so the - // backlog must not re-drain into it on the next launch. - await outbox.save([]) + } + + /// Stop ingestion and guarantee nothing else writes until the next + /// `start()`, then destructively discard both copies of the retry backlog. + /// This primitive is for callers whose destructive operation cannot roll back. Reset and + /// backup replacement instead compose ``pause()`` with a post-commit discard. + /// + /// Unlike ``pause()`` (a reversible pause that keeps the backlog), + /// `quiesce()` clears it — the store is about to be erased, so those samples + /// must not come back. + public func quiesce() async throws { + await pause() + try await discardRetryBacklog() Self.logger { .quiesced } } @@ -240,12 +340,11 @@ public actor LocationIngestor { /// /// Single-flight: a call while a capture is already in flight is a no-op. /// Only a *GPS* sample suppresses the fix; a manual entry for today doesn't, - /// since it isn't a passive-tracking data point. Whether to attempt this at - /// all (the user's tracking intent + authorization) is the caller's gate; - /// this stays safe regardless because `requestCurrentLocation()` returns - /// `nil` when no fix is available. + /// since it isn't a passive-tracking data point. The recording-authority gate + /// is checked before acquisition and again before persistence, so a synced Off + /// policy can revoke a fix already in flight. public func captureTodayIfNeeded(now: Date) { - guard captureTask == nil else { return } + guard acceptsSamples, captureTask == nil else { return } captureTask = Task { [weak self] in await self?.performTodayCapture(now: now) await self?.clearCaptureTask() @@ -281,16 +380,16 @@ public actor LocationIngestor { await locationSource.requestCurrentLocation() } guard let sample = fix else { return } - // The ~10s fix may have straddled a `quiesce()`; re-check the gate before + // The ~10s fix may have straddled a `pause()`; re-check the gate before // persisting, mirroring `ingest(_:)`. The guard and the `capturePersistTask` // assignment are synchronous (no `await` between), so a concurrent - // `quiesce()` either sees `acceptsSamples == false` here (we skip) or sees + // `pause()` either sees `acceptsSamples == false` here (we skip) or sees // the handle already set (it awaits us) — never neither. - guard acceptsSamples else { return } + guard !Task.isCancelled, accepts(sample) else { return } Self.logger { .capturedForegroundFix } // Persist via `processIngestedSample` on the capture's own handle rather // than `ingest(_:)`, so it never shares the stream loop's single - // `inFlightIngest` slot. `quiesce()` awaits this handle independently. + // `inFlightIngest` slot. `pause()` awaits this handle independently. let work = Task { [weak self] in guard let self else { return } await processIngestedSample(sample) @@ -312,12 +411,12 @@ public actor LocationIngestor { } /// Gate and track a single streamed sample. A sample that arrives after a - /// `quiesce()` (and before the next `start()`) is dropped rather than + /// `pause()` (and before the next `start()`) is dropped rather than /// persisted, so a teardown that wipes the store can't be clobbered by a - /// late GPS write. The persist is tracked in `inFlightIngest` so `quiesce()` + /// late GPS write. The persist is tracked in `inFlightIngest` so `pause()` /// can await it. private func ingest(_ sample: LocationSample) async { - guard acceptsSamples else { return } + guard accepts(sample) else { return } let work = Task { [weak self] in guard let self else { return } await processIngestedSample(sample) @@ -327,14 +426,22 @@ public actor LocationIngestor { inFlightIngest = nil } + private func accepts(_ sample: LocationSample) -> Bool { + guard acceptsSamples, let acceptsSamplesSince else { return false } + return sample.timestamp >= acceptsSamplesSince + } + /// Persist one GPS-sourced sample, falling back to the retry queue on /// failure. Drains any backlog first so a single transient outage doesn't /// permanently reorder samples on disk. private func processIngestedSample(_ sample: LocationSample) async { + guard let dataEpochID = authorizedDataEpochID else { return } let sample = sample.recorded(by: recordingDeviceID) - let drainedDays = await drainRetryQueue() do { - try await store.perform { try await store.add(sample: sample) } + let drainedDays = try await drainRetryQueue(expectedDataEpochID: dataEpochID) + try await store.perform(expectedDataEpochID: dataEpochID) { + try await store.add(sample: sample) + } var changedDays = drainedDays changedDays.insert(calendar.startOfDay(for: sample.timestamp)) await Self.logger.measure(.postPersist, budget: .seconds(2)) { @@ -344,6 +451,12 @@ public actor LocationIngestor { needsFullWidgetRebuild: !drainedDays.isEmpty, )) } + } catch RecordingPersistenceError.dataEpochChanged { + // A reset/Replace revoked the authority this sample was admitted + // under. Stop immediately and never put the known-stale sample into + // the durable retry sidecar; reconciliation will reopen recording + // only after resolving policy in the winning epoch. + await closeRecordingAuthority(ifAuthorizedFor: dataEpochID) } catch { // Persistence failures (SwiftData save, CloudKit, etc.) are surfaced // via `os.Logger` rather than silently dropped. The stream keeps @@ -355,34 +468,52 @@ public actor LocationIngestor { description: error.localizedDescription, ) } - enqueueForRetry(sample) + enqueueForRetry(LocationOutboxEntry(sample: sample, dataEpochID: dataEpochID)) await outbox.save(retryQueue) } } - private func enqueueForRetry(_ sample: LocationSample) { + private func enqueueForRetry(_ entry: LocationOutboxEntry) { if retryQueue.count >= retryQueueCapacity { Self.logger { .retryQueueAtCapacity(capacity: retryQueueCapacity) } retryQueue.removeFirst() } - retryQueue.append(sample) + retryQueue.append(entry) } /// Try to flush every queued sample exactly once. Anything that still fails /// is re-queued at the tail; the next call gets the chance to retry it. The /// durable backlog is rewritten to match the post-drain queue. - private func drainRetryQueue() async -> Set { + private func drainRetryQueue( + expectedDataEpochID: WhereDataEpochID, + ) async throws -> Set { // Spanned below the guard, so the common case — nothing queued, which is // every drain on a healthy device — records nothing at all. guard !retryQueue.isEmpty else { return [] } let pending = retryQueue retryQueue.removeAll(keepingCapacity: true) var persistedDays: Set = [] + var persistedSampleCount = 0 + var epochChanged = false await Self.logger.measure(.drainBacklog, budget: .seconds(5)) { - for sample in pending { + for (index, entry) in pending.enumerated() { + guard entry.dataEpochID == expectedDataEpochID else { continue } + let sample = entry.sample do { - try await store.perform { try await store.add(sample: sample) } + try await store.perform(expectedDataEpochID: entry.dataEpochID) { + try await store.add(sample: sample) + } + persistedSampleCount += 1 persistedDays.insert(calendar.startOfDay(for: sample.timestamp)) + } catch RecordingPersistenceError.dataEpochChanged { + enqueueForRetry(entry) + for remaining in pending.dropFirst(index + 1) + where remaining.dataEpochID == expectedDataEpochID + { + enqueueForRetry(remaining) + } + epochChanged = true + break } catch { Self.logger(attachments: [.error(error, name: "retry-error")]) { .retryStillFailing( @@ -390,35 +521,66 @@ public actor LocationIngestor { description: error.localizedDescription, ) } - enqueueForRetry(sample) + enqueueForRetry(entry) } } } if !persistedDays.isEmpty { Self.logger { .drainedBacklog( - sampleCount: pending.count - retryQueue.count, + sampleCount: persistedSampleCount, dayCount: persistedDays.count, ) } } await outbox.save(retryQueue) + if epochChanged { + throw RecordingPersistenceError.dataEpochChanged + } return persistedDays } } #if DEBUG extension LocationIngestor { + /// Test convenience for fixtures whose samples predate no meaningful policy cutoff. + @_spi(Testing) public func start() async throws { + let dataEpochID = try await (store.dataEpoch()).id + try await start(effectiveAt: .distantPast, dataEpochID: dataEpochID) + } + + /// Test convenience matching ``start()`` without activating background monitoring. + @_spi(Testing) public func authorizeRecording() async throws { + let dataEpochID = try await (store.dataEpoch()).id + try await authorizeRecording(effectiveAt: .distantPast, dataEpochID: dataEpochID) + } + /// Enqueue a sample for retry without persisting. Tests use this to assert /// FIFO eviction at `retryQueueCapacity` without simulating hundreds of /// persistence failures. - @_spi(Testing) public func testingEnqueueForRetry(_ sample: LocationSample) { - enqueueForRetry(sample) + @_spi(Testing) public func testingEnqueueForRetry( + _ sample: LocationSample, + dataEpochID: WhereDataEpochID, + ) { + enqueueForRetry(LocationOutboxEntry(sample: sample, dataEpochID: dataEpochID)) } /// Sample IDs currently in the retry queue, in FIFO order. @_spi(Testing) public func testingRetryQueueSampleIDs() -> [UUID] { - retryQueue.map(\.id) + retryQueue.map(\.sample.id) + } + + /// Whether the automatic-sample authority gate is currently open. + @_spi(Testing) public var testingIsAcceptingSamples: Bool { + acceptsSamples + } + + @_spi(Testing) public func testingHasConsumedSample(id: UUID) -> Bool { + testingConsumedSampleIDs.contains(id) + } + + private func recordTestingConsumption(of id: UUID) { + testingConsumedSampleIDs.insert(id) } } #endif diff --git a/Where/WhereCore/Sources/Location/LocationOutbox.swift b/Where/WhereCore/Sources/Location/LocationOutbox.swift index c2d93c8b..c8bf5ee0 100644 --- a/Where/WhereCore/Sources/Location/LocationOutbox.swift +++ b/Where/WhereCore/Sources/Location/LocationOutbox.swift @@ -1,6 +1,19 @@ import Foundation import PeriscopeCore +/// One retryable raw sample together with the logical generation that authorized it. The epoch +/// token is load-bearing: a sample captured before reset can be discarded, but can never be +/// reclassified and written into the post-reset account state. +public struct LocationOutboxEntry: Codable, Sendable, Hashable { + public let sample: LocationSample + public let dataEpochID: WhereDataEpochID + + public init(sample: LocationSample, dataEpochID: WhereDataEpochID) { + self.sample = sample + self.dataEpochID = dataEpochID + } +} + /// A durable backlog of GPS samples that failed to persist, so a transient /// store outage (SwiftData/CloudKit) that *outlives the process* doesn't /// silently drop measurements: the backlog is reloaded and re-tried on the next @@ -9,13 +22,17 @@ import PeriscopeCore /// Deliberately separate from `WhereStore`: the store is the thing that's /// failing when samples land here, so the backlog must not depend on it. The /// production implementation is a small atomically-written JSON file in the -/// app's own sandbox (the samples are sensitive raw locations — not the App -/// Group the widget reads). +/// app's own sandbox, explicitly excluded from device backups (the samples are +/// sensitive raw locations — not the App Group the widget reads). public protocol LocationOutbox: Sendable { - /// The persisted backlog, or empty when there's none / it can't be read. - func load() async -> [LocationSample] - /// Replace the persisted backlog with `samples`; an empty array clears it. - func save(_ samples: [LocationSample]) async + /// The persisted backlog, or empty when none exists. A read/security/decoding failure throws; + /// callers must not treat an unreadable raw-location file as an empty successful load. + func load() async throws -> [LocationOutboxEntry] + /// Replace the persisted backlog with `entries`; an empty array clears it. + func save(_ entries: [LocationOutboxEntry]) async + /// Remove every persisted retry sample. Reset uses the throwing path so it cannot report a + /// successful erase while raw locations remain able to repopulate the next installation. + func clear() async throws } /// A no-op outbox: nothing is persisted, so the retry queue is in-memory only @@ -23,11 +40,12 @@ public protocol LocationOutbox: Sendable { /// fallback when no durable location is available. public struct NoOpLocationOutbox: LocationOutbox { public init() {} - public func load() async -> [LocationSample] { + public func load() async throws -> [LocationOutboxEntry] { [] } - public func save(_: [LocationSample]) async {} + public func save(_: [LocationOutboxEntry]) async {} + public func clear() async throws {} } /// File-backed `LocationOutbox`: the backlog is one atomically-written JSON file @@ -35,12 +53,52 @@ public struct NoOpLocationOutbox: LocationOutbox { /// previously good backlog. An `actor` so its disk I/O runs off the /// `LocationIngestor`'s executor. public actor FileLocationOutbox: LocationOutbox { + private static let directoryName = "LocationRetryOutbox" + private static let fileName = "outbox.json" + private static let legacyFileName = "location-retry-outbox.json" + private let fileURL: URL + /// Retained when composing the production outbox so a failed legacy migration cannot leave + /// raw locations outside the scope of a later reset. + private let legacyFileURL: URL? + private let readData: @Sendable (URL) throws -> Data + private let excludeFromBackup: @Sendable (URL) throws -> Void private static let logger = WhereLog.location(LocationOutboxLog.self) public init(fileURL: URL) { + self.init( + fileURL: fileURL, + legacyFileURL: nil, + readData: { try Self.readDataFromDisk(at: $0) }, + excludeFromBackup: { try Self.excludeFromBackup($0) }, + ) + } + + init(fileURL: URL, legacyFileURL: URL?) { + self.init( + fileURL: fileURL, + legacyFileURL: legacyFileURL, + readData: { try Self.readDataFromDisk(at: $0) }, + excludeFromBackup: { try Self.excludeFromBackup($0) }, + ) + } + + private init( + fileURL: URL, + legacyFileURL: URL?, + readData: @escaping @Sendable (URL) throws -> Data, + excludeFromBackup: @escaping @Sendable (URL) throws -> Void, + ) { self.fileURL = fileURL + self.legacyFileURL = legacyFileURL + self.readData = readData + self.excludeFromBackup = excludeFromBackup + Self.recoverExistingDirectory( + containing: fileURL, + fileManager: .default, + excludeFromBackup: excludeFromBackup, + ) } /// An outbox at the app sandbox's Application Support directory, or a @@ -58,35 +116,338 @@ public actor FileLocationOutbox: LocationOutbox { logger { .noApplicationSupport } return NoOpLocationOutbox() } - return FileLocationOutbox(fileURL: directory.appending(path: "location-retry-outbox.json")) + let fileURL = directory + .appending(path: directoryName, directoryHint: .isDirectory) + .appending(path: fileName) + let legacyFileURL = directory.appending(path: legacyFileName) + migrateLegacyFileIfNeeded( + from: legacyFileURL, + to: fileURL, + fileManager: fileManager, + ) + return FileLocationOutbox(fileURL: fileURL, legacyFileURL: legacyFileURL) } - public func load() async -> [LocationSample] { - guard let data = try? Data(contentsOf: fileURL) else { return [] } + public func load() async throws -> [LocationOutboxEntry] { + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return [] + } do { - return try JSONDecoder().decode([LocationSample].self, from: data) + try excludeFromBackup(fileURL.deletingLastPathComponent()) + try excludeFromBackup(fileURL) } catch { - // A decode failure means a corrupt or stale-format file; drop it - // rather than crash-looping on every launch. + Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + .excludeFromBackupFailed(description: error.localizedDescription) + } + Self.discardInsecureFile(at: fileURL) + throw error + } + + let data: Data + do { + data = try readData(fileURL) + } catch { + // File protection and transient I/O failures can clear later. Preserve the only + // durable copy so a subsequent load can retry it. Self.logger(attachments: [.error(error, name: "read-error")]) { + .readBacklogFailed(description: error.localizedDescription) + } + throw error + } + + do { + return try Self.decodeEntries(from: data) + } catch { + // Decoding validly read bytes cannot recover without a format change. Drop them rather + // than crash-looping on the same corrupt backlog every launch. + Self.logger(attachments: [.error(error, name: "decode-error")]) { .droppedUnreadableBacklog(description: error.localizedDescription) } - return [] + Self.discardInsecureFile(at: fileURL) + throw error } } - public func save(_ samples: [LocationSample]) async { - guard !samples.isEmpty else { - try? FileManager.default.removeItem(at: fileURL) + public func save(_ entries: [LocationOutboxEntry]) async { + guard !entries.isEmpty else { + do { + try await clear() + } catch { + Self.logger(attachments: [.error(error, name: "clear-error")]) { + .persistBacklogFailed(description: error.localizedDescription) + } + } return } + var publishedNewFile = false do { - let data = try JSONEncoder().encode(samples) - try data.write(to: fileURL, options: .atomic) + let data = try JSONEncoder().encode(entries) + let directoryURL = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + ) + // Secure the empty directory before writing either atomic-write scratch or pending + // bytes, closing the crash window between a completed write and per-file exclusion. + try excludeFromBackup(directoryURL) + let pendingURL = fileURL.appendingPathExtension("pending") + if FileManager.default.fileExists(atPath: pendingURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: pendingURL) + } + // Exclude the new inode before it acquires the authoritative path. A crash or + // exclusion failure can therefore never publish backup-eligible raw locations. + try data.write(to: pendingURL, options: .atomic) + try excludeFromBackup(pendingURL) + if FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) { + _ = try FileManager.default.replaceItemAt( + fileURL, + withItemAt: pendingURL, + backupItemName: nil, + options: .usingNewMetadataOnly, + ) + } else { + try FileManager.default.moveItem(at: pendingURL, to: fileURL) + } + publishedNewFile = true + try excludeFromBackup(fileURL) } catch { Self.logger(attachments: [.error(error, name: "persist-error")]) { .persistBacklogFailed(description: error.localizedDescription) } + if publishedNewFile { + Self.discardInsecureFile(at: fileURL) + } + } + } + + public func clear() async throws { + let pendingURL = fileURL.appendingPathExtension("pending") + for url in [fileURL, pendingURL] + [legacyFileURL].compactMap(\.self) + where FileManager.default.fileExists( + atPath: url.path(percentEncoded: false), + ) + { + try FileManager.default.removeItem(at: url) + } + } + + /// Secure an outbox directory left by an interrupted write even when recording is Off and + /// the ingestor never loads it. A complete pending file is the newest atomically-written + /// backlog, so promote it instead of dropping samples merely because the process died before + /// the final rename. If exclusion cannot be proven for either raw copy, privacy still wins + /// over that copy's retry durability and it is discarded. + private static func recoverExistingDirectory( + containing fileURL: URL, + fileManager: FileManager, + excludeFromBackup: @Sendable (URL) throws -> Void, + ) { + let directoryURL = fileURL.deletingLastPathComponent() + guard fileManager.fileExists(atPath: directoryURL.path(percentEncoded: false)) + else { + return + } + let pendingURL = fileURL.appendingPathExtension("pending") + + do { + try excludeFromBackup(directoryURL) + } catch { + Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + .excludeFromBackupFailed(description: error.localizedDescription) + } + } + + func secureExistingFile(at url: URL) -> Bool { + guard fileManager.fileExists(atPath: url.path(percentEncoded: false)) else { + return false + } + do { + try excludeFromBackup(url) + return true + } catch { + Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + .excludeFromBackupFailed(description: error.localizedDescription) + } + discardInsecureFile(at: url) + return false + } + } + + _ = secureExistingFile(at: fileURL) + guard secureExistingFile(at: pendingURL) else { + return + } + let pendingData: Data + do { + pendingData = try Data(contentsOf: pendingURL) + } catch { + // File protection and transient I/O failures can clear later. Both copies are already + // excluded, so preserve the pending file for the next construction attempt. + Self.logger(attachments: [.error(error, name: "pending-read-error")]) { + .readBacklogFailed(description: error.localizedDescription) + } + return + } + do { + _ = try decodeEntries(from: pendingData) + } catch { + // Atomic write completion makes a decodable pending file safe to promote. Invalid bytes + // cannot become a backlog, so retain any older authoritative copy and drop only these. + Self.logger(attachments: [.error(error, name: "pending-decode-error")]) { + .droppedUnreadableBacklog(description: error.localizedDescription) + } + discardInsecureFile(at: pendingURL) + return + } + + do { + if fileManager.fileExists(atPath: fileURL.path(percentEncoded: false)) { + _ = try fileManager.replaceItemAt( + fileURL, + withItemAt: pendingURL, + backupItemName: nil, + options: .usingNewMetadataOnly, + ) + } else { + try fileManager.moveItem(at: pendingURL, to: fileURL) + } + } catch { + // Both copies remain excluded. Keep them so another launch can retry publication rather + // than turning a recoverable rename failure into location loss. + Self.logger(attachments: [.error(error, name: "pending-promotion-error")]) { + .persistBacklogFailed(description: error.localizedDescription) + } + return + } + + do { + // Moving preserves the pending inode and replacement metadata rules are subtle. Prove + // the final authoritative path is still excluded before allowing it to survive. + try excludeFromBackup(fileURL) + } catch { + Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + .excludeFromBackupFailed(description: error.localizedDescription) + } + discardInsecureFile(at: fileURL) + discardInsecureFile(at: pendingURL) + } + } + + /// Move the former single-file outbox into the pre-excluded directory. This runs when the + /// app composes its outbox, independently of recording policy, so an Off device cannot leave + /// an older raw-location file backup-eligible indefinitely. + private static func migrateLegacyFileIfNeeded( + from legacyURL: URL, + to fileURL: URL, + fileManager: FileManager, + ) { + guard fileManager.fileExists(atPath: legacyURL.path(percentEncoded: false)) else { return } + do { + try excludeFromBackup(legacyURL) + let directoryURL = fileURL.deletingLastPathComponent() + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + try excludeFromBackup(directoryURL) + if fileManager.fileExists(atPath: fileURL.path(percentEncoded: false)) { + try fileManager.removeItem(at: legacyURL) + } else { + try fileManager.moveItem(at: legacyURL, to: fileURL) + try excludeFromBackup(fileURL) + } + } catch { + logger(attachments: [.error(error, name: "legacy-migration-error")]) { + .persistBacklogFailed(description: error.localizedDescription) + } + // If migration cannot complete, at least prove the old file is excluded. The helper + // deletes it when that cannot be guaranteed. + secureExistingFile(at: legacyURL) + } + } + + private static func secureExistingFile(at fileURL: URL) { + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return + } + do { + try excludeFromBackup(fileURL) + } catch { + logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + .excludeFromBackupFailed(description: error.localizedDescription) + } + discardInsecureFile(at: fileURL) + } + } + + private static func excludeFromBackup(_ fileURL: URL) throws { + var persistedURL = fileURL + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try persistedURL.setResourceValues(resourceValues) + } + + private static func readDataFromDisk(at fileURL: URL) throws -> Data { + try Data(contentsOf: fileURL) + } + + /// Decode the epoch-bearing format, with a one-way compatibility path for the pre-epoch + /// sample array. Legacy entries belong to the implicit initial generation and are therefore + /// automatically discarded rather than replayed after any destructive rotation. + private static func decodeEntries(from data: Data) throws -> [LocationOutboxEntry] { + let decoder = JSONDecoder() + do { + return try decoder.decode([LocationOutboxEntry].self, from: data) + } catch let currentError { + do { + return try decoder.decode([LocationSample].self, from: data).map { + LocationOutboxEntry(sample: $0, dataEpochID: .initial) + } + } catch { + throw currentError + } + } + } + + private static func discardInsecureFile(at fileURL: URL) { + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return + } + do { + try FileManager.default.removeItem(at: fileURL) + } catch { + logger(attachments: [.error(error, name: "insecure-discard-error")]) { + .discardInsecureBacklogFailed(description: error.localizedDescription) + } } } } + +#if DEBUG + extension FileLocationOutbox { + /// Injects a deterministic file reader for testing transient read failures. + @_spi(Testing) + public init( + fileURL: URL, + readData: @escaping @Sendable (URL) throws -> Data, + ) { + self.init( + fileURL: fileURL, + legacyFileURL: nil, + readData: readData, + excludeFromBackup: { try Self.excludeFromBackup($0) }, + ) + } + + /// Injects backup-exclusion behavior to verify privacy fail-closed recovery paths. + @_spi(Testing) + public init( + fileURL: URL, + readData: @escaping @Sendable (URL) throws -> Data, + excludeFromBackup: @escaping @Sendable (URL) throws -> Void, + ) { + self.init( + fileURL: fileURL, + legacyFileURL: nil, + readData: readData, + excludeFromBackup: excludeFromBackup, + ) + } + } +#endif diff --git a/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift new file mode 100644 index 00000000..43875f50 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift @@ -0,0 +1,25 @@ +import PeriscopeCore + +/// Structured failures from background recording-policy reconciliation. +enum DeviceRecordingControllerLog: LogEvent { + case policyObservationFailed(description: String) + case rollbackRecoveryFailed(description: String) + case importRecoveryFailed(description: String) + + static let eventName = "DeviceRecordingController" + + var level: LogLevel { + .error + } + + var message: String { + switch self { + case let .policyObservationFailed(description): + "Failed to apply a synced recording policy; recording was stopped: \(description)" + case let .rollbackRecoveryFailed(description): + "Failed to restore recording after an operation rolled back: \(description)" + case let .importRecoveryFailed(description): + "Backup committed, but recording could not be restored and was stopped: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift b/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift index c5aff773..3cd337f0 100644 --- a/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift +++ b/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift @@ -2,18 +2,26 @@ import PeriscopeCore /// Structured events for `FileLocationOutbox`, the durable mirror of the GPS /// retry queue. A missing Application Support directory is degraded-but-handled -/// (`.warning`); read/write failures are surfaced as `.error`. +/// (`.warning`); read/write and backup-exclusion failures are surfaced as +/// `.error`. enum LocationOutboxLog: LogEvent { case noApplicationSupport case droppedUnreadableBacklog(description: String) + case readBacklogFailed(description: String) case persistBacklogFailed(description: String) + case excludeFromBackupFailed(description: String) + case discardInsecureBacklogFailed(description: String) static let eventName = "LocationOutbox" var level: LogLevel { switch self { case .noApplicationSupport: .warning - case .droppedUnreadableBacklog, .persistBacklogFailed: .error + case .droppedUnreadableBacklog, + .readBacklogFailed, + .persistBacklogFailed, + .excludeFromBackupFailed, + .discardInsecureBacklogFailed: .error } } @@ -23,8 +31,14 @@ enum LocationOutboxLog: LogEvent { "No Application Support directory; using in-memory retry queue (backlog won't survive relaunch)" case let .droppedUnreadableBacklog(description): "Dropping unreadable location retry backlog: \(description)" + case let .readBacklogFailed(description): + "Failed to read location retry backlog; preserving it for retry: \(description)" case let .persistBacklogFailed(description): "Failed to persist location retry backlog: \(description)" + case let .excludeFromBackupFailed(description): + "Failed to exclude location retry backlog from device backup: \(description)" + case let .discardInsecureBacklogFailed(description): + "Failed to discard a backup-eligible location retry backlog: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift index f3c2b3ff..210fb1a9 100644 --- a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift +++ b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift @@ -41,14 +41,22 @@ enum SwiftDataStoreLog: LogEvent { case ignoredUnknownPrimaryRegions(ids: [String]) /// Dropped a record that failed to materialize into a domain value. case droppedCorruptRecord(type: String) + /// Chose a deterministic value when CloudKit delivered conflicting rows for an immutable id. + case resolvedConflictingImmutableRecords(type: String, id: String, count: Int) + /// Persistent history could not distinguish a local save from an external import; the + /// observer fails open and performs the remote reconciliation rather than miss new data. + case remoteChangeClassificationFailed(description: String) static let eventName = "SwiftDataStore" var level: LogLevel { switch self { case .openedInMemory, .openedOnDisk: .info - case .ignoredUnknownTrackedRegions, .ignoredUnknownPrimaryRegions: .warning - case .droppedCorruptRecord: .fault + case .ignoredUnknownTrackedRegions, + .ignoredUnknownPrimaryRegions, + .remoteChangeClassificationFailed: + .warning + case .droppedCorruptRecord, .resolvedConflictingImmutableRecords: .fault } } @@ -64,6 +72,10 @@ enum SwiftDataStoreLog: LogEvent { "Ignored \(ids.count) unknown primary-region id(s): \(ids.joined(separator: ", "))" case let .droppedCorruptRecord(type): "Dropped corrupt SwiftData record of type \(type)" + case let .resolvedConflictingImmutableRecords(type, id, count): + "Resolved \(count) conflicting immutable \(type) records for id \(id)" + case let .remoteChangeClassificationFailed(description): + "Could not classify persistent-store change; reconciling defensively: \(description)" } } } diff --git a/Where/WhereCore/Sources/Persistence/RemoteDataChangeReconciler.swift b/Where/WhereCore/Sources/Persistence/RemoteDataChangeReconciler.swift new file mode 100644 index 00000000..ed332e98 --- /dev/null +++ b/Where/WhereCore/Sources/Persistence/RemoteDataChangeReconciler.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Rebuilds headless derived outputs after a CloudKit or sibling-process import. +/// +/// Local writers await their focused reconciliation inline; remote imports have no caller in this +/// process, so they need one long-lived observer. The source stream buffers only its newest event, +/// which coalesces a burst of imported transactions while a rebuild is already running. +final class RemoteDataChangeReconciler: @unchecked Sendable { + private let task: Task + + init( + changes: AsyncStream, + reconcile: @escaping @Sendable () async -> Void, + ) { + task = Task { + for await _ in changes { + guard !Task.isCancelled else { break } + await reconcile() + } + } + } + + deinit { + task.cancel() + } +} diff --git a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift index 97aab17e..307a1e3a 100644 --- a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift +++ b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift @@ -1,5 +1,7 @@ import CoreData import Foundation +import PeriscopeCore +import SwiftData /// Abstraction over "the persistent store imported changes from elsewhere" — /// for a CloudKit-backed store, a sync landing from another device. A @@ -33,49 +35,170 @@ protocol StoreRemoteChangeSource: AnyObject, Sendable { /// on-disk stores. Observing it and re-reading is Apple's documented way to /// react to remote SwiftData/CloudKit and cross-process changes. /// +/// Despite its name, Core Data posts the notification for this process's own +/// writes too when persistent-history notifications are enabled. The source +/// therefore stamps local `ModelContext` saves with a per-store author and +/// consults SwiftData history before forwarding only external transactions. +/// /// SwiftData doesn't expose its underlying `NSPersistentStoreCoordinator`, so /// notifications are scoped by Apple's `NSPersistentStoreURLKey` instead. The /// app also owns a separate Periscope store; its commits must not masquerade as /// changes to Where's domain data and trigger a refresh/logging feedback loop. -final class PersistentStoreRemoteChangeSource: StoreRemoteChangeSource, @unchecked Sendable { +final class PersistentStoreRemoteChangeSource: NSObject, StoreRemoteChangeSource, + @unchecked Sendable +{ + private static let logger = WhereLog.root(SwiftDataStoreLog.self) + let remoteChanges: AsyncStream + private let center: NotificationCenter + private let observedStoreURL: URL private let continuation: AsyncStream.Continuation - private let observer: NSObjectProtocol + private let candidateContinuation: AsyncStream.Continuation + private let classificationTask: Task + + convenience init( + modelContainer: ModelContainer, + storeURL: URL, + localTransactionAuthor: String, + center: NotificationCenter, + ) throws { + try self.init( + modelContainer: modelContainer, + storeURL: storeURL, + localTransactionAuthor: localTransactionAuthor, + center: center, + afterHistoryBaseline: {}, + ) + } + + #if DEBUG + /// Test seam for committing a transaction in the narrow interval after the history + /// baseline is captured but before notification observation begins. + convenience init( + modelContainer: ModelContainer, + storeURL: URL, + localTransactionAuthor: String, + center: NotificationCenter, + testingAfterHistoryBaseline: () throws -> Void, + ) throws { + try self.init( + modelContainer: modelContainer, + storeURL: storeURL, + localTransactionAuthor: localTransactionAuthor, + center: center, + afterHistoryBaseline: testingAfterHistoryBaseline, + ) + } + #endif - init(storeURL: URL, center: NotificationCenter = .default) { + private init( + modelContainer: ModelContainer, + storeURL: URL, + localTransactionAuthor: String, + center: NotificationCenter, + afterHistoryBaseline: () throws -> Void, + ) throws { self.center = center - let observedStoreURL = storeURL.standardizedFileURL + observedStoreURL = storeURL.standardizedFileURL let (stream, continuation) = AsyncStream.makeStream( of: Void.self, bufferingPolicy: .bufferingNewest(1), ) remoteChanges = stream self.continuation = continuation - // Capture the continuation in a local — deliberately *not* - // `self.continuation` — so the long-lived observer block (which - // `NotificationCenter` retains until `removeObserver`) doesn't capture - // `self`. Capturing `self` would keep this source alive for as long as - // the observer is registered, so `deinit` (which removes it) could - // never run. The stored `continuation` property exists only for - // `deinit` to `finish()`. - let captured = continuation - observer = center.addObserver( - forName: .NSPersistentStoreRemoteChange, - object: nil, - queue: nil, - ) { notification in - guard let changedStoreURL = notification.userInfo?[NSPersistentStoreURLKey] as? URL, - changedStoreURL.standardizedFileURL == observedStoreURL - else { return } - captured.yield() + let (candidates, candidateContinuation) = AsyncStream.makeStream( + of: Void.self, + bufferingPolicy: .bufferingNewest(1), + ) + self.candidateContinuation = candidateContinuation + let classifier = try PersistentHistoryRemoteChangeClassifier( + modelContainer: modelContainer, + localTransactionAuthor: localTransactionAuthor, + ) + try afterHistoryBaseline() + classificationTask = Task { + for await _ in candidates { + do { + if try await classifier.hasExternalTransactionsSinceLastNotification() { + continuation.yield() + } + } catch { + // Fail open: a missed remote refresh is less honest than a + // duplicate rebuild. Log the classification failure so the + // degraded behavior is observable. + Self.logger(attachments: [.error(error, name: "history-error")]) { + .remoteChangeClassificationFailed(description: error.localizedDescription) + } + continuation.yield() + } + } } + super.init() + center.addObserver( + self, + selector: #selector(persistentStoreDidChange(_:)), + name: .NSPersistentStoreRemoteChange, + object: nil, + ) + // The history baseline necessarily predates target/selector registration. Classify once + // after registration to close that gap: a transaction committed there already missed its + // notification, but its durable history row is now visible to this catch-up pass. + candidateContinuation.yield() } deinit { - center.removeObserver(observer) + center.removeObserver(self) + candidateContinuation.finish() + classificationTask.cancel() continuation.finish() } + + @objc private func persistentStoreDidChange(_ notification: Notification) { + guard let changedStoreURL = notification.userInfo?[NSPersistentStoreURLKey] as? URL, + changedStoreURL.standardizedFileURL == observedStoreURL + else { return } + candidateContinuation.yield() + } +} + +/// Classifies persistent-store notifications through SwiftData history. Core +/// Data posts its so-called remote notification for every write when the option +/// is enabled, including this process's own saves; transaction authors are the +/// durable distinction between those local commits and CloudKit/sibling-process +/// imports. +private actor PersistentHistoryRemoteChangeClassifier { + private let context: ModelContext + private let localTransactionAuthor: String + private var lastTransactionID: Int64 + + init( + modelContainer: ModelContainer, + localTransactionAuthor: String, + ) throws { + let context = ModelContext(modelContainer) + self.context = context + self.localTransactionAuthor = localTransactionAuthor + var latest = HistoryDescriptor( + sortBy: [SortDescriptor(\.transactionIdentifier, order: .reverse)], + ) + latest.fetchLimit = 1 + lastTransactionID = try context.fetchHistory(latest).first?.transactionIdentifier ?? .min + } + + func hasExternalTransactionsSinceLastNotification() throws -> Bool { + let previousTransactionID = lastTransactionID + let descriptor = HistoryDescriptor( + predicate: #Predicate { transaction in + transaction.transactionIdentifier > previousTransactionID + }, + ) + let transactions = try context.fetchHistory(descriptor) + if let newest = transactions.map(\.transactionIdentifier).max() { + lastTransactionID = newest + } + return transactions.contains { $0.author != localTransactionAuthor } + } } #if DEBUG @@ -92,6 +215,7 @@ final class PersistentStoreRemoteChangeSource: StoreRemoteChangeSource, @uncheck @unchecked Sendable { let remoteChanges: AsyncStream + private let continuation: AsyncStream.Continuation public init() { diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index f6774c41..5973c1f4 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -62,44 +62,18 @@ import SwiftData /// one-shot capture) queue instead of clobbering each other. @ModelActor public actor SwiftDataStore: WhereStore, EvidenceBlobStore { - /// Backing storage for a `SwiftDataStore`. CloudKit mode is the - /// production default; the other two are for tests and local - /// development. - public enum Storage: Sendable { - /// In-memory only. No disk, no CloudKit. Test/preview default. + /// Backing storage for a `SwiftDataStore`. Callers choose explicitly so a + /// developer build cannot accidentally validate local-only persistence + /// while appearing to exercise CloudKit. + public enum Storage: Sendable, Equatable { + /// In-memory only. No disk, no CloudKit. Used by tests and previews. case inMemory /// On-disk SwiftData store with CloudKit sync disabled. case localOnly /// On-disk SwiftData store backed by the user's private - /// CloudKit database. Production default. + /// CloudKit database. case cloudKit - /// Build- and test-aware default suitable for app-level wiring. - /// - /// - When tests are running (detected via the - /// `XCTestConfigurationFilePath` env var, which both XCTest - /// and Swift Testing under `xcodebuild` / `swift test` set), - /// returns `.inMemory` so tests can't accidentally write - /// into the user's local on-disk store. - /// - In debug app builds, returns `.localOnly` so iteration is - /// fast and CloudKit doesn't sync experimental records. - /// - In release builds, returns `.cloudKit` for production - /// sync. - /// - /// Tests that want a specific mode (or that construct stores - /// outside `WhereServices`) should still pass `.inMemory` - /// explicitly via `SwiftDataStore.inMemory()`. - public static var `default`: Storage { - if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { - return .inMemory - } - #if DEBUG - return .localOnly - #else - return .cloudKit - #endif - } - /// Whether a store of this mode can receive writes from outside this /// process — a sibling App Group process (the share extension) for any /// on-disk store, or a CloudKit sync from another device — surfaced as @@ -174,9 +148,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { return SwiftDataStore(modelContainer: container) } - /// App-wiring factory: builds a store for the given storage mode - /// (defaulting to the build/test-aware `Storage.default`) and wraps - /// it in a `SwiftDataStore`. The `@ModelActor`-generated + /// App-wiring factory: builds a store for the explicitly selected storage + /// mode and wraps it in a `SwiftDataStore`. The `@ModelActor`-generated /// `init(modelContainer:)` is not reachable from other modules, so /// this is the supported entry point for opening a store. /// @@ -188,7 +161,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// caller opening another container over the same file (two containers /// racing to *create* the store on a fresh install is how the launch /// once failed with `SwiftDataError`). - public static func make(storage: Storage = .default) throws -> SwiftDataStore { + public static func make(storage: Storage) throws -> SwiftDataStore { let container = try logger.measure(.open) { try makeContainer(storage: storage) } if storage == .inMemory { logger { .openedInMemory(mode: String(describing: storage)) } @@ -215,14 +188,18 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // (the share extension) — or, for CloudKit, a sync from another device — // can commit behind our back. Both surface as // `.NSPersistentStoreRemoteChange` (persistent-history tracking is on for - // on-disk stores); forward those into `changes()` so an external write - // refreshes the UI like a local commit. This is what makes a + // on-disk stores). Core Data posts that notification for local saves as + // well, so the source filters history by this store instance's author + // before forwarding only external writes into `changes()`. This makes a // share-extension add show up live in the running app (debug included), // not just on next launch. if storage.observesRemoteChanges { if let storeURL = container.configurations.first?.url { - store.startObservingRemoteChanges(PersistentStoreRemoteChangeSource( + try store.startObservingRemoteChanges(PersistentStoreRemoteChangeSource( + modelContainer: container, storeURL: storeURL, + localTransactionAuthor: store.localTransactionAuthor, + center: .default, )) } else { assertionFailure("An on-disk Where store must have a resolved URL") @@ -244,7 +221,21 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { remoteChangeSource: ScriptedStoreRemoteChangeSource, ) throws -> SwiftDataStore { let container = try makeContainer(storage: .inMemory) - let store = SwiftDataStore(modelContainer: container) + return inMemory( + modelContainer: container, + remoteChangeSource: remoteChangeSource, + ) + } + + /// Variant that exposes the shared container to persistence-boundary + /// tests, allowing them to commit a same-epoch external write before + /// driving the corresponding remote-change notification. + @_spi(Testing) + public static func inMemory( + modelContainer: ModelContainer, + remoteChangeSource: ScriptedStoreRemoteChangeSource, + ) -> SwiftDataStore { + let store = SwiftDataStore(modelContainer: modelContainer) store.startObservingRemoteChanges(remoteChangeSource) return store } @@ -256,21 +247,30 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// internal) record types. Mirrors the `Schema` in `makeContainer`. public static var inspectorModelTypes: [any PersistentModel.Type] { [ + SDWhereDataEpoch.self, + SDBackupImportReceipt.self, SDLocationSample.self, SDEvidence.self, SDManualDay.self, SDDismissedIssue.self, SDTrackedRegion.self, - SDRecordingDevice.self, + SDRecordingDeviceProfile.self, + SDRecordingDeviceMetadataChange.self, + SDRecordingDeviceCheckIn.self, SDRecordingPolicyChange.self, ] } private static let logger = WhereLog.root(SwiftDataStoreLog.self) + /// Process/store-instance author stamped on every local write. A sibling + /// process opens a distinct store instance and therefore gets a distinct + /// value, allowing persistent history to distinguish its commits from ours. + private nonisolated let localTransactionAuthor = "where-\(UUID().uuidString)" /// Fans "committed data changed" pings to `changes()` subscribers. Fired /// once per outermost `perform` commit (see `perform`). private let changeBroadcaster = StoreChangeBroadcaster() + private let remoteChangeBroadcaster = StoreChangeBroadcaster() /// A fresh stream that pings whenever committed data changes (see the /// `WhereStore` contract). `nonisolated` so a subscriber needn't hop onto @@ -280,11 +280,14 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { changeBroadcaster.subscribe() } + public nonisolated func remoteChanges() -> AsyncStream { + remoteChangeBroadcaster.subscribe() + } + /// Forwards a `StoreRemoteChangeSource`'s remote-import events into the same /// `changes()` fan-out a local commit pings. `nonisolated(unsafe)` for the /// same reason as the scanner's: assigned once during setup, cancelled in - /// `deinit`, never accessed concurrently. The task captures only the - /// `Sendable` broadcaster + source (no `self`), so there's no retain cycle. + /// `deinit`, never accessed concurrently. private nonisolated(unsafe) var remoteChangeTask: Task? /// Begin re-pinging `changes()` on every remote import from `source`, so a @@ -300,21 +303,34 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// re-arm/cancel dance: it's assigned once before the store is shared and /// only read again in `deinit`. private nonisolated func startObservingRemoteChanges(_ source: any StoreRemoteChangeSource) { - remoteChangeTask = Task { [changeBroadcaster] in + remoteChangeTask = Task { [changeBroadcaster, remoteChangeBroadcaster] in for await _ in source.remoteChanges { changeBroadcaster.send() + remoteChangeBroadcaster.send() } } } deinit { remoteChangeTask?.cancel() + changeBroadcaster.finishAll() + remoteChangeBroadcaster.finishAll() } /// Peer `ModelContext` active for the duration of an outermost /// `perform { ... }` block. `nil` outside `perform`. See the /// type doc for the full context-strategy explanation. private var writerContext: ModelContext? + /// Logical generation every write in the active transaction belongs to, plus the real + /// maximal heads a rotation must join when the write id is a synthetic reset conflict. + /// Cached once per outer transaction so a large backup import does not refetch the tiny epoch + /// ledger for every row; ``rotateDataEpoch(reason:changedBy:at:)`` replaces it in-place. + private var writerEpoch: WhereDataEpoch.Resolution? + /// Dedicated read context and epoch active for one multi-table snapshot. Reads inside the + /// snapshot use this pair even if CloudKit imports a newer generation mid-block; the block + /// then fails its end validation rather than returning mixed-generation state. + private var snapshotContext: ModelContext? + private var snapshotEpoch: WhereDataEpoch? /// The store identities that currently have an outermost `perform` /// transaction open *on the current task's* call stack. A `perform` whose @@ -323,6 +339,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// another task) is a new outermost transaction. Task-local so reentrancy /// on a *different* task can't be mistaken for nesting — see the type doc. @TaskLocal private static var activeTransactionStores: Set = [] + @TaskLocal private static var activeSnapshotStores: Set = [] /// Whether an outermost transaction is currently live. Guards the /// serialization gate below. @@ -361,7 +378,73 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func perform( _ block: @Sendable () async throws -> T, ) async throws -> T { - try await perform(sendsChange: true, block) + try await perform(sendsChange: true, expectedDataEpochID: nil, block) + } + + public func perform( + expectedDataEpochID: WhereDataEpochID, + _ block: @Sendable () async throws -> T, + ) async throws -> T { + try await perform( + sendsChange: true, + expectedDataEpochID: expectedDataEpochID, + block, + ) + } + + public func readSnapshot( + _ block: @Sendable () async throws -> T, + ) async throws -> T { + let storeID = ObjectIdentifier(self) + if Self.activeSnapshotStores.contains(storeID) + || Self.activeTransactionStores.contains(storeID) + { + return try await block() + } + + await beginExclusive() + let peer = ModelContext(modelContainer) + snapshotContext = peer + defer { + snapshotEpoch = nil + snapshotContext = nil + endExclusive() + } + // A persistent-store transaction becomes fetch-visible atomically with + // its history row, but Core Data is allowed to post the corresponding + // remote-change notification later. Bracket every table fetch with the + // history head from this same peer context: if an external transaction + // lands anywhere across the block, its monotonically increasing id + // changes and the assembled value is rejected. Our own `perform`s are + // held behind `beginExclusive`, so a crossing commit can only come from + // another process or CloudKit. + let startingHistoryTransactionID = try Self.latestHistoryTransactionID(in: peer) + let epoch = try Self.resolvedDataEpoch(in: peer) + snapshotEpoch = epoch + let result = try await Self.$activeSnapshotStores.withValue( + Self.activeSnapshotStores.union([storeID]), + ) { + try await block() + } + guard try Self.latestHistoryTransactionID(in: peer) == startingHistoryTransactionID else { + throw RecordingPersistenceError.dataEpochChanged + } + let current = try Self.resolvedDataEpoch(in: ModelContext(modelContainer)) + guard current.id == epoch.id else { + throw RecordingPersistenceError.dataEpochChanged + } + return result + } + + /// The durable store generation used to bracket a multi-table read. Unlike + /// `.NSPersistentStoreRemoteChange`, persistent history is committed in the + /// same transaction as the rows it describes, so it cannot lag visibility. + private static func latestHistoryTransactionID(in context: ModelContext) throws -> Int64 { + var descriptor = HistoryDescriptor( + sortBy: [SortDescriptor(\.transactionIdentifier, order: .reverse)], + ) + descriptor.fetchLimit = 1 + return try context.fetchHistory(descriptor).first?.transactionIdentifier ?? .min } #if DEBUG @@ -371,30 +454,63 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// only refresh through the production remote-import path. @_spi(Testing) public func simulateRemoteRecordingImport( - devices: [RecordingDevice], + profiles: [RecordingDeviceProfile], + metadataChanges: [RecordingDeviceMetadataChange], + checkIns: [RecordingDeviceCheckIn], policyChanges: [RecordingPolicyChange], ) async throws { - try await perform(sendsChange: false) { - for device in devices { - try await self.setRecordingDevice(device) + try await perform(sendsChange: false, expectedDataEpochID: nil) { + for profile in profiles { + try await self.addRecordingDeviceProfile(profile) + } + for metadataChange in metadataChanges { + try await self.addRecordingDeviceMetadataChange(metadataChange) + } + for checkIn in checkIns { + try await self.setRecordingDeviceCheckIn(checkIn) } for policyChange in policyChanges { try await self.addRecordingPolicyChange(policyChange) } } } + + /// Test seam for remote day data, paired with + /// `ScriptedStoreRemoteChangeSource.yield()` just like the recording import seam above. + @_spi(Testing) + public func simulateRemoteDayImport( + samples: [LocationSample], + manualDays: [DayPresence], + ) async throws { + try await perform(sendsChange: false, expectedDataEpochID: nil) { + for sample in samples { + try await self.add(sample: sample) + } + for manualDay in manualDays { + try await self.setManualDay(manualDay) + } + } + } #endif private func perform( sendsChange: Bool, + expectedDataEpochID: WhereDataEpochID?, _ block: @Sendable () async throws -> T, ) async throws -> T { + precondition( + !Self.activeSnapshotStores.contains(ObjectIdentifier(self)), + "A read snapshot cannot start a store mutation.", + ) // Genuine nested call on this task: a write transaction is already in // flight for this store. Reuse its peer so nested writes coalesce into // the same save / discard decision; only the outermost perform decides // commit vs. rollback. (Task-local, so a concurrent perform on another // task doesn't take this branch — see the type doc.) if Self.activeTransactionStores.contains(ObjectIdentifier(self)) { + if let expectedDataEpochID, writerEpoch?.current.id != expectedDataEpochID { + throw RecordingPersistenceError.dataEpochChanged + } return try await block() } // Outermost call: serialize against any other in-flight transaction so @@ -402,11 +518,17 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // actor reentrancy. await beginExclusive() let peer = ModelContext(modelContainer) + peer.author = localTransactionAuthor writerContext = peer defer { + writerEpoch = nil writerContext = nil endExclusive() } + writerEpoch = try Self.resolvedDataEpochResolution(in: peer) + if let expectedDataEpochID, writerEpoch?.current.id != expectedDataEpochID { + throw RecordingPersistenceError.dataEpochChanged + } // One span per committed transaction, opened *after* the exclusivity // wait so it measures the write rather than the queueing behind another // writer. Only the outermost `perform` spans, so a nested write doesn't @@ -427,6 +549,19 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // transaction — while `defer` still clears `writerContext` and // releases the gate. try peer.save() + // The persistent store can import a CloudKit reset while this + // asynchronous transaction body is suspended. Saving old-epoch + // rows is harmless (they are inert), but reporting success would + // let callers run post-commit side effects under stale authority. + // Re-resolve through a fresh context after the commit and fail the + // operation if its transaction epoch lost before returning. + guard let committedEpochID = writerEpoch?.current.id else { + preconditionFailure("A store transaction must retain its data epoch through save.") + } + let currentEpoch = try Self.resolvedDataEpoch(in: ModelContext(modelContainer)) + guard currentEpoch.id == committedEpochID else { + throw RecordingPersistenceError.dataEpochChanged + } // Committed: ping `changes()` subscribers so they re-read. Only the // outermost `perform` reaches here (nested calls returned above // without saving), so a transaction pings exactly once. The DEBUG @@ -458,23 +593,280 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// visible to subsequent reads in the same block. Outside, reads /// observe the main `modelContext` (committed state only). private func readContext() -> ModelContext { - writerContext ?? modelContext + let storeID = ObjectIdentifier(self) + if Self.activeTransactionStores.contains(storeID) { + guard let writerContext else { + preconditionFailure("An active store transaction must own a writer context.") + } + return writerContext + } + if Self.activeSnapshotStores.contains(storeID) { + guard let snapshotContext else { + preconditionFailure("An active store snapshot must own a read context.") + } + return snapshotContext + } + return modelContext + } + + public func dataEpoch() async throws -> WhereDataEpoch { + if Self.activeTransactionStores.contains(ObjectIdentifier(self)), let writerEpoch { + return writerEpoch.current + } + if Self.activeSnapshotStores.contains(ObjectIdentifier(self)), let snapshotEpoch { + return snapshotEpoch + } + return try Self.resolvedDataEpoch(in: readContext()) + } + + public func rotateDataEpoch( + reason: WhereDataEpochReason, + changedBy deviceID: RecordingDeviceID, + at date: Date, + ) async throws -> WhereDataEpoch { + precondition(reason.isDestructive, "Only a destructive operation rotates the data epoch.") + let context = mutationContext() + guard let resolution = writerEpoch else { + preconditionFailure("A store transaction must resolve its data epoch before mutation.") + } + let current = resolution.current + let history = try Self.dataEpochHistory(in: context) + let refreshedResolution = try WhereDataEpoch.resolve(in: history) + guard refreshedResolution.current.id == current.id else { + throw RecordingPersistenceError.dataEpochChanged + } + let heads = refreshedResolution.realHeads + + // Remove only the logical state being replaced. Older generations may still be present + // because CloudKit is eventually consistent; they remain inert, and deleting them is an + // opportunistic storage cleanup rather than the correctness boundary. + try Self.deleteRows(in: context, belongingTo: current.id) + + // One semantic destructive operation causally joins every observed real head. Resolve + // this single persisted node before any following write asks `mutationEpochID()` for its + // scope; a synthetic reset-conflict id is never stored as a parent. + let changedAt = heads.reduce(date) { partialResult, head in + max(partialResult, head.changedAt) + } + guard let maximumRevision = heads.map(\.revision).max() else { + preconditionFailure("The implicit data epoch must always be a real causal head.") + } + let (revision, overflow) = maximumRevision.addingReportingOverflow(1) + guard !overflow else { + throw RecordingPersistenceError.dataEpochRevisionExhausted + } + let next = WhereDataEpoch( + id: WhereDataEpochID(rawValue: UUID()), + parentIDs: heads.map(\.id), + revision: revision, + changedAt: changedAt, + changedByDeviceID: deviceID, + reason: reason, + ) + context.insert(SDWhereDataEpoch(value: next)) + let nextResolution = try WhereDataEpoch.resolve(in: history + [next]) + guard nextResolution.current == next, nextResolution.realHeads == [next] else { + preconditionFailure("A complete epoch join must resolve to its new persisted node.") + } + writerEpoch = nextResolution + return next + } + + public func backupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws -> BackupImportReceipt? { + let installationID = installationID.rawValue + let records = try readContext().fetch(FetchDescriptor( + predicate: #Predicate { + $0.id == id && $0.installationID == installationID + }, + )) + guard records.count <= 1 else { + Self.logImmutableConflict( + type: String(describing: BackupImportReceipt.self), + id: id.uuidString, + count: records.count, + ) + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + } + guard let record = records.first else { return nil } + guard let value = record.toValue() else { + Self.logFault(forCorrupt: record) + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + } + return value + } + + public func addBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws { + let context = mutationContext() + let receipt = BackupImportReceipt( + id: id, + installationID: installationID, + dataEpochID: mutationEpochID(), + ) + let records = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.id == id }, + )) + if records.isEmpty { + context.insert(SDBackupImportReceipt(value: receipt)) + return + } + guard records.count == 1, records.first?.toValue() == receipt else { + Self.logImmutableConflict( + type: String(describing: BackupImportReceipt.self), + id: id.uuidString, + count: records.count, + ) + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + } + } + + public func removeBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws { + let context = mutationContext() + let installationID = installationID.rawValue + for record in try context.fetch(FetchDescriptor( + predicate: #Predicate { + $0.id == id && $0.installationID == installationID + }, + )) { + context.delete(record) + } + } + + private static func dataEpochHistory(in context: ModelContext) throws -> [WhereDataEpoch] { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.revision), SortDescriptor(\.id)], + ) + descriptor.includePendingChanges = true + let records = try context.fetch(descriptor) + var values: [WhereDataEpoch] = [] + for record in records { + guard let value = record.toValue() else { + logFault(forCorrupt: record) + throw RecordingPersistenceError.incompleteDataEpochHistory + } + values.append(value) + } + var canonical: [WhereDataEpoch] = [] + for (id, duplicates) in Dictionary(grouping: values, by: \.id) { + guard Set(duplicates).count == 1 else { + logImmutableConflict( + type: String(describing: WhereDataEpoch.self), + id: id.rawValue.uuidString, + count: duplicates.count, + ) + throw RecordingPersistenceError.conflictingImmutableRecord(id: id.rawValue) + } + if let value = duplicates.first { canonical.append(value) } + } + return canonical + } + + private static func resolvedDataEpochResolution( + in context: ModelContext, + ) throws -> WhereDataEpoch.Resolution { + try WhereDataEpoch.resolve(in: dataEpochHistory(in: context)) + } + + private static func resolvedDataEpoch(in context: ModelContext) throws -> WhereDataEpoch { + try resolvedDataEpochResolution(in: context).current + } + + private func mutationEpochID() -> WhereDataEpochID { + guard let writerEpoch else { + preconditionFailure("SwiftDataStore mutations require an active data epoch.") + } + return writerEpoch.current.id + } + + private func readEpochID(in context: ModelContext) throws -> WhereDataEpochID { + if Self.activeTransactionStores.contains(ObjectIdentifier(self)), let writerEpoch { + return writerEpoch.current.id + } + if Self.activeSnapshotStores.contains(ObjectIdentifier(self)), let snapshotEpoch { + return snapshotEpoch.id + } + return try Self.resolvedDataEpoch(in: context).id + } + + private static func belongs(_ storedEpochID: UUID?, to epochID: WhereDataEpochID) -> Bool { + WhereDataEpochID(rawValue: storedEpochID ?? WhereDataEpochID.initial.rawValue) == epochID + } + + private static func deleteRows( + in context: ModelContext, + belongingTo epochID: WhereDataEpochID, + ) throws { + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } } public func add(sample: LocationSample) async throws { let context = mutationContext() + let epochID = mutationEpochID() let id = sample.id - if let existing = try context.fetch( + let existing = try context.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id }), - ).first { - existing.update(from: sample) + ) + let active = existing.filter { Self.belongs($0.epochID, to: epochID) } + if let canonical = active.first { + canonical.update(from: sample, epochID: epochID) + for duplicate in active.dropFirst() { + context.delete(duplicate) + } } else { - context.insert(SDLocationSample(value: sample)) + context.insert(SDLocationSample(value: sample, epochID: epochID)) } } public func samples(in interval: DateInterval) async throws -> [LocationSample] { let context = readContext() + let epochID = try readEpochID(in: context) let start = interval.start let end = interval.end var descriptor = FetchDescriptor( @@ -493,6 +885,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // splitting them would only obscure it. return try Self.logger.measure(.fetchSamples) { try context.fetch(descriptor).compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } let value = record.toValue() if value == nil { Self.logFault(forCorrupt: record) } return value @@ -502,9 +895,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func allSamples() async throws -> [LocationSample] { let context = readContext() + let epochID = try readEpochID(in: context) var descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.timestamp)]) descriptor.includePendingChanges = true return try context.fetch(descriptor).compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } let value = record.toValue() if value == nil { Self.logFault(forCorrupt: record) } return value @@ -514,98 +909,285 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // MARK: - Recording devices public func recordingDevices() async throws -> [RecordingDevice] { + async let profiles = recordingDeviceProfiles() + async let metadataChanges = recordingDeviceMetadataChanges() + async let checkIns = recordingDeviceCheckIns() + async let policies = recordingPolicyChanges() + let (resolvedProfiles, resolvedMetadata, resolvedCheckIns, resolvedPolicies) = try await ( + profiles, + metadataChanges, + checkIns, + policies, + ) + let latestNicknames = Dictionary( + grouping: resolvedMetadata.filter { $0.field == .nickname }, + by: \.deviceID, + ) + .compactMapValues { $0.max(by: RecordingDeviceMetadataChange.isOrderedBefore) } + let checkInsByDevice = Dictionary(uniqueKeysWithValues: resolvedCheckIns.map { + ($0.deviceID, $0) + }) + let policyTimelines = Dictionary(grouping: resolvedPolicies, by: \.deviceID) + for (deviceID, timeline) in policyTimelines { + guard RecordingPolicyChange.formValidPersistedTimelines(timeline) else { + throw RecordingPersistenceError.incompletePolicyHistory(deviceID) + } + } + let policiesByDevice = policyTimelines + .compactMapValues { RecordingPolicyChange.canonicalHead(in: $0) } + return resolvedProfiles + .map { + RecordingDevice( + profile: $0, + nicknameChange: latestNicknames[$0.id], + checkIn: checkInsByDevice[$0.id], + policyChange: policiesByDevice[$0.id], + ) + } + .sorted { + if $0.lastSeenAt != $1.lastSeenAt { return $0.lastSeenAt > $1.lastSeenAt } + return $0.id.storeURL.absoluteString < $1.id.storeURL.absoluteString + } + } + + public func recordingDeviceProfiles() async throws -> [RecordingDeviceProfile] { let context = readContext() - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.lastSeenAt, order: .reverse)], + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.registeredAt)], ) descriptor.includePendingChanges = true - let values = try context.fetch(descriptor).compactMap { record in + let values: [RecordingDeviceProfile] = try context.fetch(descriptor).compactMap { record in let value = record.toValue() if value == nil { Self.logFault(forCorrupt: record) } return value } - // CloudKit cannot enforce uniqueness. Converge duplicate rows by taking - // the most recently seen profile for each stable installation id. + // CloudKit cannot enforce uniqueness. Profiles are immutable, so identical retries + // converge naturally; a conflicting duplicate resolves deterministically to the + // earliest registration and is prevented on every local write path below. return Dictionary(grouping: values, by: \.id) - .compactMap { _, duplicates in - duplicates.max { $0.lastSeenAt < $1.lastSeenAt } + .compactMap { id, duplicates in + if Set(duplicates).count > 1 { + Self.logImmutableConflict( + type: String(describing: RecordingDeviceProfile.self), + id: id.storeURL.absoluteString, + count: duplicates.count, + ) + } + return duplicates.min { + if $0.registeredAt != $1.registeredAt { + return $0.registeredAt < $1.registeredAt + } + if $0.systemName != $1.systemName { return $0.systemName < $1.systemName } + if $0.kind != $1.kind { return $0.kind.rawValue < $1.kind.rawValue } + return $0.registrationEpochID.rawValue.uuidString + < $1.registrationEpochID.rawValue.uuidString + } } - .sorted { - if $0.lastSeenAt != $1.lastSeenAt { return $0.lastSeenAt > $1.lastSeenAt } - return $0.id.storeURL.absoluteString < $1.id.storeURL.absoluteString + .sorted { $0.id.storeURL.absoluteString < $1.id.storeURL.absoluteString } + } + + public func addRecordingDeviceProfile(_ profile: RecordingDeviceProfile) async throws { + let context = mutationContext() + let id = profile.id.rawValue + let existing = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id }), + ) + guard !existing.isEmpty else { + context.insert(SDRecordingDeviceProfile(value: profile)) + return + } + guard existing.allSatisfy({ $0.toValue() == profile }) else { + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + } + for duplicate in existing.dropFirst() { + context.delete(duplicate) + } + } + + public func recordingDeviceMetadataChanges() async throws -> [RecordingDeviceMetadataChange] { + let context = readContext() + let epochID = try readEpochID(in: context) + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.revision), SortDescriptor(\.id)], + ) + descriptor.includePendingChanges = true + let values: [RecordingDeviceMetadataChange] = try context.fetch(descriptor) + .compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value } + return Dictionary(grouping: values, by: \.id) + .compactMap { id, duplicates in + if Set(duplicates).count > 1 { + Self.logImmutableConflict( + type: String(describing: RecordingDeviceMetadataChange.self), + id: id.uuidString, + count: duplicates.count, + ) + } + return duplicates.min(by: RecordingDeviceMetadataChange.isCanonicalBefore) + } + .sorted(by: RecordingDeviceMetadataChange.isOrderedBefore) } - public func setRecordingDevice(_ device: RecordingDevice) async throws { + public func addRecordingDeviceMetadataChange( + _ change: RecordingDeviceMetadataChange, + ) async throws { let context = mutationContext() - let id = device.id.rawValue + let epochID = mutationEpochID() + let id = change.id let existing = try context.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == id }), + FetchDescriptor(predicate: #Predicate { $0.id == id }), ) - if let first = existing.first { - first.update(from: device) - for duplicate in existing.dropFirst() { - context.delete(duplicate) + let active = existing.filter { Self.belongs($0.epochID, to: epochID) } + guard !active.isEmpty else { + context.insert(SDRecordingDeviceMetadataChange(value: change, epochID: epochID)) + return + } + guard active.allSatisfy({ $0.toValue() == change }) else { + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + } + for duplicate in active.dropFirst() { + context.delete(duplicate) + } + } + + public func recordingDeviceCheckIns() async throws -> [RecordingDeviceCheckIn] { + let context = readContext() + let epochID = try readEpochID(in: context) + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.lastSeenAt, order: .reverse)], + ) + descriptor.includePendingChanges = true + let values: [RecordingDeviceCheckIn] = try context.fetch(descriptor).compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value + } + return Dictionary(grouping: values, by: \.deviceID) + .compactMap { _, duplicates in + duplicates.max { RecordingDeviceCheckIn.isOlder($0, than: $1) } } + .sorted { $0.deviceID.storeURL.absoluteString < $1.deviceID.storeURL.absoluteString } + } + + public func setRecordingDeviceCheckIn(_ checkIn: RecordingDeviceCheckIn) async throws { + let context = mutationContext() + let epochID = mutationEpochID() + let deviceID = checkIn.deviceID.rawValue + let allExisting = try context.fetch( + FetchDescriptor(predicate: #Predicate { + $0.deviceID == deviceID + }), + ) + let existing = allExisting.filter { Self.belongs($0.epochID, to: epochID) } + guard let canonical = existing.first else { + context.insert(SDRecordingDeviceCheckIn(value: checkIn, epochID: epochID)) + return + } + let current = existing.compactMap { $0.toValue() } + .max { RecordingDeviceCheckIn.isOlder($0, than: $1) } + let winner = if let current, + RecordingDeviceCheckIn.isOlder(checkIn, than: current) + { + current } else { - context.insert(SDRecordingDevice(value: device)) + checkIn + } + // Always copy the selected winner into the row we retain. `existing.first` is not + // guaranteed to be the row `max` selected; retaining it unchanged could delete the + // winner while collapsing CloudKit duplicates. + canonical.update(from: winner, epochID: epochID) + for duplicate in existing.dropFirst() { + context.delete(duplicate) } } public func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { let context = readContext() + let epochID = try readEpochID(in: context) var descriptor = FetchDescriptor( sortBy: [ - SortDescriptor(\.effectiveAt), + SortDescriptor(\.revision), SortDescriptor(\.id), ], ) descriptor.includePendingChanges = true - let values = try context.fetch(descriptor).compactMap { record in - let value = record.toValue() - if value == nil { Self.logFault(forCorrupt: record) } - return value + let records = try context.fetch(descriptor) + var values: [RecordingPolicyChange] = [] + for record in records where Self.belongs(record.epochID, to: epochID) { + guard let value = record.toValue() else { + Self.logFault(forCorrupt: record) + throw RecordingPersistenceError.corruptRecordingPolicyHistory + } + values.append(value) } - // Keep one value per event id if CloudKit delivers duplicate rows. + // Keep one immutable value per event id if CloudKit delivers duplicate rows. return Dictionary(grouping: values, by: \.id) - .compactMap { _, duplicates in duplicates.first } + .compactMap { id, duplicates in + if Set(duplicates).count > 1 { + Self.logImmutableConflict( + type: String(describing: RecordingPolicyChange.self), + id: id.uuidString, + count: duplicates.count, + ) + } + return duplicates.min(by: RecordingPolicyChange.isCanonicalBefore) + } .sorted(by: RecordingPolicyChange.isOrderedBefore) } public func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { let context = mutationContext() + let epochID = mutationEpochID() let id = change.id let existing = try context.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id }), ) - if let first = existing.first { - first.update(from: change) - for duplicate in existing.dropFirst() { - context.delete(duplicate) - } - } else { - context.insert(SDRecordingPolicyChange(value: change)) + let active = existing.filter { Self.belongs($0.epochID, to: epochID) } + guard !active.isEmpty else { + context.insert(SDRecordingPolicyChange(value: change, epochID: epochID)) + return + } + guard active.allSatisfy({ $0.toValue() == change }) else { + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + } + for duplicate in active.dropFirst() { + context.delete(duplicate) } } public func write(evidence: Evidence, blob: Data?) async throws { let context = mutationContext() + let epochID = mutationEpochID() let id = evidence.id - if let existing = try context.fetch( + let allExisting = try context.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id }), - ).first { + ) + let active = allExisting.filter { Self.belongs($0.epochID, to: epochID) } + if let existing = active.first { // Treat `blob == nil` as "no change" so a metadata-only edit // (note, kind, region) does not wipe a previously stored // attachment. Callers that need to remove the blob explicitly // use `delete(for:)` from the `EvidenceBlobStore` API. - existing.update(from: evidence, blob: blob ?? existing.blob) + existing.update(from: evidence, blob: blob ?? existing.blob, epochID: epochID) + for duplicate in active.dropFirst() { + context.delete(duplicate) + } } else { - context.insert(SDEvidence(value: evidence, blob: blob)) + // An inactive same-id row is retained only as superseded sync history. Never carry + // its attachment bytes into the current epoch when a backup intentionally restores + // metadata without a declared asset. + context.insert(SDEvidence(value: evidence, blob: blob, epochID: epochID)) } } public func evidence(in interval: DateInterval) async throws -> [Evidence] { let context = readContext() + let epochID = try readEpochID(in: context) let start = interval.start let end = interval.end var descriptor = FetchDescriptor( @@ -621,6 +1203,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { descriptor.includePendingChanges = true return try Self.logger.measure(.fetchEvidence) { try context.fetch(descriptor).compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } let value = record.toValue() if value == nil { Self.logFault(forCorrupt: record) } return value @@ -630,9 +1213,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func allEvidence() async throws -> [Evidence] { let context = readContext() + let epochID = try readEpochID(in: context) var descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.capturedAt)]) descriptor.includePendingChanges = true return try context.fetch(descriptor).compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } let value = record.toValue() if value == nil { Self.logFault(forCorrupt: record) } return value @@ -641,18 +1226,24 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func evidenceBlob(for id: UUID) async throws -> Data? { let context = readContext() + let epochID = try readEpochID(in: context) let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) // Blobs live in external storage, so this is a file read behind a fetch // — the one evidence read whose cost scales with the attachment. return try Self.logger.measure(.fetchEvidenceBlob) { - try context.fetch(descriptor).first?.blob + try context.fetch(descriptor).first(where: { + Self.belongs($0.epochID, to: epochID) + })?.blob } } public func write(blob: Data, for id: UUID) async throws { let context = mutationContext() + let epochID = mutationEpochID() let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) - guard let record = try context.fetch(descriptor).first else { return } + guard let record = try context.fetch(descriptor).first(where: { + Self.belongs($0.epochID, to: epochID) + }) else { return } record.blob = blob } @@ -662,20 +1253,31 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func delete(for id: UUID) async throws { let context = mutationContext() + let epochID = mutationEpochID() let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) - guard let record = try context.fetch(descriptor).first else { return } + guard let record = try context.fetch(descriptor).first(where: { + Self.belongs($0.epochID, to: epochID) + }) else { return } record.blob = nil } public func setManualDay(_ day: DayPresence) async throws { let context = mutationContext() + let epochID = mutationEpochID() let key = day.day.description - if let existing = try context.fetch( + let existing = try context.fetch( FetchDescriptor(predicate: #Predicate { $0.dayKey == key }), - ).first { - existing.update(from: Self.resolved(incoming: day, existing: existing)) + ).filter { Self.belongs($0.epochID, to: epochID) } + if let canonical = existing.first { + canonical.update( + from: Self.resolved(incoming: day, existing: canonical), + epochID: epochID, + ) + for duplicate in existing.dropFirst() { + context.delete(duplicate) + } } else { - context.insert(SDManualDay(value: day)) + context.insert(SDManualDay(value: day, epochID: epochID)) } } @@ -702,15 +1304,19 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func clearManualDay(_ day: CalendarDay) async throws { let context = mutationContext() + let epochID = mutationEpochID() let key = day.description let descriptor = FetchDescriptor(predicate: #Predicate { $0.dayKey == key }) - for record in try context.fetch(descriptor) { + for record in try context.fetch(descriptor) + where Self.belongs(record.epochID, to: epochID) + { context.delete(record) } } public func manualDays(in dayRange: ClosedRange) async throws -> [DayPresence] { let context = readContext() + let epochID = try readEpochID(in: context) // ISO `YYYY-MM-DD` sorts lexicographically, so a string range is a // correct inclusive day range. let low = dayRange.lowerBound.description @@ -728,6 +1334,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { descriptor.includePendingChanges = true return try Self.logger.measure(.fetchManualDays) { try context.fetch(descriptor).compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } let value = record.toValue() if value == nil { Self.logFault(forCorrupt: record) } return value @@ -737,9 +1344,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func allManualDays() async throws -> [DayPresence] { let context = readContext() + let epochID = try readEpochID(in: context) var descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.dayKey)]) descriptor.includePendingChanges = true return try context.fetch(descriptor).compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } let value = record.toValue() if value == nil { Self.logFault(forCorrupt: record) } return value @@ -751,6 +1360,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { manualDays dayRange: ClosedRange, ) async throws { let context = mutationContext() + let epochID = mutationEpochID() let start = interval.start let end = interval.end let samples = try context.fetch( @@ -762,7 +1372,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } }), ) - for record in samples { + for record in samples where Self.belongs(record.epochID, to: epochID) { context.delete(record) } let evidences = try context.fetch( @@ -774,7 +1384,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } }), ) - for record in evidences { + for record in evidences where Self.belongs(record.epochID, to: epochID) { context.delete(record) } let low = dayRange.lowerBound.description @@ -788,41 +1398,18 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } }), ) - for record in manuals { + for record in manuals where Self.belongs(record.epochID, to: epochID) { context.delete(record) } } - public func clearAll() async throws { - let context = mutationContext() - for sample in try context.fetch(FetchDescriptor()) { - context.delete(sample) - } - for evidence in try context.fetch(FetchDescriptor()) { - context.delete(evidence) - } - for manual in try context.fetch(FetchDescriptor()) { - context.delete(manual) - } - for dismissed in try context.fetch(FetchDescriptor()) { - context.delete(dismissed) - } - for tracked in try context.fetch(FetchDescriptor()) { - context.delete(tracked) - } - for device in try context.fetch(FetchDescriptor()) { - context.delete(device) - } - for policy in try context.fetch(FetchDescriptor()) { - context.delete(policy) - } - } - public func dismissedIssueIDs() async throws -> Set { let context = readContext() + let epochID = try readEpochID(in: context) var descriptor = FetchDescriptor() descriptor.includePendingChanges = true let ids = try context.fetch(descriptor).compactMap { record -> DataIssueID? in + guard Self.belongs(record.epochID, to: epochID) else { return nil } let value = record.toValue() if value == nil { Self.logFault(forCorrupt: record) } return value?.id @@ -832,9 +1419,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func allDismissedIssues() async throws -> [DismissedIssue] { let context = readContext() + let epochID = try readEpochID(in: context) var descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.key)]) descriptor.includePendingChanges = true return try context.fetch(descriptor).compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } let value = record.toValue() if value == nil { Self.logFault(forCorrupt: record) } return value @@ -843,12 +1432,14 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func setIssueDismissed(_ dismissed: Bool, id: DataIssueID) async throws { let context = mutationContext() + let epochID = mutationEpochID() let key = id.storeURL.absoluteString let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) let existing = try context.fetch(descriptor) + .filter { Self.belongs($0.epochID, to: epochID) } if dismissed { guard existing.isEmpty else { return } - context.insert(SDDismissedIssue(key: key, dismissedAt: Date())) + context.insert(SDDismissedIssue(key: key, dismissedAt: Date(), epochID: epochID)) } else { for record in existing { context.delete(record) @@ -858,12 +1449,19 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func restoreDismissedIssue(_ issue: DismissedIssue) async throws { let context = mutationContext() + let epochID = mutationEpochID() let key = issue.id.storeURL.absoluteString let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) - if let record = try context.fetch(descriptor).first { + if let record = try context.fetch(descriptor).first(where: { + Self.belongs($0.epochID, to: epochID) + }) { record.dismissedAt = issue.dismissedAt } else { - context.insert(SDDismissedIssue(key: key, dismissedAt: issue.dismissedAt)) + context.insert(SDDismissedIssue( + key: key, + dismissedAt: issue.dismissedAt, + epochID: epochID, + )) } } @@ -871,9 +1469,13 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func trackedRegions() async throws -> Set { let context = readContext() + let epochID = try readEpochID(in: context) var descriptor = FetchDescriptor() descriptor.includePendingChanges = true - let ids = try context.fetch(descriptor).compactMap(\.regionID) + let ids: [String] = try context.fetch(descriptor).compactMap { record in + guard Self.belongs(record.epochID, to: epochID) else { return nil } + return record.regionID + } // No rows means the user hasn't chosen yet — fall back to the default // set (applied identically in every process). Once any row exists, the // tracked set is exactly the persisted rows. @@ -901,10 +1503,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func setTrackedRegion(_ tracked: Bool, id: String) async throws { let context = mutationContext() + let epochID = mutationEpochID() let descriptor = FetchDescriptor( predicate: #Predicate { $0.regionID == id }, ) let existing = try context.fetch(descriptor) + .filter { Self.belongs($0.epochID, to: epochID) } if tracked { // Dedupe defensively: CloudKit can't enforce uniqueness, so collapse // any accidental duplicate rows to one on write. @@ -914,7 +1518,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } return } - context.insert(SDTrackedRegion(regionID: id)) + context.insert(SDTrackedRegion(regionID: id, epochID: epochID)) } else { // TODO: Untracking deletes the row, which drops the region from the // attributor's load set — so re-aggregating a past year would @@ -933,9 +1537,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func primaryRegions() async throws -> [PrimaryRegion] { let context = readContext() + let epochID = try readEpochID(in: context) var descriptor = FetchDescriptor() descriptor.includePendingChanges = true let rows = try context.fetch(descriptor) + .filter { Self.belongs($0.epochID, to: epochID) } // No rows means the user hasn't chosen yet — mirror `trackedRegions()`'s // default fallback so the picker/customization UI opens on the // out-of-the-box set rather than empty. @@ -978,10 +1584,13 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func setPrimaryRegions(_ regions: [PrimaryRegion]) async throws { let context = mutationContext() + let epochID = mutationEpochID() let desiredIDs = Set(regions.map(\.region.rawValue)) // Delete every tracked row not in the desired set (and any row with a // nil id, which we can't resolve) — removals happen by omission. - for row in try context.fetch(FetchDescriptor()) { + for row in try context.fetch(FetchDescriptor()) + where Self.belongs(row.epochID, to: epochID) + { if let id = row.regionID, desiredIDs.contains(id) { continue } context.delete(row) } @@ -992,7 +1601,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let id = entry.region.rawValue let existing = try context.fetch(FetchDescriptor( predicate: #Predicate { $0.regionID == id }, - )) + )).filter { Self.belongs($0.epochID, to: epochID) } let row: SDTrackedRegion if let first = existing.first { for extra in existing.dropFirst() { @@ -1000,7 +1609,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } row = first } else { - row = SDTrackedRegion(regionID: id) + row = SDTrackedRegion(regionID: id, epochID: epochID) context.insert(row) } row.apply(appearance: entry.appearance, order: entry.order) @@ -1010,12 +1619,104 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { private static func logFault(forCorrupt _: Record) { logger { .droppedCorruptRecord(type: String(describing: Record.self)) } } + + private static func logImmutableConflict(type: String, id: String, count: Int) { + logger { .resolvedConflictingImmutableRecords(type: type, id: id, count: count) } + } +} + +/// Installation-scoped commit proof for the backup import two-phase protocol. +@Model +final class SDBackupImportReceipt { + var id: UUID? + var installationID: UUID? + var epochID: UUID? + + init() {} + + convenience init(value: BackupImportReceipt) { + self.init() + id = value.id + installationID = value.installationID.rawValue + epochID = value.dataEpochID.rawValue + } + + func toValue() -> BackupImportReceipt? { + guard let id, let installationID, let epochID else { return nil } + return BackupImportReceipt( + id: id, + installationID: RecordingDeviceID(rawValue: installationID), + dataEpochID: WhereDataEpochID(rawValue: epochID), + ) + } } // MARK: - SwiftData models (internal) +/// Append-only account-wide logical-generation change. Revision zero is synthesized in Core; +/// only destructive rotations are persisted here. +@Model +final class SDWhereDataEpoch { + var id: UUID? + /// Legacy scalar parent. New multi-parent rows leave this nil so delivery of the new array + /// cannot be mistaken for a complete one-parent command before all CloudKit fields arrive. + var parentID: UUID? + var parentIDs: [UUID]? + var revision: Int64? + var changedAt: Date? + var changedByDeviceID: UUID? + var reasonRaw: String? + + init() {} + + convenience init(value: WhereDataEpoch) { + self.init() + id = value.id.rawValue + parentID = nil + parentIDs = value.parentIDs.map(\.rawValue) + revision = value.revision + changedAt = value.changedAt + changedByDeviceID = value.changedByDeviceID?.rawValue + reasonRaw = value.reason.rawValue + } + + func toValue() -> WhereDataEpoch? { + guard let id, + let revision, + revision > 0, + let changedAt, + let changedByDeviceID, + let reasonRaw, + let reason = WhereDataEpochReason(rawValue: reasonRaw), + reason.isDestructive + else { return nil } + let resolvedParentIDs: [UUID] + if let parentIDs { + resolvedParentIDs = parentIDs + } else if let parentID { + resolvedParentIDs = [parentID] + } else { + return nil + } + guard resolvedParentIDs.isEmpty == false, + Set(resolvedParentIDs).count == resolvedParentIDs.count, + resolvedParentIDs.contains(id) == false + else { return nil } + return WhereDataEpoch( + id: WhereDataEpochID(rawValue: id), + parentIDs: resolvedParentIDs.map(WhereDataEpochID.init(rawValue:)), + revision: revision, + changedAt: changedAt, + changedByDeviceID: RecordingDeviceID(rawValue: changedByDeviceID), + reason: reason, + ) + } +} + @Model final class SDLocationSample { + /// Nil belongs to the implicit initial epoch, preserving rows from builds before epochs. + var epochID: UUID? var id: UUID? var timestamp: Date? var latitude: Double? @@ -1036,12 +1737,13 @@ final class SDLocationSample { init() {} - convenience init(value: LocationSample) { + convenience init(value: LocationSample, epochID: WhereDataEpochID) { self.init() - update(from: value) + update(from: value, epochID: epochID) } - func update(from value: LocationSample) { + func update(from value: LocationSample, epochID: WhereDataEpochID) { + self.epochID = epochID.rawValue id = value.id timestamp = value.timestamp latitude = value.coordinate.latitude @@ -1075,6 +1777,7 @@ final class SDLocationSample { @Model final class SDEvidence { + var epochID: UUID? var id: UUID? /// `EvidenceKind.discriminator` ("planeTicket", "other", etc.). var kindRaw: String? @@ -1097,12 +1800,13 @@ final class SDEvidence { init() {} - convenience init(value: Evidence, blob: Data?) { + convenience init(value: Evidence, blob: Data?, epochID: WhereDataEpochID) { self.init() - update(from: value, blob: blob) + update(from: value, blob: blob, epochID: epochID) } - func update(from value: Evidence, blob: Data?) { + func update(from value: Evidence, blob: Data?, epochID: WhereDataEpochID) { + self.epochID = epochID.rawValue id = value.id kindRaw = value.kind.discriminator otherLabel = if case let .other(label) = value.kind { label } else { nil } @@ -1134,6 +1838,7 @@ final class SDEvidence { @Model final class SDManualDay { + var epochID: UUID? /// Canonical, timezone-independent identity: the day's `CalendarDay` ISO /// string (`YYYY-MM-DD`). Optional only because the CloudKit mirror requires /// it; a row that somehow has no `dayKey` can't be placed on a day and is @@ -1159,12 +1864,13 @@ final class SDManualDay { init() {} - convenience init(value: DayPresence) { + convenience init(value: DayPresence, epochID: WhereDataEpochID) { self.init() - update(from: value) + update(from: value, epochID: epochID) } - func update(from value: DayPresence) { + func update(from value: DayPresence, epochID: WhereDataEpochID) { + self.epochID = epochID.rawValue dayKey = value.day.description regionRaws = value.regions.map(\.rawValue).sorted() isAuthoritative = value.isAuthoritative @@ -1222,6 +1928,7 @@ final class SDManualDay { @Model final class SDDismissedIssue { + var epochID: UUID? /// The dismissed issue's identity, stored as its `DataIssueID` `store://` /// URL string (`id.storeURL.absoluteString`). A plain string column so /// `#Predicate` dedup/upsert stays a real query. @@ -1230,7 +1937,8 @@ final class SDDismissedIssue { init() {} - init(key: String, dismissedAt: Date) { + init(key: String, dismissedAt: Date, epochID: WhereDataEpochID) { + self.epochID = epochID.rawValue self.key = key self.dismissedAt = dismissedAt } @@ -1257,6 +1965,7 @@ final class SDDismissedIssue { /// needs all three style fields present. @Model final class SDTrackedRegion { + var epochID: UUID? var regionID: String? var colorRaw: String? var emoji: String? @@ -1265,7 +1974,8 @@ final class SDTrackedRegion { init() {} - init(regionID: String) { + init(regionID: String, epochID: WhereDataEpochID) { + self.epochID = epochID.rawValue self.regionID = regionID } @@ -1287,58 +1997,146 @@ final class SDTrackedRegion { } } -/// One synced installation profile. Every field is optional because CloudKit -/// may materialize a partial row before all fields arrive. +/// Immutable identity row written once by its installation. Every field is optional because +/// CloudKit may materialize a partial record before all fields arrive. @Model -final class SDRecordingDevice { +final class SDRecordingDeviceProfile { var id: UUID? var systemName: String? - var nickname: String? var kindRaw: String? var registeredAt: Date? - var lastSeenAt: Date? - var archivedAt: Date? - var lastAppliedPolicyChangeID: UUID? - var statusRaw: String? + var registrationEpochID: UUID? init() {} - convenience init(value: RecordingDevice) { + convenience init(value: RecordingDeviceProfile) { self.init() - update(from: value) - } - - func update(from value: RecordingDevice) { id = value.id.rawValue systemName = value.systemName - nickname = value.nickname kindRaw = value.kind.rawValue registeredAt = value.registeredAt - lastSeenAt = value.lastSeenAt - archivedAt = value.archivedAt - lastAppliedPolicyChangeID = value.lastAppliedPolicyChangeID - statusRaw = value.status.rawValue + registrationEpochID = value.registrationEpochID.rawValue } - func toValue() -> RecordingDevice? { + func toValue() -> RecordingDeviceProfile? { guard let id, let systemName, let kindRaw, let kind = RecordingDeviceKind(rawValue: kindRaw), let registeredAt, - let lastSeenAt, - let statusRaw, - let status = RecordingDeviceStatus(rawValue: statusRaw) + let registrationEpochID else { return nil } - return RecordingDevice( + return RecordingDeviceProfile( id: RecordingDeviceID(rawValue: id), systemName: systemName, - nickname: nickname, kind: kind, registeredAt: registeredAt, + registrationEpochID: WhereDataEpochID(rawValue: registrationEpochID), + ) + } +} + +/// Append-only nickname edit. Effective archive authority is a policy state. +@Model +final class SDRecordingDeviceMetadataChange { + var epochID: UUID? + var id: UUID? + var deviceID: UUID? + var fieldRaw: String? + var revision: Int64? + var changedAt: Date? + var changedByDeviceID: UUID? + var nickname: String? + + init() {} + + convenience init(value: RecordingDeviceMetadataChange, epochID: WhereDataEpochID) { + self.init() + self.epochID = epochID.rawValue + id = value.id + deviceID = value.deviceID.rawValue + fieldRaw = value.field.rawValue + revision = value.revision + changedAt = value.changedAt + changedByDeviceID = value.changedByDeviceID.rawValue + nickname = value.nickname + } + + func toValue() -> RecordingDeviceMetadataChange? { + guard let id, + let deviceID, + let fieldRaw, + let field = RecordingDeviceMetadataField(rawValue: fieldRaw), + let revision, + revision >= 0, + let changedAt, + let changedByDeviceID + else { return nil } + guard field == .nickname else { return nil } + return RecordingDeviceMetadataChange( + id: id, + deviceID: RecordingDeviceID(rawValue: deviceID), + revision: revision, + changedAt: changedAt, + changedByDeviceID: RecordingDeviceID(rawValue: changedByDeviceID), + nickname: nickname, + ) + } +} + +/// Target-owned acknowledgement/check-in row. No other installation writes this row during +/// normal operation, so a whole-value update cannot clobber user metadata. +@Model +final class SDRecordingDeviceCheckIn { + var epochID: UUID? + var deviceID: UUID? + var revision: Int64? + var lastSeenAt: Date? + var appliedAt: Date? + var lastAppliedPolicyChangeID: UUID? + /// Singleton destructive event id or deterministic multi-head frontier digest. The field name + /// predates multi-parent policy and remains stable for CloudKit compatibility. + var lastDiscardedPolicyChangeID: UUID? + var statusRaw: String? + + init() {} + + convenience init(value: RecordingDeviceCheckIn, epochID: WhereDataEpochID) { + self.init() + update(from: value, epochID: epochID) + } + + func update(from value: RecordingDeviceCheckIn, epochID: WhereDataEpochID) { + self.epochID = epochID.rawValue + deviceID = value.deviceID.rawValue + revision = value.revision + lastSeenAt = value.lastSeenAt + appliedAt = value.appliedAt + lastAppliedPolicyChangeID = value.lastAppliedPolicyChangeID + lastDiscardedPolicyChangeID = value.lastDiscardedPolicyChangeID + statusRaw = value.status.rawValue + } + + func toValue() -> RecordingDeviceCheckIn? { + guard let deviceID, + let revision, + revision >= 0, + let lastSeenAt, + let appliedAt, + let lastAppliedPolicyChangeID, + let statusRaw, + let status = RecordingDeviceStatus(rawValue: statusRaw), + status != .unknown + else { return nil } + return RecordingDeviceCheckIn( + deviceID: RecordingDeviceID(rawValue: deviceID), + revision: revision, lastSeenAt: lastSeenAt, - archivedAt: archivedAt, + appliedAt: appliedAt, lastAppliedPolicyChangeID: lastAppliedPolicyChangeID, + lastDiscardedPolicyFrontierToken: lastDiscardedPolicyChangeID.map { + RecordingPolicyCleanupToken(rawValue: $0) + }, status: status, ) } @@ -1348,32 +2146,79 @@ final class SDRecordingDevice { /// schema additive and tolerant of partially synced rows. @Model final class SDRecordingPolicyChange { + var epochID: UUID? var id: UUID? var deviceID: UUID? + /// Legacy scalar parent. New multi-parent rows leave it nil; a non-root row therefore remains + /// unavailable until its complete parent-id array arrives. + var parentID: UUID? + var parentIDs: [UUID]? + var revision: Int64? + var issuedAt: Date? + var issuedByDeviceID: UUID? var effectiveAt: Date? - var isEnabled: Bool? + var stateRaw: String? + var reasonRaw: String? init() {} - convenience init(value: RecordingPolicyChange) { + convenience init(value: RecordingPolicyChange, epochID: WhereDataEpochID) { self.init() - update(from: value) + update(from: value, epochID: epochID) } - func update(from value: RecordingPolicyChange) { + func update(from value: RecordingPolicyChange, epochID: WhereDataEpochID) { + self.epochID = epochID.rawValue id = value.id deviceID = value.deviceID.rawValue + parentID = nil + parentIDs = value.parentIDs + revision = value.revision + issuedAt = value.issuedAt + issuedByDeviceID = value.issuedByDeviceID.rawValue effectiveAt = value.effectiveAt - isEnabled = value.isEnabled + stateRaw = value.state.rawValue + reasonRaw = value.reason.rawValue } func toValue() -> RecordingPolicyChange? { - guard let id, let deviceID, let effectiveAt, let isEnabled else { return nil } - return RecordingPolicyChange( + guard let id, + let deviceID, + let revision, + revision >= 0, + let issuedAt, + let issuedByDeviceID, + let effectiveAt, + let stateRaw, + let state = RecordingPolicyState(rawValue: stateRaw), + let reasonRaw, + let reason = RecordingPolicyReason(rawValue: reasonRaw) + else { return nil } + let resolvedParentIDs: [UUID] + if let parentIDs { + resolvedParentIDs = parentIDs + } else if let parentID { + resolvedParentIDs = [parentID] + } else if revision == 0 { + resolvedParentIDs = [] + } else { + return nil + } + guard (revision == 0) == resolvedParentIDs.isEmpty, + Set(resolvedParentIDs).count == resolvedParentIDs.count, + resolvedParentIDs.contains(id) == false + else { return nil } + let value = RecordingPolicyChange( id: id, deviceID: RecordingDeviceID(rawValue: deviceID), + parentIDs: resolvedParentIDs, + revision: revision, + issuedAt: issuedAt, + issuedByDeviceID: RecordingDeviceID(rawValue: issuedByDeviceID), effectiveAt: effectiveAt, - isEnabled: isEnabled, + state: state, + reason: reason, ) + return value.hasValidReasonAndState ? value : nil } } diff --git a/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift b/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift new file mode 100644 index 00000000..37cb7a47 --- /dev/null +++ b/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift @@ -0,0 +1,268 @@ +import CryptoKit +import Foundation + +/// Typed identity of one account-wide logical data generation. +/// +/// Every synced user-data row belongs to exactly one epoch. Reset and backup Replace append a +/// new epoch before writing their result, so records uploaded later by an offline device remain +/// in the superseded epoch and cannot repopulate or alter the new account state. +public struct WhereDataEpochID: RawRepresentable, Codable, Sendable, Hashable { + public let rawValue: UUID + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + + /// Epoch used by rows created before the first destructive account operation. It is + /// implicit rather than persisted, so independently installed devices begin in the same + /// generation without racing to create a singleton row. + public static let initial = WhereDataEpochID( + rawValue: UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!, + ) +} + +/// Operation that began a logical data epoch. +public enum WhereDataEpochReason: String, Codable, Sendable, Hashable { + case initial + case accountReset + case backupReplace + + var isDestructive: Bool { + self != .initial + } + + /// A concurrent account reset wins over Replace because erasure is the stronger privacy + /// command. A causally later Replace still wins through its higher revision. + fileprivate var conflictPriority: Int { + switch self { + case .initial: 0 + case .backupReplace: 1 + case .accountReset: 2 + } + } +} + +/// Current account-wide logical data generation. +/// +/// Revision zero is the implicit initial epoch. Destructive operations append immutable changes +/// at revisions one and above; equal revisions can arise from offline concurrent devices and +/// converge deterministically without trusting peer wall clocks. +public struct WhereDataEpoch: Identifiable, Codable, Sendable, Hashable { + public let id: WhereDataEpochID + public let parentIDs: [WhereDataEpochID] + public let revision: Int64 + public let changedAt: Date + public let changedByDeviceID: RecordingDeviceID? + public let reason: WhereDataEpochReason + + public init( + id: WhereDataEpochID, + parentIDs: [WhereDataEpochID], + revision: Int64, + changedAt: Date, + changedByDeviceID: RecordingDeviceID?, + reason: WhereDataEpochReason, + ) { + precondition(revision >= 0, "A data-epoch revision cannot be negative.") + precondition( + (revision == 0) == (reason == .initial), + "Only the implicit initial data epoch may use revision zero.", + ) + precondition( + (reason == .initial) == parentIDs.isEmpty, + "A destructive data epoch must identify at least one parent.", + ) + precondition( + (reason == .initial) == (changedByDeviceID == nil), + "A destructive data epoch must identify its issuing installation.", + ) + let canonicalParentIDs = parentIDs.sorted { + $0.rawValue.uuidString < $1.rawValue.uuidString + } + precondition( + Set(canonicalParentIDs).count == canonicalParentIDs.count, + "A data epoch cannot name the same parent twice.", + ) + precondition( + canonicalParentIDs.contains(id) == false, + "A data epoch cannot parent itself.", + ) + self.id = id + self.parentIDs = canonicalParentIDs + self.revision = revision + self.changedAt = changedAt + self.changedByDeviceID = changedByDeviceID + self.reason = reason + } + + public static let initial = WhereDataEpoch( + id: .initial, + parentIDs: [], + revision: 0, + changedAt: .distantPast, + changedByDeviceID: nil, + reason: .initial, + ) + + var isDestructive: Bool { + reason.isDestructive + } + + /// Validated account-generation resolution. `current.id` is the epoch stamped on ordinary + /// writes and can be a synthetic empty reset-conflict id; `realHeads` are the persisted + /// maximal nodes a later destructive rotation must causally join. + struct Resolution: Hashable { + let current: WhereDataEpoch + let realHeads: [WhereDataEpoch] + } + + /// Orders concurrent maximal heads. Reset wins over Replace because erasure is the stronger + /// privacy command. Between resets, the later erase boundary is more restrictive; immutable + /// event identity breaks the remaining tie without relying on delivery order. + static func isPreferredBefore(_ lhs: WhereDataEpoch, _ rhs: WhereDataEpoch) -> Bool { + if lhs.reason.conflictPriority != rhs.reason.conflictPriority { + return lhs.reason.conflictPriority < rhs.reason.conflictPriority + } + if lhs.reason == .accountReset, lhs.changedAt != rhs.changedAt { + return lhs.changedAt < rhs.changedAt + } + return lhs.id.rawValue.uuidString < rhs.id.rawValue.uuidString + } + + /// Validate the causal forest and return every maximal head. + /// + /// Descendants supersede every named parent, regardless of which concurrent sibling + /// previously resolved as canonical. A later semantic operation names this entire frontier, + /// causally joining everything it observed while leaving a genuinely concurrent, + /// not-yet-delivered command eligible when it arrives. + static func maximalHeads(in changes: [WhereDataEpoch]) throws -> [WhereDataEpoch] { + guard changes.allSatisfy({ + $0.id != initial.id && isReservedSyntheticID($0.id) == false + }) else { + // The implicit root and UUIDv8 synthetic namespace are reserved. Synthetic epochs + // are derived read state, never persisted events; accepting either identity on the + // wire would let a real row masquerade as resolver-owned authority. + throw RecordingPersistenceError.incompleteDataEpochHistory + } + let groupedByID = Dictionary(grouping: changes, by: \.id) + guard groupedByID.values.allSatisfy({ $0.count == 1 }) else { + throw RecordingPersistenceError.incompleteDataEpochHistory + } + var byID = groupedByID.compactMapValues(\.first) + byID[initial.id] = initial + + var parentIDs = Set() + for change in changes { + let canonicalParentIDs = change.parentIDs.sorted { + $0.rawValue.uuidString < $1.rawValue.uuidString + } + guard change.parentIDs.isEmpty == false, + change.parentIDs == canonicalParentIDs, + Set(change.parentIDs).count == change.parentIDs.count, + change.parentIDs.contains(change.id) == false + else { + throw RecordingPersistenceError.incompleteDataEpochHistory + } + let parents = change.parentIDs.compactMap { byID[$0] } + guard parents.count == change.parentIDs.count, + let maximumRevision = parents.map(\.revision).max(), + maximumRevision < Int64.max, + change.revision == maximumRevision + 1, + parents.allSatisfy({ change.changedAt >= $0.changedAt }) + else { + throw RecordingPersistenceError.incompleteDataEpochHistory + } + parentIDs.formUnion(change.parentIDs) + } + + return byID.values + .filter { parentIDs.contains($0.id) == false } + .sorted(by: isPreferredBefore) + } + + /// Resolve the current logical generation and retain the real causal frontier needed for a + /// later join. Two unjoined reset heads resolve to a deterministic empty UUIDv8 synthetic + /// epoch, so neither reset branch's rows can defeat the other's erase intent through a UUID + /// tie-break. UUIDv8 is reserved for this derived authority and is never a persisted event id. + static func resolve(in changes: [WhereDataEpoch]) throws -> Resolution { + let heads = try maximalHeads(in: changes) + guard let head = heads.max(by: isPreferredBefore) else { + preconditionFailure("The implicit data epoch must always form a causal head.") + } + let resetHeads = heads + .filter { $0.reason == .accountReset } + .sorted { $0.id.rawValue.uuidString < $1.id.rawValue.uuidString } + guard resetHeads.count > 1 else { + return Resolution(current: head, realHeads: heads) + } + + let syntheticID = resetConflictID(for: resetHeads) + guard syntheticID != initial.id, + changes.contains(where: { $0.id == syntheticID }) == false + else { + throw RecordingPersistenceError.incompleteDataEpochHistory + } + guard let maximumRevision = resetHeads.map(\.revision).max(), + maximumRevision < Int64.max, + let changedAt = resetHeads.map(\.changedAt).max(), + let issuer = resetHeads.max(by: isPreferredBefore)?.changedByDeviceID + else { + throw RecordingPersistenceError.incompleteDataEpochHistory + } + let synthetic = WhereDataEpoch( + id: syntheticID, + parentIDs: resetHeads.map(\.id), + revision: maximumRevision + 1, + changedAt: changedAt, + changedByDeviceID: issuer, + reason: .accountReset, + ) + return Resolution(current: synthetic, realHeads: heads) + } + + static func canonicalHead(in changes: [WhereDataEpoch]) throws -> WhereDataEpoch { + try resolve(in: changes).current + } + + /// Versioned, domain-separated digest locked by `WhereDataEpochTests`. Only reset-head ids + /// participate, keeping the synthetic empty generation stable when a weaker concurrent + /// Replace arrives while still changing it for every newly relevant reset. + private static func resetConflictID( + for resetHeads: [WhereDataEpoch], + ) -> WhereDataEpochID { + var hasher = SHA256() + hasher.update(data: Data("com.stuff.where.data-epoch.reset-conflict.v1".utf8)) + for head in resetHeads { + hasher.update(data: Data("\n\(head.id.rawValue.uuidString)".utf8)) + } + var bytes = Array(hasher.finalize().prefix(16)) + // UUIDv8 is the RFC-defined application-specific namespace. Persisted event ids come + // from UUID() (v4), so the version nibble makes synthetic read authority recognizable + // and rejectable without maintaining a registry of every possible reset frontier. + bytes[6] = (bytes[6] & 0x0F) | 0x80 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return WhereDataEpochID(rawValue: UUID(uuid: ( + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15], + ))) + } + + private static func isReservedSyntheticID(_ id: WhereDataEpochID) -> Bool { + let bytes = id.rawValue.uuid + return bytes.6 & 0xF0 == 0x80 + } +} diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 388039d9..ed02c2c7 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -8,8 +8,8 @@ import RegionKit /// All methods are `async throws` so the production CloudKit-backed /// implementation has somewhere to surface I/O errors. /// -/// All mutating methods (`add(sample:)`, `setRecordingDevice`, -/// `addRecordingPolicyChange`, `write(evidence:blob:)`, `setManualDay`, +/// All mutating methods (`add(sample:)`, recording profile/metadata/check-in/event writes, +/// `write(evidence:blob:)`, `setManualDay`, /// `clearManualDay`, `clear(in:)`, and the `EvidenceBlobStore` writers) /// MUST be called from inside a `perform { ... }` block — the block /// boundary is what owns the underlying write transaction. The @@ -28,6 +28,24 @@ public protocol WhereStore: Sendable { _ block: @Sendable () async throws -> T, ) async throws -> T + /// Run a mutation only if the account is still in `expectedDataEpochID`. Callers capture the + /// epoch before any suspension that informs the write; a reset/Replace crossing that work + /// then fails instead of admitting a stale decision into the new generation. + @discardableResult + func perform( + expectedDataEpochID: WhereDataEpochID, + _ block: @Sendable () async throws -> T, + ) async throws -> T + + /// Pin every read in `block` to one logical data epoch and verify that epoch and the durable + /// store generation are still current before returning. This is the multi-table read boundary + /// for policy decisions and backup export; a remote commit crossing its reads invalidates the + /// result through persistent history even if its notification has not arrived yet. + @discardableResult + func readSnapshot( + _ block: @Sendable () async throws -> T, + ) async throws -> T + /// A fresh stream that emits whenever committed data changes — once after /// every outermost `perform` transaction commits, and (for a CloudKit-backed /// store) on a remote import synced from another device. The payload is a @@ -40,22 +58,81 @@ public protocol WhereStore: Sendable { /// import, so a consumer that re-derives on each ping can't go stale. func changes() -> AsyncStream + /// Remote-import subset of ``changes()``. On-disk implementations emit only when another + /// process or CloudKit changes the store; local `perform` commits do not. Headless derived + /// outputs subscribe here so remote data refreshes them without duplicating the synchronous + /// reconciliation local writers already await. + func remoteChanges() -> AsyncStream + + /// Current account-wide logical generation. Rows from older epochs are retained only as + /// sync/audit history and never participate in normal reads. + func dataEpoch() async throws -> WhereDataEpoch + + /// Atomically erase the active epoch's synced rows and append a fresh destructive epoch. + /// Every subsequent write in the same transaction is stamped into the returned epoch. + /// Immutable device profiles remain global so a late/offline installation can still be + /// identified, but its old policy and user-data rows cannot affect the new generation. + func rotateDataEpoch( + reason: WhereDataEpochReason, + changedBy deviceID: RecordingDeviceID, + at date: Date, + ) async throws -> WhereDataEpoch + + /// Receipt inserted atomically with an import's rows. Lookup is by both the random token and + /// local installation identity; callers inspect the stamped epoch but treat a receipt in a + /// superseded epoch as proof that the physical save occurred. + func backupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws -> BackupImportReceipt? + + /// Insert an immutable import receipt. Must run inside `perform { ... }`. + func addBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws + + /// Remove an import receipt after its device-local recovery marker is durably committed. + /// Must run inside `perform { ... }`. + func removeBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws + func add(sample: LocationSample) async throws func samples(in interval: DateInterval) async throws -> [LocationSample] func allSamples() async throws -> [LocationSample] - /// Every synced device profile, including archived devices. Callers decide - /// whether archived rows belong in their surface. + /// Every assembled synced device read model, including archived devices. func recordingDevices() async throws -> [RecordingDevice] - /// Upsert one synced device profile by ``RecordingDevice/id``. Must run - /// inside `perform { ... }`. - func setRecordingDevice(_ device: RecordingDevice) async throws + /// Immutable installation profiles. + func recordingDeviceProfiles() async throws -> [RecordingDeviceProfile] + + /// Insert a profile, accepting an identical retry and rejecting conflicting contents for + /// an existing installation id. Must run inside `perform { ... }`. + func addRecordingDeviceProfile(_ profile: RecordingDeviceProfile) async throws + + /// Full append-only nickname timeline. Effective archive authority lives in + /// ``recordingPolicyChanges()``. + func recordingDeviceMetadataChanges() async throws -> [RecordingDeviceMetadataChange] + + /// Insert an immutable metadata event. Must run inside `perform { ... }`. + func addRecordingDeviceMetadataChange(_ change: RecordingDeviceMetadataChange) async throws + + /// Latest target-owned check-in for each installation. + func recordingDeviceCheckIns() async throws -> [RecordingDeviceCheckIn] + + /// Upsert one target-owned check-in, preserving a newer existing value during backup merge. + /// Must run inside `perform { ... }`. + func setRecordingDeviceCheckIn(_ checkIn: RecordingDeviceCheckIn) async throws /// Every append-only recording-policy event, oldest first. func recordingPolicyChanges() async throws -> [RecordingPolicyChange] - /// Add or update one policy event by id. Must run inside `perform { ... }`. + /// Insert one immutable policy event naming every causal head its command observed. An + /// identical retry is idempotent; a different value with the same id throws. Must run inside + /// `perform { ... }`. func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws func write(evidence: Evidence, blob: Data?) async throws @@ -89,10 +166,6 @@ public protocol WhereStore: Sendable { manualDays dayRange: ClosedRange, ) async throws - /// Erase every sample / evidence / manual entry in the store. Used by the - /// "replace" backup-import strategy to mirror the imported file exactly. - func clearAll() async throws - /// Every persisted dismissed data-resolution issue id. Used by the scanner /// to filter out already-dismissed issues (it only needs the ids). func dismissedIssueIDs() async throws -> Set @@ -142,6 +215,34 @@ public protocol WhereStore: Sendable { } extension WhereStore { + public func perform( + expectedDataEpochID: WhereDataEpochID, + _ block: @Sendable () async throws -> T, + ) async throws -> T { + try await perform { + guard try await (dataEpoch()).id == expectedDataEpochID else { + throw RecordingPersistenceError.dataEpochChanged + } + return try await block() + } + } + + public func readSnapshot( + _ block: @Sendable () async throws -> T, + ) async throws -> T { + let expected = try await (dataEpoch()).id + let result = try await block() + guard try await (dataEpoch()).id == expected else { + throw RecordingPersistenceError.dataEpochChanged + } + return result + } + + /// Stores without an external writer never emit remote changes. + public func remoteChanges() -> AsyncStream { + AsyncStream { $0.finish() } + } + /// Regions tracked out of the box, until the user chooses their own. The /// "no rows yet" fallback for ``trackedRegions()`` and the historical /// California / New York / Canada / European Union set. diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index 20ff9b2e..703e710a 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -1,7 +1,7 @@ import Foundation -/// The app's persisted user intent — onboarding completion, background-tracking -/// intent, and the reminder / daily-summary schedules — behind a `KeyValueStore` +/// The app's persisted user intent — onboarding completion and the reminder / +/// daily-summary schedules — behind a `KeyValueStore` /// so production uses `UserDefaults` and tests use an in-memory double. /// /// `store` is deliberately not defaulted: defaulting it to @@ -29,22 +29,6 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.hasOnboarded.rawValue) } } - /// Whether this installation has explicitly confirmed its initial - /// automatic-recording choice. Device-local rather than CloudKit-synced: - /// every installation must make its own decision before it registers a - /// synced recording policy. - public var hasConfirmedRecordingChoice: Bool { - get { store.bool(forKey: Keys.hasConfirmedRecordingChoice.rawValue) } - set { store.set(newValue, forKey: Keys.hasConfirmedRecordingChoice.rawValue) } - } - - /// Persisted intent to track in the background. Defaults to `true` so that, - /// once the user grants Always, tracking resumes automatically every launch. - public var wantsTracking: Bool { - get { store.object(forKey: Keys.wantsTracking.rawValue) as? Bool ?? true } - set { store.set(newValue, forKey: Keys.wantsTracking.rawValue) } - } - /// Whether the daily "log before the day ends" reminder is enabled. Defaults /// to `true` so the safety net is active out of the box. public var remindersEnabled: Bool { @@ -109,8 +93,8 @@ public final class WherePreferences { } /// Clear every persisted preference so the next launch behaves like a fresh - /// install: onboarding shows again, background tracking returns to its - /// default intent, and the reminder/summary schedules revert to defaults. + /// install: onboarding shows again and the reminder/summary schedules revert + /// to defaults. /// Removing the keys (rather than writing `false`/`0`) lets the /// default-valued getters report first-install state again. public func reset() { @@ -124,8 +108,6 @@ public final class WherePreferences { /// sync — adding a case is all it takes to have it reset. private enum Keys: String, CaseIterable { case hasOnboarded = "where.hasOnboarded" - case hasConfirmedRecordingChoice = "where.hasConfirmedRecordingChoice" - case wantsTracking = "where.wantsBackgroundTracking" case remindersEnabled = "where.remindersEnabled" case reminderHour = "where.reminderHour" case reminderMinute = "where.reminderMinute" diff --git a/Where/WhereCore/Sources/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift index 5f9feea7..01f92909 100644 --- a/Where/WhereCore/Sources/RegionAttribution.swift +++ b/Where/WhereCore/Sources/RegionAttribution.swift @@ -13,6 +13,30 @@ import RegionKit /// the tracked *set* actually changes, so reacting to every `changes()` ping /// stays cheap on the GPS hot path (a fetch + a set compare). final class RegionAttribution: RegionAttributing { + /// Serializes the background observer with explicit full-fan-out reconciliations. Actor + /// isolation alone would still be reentrant across the store read, so ownership is handed + /// directly to one waiter at a time. + private actor ReconciliationGate { + private var isOccupied = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if isOccupied { + await withCheckedContinuation { waiters.append($0) } + } else { + isOccupied = true + } + } + + func release() { + if waiters.isEmpty { + isOccupied = false + } else { + waiters.removeFirst().resume() + } + } + } + private struct State { var attributor: RegionAttributor var trackedIDs: Set @@ -22,24 +46,31 @@ final class RegionAttribution: RegionAttributing { private let store: any WhereStore private let state: OSAllocatedUnfairLock + private let reconciliationGate = ReconciliationGate() /// Set once in `init` and only cancelled in `deinit`, so there's no /// concurrent access to guard. private nonisolated(unsafe) var observer: Task? /// - Parameters: - /// - store: the source of tracked regions and the `changes()` signal. + /// - store: the source of tracked regions. + /// - changes: the committed-data signal this live attribution observes. /// - initial: the attributor for the tracked set as of construction (built /// by ``WhereServices/make(store:locationSource:)`` after reading the /// store, so there's no flash of the wrong set at launch). /// - trackedIDs: the region ids `initial` was built from. - init(store: any WhereStore, initial: RegionAttributor, trackedIDs: Set) { + init( + store: any WhereStore, + changes: AsyncStream, + initial: RegionAttributor, + trackedIDs: Set, + ) { self.store = store state = OSAllocatedUnfairLock(initialState: State( attributor: initial, trackedIDs: trackedIDs, )) observer = Task { [weak self] in - for await _ in store.changes() { + for await _ in changes { await self?.reconcile() } } @@ -67,9 +98,15 @@ final class RegionAttribution: RegionAttributing { /// Re-read the tracked regions and rebuild the attributor when the set /// changed. Cheap when nothing changed (a fetch + a set compare); the file - /// parse runs only on an actual change. Serialized by the single observer - /// task; also exposed so callers/tests can reconcile deterministically. + /// parse runs only on an actual change. Serialized across the background + /// observer and explicit full-fan-out callers. func reconcile() async { + await reconciliationGate.acquire() + await reconcileExclusively() + await reconciliationGate.release() + } + + private func reconcileExclusively() async { let tracked: Set do { tracked = try await store.trackedRegions() diff --git a/Where/WhereCore/Sources/Reporting/ReportReader.swift b/Where/WhereCore/Sources/Reporting/ReportReader.swift index 01c071be..afe3caca 100644 --- a/Where/WhereCore/Sources/Reporting/ReportReader.swift +++ b/Where/WhereCore/Sources/Reporting/ReportReader.swift @@ -36,15 +36,17 @@ public struct ReportReader: Sendable { /// is visibly waiting. public func yearReport(for year: Int) async throws -> YearReport { try await Self.logger.measure(.yearReport, budget: .seconds(1)) { - let interval = aggregator.yearInterval(year: year) - let samples = try await history.samples(in: interval) - let manuals = try await store.manualDays(in: dayRange(for: year)) - return aggregator.report( - for: year, - samples: samples, - manualDays: manuals, - attributor: attributor, - ) + try await store.readSnapshot { + let interval = aggregator.yearInterval(year: year) + let samples = try await history.samples(in: interval) + let manuals = try await store.manualDays(in: dayRange(for: year)) + return aggregator.report( + for: year, + samples: samples, + manualDays: manuals, + attributor: attributor, + ) + } } } @@ -56,27 +58,31 @@ public struct ReportReader: Sendable { /// raw); the `DaySamples` grouping is itself deferred until a detector asks. public func dataIssueReads(for year: Int) async throws -> DataIssueReads { try await Self.logger.measure(.dataIssueReads, budget: .seconds(2)) { - let samples = try await history.samples(in: aggregator.yearInterval(year: year)) - let manuals = try await store.manualDays(in: dayRange(for: year)) - let report = aggregator.report( - for: year, - samples: samples, - manualDays: manuals, - attributor: attributor, - ) - let otherLocations = aggregator.locations( - in: .other, - samples: samples, - attributor: attributor, - ) - let otherDayCoordinates = Dictionary( - uniqueKeysWithValues: otherLocations.map { ($0.day, $0.points.map(\.coordinate)) }, - ) - return DataIssueReads( - report: report, - otherDayCoordinates: otherDayCoordinates, - daySamples: DaySamples(samples: samples, calendar: aggregator.calendar), - ) + try await store.readSnapshot { + let samples = try await history.samples(in: aggregator.yearInterval(year: year)) + let manuals = try await store.manualDays(in: dayRange(for: year)) + let report = aggregator.report( + for: year, + samples: samples, + manualDays: manuals, + attributor: attributor, + ) + let otherLocations = aggregator.locations( + in: .other, + samples: samples, + attributor: attributor, + ) + let otherDayCoordinates = Dictionary( + uniqueKeysWithValues: otherLocations.map { + ($0.day, $0.points.map(\.coordinate)) + }, + ) + return DataIssueReads( + report: report, + otherDayCoordinates: otherDayCoordinates, + daySamples: DaySamples(samples: samples, calendar: aggregator.calendar), + ) + } } } diff --git a/Where/WhereCore/Sources/Resources/Localizable.xcstrings b/Where/WhereCore/Sources/Resources/Localizable.xcstrings index be757567..240dd3e2 100644 --- a/Where/WhereCore/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereCore/Sources/Resources/Localizable.xcstrings @@ -1,6 +1,39 @@ { "sourceLanguage" : "en", "strings" : { + "backup.error.committedCleanup.merge" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The backup was imported, but Where could not finish recording cleanup. Recording remains off. Close and reopen Where to finish safely; do not import the backup again." + } + } + } + }, + "backup.error.committedCleanup.replace" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The backup was restored, but Where could not remove pending location fixes. Recording remains off. Close and reopen Where to finish safely; do not restore the backup again." + } + } + } + }, + "backup.error.invalidRecordingData" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This backup contains invalid recording-device data and can't be imported." + } + } + } + }, "backup.error.manifestMissing" : { "extractionState" : "manual", "localizations" : { @@ -12,6 +45,17 @@ } } }, + "backup.error.recoveryRequired" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where must finish cleanup from the previous backup before another backup can be imported." + } + } + } + }, "backup.error.unsupportedFormatVersion" : { "extractionState" : "manual", "localizations" : { @@ -57,6 +101,149 @@ } } }, + "dataReset.error.committedCleanup" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your synced data was erased, but pending location fixes could not be removed. Recording remains off. Close and reopen Where, then retry reset." + } + } + } + }, + "recording.error.conflictingImmutableRecord" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A synced recording event conflicts with one already stored." + } + } + } + }, + "recording.error.corruptPolicyHistory" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A synced recording-setting event is incomplete or invalid." + } + } + } + }, + "recording.error.currentDeviceNotRegistered" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device has not finished registering for automatic recording." + } + } + } + }, + "recording.error.currentDevicePolicyUnknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device's recording setting has not finished syncing." + } + } + } + }, + "recording.error.dataEpochChanged" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The account data changed while this operation was in progress. Please try again." + } + } + } + }, + "recording.error.dataEpochRevisionExhausted" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The account data history cannot accept another generation." + } + } + } + }, + "recording.error.deviceNotFound" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This recording device is no longer available." + } + } + } + }, + "recording.error.devicePolicyUnknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This recording device's setting has not finished syncing." + } + } + } + }, + "recording.error.incompleteDataEpochHistory" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The account data generation has not finished syncing." + } + } + } + }, + "recording.error.incompletePolicyHistory" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device's recording-setting history has not finished syncing." + } + } + } + }, + "recording.error.revisionExhausted" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device's recording history cannot accept another change." + } + } + } + }, + "recording.error.rewriteInProgress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Another recording-data reset or import is already in progress." + } + } + } + }, "reminder.notification.body" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereCore/Sources/WhereServices+Intents.swift b/Where/WhereCore/Sources/WhereServices+Intents.swift index 1ae86a55..720be02e 100644 --- a/Where/WhereCore/Sources/WhereServices+Intents.swift +++ b/Where/WhereCore/Sources/WhereServices+Intents.swift @@ -1,11 +1,8 @@ import Foundation extension WhereServices { - /// Assemble the App Intents stack (Siri, Spotlight, Shortcuts — executing - /// in the app's own process) over the **same store, live attribution, - /// aggregation calendar, and clock `base` already holds** — only the - /// location source differs (``IdleLocationSource``, so resolving an - /// intent never starts GPS). + /// Hand App Intents (Siri, Spotlight, Shortcuts — executing in the app's own process) the + /// exact assembled stack the app already owns. /// /// This is the *only* way an intents stack is built, and it is /// deliberately synchronous and non-throwing: deriving from an assembled @@ -18,23 +15,12 @@ extension WhereServices { /// intent write pings the same `changes()` signal the running UI /// refreshes from. /// - /// The notification and widget seams come from `base` for the same reason - /// the attributor does: a stack derived from the demo world is built out of - /// no-ops, and minting real ones here would let a demo intent post a real - /// notification or reload the user's widgets. + /// Sharing the value also shares its actor references, especially the one + /// `DeviceRecordingController` that owns this installation's check-in. Rebuilding a nominally + /// GPS-free stack would create a second controller capable of acknowledging `.recording` + /// through an idle source and racing the app's real authority. public static func forIntents(sharingStoreOf base: WhereServices) -> WhereServices { - WhereServices( - store: base.store, - locationSource: IdleLocationSource(), - currentDevice: base.currentDevice, - attributor: base.attributor, - aggregator: base.aggregator, - reminderScheduler: base.reminderScheduler, - summaryScheduler: base.summaryScheduler, - issueAlertScheduler: base.issueAlertScheduler, - widgetRefresher: base.widgetRefresher, - now: base.now, - ) + base } /// Test seam: wraps an in-memory `store` in the same GPS-free service @@ -50,11 +36,12 @@ extension WhereServices { try await make( store: store, locationSource: IdleLocationSource(), - currentDevice: .preview, + installationContext: .testing, reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), + importRecoveryPersistence: .none, now: now, ) } diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 2d4b4433..3db59f87 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -9,8 +9,9 @@ import RegionKit /// (`await services.journal.…`, `await services.reports.…`). /// /// The only cross-cutting operation that doesn't belong to a single -/// collaborator is `reset()` (stop GPS, then wipe the store) — it lives here so -/// teardown stays in Core rather than leaking into the UI layer. +/// collaborator is `reset()` (pause GPS, erase synced user data, retire recording +/// authority, then discard pending fixes) — it lives here so teardown stays in +/// Core rather than leaking into the UI layer. public struct WhereServices: Sendable { /// Pure reads: `YearReport` + location projections. public let reports: ReportReader @@ -68,9 +69,12 @@ public struct WhereServices: Sendable { /// The clock the stack was built with, retained so a derived stack can't /// diverge from an injected test/preview clock. let now: @Sendable () -> Date - /// Installation identity used to stamp automatic samples. Retained so a - /// derived App Intents stack preserves the same composition value. - let currentDevice: CurrentRecordingDevice + /// Device-local installation identity and its explicitly confirmed first policy. + /// Retained as one composition value so registration, sample attribution, and a derived + /// App Intents stack cannot accidentally describe different installations. + let installationContext: InstallationRecordingContext + /// Owns the remote-import observation task for this service lifetime. + private let remoteDataChangeReconciler: RemoteDataChangeReconciler /// Synchronous assembly with an explicitly-provided `attributor` (default: /// the historical four via `RegionAttributor.shared`). For **tests and /// previews** — hence `@_spi(Testing)` — which build in-memory stacks without @@ -87,7 +91,7 @@ public struct WhereServices: Sendable { public init( store: any WhereStore, locationSource: any LocationSource, - currentDevice: CurrentRecordingDevice = .preview, + installationContext: InstallationRecordingContext = .testing, attributor: any RegionAttributing = RegionAttributor.shared, aggregator: DayAggregator = DayAggregator(), reminderScheduler: any LoggingReminderScheduling = NoopLoggingReminderScheduler(), @@ -95,9 +99,11 @@ public struct WhereServices: Sendable { issueAlertScheduler: any DataIssueAlertScheduling = NoopDataIssueAlertScheduler(), widgetRefresher: any WidgetTimelineRefreshing = NoopWidgetTimelineRefresher(), locationOutbox: any LocationOutbox = NoOpLocationOutbox(), + importRecoveryPersistence: BackupCoordinator.ImportRecoveryPersistence = .none, activitySummaryGenerator: any ActivitySummaryGenerating = FoundationModelSummaryGenerator(), now: @escaping @Sendable () -> Date = { Date() }, ) { + let currentDevice = installationContext.currentDevice let reports = ReportReader(store: store, aggregator: aggregator, attributor: attributor) let evidence = EvidenceReader(store: store, aggregator: aggregator) // Built before the reconcilers that consume it: the reminder reconciler @@ -147,6 +153,21 @@ public struct WhereServices: Sendable { calendar: aggregator.calendar, now: now, ) + let liveAttribution = attributor as? RegionAttribution + let reconcileAllDerivedData: @Sendable () async -> Void = { + // Remote, backup, and recording-policy writes can change the tracked set at the same + // time as the data being rebuilt. Await the shared live attributor first so every + // downstream projection starts from current attribution instead of racing its + // independent store-change observer. + if let liveAttribution { + await liveAttribution.reconcile() + } + await resolution.invalidate() + await reminders.reconcile() + await summary.reconcile() + await issueAlerts.reconcile() + await widgets.publish() + } // After each committed GPS persist, reconcile the badge/reminders and // republish the widget snapshot. A live single sample uses the cheap // change-detection unless a drain also re-persisted other days; a @@ -183,8 +204,13 @@ public struct WhereServices: Sendable { let recording = DeviceRecordingController( store: store, ingestor: ingestor, - currentDevice: currentDevice, + installationContext: installationContext, now: now, + onPolicyChanged: { + // A cutoff can remove already-materialized history, so every derived output + // must rebuild rather than waiting for its normal freshness window. + await reconcileAllDerivedData() + }, ) let journal = DayJournal( store: store, @@ -193,13 +219,38 @@ public struct WhereServices: Sendable { issueAlerts: issueAlerts, issueScanner: resolution, widgets: widgets, + currentDeviceID: currentDevice.id, + now: now, ) - // An import changes day data, so it reuses the journal's post-day-change - // reconcile (scanner invalidate + badge/notification reconcile + widget - // publish) rather than duplicating that fan-out. let backup = BackupCoordinator( store: store, - onImport: { await journal.reconcileAfterDayChange() }, + currentDeviceID: currentDevice.id, + now: now, + importLifecycle: .init( + prepare: { _ in try await recording.pause() }, + didCommit: { strategy in + do { + try await recording.resumeAfterImport( + discardPendingSamples: strategy == .replace, + ) + } catch { + // The data transaction committed even though privacy-critical sidecar + // cleanup did not. Rebuild every projection before surfacing that honest + // partial-success error; never leave widgets/notifications on old data. + await reconcileAllDerivedData() + throw error + } + await reconcileAllDerivedData() + }, + didRollBack: { _ in await recording.resumeAfterImportRollback() }, + ), + importRecoveryPersistence: importRecoveryPersistence, + ) + // Local writers await their focused fan-out above. A CloudKit/sibling-process import has + // no local caller, so observe the remote-only stream and rebuild every derived output. + let remoteDataChangeReconciler = RemoteDataChangeReconciler( + changes: store.remoteChanges(), + reconcile: reconcileAllDerivedData, ) let recentActivity = RecentActivitySummarizer( store: store, @@ -230,7 +281,8 @@ public struct WhereServices: Sendable { self.issueAlertScheduler = issueAlertScheduler self.widgetRefresher = widgetRefresher self.now = now - self.currentDevice = currentDevice + self.installationContext = installationContext + self.remoteDataChangeReconciler = remoteDataChangeReconciler } /// Assemble services whose attributor is derived from the store's **tracked @@ -246,19 +298,21 @@ public struct WhereServices: Sendable { public static func make( store: any WhereStore, locationSource: any LocationSource, - currentDevice: CurrentRecordingDevice, + installationContext: InstallationRecordingContext, aggregator: DayAggregator = DayAggregator(), reminderScheduler: any LoggingReminderScheduling, summaryScheduler: any DailySummaryScheduling, issueAlertScheduler: any DataIssueAlertScheduling, widgetRefresher: any WidgetTimelineRefreshing, locationOutbox: any LocationOutbox = NoOpLocationOutbox(), + importRecoveryPersistence: BackupCoordinator.ImportRecoveryPersistence, activitySummaryGenerator: any ActivitySummaryGenerating = FoundationModelSummaryGenerator(), now: @escaping @Sendable () -> Date = { Date() }, ) async throws -> WhereServices { let tracked = try await store.trackedRegions() let attribution = RegionAttribution( store: store, + changes: store.changes(), // Canonical order (not `Array(Set)`) so the attributor's first-match // priority is deterministic and matches the catalog order. initial: RegionAttributor(for: Region.inCanonicalOrder(tracked)), @@ -267,7 +321,7 @@ public struct WhereServices: Sendable { return WhereServices( store: store, locationSource: locationSource, - currentDevice: currentDevice, + installationContext: installationContext, attributor: attribution, aggregator: aggregator, reminderScheduler: reminderScheduler, @@ -275,6 +329,7 @@ public struct WhereServices: Sendable { issueAlertScheduler: issueAlertScheduler, widgetRefresher: widgetRefresher, locationOutbox: locationOutbox, + importRecoveryPersistence: importRecoveryPersistence, activitySummaryGenerator: activitySummaryGenerator, now: now, ) @@ -310,37 +365,50 @@ public struct WhereServices: Sendable { /// (upserts + removals-by-omission) is a single atomic transaction that /// pings `changes()` once. public func setPrimaryRegions(_ regions: [PrimaryRegion]) async throws { - try await store.perform { + let epochID = try await (store.dataEpoch()).id + try await store.perform(expectedDataEpochID: epochID) { try await store.setPrimaryRegions(regions) } } - /// Return the services to a clean slate for the app's "erase all data & - /// reset" teardown: quiesce GPS ingestion (stop monitoring, refuse further - /// samples, await any in-flight write, and drop the retry backlog) so - /// nothing can write into the store as it's wiped, then erase everything - /// (which also reconciles the badge/reminders and republishes an empty - /// widget snapshot). + /// Return the services to a clean slate for the app's "erase all data & reset" teardown: + /// pause GPS ingestion, atomically erase user data and retire this installation's authority, + /// then discard its pending sample backlog after the transaction commits. /// /// This is the one inherently cross-collaborator operation; keeping it here - /// keeps teardown ordering in Core rather than the UI. Quiescing before the - /// wipe is what makes the erase stick: a plain `stop()` would leave the - /// ingestion loop and its retry queue able to repopulate the store. Throws - /// on persistence failure so the caller can surface it rather than silently - /// half-erasing. + /// keeps teardown ordering in Core rather than the UI. A failed transaction resumes the + /// exact old authority and backlog. Throws on persistence failure so the caller can surface + /// it rather than silently half-erasing. public func reset() async throws { - await recording.quiesce() + try await recording.pause() do { try await journal.eraseAllData() } catch { await recording.resumeAfterFailedReset() throw error } - // `eraseAllData()` commits, which pings `store.changes()` and the - // scanner self-invalidates off it — but that observation is async. Drop - // the cache inline too so it's provably empty by the time `reset()` - // returns rather than racing the observer; this is the deterministic - // half of that pair, not redundant with it. + // The erase committed even if sidecar cleanup below fails. Refresh every derived + // projection that `DayJournal` does not already own before reporting that partial result. await resolution.invalidate() + await summary.reconcile() + do { + try await recording.finishReset() + } catch { + throw ResetCleanupError(underlying: error) + } + } + + /// Synced data committed as erased, but the local raw-location sidecar could not be removed. + /// The installation context is deliberately retained so retrying reset can finish safely. + public struct ResetCleanupError: LocalizedError, @unchecked Sendable { + public let underlying: any Error + + public init(underlying: any Error) { + self.underlying = underlying + } + + public var errorDescription: String? { + String(localized: .dataResetErrorCommittedCleanup) + } } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift index e333b02c..d5586567 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift @@ -86,33 +86,35 @@ public struct WidgetDataReader: Sendable { /// that day's year from the store. Same aggregation rules as the app's /// year report, so the widget and the app never disagree on a count. public func snapshot(asOf date: Date) async throws -> WidgetSnapshot { - let calendar = aggregator.calendar - let startOfDay = calendar.startOfDay(for: date) - let calendarDay = CalendarDay(from: date, in: calendar) - let year = calendarDay.year - let interval = aggregator.yearInterval(year: year) - let dayRange = CalendarDay.yearRange(year) - let samples = try await history.samples(in: interval) - let manualDays = try await store.manualDays(in: dayRange) - let report = aggregator.report( - for: year, - samples: samples, - manualDays: manualDays, - attributor: attributor, - ) - let dayRegions = report.days - .first { $0.day == calendarDay }? - .regions ?? [] - var appearances: [Region: RegionAppearance] = [:] - for primary in try await store.primaryRegions() { - if let appearance = primary.appearance { appearances[primary.region] = appearance } + try await store.readSnapshot { + let calendar = aggregator.calendar + let startOfDay = calendar.startOfDay(for: date) + let calendarDay = CalendarDay(from: date, in: calendar) + let year = calendarDay.year + let interval = aggregator.yearInterval(year: year) + let dayRange = CalendarDay.yearRange(year) + let samples = try await history.samples(in: interval) + let manualDays = try await store.manualDays(in: dayRange) + let report = aggregator.report( + for: year, + samples: samples, + manualDays: manualDays, + attributor: attributor, + ) + let dayRegions = report.days + .first { $0.day == calendarDay }? + .regions ?? [] + var appearances: [Region: RegionAppearance] = [:] + for primary in try await store.primaryRegions() { + if let appearance = primary.appearance { appearances[primary.region] = appearance } + } + return WidgetSnapshot( + day: startOfDay, + year: year, + dayRegions: dayRegions, + totals: report.totals, + appearances: appearances, + ) } - return WidgetSnapshot( - day: startOfDay, - year: year, - dayRegions: dayRegions, - totals: report.totals, - appearances: appearances, - ) } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift index 2866ec21..380384a7 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift @@ -87,6 +87,21 @@ public actor WidgetSnapshotPublisher { regionCount: snapshot.dayRegions.count, ) } + } catch let error as RecordingPersistenceError { + // Epoch/policy gaps mean a destructive CloudKit change may already be known even + // though its complete rows have not arrived. Keeping the last good snapshot would + // continue exposing history the user erased, so publish an honest empty value + // until a later remote-change reconcile can build the new generation. + let date = now() + let snapshot = WidgetSnapshot( + day: calendar.startOfDay(for: date), + year: CalendarDay(from: date, in: calendar).year, + dayRegions: [], + totals: [:], + ) + await widgetRefresher.publish(snapshot) + lastPublished = PublishedWidgetSnapshot(snapshot: snapshot, publishedAt: date) + Self.logger { .buildFailed(description: error.localizedDescription) } } catch { Self.logger { .buildFailed(description: error.localizedDescription) } } diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 2dfc1f5d..9c160717 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -3,16 +3,16 @@ import RegionKit import Testing @testable import WhereCore -/// Covers export/import round-trips and the post-import `onImport` hook the +/// Covers export/import round-trips and the post-commit lifecycle hook the /// coordinator invokes once new data lands. struct BackupCoordinatorTests { private struct Harness { let coordinator: BackupCoordinator let store: SwiftDataStore - let onImport: HookSpy + let didCommit: HookSpy } - /// Records how many times the coordinator invoked its `onImport` hook, so a + /// Records how many times the coordinator invoked its commit hook, so a /// test can assert an import triggers the (composition-root-supplied) /// badge / notification / widget reconcile exactly once. private actor HookSpy { @@ -22,14 +22,57 @@ struct BackupCoordinatorTests { } } + private struct CleanupFailure: Error {} + + private actor CleanupSpy { + private var shouldFail = true + private(set) var count = 0 + + func run() throws { + count += 1 + if shouldFail { throw CleanupFailure() } + } + + func allowSuccess() { + shouldFail = false + } + } + + private actor RecoveryPersistenceSpy { + private(set) var recovery: BackupCoordinator.DurableImportRecovery? + + init(_ recovery: BackupCoordinator.DurableImportRecovery? = nil) { + self.recovery = recovery + } + + nonisolated var persistence: BackupCoordinator.ImportRecoveryPersistence { + BackupCoordinator.ImportRecoveryPersistence( + load: { await self.recovery }, + save: { await self.save($0) }, + recordOnboardingCompletion: { _ in }, + ) + } + + private func save(_ recovery: BackupCoordinator.DurableImportRecovery?) { + self.recovery = recovery + } + } + private static func makeHarness() throws -> Harness { let store = try SwiftDataStore.inMemory() let hook = HookSpy() let coordinator = BackupCoordinator( store: store, - onImport: { await hook.run() }, + currentDeviceID: recordingDeviceID, + now: { Date(timeIntervalSinceReferenceDate: 1000) }, + importLifecycle: .init( + prepare: { _ in }, + didCommit: { _ in await hook.run() }, + didRollBack: { _ in }, + ), + importRecoveryPersistence: .none, ) - return Harness(coordinator: coordinator, store: store, onImport: hook) + return Harness(coordinator: coordinator, store: store, didCommit: hook) } private static let evidence = Evidence( @@ -64,27 +107,44 @@ struct BackupCoordinatorTests { regions: [.newYork], )) try await store.restoreDismissedIssue(dismissal) - try await store.setRecordingDevice(RecordingDevice( + try await store.addRecordingDeviceProfile(RecordingDeviceProfile( id: recordingDeviceID, systemName: "iPad", - nickname: "Travel iPad", kind: .tablet, registeredAt: dismissal.dismissedAt, + registrationEpochID: .initial, + )) + try await store.addRecordingDeviceMetadataChange(RecordingDeviceMetadataChange( + id: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!, + deviceID: recordingDeviceID, + revision: 0, + changedAt: dismissal.dismissedAt, + changedByDeviceID: recordingDeviceID, + nickname: "Travel iPad", + )) + try await store.setRecordingDeviceCheckIn(RecordingDeviceCheckIn( + deviceID: recordingDeviceID, + revision: 0, lastSeenAt: dismissal.dismissedAt, - archivedAt: nil, + appliedAt: dismissal.dismissedAt, lastAppliedPolicyChangeID: recordingPolicyID, status: .recording, )) try await store.addRecordingPolicyChange(RecordingPolicyChange( id: recordingPolicyID, deviceID: recordingDeviceID, + parentIDs: [], + revision: 0, + issuedAt: dismissal.dismissedAt, + issuedByDeviceID: recordingDeviceID, effectiveAt: dismissal.dismissedAt, - isEnabled: true, + state: .on, + reason: .initialRegistration, )) } } - @Test func exportThenMergeImportReproducesEveryTable() async throws { + @Test func exportThenMergeImportReproducesRestorableTables() async throws { let source = try Self.makeHarness() try await Self.seed(source.store) @@ -108,12 +168,288 @@ struct BackupCoordinatorTests { #expect(try await destination.store.allDismissedIssues() == source.store .allDismissedIssues()) #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) - #expect(try await destination.store.recordingDevices() == source.store.recordingDevices()) - #expect(try await destination.store.recordingPolicyChanges() == source.store - .recordingPolicyChanges()) + #expect(try await destination.store.recordingDeviceProfiles() == source.store + .recordingDeviceProfiles()) + #expect(try await destination.store.recordingDeviceMetadataChanges() == source.store + .recordingDeviceMetadataChanges()) + // Check-ins prove that a particular installation applied policy and cleared its own + // outbox. A backup cannot safely reproduce that proof on another installation. + #expect(try await destination.store.recordingDeviceCheckIns().isEmpty) + let policies = try await destination.store.recordingPolicyChanges() + let sourcePolicies = try await source.store.recordingPolicyChanges() + #expect(Array(policies.dropLast()) == sourcePolicies) + #expect(policies.last?.parentIDs == [Self.recordingPolicyID]) + #expect(policies.last?.state == .off) + #expect(policies.last?.reason == .backupMerge) #expect(try await destination.store.evidenceBlob(for: Self.evidence.id) == Self.blob) - // An import that lands new data runs the post-import hook once. - #expect(await destination.onImport.count == 1) + // An import that lands new data runs the post-commit hook once. + #expect(await destination.didCommit.count == 1) + } + + @Test func exportOmitsTargetOwnedRecordingCheckIns() async throws { + let source = try Self.makeHarness() + try await Self.seed(source.store) + + let url = try await source.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let archive = try BackupService().readArchive(at: url).archive + #expect(archive.recordingDeviceCheckIns.isEmpty) + } + + @Test func importTreatsLegacyRecordingCheckInsAsInertHistory() async throws { + let profile = RecordingDeviceProfile( + id: Self.recordingDeviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: Self.dismissal.dismissedAt, + registrationEpochID: .initial, + ) + let policy = RecordingPolicyChange( + id: Self.recordingPolicyID, + deviceID: Self.recordingDeviceID, + parentIDs: [], + revision: 0, + issuedAt: Self.dismissal.dismissedAt, + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: Self.dismissal.dismissedAt, + state: .off, + reason: .userCommand, + ) + let checkIn = RecordingDeviceCheckIn( + deviceID: Self.recordingDeviceID, + revision: 0, + lastSeenAt: Self.dismissal.dismissedAt, + appliedAt: Self.dismissal.dismissedAt, + lastAppliedPolicyChangeID: Self.recordingPolicyID, + status: .off, + ) + let url = try BackupService().makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [profile], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [checkIn], + recordingPolicyChanges: [policy], + blobs: [:], + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let destination = try Self.makeHarness() + _ = try await destination.coordinator.importBackup(from: url, strategy: .merge) + + #expect(try await destination.store.recordingDeviceProfiles() == [profile]) + let policies = try await destination.store.recordingPolicyChanges() + #expect(policies.first == policy) + #expect(policies.last?.parentIDs == [policy.id]) + #expect(policies.last?.state == .off) + #expect(policies.last?.reason == .backupMerge) + #expect(try await destination.store.recordingDeviceCheckIns().isEmpty) + } + + @Test func mergeGivesANewProfileWithoutPolicyAnOffRoot() async throws { + let profile = RecordingDeviceProfile( + id: Self.recordingDeviceID, + systemName: "Travel iPad", + kind: .tablet, + registeredAt: Self.dismissal.dismissedAt, + registrationEpochID: .initial, + ) + let url = try BackupService().makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [profile], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [], + blobs: [:], + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let destination = try Self.makeHarness() + _ = try await destination.coordinator.importBackup(from: url, strategy: .merge) + + #expect(try await destination.store.recordingDeviceProfiles() == [profile]) + let policy = try #require(try await destination.store.recordingPolicyChanges().first) + #expect(policy.deviceID == profile.id) + #expect(policy.revision == 0) + #expect(policy.parentIDs.isEmpty) + #expect(policy.state == .off) + #expect(policy.reason == .backupMerge) + } + + @Test func mergeImportReassertsTheAuthorityThatExistedBeforeImportedPolicy() async throws { + let root = try RecordingPolicyChange( + id: #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")), + deviceID: Self.recordingDeviceID, + parentIDs: [], + revision: 0, + issuedAt: Date(timeIntervalSinceReferenceDate: 100), + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: Date(timeIntervalSinceReferenceDate: 100), + state: .on, + reason: .initialRegistration, + ) + let off = try RecordingPolicyChange( + id: #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")), + deviceID: Self.recordingDeviceID, + parentIDs: [root.id], + revision: 1, + issuedAt: Date(timeIntervalSinceReferenceDate: 200), + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: Date(timeIntervalSinceReferenceDate: 200), + state: .off, + reason: .userCommand, + ) + let importedOn = try RecordingPolicyChange( + id: #require(UUID(uuidString: "30000000-0000-0000-0000-000000000000")), + deviceID: Self.recordingDeviceID, + parentIDs: [off.id], + revision: 2, + issuedAt: Date(timeIntervalSinceReferenceDate: 300), + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: Date(timeIntervalSinceReferenceDate: 300), + state: .on, + reason: .userCommand, + ) + let url = try BackupService().makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [root, off, importedOn], + blobs: [:], + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let destination = try Self.makeHarness() + try await destination.store.perform { + try await destination.store.addRecordingPolicyChange(root) + try await destination.store.addRecordingPolicyChange(off) + } + + _ = try await destination.coordinator.importBackup(from: url, strategy: .merge) + + let timeline = try #require(try await RecordingPolicyChange.canonicalTimeline( + in: destination.store.recordingPolicyChanges(), + )) + #expect(timeline.map(\.state) == [.on, .off, .on, .off]) + #expect(timeline.last?.parentIDs == [importedOn.id]) + #expect(timeline.last?.reason == .backupMerge) + } + + @Test func mergePreservesImplicitArchiveAuthorityAfterReplace() async throws { + let importedOn = try RecordingPolicyChange( + id: #require(UUID(uuidString: "40000000-0000-0000-0000-000000000000")), + deviceID: Self.recordingDeviceID, + parentIDs: [], + revision: 0, + issuedAt: Date(timeIntervalSinceReferenceDate: 100), + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: Date(timeIntervalSinceReferenceDate: 100), + state: .on, + reason: .initialRegistration, + ) + let emptyURL = try BackupService().makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [], + blobs: [:], + ) + defer { try? FileManager.default.removeItem(at: emptyURL.deletingLastPathComponent()) } + let mergeURL = try BackupService().makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [importedOn], + blobs: [:], + ) + defer { try? FileManager.default.removeItem(at: mergeURL.deletingLastPathComponent()) } + + let destination = try Self.makeHarness() + try await destination.store.perform { + try await destination.store.addRecordingDeviceProfile(RecordingDeviceProfile( + id: Self.recordingDeviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: Date(timeIntervalSinceReferenceDate: 50), + registrationEpochID: .initial, + )) + } + _ = try await destination.coordinator.importBackup(from: emptyURL, strategy: .replace) + #expect(try await destination.store.dataEpoch().isDestructive) + #expect(try await destination.store.recordingPolicyChanges().isEmpty) + + _ = try await destination.coordinator.importBackup(from: mergeURL, strategy: .merge) + + let timeline = try #require(try await RecordingPolicyChange.canonicalTimeline( + in: destination.store.recordingPolicyChanges(), + )) + #expect(timeline.map(\.state) == [.on, .archived]) + #expect(timeline.last?.parentIDs == [importedOn.id]) + #expect(timeline.last?.reason == .backupMerge) + } + + @Test func replaceAddsABarrierForAPolicyOnlyDevice() async throws { + let policyOnlyDeviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + let importedOn = try RecordingPolicyChange( + id: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), + deviceID: policyOnlyDeviceID, + parentIDs: [], + revision: 0, + issuedAt: Date(timeIntervalSinceReferenceDate: 100), + issuedByDeviceID: policyOnlyDeviceID, + effectiveAt: Date(timeIntervalSinceReferenceDate: 100), + state: .on, + reason: .initialRegistration, + ) + let url = try BackupService().makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [importedOn], + blobs: [:], + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let destination = try Self.makeHarness() + _ = try await destination.coordinator.importBackup(from: url, strategy: .replace) + + var policies = try await destination.store.recordingPolicyChanges() + #expect(policies.map(\.state) == [.on, .off]) + #expect(policies.last?.parentIDs == [importedOn.id]) + #expect(policies.last?.reason == .backupReplace) + + try await destination.store.perform { + try await destination.store.addRecordingDeviceProfile(RecordingDeviceProfile( + id: policyOnlyDeviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: Date(timeIntervalSinceReferenceDate: 100), + registrationEpochID: .initial, + )) + } + policies = try await destination.store.recordingPolicyChanges() + let device = try #require(try await destination.store.recordingDevices().first) + #expect(device.id == policyOnlyDeviceID) + #expect(policies.last?.deviceID == device.id) + #expect(policies.last?.state == .off) } @Test func mergeImportKeepsPreexistingRows() async throws { @@ -148,7 +484,7 @@ struct BackupCoordinatorTests { regions: [.canada], )) // A preexisting dismissal that the file doesn't contain must be wiped - // by `.replace` so the device mirrors the file exactly. + // by `.replace` so synced user data mirrors the file. try await destination.store.restoreDismissedIssue(DismissedIssue( id: .missingDays(start: CalendarDay(year: 2026, month: 1, day: 2)), dismissedAt: Date(timeIntervalSince1970: 1), @@ -281,23 +617,185 @@ struct BackupCoordinatorTests { } /// Regression guard: an import rewrites day data, so the coordinator must - /// invoke its `onImport` hook once new data lands — the composition root + /// invoke its commit hook once new data lands — the composition root /// wires that hook to the badge / notification / widget reconcile, so /// skipping it leaves the home-screen badge and issues alert stuck at their /// pre-import values (the "badge stuck at 157 after replace import" bug). /// The end-to-end badge recount is asserted in `WhereServicesTests`. - @Test func replaceImportInvokesTheOnImportHook() async throws { + @Test func replaceImportInvokesTheCommitHook() async throws { let source = try Self.makeHarness() try await Self.seed(source.store) let url = try await source.coordinator.exportBackup() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let destination = try Self.makeHarness() - #expect(await destination.onImport.count == 0) + #expect(await destination.didCommit.count == 0) _ = try await destination.coordinator.importBackup(from: url, strategy: .replace) - #expect(await destination.onImport.count == 1) + #expect(await destination.didCommit.count == 1) + } + + @Test func committedCleanupFailureBlocksReimportUntilCleanupRetrySucceeds() async throws { + let source = try Self.makeHarness() + let url = try await source.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let store = try SwiftDataStore.inMemory() + let cleanup = CleanupSpy() + let coordinator = BackupCoordinator( + store: store, + currentDeviceID: Self.recordingDeviceID, + now: { Date(timeIntervalSinceReferenceDate: 1000) }, + importLifecycle: .init( + prepare: { _ in }, + didCommit: { _ in try await cleanup.run() }, + didRollBack: { _ in }, + ), + importRecoveryPersistence: .none, + ) + + let committedError = await #expect( + throws: BackupCoordinator.CommittedImportCleanupError.self, + ) { + try await coordinator.importBackup(from: url, strategy: .merge) + } + let summary = try #require(committedError?.summary) + #expect(try await coordinator.importRecoveryState() == .cleanupRequired(summary)) + + let recoveryError = await #expect( + throws: BackupCoordinator.ImportRecoveryRequiredError.self, + ) { + try await coordinator.importBackup(from: url, strategy: .merge) + } + #expect(recoveryError?.summary == summary) + #expect(await cleanup.count == 1) + + await #expect(throws: BackupCoordinator.CommittedImportCleanupError.self) { + try await coordinator.retryImportCleanup() + } + #expect(try await coordinator.importRecoveryState() == .cleanupRequired(summary)) + #expect(await cleanup.count == 2) + + await cleanup.allowSuccess() + try await coordinator.retryImportCleanup() + + #expect(try await coordinator.importRecoveryState() == .ready) + #expect(await cleanup.count == 3) + } + + @Test func recreatedCoordinatorHydratesAndGatesCommittedCleanup() async throws { + let source = try Self.makeHarness() + let url = try await source.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let store = try SwiftDataStore.inMemory() + let cleanup = CleanupSpy() + let persistence = RecoveryPersistenceSpy() + func makeCoordinator() -> BackupCoordinator { + BackupCoordinator( + store: store, + currentDeviceID: Self.recordingDeviceID, + now: { Date(timeIntervalSinceReferenceDate: 1000) }, + importLifecycle: .init( + prepare: { _ in }, + didCommit: { _ in try await cleanup.run() }, + didRollBack: { _ in }, + ), + importRecoveryPersistence: persistence.persistence, + ) + } + + let first = makeCoordinator() + let committedError = await #expect( + throws: BackupCoordinator.CommittedImportCleanupError.self, + ) { + try await first.importBackup(from: url, strategy: .merge) + } + let summary = try #require(committedError?.summary) + + let recreated = makeCoordinator() + #expect(try await recreated.importRecoveryState() == .cleanupRequired(summary)) + await #expect(throws: BackupCoordinator.ImportRecoveryRequiredError.self) { + try await recreated.importBackup(from: url, strategy: .replace) + } + + await cleanup.allowSuccess() + try await recreated.retryImportCleanup() + + #expect(try await recreated.importRecoveryState() == .ready) + #expect(await persistence.recovery == nil) + } + + @Test func concurrentImportCannotPassReadyWhileTheFirstImportFinishesCleanup() async throws { + let source = try Self.makeHarness() + let url = try await source.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let secondSample = Self.sample(at: "2026-08-03T10:00:00-07:00") + let secondURL = try BackupService().makeArchiveFile( + samples: [secondSample], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [], + blobs: [:], + ) + defer { try? FileManager.default.removeItem(at: secondURL.deletingLastPathComponent()) } + + let prepare = HookSpy() + let (didCommitStarted, didCommitStartedContinuation) = AsyncStream.makeStream(of: Void.self) + let (releaseDidCommit, releaseDidCommitContinuation) = AsyncStream.makeStream(of: Void.self) + let destinationStore = try SwiftDataStore.inMemory() + let coordinator = BackupCoordinator( + store: destinationStore, + currentDeviceID: Self.recordingDeviceID, + now: { Date(timeIntervalSinceReferenceDate: 1000) }, + importLifecycle: .init( + prepare: { _ in await prepare.run() }, + didCommit: { _ in + didCommitStartedContinuation.yield() + for await _ in releaseDidCommit { + break + } + throw CleanupFailure() + }, + didRollBack: { _ in }, + ), + importRecoveryPersistence: .none, + ) + let firstImport = Task { + do { + _ = try await coordinator.importBackup(from: url, strategy: .merge) + return false + } catch is BackupCoordinator.CommittedImportCleanupError { + return true + } catch { + return false + } + } + var didCommitStartedIterator = didCommitStarted.makeAsyncIterator() + _ = await didCommitStartedIterator.next() + + let secondError = await #expect( + throws: BackupCoordinator.ImportRecoveryRequiredError.self, + ) { + try await coordinator.importBackup(from: secondURL, strategy: .merge) + } + #expect(secondError?.summary.sampleCount == 0) + #expect(await prepare.count == 1) + #expect(try await destinationStore.allSamples().isEmpty) + + releaseDidCommitContinuation.yield() + releaseDidCommitContinuation.finish() + #expect(await firstImport.value) + switch try await coordinator.importRecoveryState() { + case .cleanupRequired: + break + case .ready, .onboardingAcknowledgementRequired: + Issue.record("The first committed import must retain its cleanup recovery gate.") + } } /// The coordinator owns the export staging directory's lifecycle: starting a diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index 8cd64bf4..424a0e32 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -1,13 +1,11 @@ import Foundation import RegionKit import Testing -import WhereCore +@testable import WhereCore struct BackupServiceTests { private static let calendar = WhereCoreTestSupport.calendar() - // Whole-second timestamps so the `.iso8601` date strategy (no - // fractional seconds) round-trips exactly. private static let exportDate = Date(timeIntervalSince1970: 1_700_000_000) private static let evidenceWithBlobId = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! @@ -38,16 +36,41 @@ struct BackupServiceTests { ] } - private static func recordingDeviceFixtures() -> [RecordingDevice] { + private static let recordingMetadataID = + UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")! + + private static func recordingDeviceProfileFixtures() -> [RecordingDeviceProfile] { [ - RecordingDevice( + RecordingDeviceProfile( id: recordingDeviceID, systemName: "iPad", - nickname: "Travel iPad", kind: .tablet, registeredAt: exportDate, + registrationEpochID: .initial, + ), + ] + } + + private static func recordingDeviceMetadataFixtures() -> [RecordingDeviceMetadataChange] { + [ + RecordingDeviceMetadataChange( + id: recordingMetadataID, + deviceID: recordingDeviceID, + revision: 0, + changedAt: exportDate, + changedByDeviceID: recordingDeviceID, + nickname: "Travel iPad", + ), + ] + } + + private static func recordingDeviceCheckInFixtures() -> [RecordingDeviceCheckIn] { + [ + RecordingDeviceCheckIn( + deviceID: recordingDeviceID, + revision: 0, lastSeenAt: exportDate, - archivedAt: nil, + appliedAt: exportDate, lastAppliedPolicyChangeID: recordingPolicyID, status: .recording, ), @@ -59,12 +82,36 @@ struct BackupServiceTests { RecordingPolicyChange( id: recordingPolicyID, deviceID: recordingDeviceID, + parentIDs: [], + revision: 0, + issuedAt: exportDate, + issuedByDeviceID: recordingDeviceID, effectiveAt: exportDate, - isEnabled: true, + state: .on, + reason: .initialRegistration, ), ] } + private static func archive( + recordingPolicyChanges: [RecordingPolicyChange], + ) -> BackupArchive { + BackupArchive( + exportedAt: exportDate, + samples: [], + evidence: [], + manualDays: [], + dismissedIssues: [], + trackedRegions: [], + primaryRegions: [], + recordingDeviceProfiles: recordingDeviceProfileFixtures(), + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: recordingPolicyChanges, + assets: [], + ) + } + private static func evidenceFixtures() -> [Evidence] { [ Evidence( @@ -117,7 +164,9 @@ struct BackupServiceTests { let blobs: [UUID: Data] = [Self.evidenceWithBlobId: Data("boarding-pass-pdf".utf8)] let dismissedIssues = Self.dismissedIssueFixtures() - let recordingDevices = Self.recordingDeviceFixtures() + let recordingDeviceProfiles = Self.recordingDeviceProfileFixtures() + let recordingDeviceMetadataChanges = Self.recordingDeviceMetadataFixtures() + let recordingDeviceCheckIns = Self.recordingDeviceCheckInFixtures() let recordingPolicies = Self.recordingPolicyFixtures() let url = try service.makeArchiveFile( @@ -125,7 +174,9 @@ struct BackupServiceTests { evidence: evidence, manualDays: manualDays, dismissedIssues: dismissedIssues, - recordingDevices: recordingDevices, + recordingDeviceProfiles: recordingDeviceProfiles, + recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, + recordingDeviceCheckIns: recordingDeviceCheckIns, recordingPolicyChanges: recordingPolicies, blobs: blobs, exportedAt: Self.exportDate, @@ -144,19 +195,234 @@ struct BackupServiceTests { #expect(result.archive.manualDays == manualDays) // Dismissals round-trip verbatim, id and timestamp. #expect(result.archive.dismissedIssues == dismissedIssues) - #expect(result.archive.recordingDevices == recordingDevices) + #expect(result.archive.recordingDeviceProfiles == recordingDeviceProfiles) + #expect(result.archive.recordingDeviceMetadataChanges == recordingDeviceMetadataChanges) + #expect(result.archive.recordingDeviceCheckIns == recordingDeviceCheckIns) #expect(result.archive.recordingPolicyChanges == recordingPolicies) + let encodedManifest = try #require(String( + data: BackupService.makeEncoder().encode(result.archive), + encoding: .utf8, + )) + #expect(encodedManifest.contains("\"state\" : \"on\"")) + #expect(encodedManifest.contains("\"reason\" : \"initialRegistration\"")) + #expect(encodedManifest.contains("\"isEnabled\"") == false) + #expect(encodedManifest.contains("\"registrationEpochID\"")) + #expect(encodedManifest.contains("00000000-0000-0000-0000-0000000000E0")) // Only the evidence with bytes gets an asset; the other is metadata-only. #expect(result.archive.assets.map(\.evidenceId) == [Self.evidenceWithBlobId]) #expect(result.blobs == blobs) } + @Test func rapidPolicyChangesPreserveTheirSubsecondOrder() throws { + let service = BackupService() + let firstDate = Date(timeIntervalSince1970: 1_700_000_000.123_456) + let policies = try [ + RecordingPolicyChange( + id: #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")), + deviceID: Self.recordingDeviceID, + parentIDs: [], + revision: 0, + issuedAt: firstDate, + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: firstDate, + state: .on, + reason: .initialRegistration, + ), + RecordingPolicyChange( + id: #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")), + deviceID: Self.recordingDeviceID, + parentIDs: [#require(UUID( + uuidString: "11111111-1111-1111-1111-111111111111", + ))], + revision: 1, + issuedAt: firstDate.addingTimeInterval(0.000_001), + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: firstDate.addingTimeInterval(0.000_001), + state: .off, + reason: .userCommand, + ), + ] + let url = try service.makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: policies, + blobs: [:], + exportedAt: Self.exportDate, + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let restored = try service.readArchive(at: url).archive.recordingPolicyChanges + + #expect(restored == policies) + let first = try #require(restored.first) + let last = try #require(restored.last) + #expect(first.effectiveAt < last.effectiveAt) + } + + @Test func decoderAcceptsLegacyWholeSecondISO8601Dates() throws { + let archive = BackupArchive( + exportedAt: Self.exportDate, + samples: [], + evidence: [], + manualDays: [], + dismissedIssues: [], + trackedRegions: [], + primaryRegions: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [], + assets: [], + ) + let legacyEncoder = JSONEncoder() + legacyEncoder.dateEncodingStrategy = .iso8601 + let legacyData = try legacyEncoder.encode(archive) + + let decoded = try BackupService.makeDecoder().decode(BackupArchive.self, from: legacyData) + + #expect(decoded == archive) + } + + @Test func olderFormatIsRejectedBeforeItsMissingCurrentFieldsAreDecoded() { + let legacyManifest = Data(#"{"formatVersion":4}"#.utf8) + + do { + _ = try BackupService.decodeManifest(legacyManifest) + Issue.record("Expected the legacy backup format to be rejected.") + } catch BackupService.BackupError.unsupportedFormatVersion(4) { + // Expected: the version envelope was decoded before the strict v6 shape. + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func currentFormatDoesNotSilentlyBackfillAMissingProfileEpoch() throws { + let data = try BackupService.makeEncoder().encode( + Self.archive(recordingPolicyChanges: Self.recordingPolicyFixtures()), + ) + var manifest = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any], + ) + let profiles = try #require( + manifest["recordingDeviceProfiles"] as? [[String: Any]], + ) + manifest["recordingDeviceProfiles"] = profiles.map { profile in + var profile = profile + profile.removeValue(forKey: "registrationEpochID") + return profile + } + + do { + _ = try BackupService.decodeManifest( + JSONSerialization.data(withJSONObject: manifest), + ) + Issue.record("Expected the missing registration epoch to be rejected.") + } catch let DecodingError.keyNotFound(key, _) { + #expect(key.stringValue == "registrationEpochID") + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func validationRejectsANegativePolicyRevisionDecodedFromABackup() throws { + let archive = try BackupArchive( + exportedAt: Self.exportDate, + samples: [], + evidence: [], + manualDays: [], + dismissedIssues: [], + trackedRegions: [], + primaryRegions: [], + recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [#require(Self.recordingPolicyFixtures().first)], + assets: [], + ) + var json = try #require(String( + data: BackupService.makeEncoder().encode(archive), + encoding: .utf8, + )) + let revision = try #require(json.range(of: "\"revision\" : 0")) + json.replaceSubrange(revision, with: "\"revision\" : -1") + let decoded = try BackupService.makeDecoder().decode( + BackupArchive.self, + from: Data(json.utf8), + ) + + do { + try BackupService.validateRecordingData(decoded) + Issue.record("Expected the negative policy revision to be rejected.") + } catch BackupService.BackupError.invalidRecordingData { + // Expected. + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func validationRejectsAGapInADevicePolicyTimeline() throws { + let initial = try #require(Self.recordingPolicyFixtures().first) + let skippedRevision = try RecordingPolicyChange( + id: #require(UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")), + deviceID: Self.recordingDeviceID, + parentIDs: [initial.id], + revision: 2, + issuedAt: Self.exportDate.addingTimeInterval(1), + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: Self.exportDate.addingTimeInterval(1), + state: .off, + reason: .userCommand, + ) + let archive = Self.archive(recordingPolicyChanges: [initial, skippedRevision]) + + do { + try BackupService.validateRecordingData(archive) + Issue.record("Expected the incomplete policy timeline to be rejected.") + } catch BackupService.BackupError.invalidRecordingData { + // Expected. + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func validationRejectsADestructivePolicyThatTurnsRecordingOn() throws { + let invalid = RecordingPolicyChange( + id: Self.recordingPolicyID, + deviceID: Self.recordingDeviceID, + parentIDs: [], + revision: 0, + issuedAt: Self.exportDate, + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: Self.exportDate, + state: .on, + reason: .accountReset, + ) + let archive = Self.archive(recordingPolicyChanges: [invalid]) + + do { + try BackupService.validateRecordingData(archive) + Issue.record("Expected the invalid reason/state pair to be rejected.") + } catch BackupService.BackupError.invalidRecordingData { + // Expected. + } catch { + Issue.record("Unexpected error: \(error)") + } + } + @Test func archiveNameIsDateAndTimeStamped() throws { let service = BackupService() let url = try service.makeArchiveFile( samples: [], evidence: [], manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -185,6 +451,10 @@ struct BackupServiceTests { samples: [], evidence: [], manualDays: manualDays, + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -203,6 +473,10 @@ struct BackupServiceTests { evidence: [], manualDays: [], trackedRegions: [.california, texas], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -233,6 +507,10 @@ struct BackupServiceTests { manualDays: [], trackedRegions: primary.map(\.region), primaryRegions: primary, + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -267,6 +545,10 @@ struct BackupServiceTests { samples: [], evidence: [], manualDays: manualDays, + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -297,7 +579,9 @@ struct BackupServiceTests { ), PrimaryRegion(region: .newYork, appearance: nil, order: 1), ], - recordingDevices: Self.recordingDeviceFixtures(), + recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), + recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), + recordingDeviceCheckIns: Self.recordingDeviceCheckInFixtures(), recordingPolicyChanges: Self.recordingPolicyFixtures(), assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, @@ -305,13 +589,8 @@ struct BackupServiceTests { )], ) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(archive) - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let decoded = try decoder.decode(BackupArchive.self, from: data) + let data = try BackupService.makeEncoder().encode(archive) + let decoded = try BackupService.makeDecoder().decode(BackupArchive.self, from: data) #expect(decoded == archive) #expect(decoded.formatVersion == BackupArchive.currentFormatVersion) @@ -328,4 +607,29 @@ struct BackupServiceTests { _ = try service.readArchive(at: bogus) } } + + @Test func loadingADeclaredAssetThrowsWhenItsFileIsMissing() throws { + let extractDirectory = FileManager.default.temporaryDirectory.appending( + path: "where-missing-backup-asset-\(UUID().uuidString)", + directoryHint: .isDirectory, + ) + try FileManager.default.createDirectory( + at: extractDirectory, + withIntermediateDirectories: true, + ) + defer { try? FileManager.default.removeItem(at: extractDirectory) } + let entry = BackupAssetEntry( + evidenceId: Self.evidenceWithBlobId, + filename: "assets/\(Self.evidenceWithBlobId.uuidString)", + ) + + do { + _ = try BackupService.loadAssets([entry], from: extractDirectory) + Issue.record("Expected a missing manifest-declared asset to throw.") + } catch let error as CocoaError { + #expect(error.code == .fileReadNoSuchFile) + } catch { + Issue.record("Unexpected error: \(error)") + } + } } diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift index aad946b6..0264040b 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -99,6 +99,8 @@ struct DayJournalTests { issueAlerts: issueAlerts, issueScanner: scanner, widgets: widgets, + currentDeviceID: CurrentRecordingDevice.preview.id, + now: now, ) return Harness( journal: journal, diff --git a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift index 9eb7b98f..18524867 100644 --- a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift +++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift @@ -1,65 +1,211 @@ import Foundation +import RegionKit import Testing @_spi(Testing) @testable import WhereCore struct DeviceRecordingControllerTests { private static let now = WhereCoreTestSupport.iso("2026-07-30T12:00:00-07:00") - + private static let initialPolicyID = UUID( + uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + )! + private static let remoteDeviceID = RecordingDeviceID( + rawValue: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, + ) + private static let remotePolicyID = UUID( + uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + )! private static func makeServices( authorization: LocationAuthorizationStatus, + initialEnabled: Bool = true, + now: @escaping @Sendable () -> Date = { Self.now }, + remoteChanges: ScriptedStoreRemoteChangeSource? = nil, + outbox: any LocationOutbox = NoOpLocationOutbox(), ) throws -> (WhereServices, SwiftDataStore) { - let store = try SwiftDataStore.inMemory() + let store = try if let remoteChanges { + SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) + } else { + SwiftDataStore.inMemory() + } let services = WhereServices( store: store, locationSource: ScriptedLocationSource(authorizationStatus: authorization), - currentDevice: .preview, - now: { now }, + installationContext: InstallationRecordingContext( + currentDevice: .preview, + registeredAt: Self.now, + initialRecordingChoice: .init( + isEnabled: initialEnabled, + policyChangeID: initialPolicyID, + confirmedAt: Self.now, + ), + ), + locationOutbox: outbox, + now: now, ) return (services, store) } - @Test func firstReconcileRegistersMigratedIntentAndAcknowledgesRecording() async throws { - let (services, store) = try Self.makeServices(authorization: .always) + private static func register( + _ services: WhereServices, + authorization: LocationAuthorizationStatus = .always, + ) async throws -> RecordingDeviceConfiguration { + try await services.recording.register(authorization: authorization) + } - let configuration = try await services.recording.reconcile( - initialEnabled: true, - authorization: .always, + private static func pendingSample( + at timestamp: Date = Self.now.addingTimeInterval(-30), + ) -> LocationSample { + LocationSample( + timestamp: timestamp, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsVisit, + recordingDeviceID: CurrentRecordingDevice.preview.id, ) + } + + private static func seedRemoteDevice( + in store: SwiftDataStore, + nickname: String? = "Travel iPad", + status: RecordingDeviceStatus = .recording, + ) async throws { + let profile = RecordingDeviceProfile( + id: remoteDeviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: now, + registrationEpochID: .initial, + ) + let policy = RecordingPolicyChange( + id: remotePolicyID, + deviceID: remoteDeviceID, + parentIDs: [], + revision: 0, + issuedAt: now.addingTimeInterval(-60), + issuedByDeviceID: remoteDeviceID, + effectiveAt: now.addingTimeInterval(-60), + state: status == .recording ? .on : .off, + reason: .initialRegistration, + ) + let metadata = RecordingDeviceMetadataChange( + id: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!, + deviceID: remoteDeviceID, + revision: 0, + changedAt: now, + changedByDeviceID: remoteDeviceID, + nickname: nickname, + ) + let checkIn = RecordingDeviceCheckIn( + deviceID: remoteDeviceID, + revision: 0, + lastSeenAt: now, + appliedAt: now, + lastAppliedPolicyChangeID: remotePolicyID, + status: status, + ) + try await store.perform { + try await store.addRecordingDeviceProfile(profile) + try await store.addRecordingDeviceMetadataChange(metadata) + try await store.addRecordingPolicyChange(policy) + try await store.setRecordingDeviceCheckIn(checkIn) + } + } + + @Test func explicitRegistrationPersistsIdentityPolicyAndAcknowledgement() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + + let configuration = try await Self.register(services) #expect(configuration.id == CurrentRecordingDevice.preview.id) - #expect(configuration.isEnabled) + #expect(configuration.isEnabled == true) #expect(configuration.isPending == false) #expect(configuration.device.status == .recording) #expect(await services.ingestor.isActive) - #expect(try await store.recordingDevices().count == 1) + #expect(try await store.recordingDeviceProfiles().count == 1) + #expect(try await store.recordingDeviceCheckIns().count == 1) + #expect(try await store.recordingPolicyChanges().map(\.id) == [Self.initialPolicyID]) + + _ = try await Self.register(services) + #expect(try await store.recordingDeviceProfiles().count == 1) #expect(try await store.recordingPolicyChanges().count == 1) } + @Test func registrationInDestructiveEpochRejectsABufferedPreEraseFix() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: .always) + let erasedAt = Self.now.addingTimeInterval(60) + let epoch = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: Self.remoteDeviceID, + at: erasedAt, + ) + } + let services = WhereServices( + store: store, + locationSource: source, + installationContext: InstallationRecordingContext( + currentDevice: .preview, + registeredAt: Self.now, + initialRecordingChoice: .init( + isEnabled: true, + policyChangeID: Self.initialPolicyID, + confirmedAt: Self.now, + ), + ), + now: { erasedAt }, + ) + let bufferedBeforeErase = Self.pendingSample( + at: erasedAt.addingTimeInterval(-1), + ) + let afterErase = Self.pendingSample( + at: erasedAt.addingTimeInterval(1), + ) + + source.emit(bufferedBeforeErase) + let configuration = try await Self.register(services) + + #expect(configuration.isEnabled == true) + #expect(await services.ingestor.isActive) + let initialPolicy = try #require(try await store.recordingPolicyChanges().first) + #expect(initialPolicy.effectiveAt == epoch.changedAt) + try await waitUntil { + await services.ingestor.testingHasConsumedSample(id: bufferedBeforeErase.id) + } + #expect(try await store.allSamples().isEmpty) + + source.emit(afterErase) + try await waitUntil { await (try? store.allSamples().count) == 1 } + #expect(try await store.allSamples().map(\.id) == [afterErase.id]) + } + @Test func enabledWithoutAlwaysPermissionIsAcknowledgedAsPermissionRequired() async throws { let (services, _) = try Self.makeServices(authorization: .whenInUse) - let configuration = try await services.recording.reconcile( - initialEnabled: true, + let configuration = try await Self.register( + services, authorization: .whenInUse, ) - #expect(configuration.isEnabled) + #expect(configuration.isEnabled == true) #expect(configuration.isPending == false) #expect(configuration.device.status == .permissionRequired) #expect(await services.ingestor.isActive == false) } @Test func rapidChangesWithTheSameClockValueKeepInvocationOrder() async throws { - let (services, _) = try Self.makeServices(authorization: .always) + let (services, store) = try Self.makeServices( + authorization: .always, + initialEnabled: false, + ) + _ = try await Self.register(services) + _ = try await services.recording.setEnabled( true, for: CurrentRecordingDevice.preview.id, - initialEnabled: false, ) let devices = try await services.recording.setEnabled( false, for: CurrentRecordingDevice.preview.id, - initialEnabled: false, ) let current = try #require( devices.first(where: { $0.id == CurrentRecordingDevice.preview.id }), @@ -69,140 +215,731 @@ struct DeviceRecordingControllerTests { #expect(current.device.status == .off) #expect(current.isPending == false) #expect(await services.ingestor.isActive == false) + let policies = try await store.recordingPolicyChanges() + .filter { $0.deviceID == CurrentRecordingDevice.preview.id } + #expect(policies.map(\.revision) == [0, 1, 2]) + #expect(Set(policies.map(\.effectiveAt)) == Set([Self.now])) } - @Test func remoteDisableIsPendingUntilThatDeviceAcknowledges() async throws { - let (services, store) = try Self.makeServices(authorization: .always) - _ = try await services.recording.reconcile( - initialEnabled: true, + @Test func localOffClosesIngestionBeforeDerivedDataFanoutFinishes() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: .always) + let ingestor = LocationIngestor( + store: store, + locationSource: source, + recordingDeviceID: CurrentRecordingDevice.preview.id, + calendar: WhereCoreTestSupport.calendar(), + onPersisted: { _ in }, + ) + let (fanoutStarted, fanoutStartedContinuation) = AsyncStream.makeStream(of: Void.self) + let (releaseFanout, releaseFanoutContinuation) = AsyncStream.makeStream(of: Void.self) + let controller = DeviceRecordingController( + store: store, + ingestor: ingestor, + installationContext: InstallationRecordingContext( + currentDevice: .preview, + registeredAt: Self.now, + initialRecordingChoice: .init( + isEnabled: true, + policyChangeID: Self.initialPolicyID, + confirmedAt: Self.now, + ), + ), + now: { Self.now }, + onPolicyChanged: { + fanoutStartedContinuation.yield() + fanoutStartedContinuation.finish() + for await _ in releaseFanout { + break + } + }, + ) + _ = try await controller.register(authorization: .always) + #expect(await ingestor.isActive) + + let command = Task { + try await controller.setEnabled( + false, + for: CurrentRecordingDevice.preview.id, + ) + } + var fanoutIterator = fanoutStarted.makeAsyncIterator() + _ = await fanoutIterator.next() + + #expect(await ingestor.isActive == false) + releaseFanoutContinuation.yield() + releaseFanoutContinuation.finish() + _ = try await command.value + } + + @Test func unreadableRetryBacklogLeavesOnPolicyUnacknowledgedAndClosed() async throws { + let outbox = ScriptedLocationOutbox(failsToLoad: true) + let (services, store) = try Self.makeServices( + authorization: .always, + outbox: outbox, + ) + + await #expect(throws: ScriptedLocationOutbox.Failure.self) { + try await Self.register(services) + } + + #expect(await services.ingestor.isActive == false) + #expect(try await store.recordingDeviceCheckIns().isEmpty) + + await outbox.setFailsToLoad(false) + let configuration = try await Self.register(services) + #expect(configuration.device.status == .recording) + #expect(configuration.isPending == false) + #expect(await services.ingestor.isActive) + } + + @Test func onboardingRetryPreservesInitialEventAndAppliesTheCurrentSelection() async throws { + let (services, store) = try Self.makeServices( authorization: .always, + initialEnabled: true, ) - let remoteID = try RecordingDeviceID( - rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), + + let configuration = try await services.recording.registerForOnboarding( + desiredEnabled: false, + authorization: .always, ) - let initialPolicyID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")) + + #expect(configuration.isEnabled == false) + #expect(configuration.device.status == .off) + #expect(await services.ingestor.isActive == false) + let policies = try await store.recordingPolicyChanges() + #expect(policies.map(\.id).contains(Self.initialPolicyID)) + #expect(policies.map(\.isEnabled) == [true, false]) + #expect(policies.map(\.revision) == [0, 1]) + } + + @Test func profileWithoutPolicyRendersUnknownInsteadOfInventingEnabled() async throws { + let (services, store) = try Self.makeServices(authorization: .always) try await store.perform { - try await store.setRecordingDevice(RecordingDevice( - id: remoteID, + try await store.addRecordingDeviceProfile(RecordingDeviceProfile( + id: Self.remoteDeviceID, systemName: "iPad", - nickname: "Travel iPad", kind: .tablet, registeredAt: Self.now, - lastSeenAt: Self.now, - archivedAt: nil, - lastAppliedPolicyChangeID: initialPolicyID, - status: .recording, - )) - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: initialPolicyID, - deviceID: remoteID, - effectiveAt: Self.now.addingTimeInterval(-60), - isEnabled: true, + registrationEpochID: .initial, )) } - let devices = try await services.recording.setEnabled( - false, - for: remoteID, - initialEnabled: true, + let remote = try #require( + try await services.recording.devices().first(where: { + $0.id == Self.remoteDeviceID + }), ) - let remote = try #require(devices.first(where: { $0.id == remoteID })) + + #expect(remote.policy == .unknown) + #expect(remote.isEnabled == nil) + #expect(remote.isPending) + #expect(remote.device.status == .unknown) + } + + @Test func remoteDisableIsPendingUntilThatDeviceAcknowledges() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + _ = try await Self.register(services) + try await Self.seedRemoteDevice(in: store) + + let devices = try await services.recording.setEnabled(false, for: Self.remoteDeviceID) + let remote = try #require(devices.first(where: { $0.id == Self.remoteDeviceID })) #expect(remote.isEnabled == false) #expect(remote.isPending) #expect(remote.device.status == .recording) + #expect(remote.device.nickname == "Travel iPad") } - @Test func archivingTurnsRemoteDeviceOffAndHidesItAtomically() async throws { + @Test func targetDeviceCheckInAcknowledgesRemoteDisableAndClearsPending() async throws { let (services, store) = try Self.makeServices(authorization: .always) - _ = try await services.recording.reconcile( - initialEnabled: true, - authorization: .always, + _ = try await Self.register(services) + try await Self.seedRemoteDevice(in: store) + + let pendingDevices = try await services.recording.setEnabled( + false, + for: Self.remoteDeviceID, ) - let remoteID = try RecordingDeviceID( - rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), + let pending = try #require( + pendingDevices.first(where: { $0.id == Self.remoteDeviceID }), ) - try await store.perform { - try await store.setRecordingDevice(RecordingDevice( - id: remoteID, - systemName: "iPad", - nickname: nil, - kind: .tablet, - registeredAt: Self.now, - lastSeenAt: Self.now, - archivedAt: nil, - lastAppliedPolicyChangeID: nil, + let disableID = try #require(pending.latestPolicyChangeID) + #expect(pending.isPending) + #expect(pending.device.status == .recording) + + try await store.simulateRemoteRecordingImport( + profiles: [], + metadataChanges: [], + checkIns: [RecordingDeviceCheckIn( + deviceID: Self.remoteDeviceID, + revision: 1, + lastSeenAt: Self.now.addingTimeInterval(60), + appliedAt: Self.now.addingTimeInterval(60), + lastAppliedPolicyChangeID: disableID, status: .off, - )) - } + )], + policyChanges: [], + ) - let visible = try await services.recording.archive( - remoteID, - initialEnabled: true, + let acknowledged = try #require( + try await services.recording.devices().first(where: { + $0.id == Self.remoteDeviceID + }), ) + #expect(acknowledged.isEnabled == false) + #expect(acknowledged.isPending == false) + #expect(acknowledged.device.status == .off) + #expect(acknowledged.latestPolicyChangeID == disableID) + } - #expect(visible.contains(where: { $0.id == remoteID }) == false) + @Test func archivingTurnsRemoteDeviceOffAndHidesItAtomically() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + _ = try await Self.register(services) + try await Self.seedRemoteDevice(in: store, nickname: nil, status: .off) + + let visible = try await services.recording.archive(Self.remoteDeviceID) + + #expect(visible.contains(where: { $0.id == Self.remoteDeviceID }) == false) let archived = try #require( - try await store.recordingDevices().first(where: { $0.id == remoteID }), + try await store.recordingDevices().first(where: { $0.id == Self.remoteDeviceID }), ) #expect(archived.archivedAt == Self.now) let latest = try #require( - try await store.recordingPolicyChanges().last(where: { $0.deviceID == remoteID }), + try await store.recordingPolicyChanges() + .filter { $0.deviceID == Self.remoteDeviceID } + .max(by: RecordingPolicyChange.isOrderedBefore), ) #expect(latest.isEnabled == false) } - @Test func archivedCurrentDeviceCanSeeItselfAndReenable() async throws { - let (services, store) = try Self.makeServices(authorization: .always) - let policyID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")) + @Test func remotelyArchivedCurrentDeviceCanSeeItselfAndReenable() async throws { + let (services, store) = try Self.makeServices( + authorization: .always, + initialEnabled: false, + ) + _ = try await Self.register(services) try await store.perform { - try await store.setRecordingDevice(RecordingDevice( - id: CurrentRecordingDevice.preview.id, - systemName: "iPhone", - nickname: nil, - kind: .phone, - registeredAt: Self.now, - lastSeenAt: Self.now, - archivedAt: Self.now, - lastAppliedPolicyChangeID: policyID, - status: .off, - )) try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: policyID, + id: UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")!, deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [Self.initialPolicyID], + revision: 1, + issuedAt: Self.now, + issuedByDeviceID: Self.remoteDeviceID, effectiveAt: Self.now, - isEnabled: false, + state: .archived, + reason: .archive, )) } - let before = try await services.recording.devices(initialEnabled: false) + let before = try await services.recording.devices() #expect(before.map(\.id) == [CurrentRecordingDevice.preview.id]) let after = try await services.recording.setEnabled( true, for: CurrentRecordingDevice.preview.id, - initialEnabled: false, ) let current = try #require(after.first) - #expect(current.isEnabled) + #expect(current.isEnabled == true) #expect(current.device.archivedAt == nil) #expect(current.device.status == .recording) } - @Test func quiescedControllerCannotRecreateRowsAfterReset() async throws { - let (services, store) = try Self.makeServices(authorization: .always) - _ = try await services.recording.reconcile( + @Test func remotePolicyNotificationStopsTheTargetAndAcknowledgesIt() async throws { + let remoteChanges = ScriptedStoreRemoteChangeSource() + let (services, store) = try Self.makeServices( + authorization: .always, + remoteChanges: remoteChanges, + ) + _ = try await Self.register(services) + await services.recording.startMonitoringPolicyChanges() + let disableID = try #require(UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")) + + try await store.simulateRemoteRecordingImport( + profiles: [], + metadataChanges: [], + checkIns: [], + policyChanges: [RecordingPolicyChange( + id: disableID, + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [Self.initialPolicyID], + revision: 1, + issuedAt: Self.now.addingTimeInterval(60), + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now.addingTimeInterval(60), + state: .off, + reason: .userCommand, + )], + ) + remoteChanges.yield() + + try await waitUntil { + let checkIn = try? await store.recordingDeviceCheckIns().first + return checkIn?.lastAppliedPolicyChangeID == disableID + } + #expect(await services.ingestor.isActive == false) + let configuration = try #require( + try await services.recording.devices().first(where: { + $0.id == CurrentRecordingDevice.preview.id + }), + ) + #expect(configuration.isEnabled == false) + #expect(configuration.isPending == false) + #expect(configuration.device.status == .off) + } + + @Test func remoteArchivedAuthorityStopsTheTargetAndAcknowledgesIt() async throws { + let remoteChanges = ScriptedStoreRemoteChangeSource() + let (services, store) = try Self.makeServices( + authorization: .always, + remoteChanges: remoteChanges, + ) + _ = try await Self.register(services) + await services.recording.startMonitoringPolicyChanges() + let archiveID = try #require( + UUID(uuidString: "ABABABAB-ABAB-ABAB-ABAB-ABABABABABAB"), + ) + + try await store.simulateRemoteRecordingImport( + profiles: [], + metadataChanges: [], + checkIns: [], + policyChanges: [RecordingPolicyChange( + id: archiveID, + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [Self.initialPolicyID], + revision: 1, + issuedAt: Self.now.addingTimeInterval(60), + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now.addingTimeInterval(60), + state: .archived, + reason: .archive, + )], + ) + remoteChanges.yield() + + try await waitUntil { + let checkIn = try? await store.recordingDeviceCheckIns().first + return checkIn?.lastAppliedPolicyChangeID == archiveID && checkIn?.status == .off + } + #expect(await services.ingestor.isActive == false) + let current = try #require(try await services.recording.devices().first) + #expect(current.id == CurrentRecordingDevice.preview.id) + #expect(current.isArchived) + #expect(current.device.status == .off) + } + + @Test func destructivePolicyClearsTheBacklogBeforeAcknowledgement() async throws { + let pending = Self.pendingSample() + let outbox = ScriptedLocationOutbox([pending]) + let (services, store) = try Self.makeServices( + authorization: .always, + initialEnabled: false, + outbox: outbox, + ) + _ = try await Self.register(services) + let barrierID = UUID() + try await store.perform { + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: barrierID, + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [Self.initialPolicyID], + revision: 1, + issuedAt: Self.now, + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now, + state: .off, + reason: .backupReplace, + )) + } + + _ = try await services.recording.reconcile(authorization: .always) + + #expect(await outbox.persistedSamples.isEmpty) + let checkIn = try #require(try await store.recordingDeviceCheckIns().first) + #expect(checkIn.lastAppliedPolicyChangeID == barrierID) + #expect(checkIn.lastDiscardedPolicyChangeID == barrierID) + #expect(checkIn.revision == 1) + } + + @Test func destructiveCleanupFailureLeavesTheBarrierUnacknowledgedAndOff() async throws { + let pending = Self.pendingSample() + let outbox = ScriptedLocationOutbox([pending], failsToClear: true) + let (services, store) = try Self.makeServices( + authorization: .always, + initialEnabled: false, + outbox: outbox, + ) + _ = try await Self.register(services) + let initialCheckIn = try #require(try await store.recordingDeviceCheckIns().first) + try await store.perform { + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: UUID(), + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [Self.initialPolicyID], + revision: 1, + issuedAt: Self.now, + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now, + state: .off, + reason: .backupReplace, + )) + } + + await #expect(throws: ScriptedLocationOutbox.Failure.self) { + try await services.recording.reconcile(authorization: .always) + } + + #expect(await outbox.persistedSamples == [pending]) + #expect(try await store.recordingDeviceCheckIns().first == initialCheckIn) + #expect(await services.ingestor.isActive == false) + } + + @Test func laterOnStillClearsAnInterveningDestructiveBarrier() async throws { + let pending = Self.pendingSample() + let outbox = ScriptedLocationOutbox([pending]) + let (services, store) = try Self.makeServices( + authorization: .always, + initialEnabled: false, + outbox: outbox, + ) + _ = try await Self.register(services) + let barrierID = UUID() + let onID = UUID() + try await store.perform { + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: barrierID, + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [Self.initialPolicyID], + revision: 1, + issuedAt: Self.now, + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now, + state: .off, + reason: .backupReplace, + )) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: onID, + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [barrierID], + revision: 2, + issuedAt: Self.now.addingTimeInterval(1), + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now.addingTimeInterval(1), + state: .on, + reason: .userCommand, + )) + } + + let configuration = try await services.recording.reconcile(authorization: .always) + + #expect(configuration.isEnabled == true) + #expect(await services.ingestor.isActive) + #expect(await outbox.persistedSamples.isEmpty) + let checkIn = try #require(try await store.recordingDeviceCheckIns().first) + #expect(checkIn.lastAppliedPolicyChangeID == onID) + #expect(checkIn.lastDiscardedPolicyChangeID == barrierID) + } + + @Test func destructiveEventOnLosingBranchStillClearsTheBacklog() async throws { + let pending = Self.pendingSample() + let outbox = ScriptedLocationOutbox([pending]) + let (services, store) = try Self.makeServices( + authorization: .always, + initialEnabled: false, + outbox: outbox, + ) + _ = try await Self.register(services) + let losingOffID = try #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")) + let archiveID = try #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")) + let barrierID = try #require(UUID(uuidString: "30000000-0000-0000-0000-000000000000")) + try await store.perform { + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: losingOffID, + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [Self.initialPolicyID], + revision: 1, + issuedAt: Self.now, + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now, + state: .off, + reason: .userCommand, + )) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: archiveID, + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [Self.initialPolicyID], + revision: 1, + issuedAt: Self.now, + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now, + state: .archived, + reason: .archive, + )) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: barrierID, + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [losingOffID], + revision: 2, + issuedAt: Self.now, + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now, + state: .off, + reason: .backupReplace, + )) + } + + let configuration = try await services.recording.reconcile(authorization: .always) + + // The destructive descendant remains a maximal head even though its parent lost the + // prior tie. Destructive safety outranks the concurrent archive state. + #expect(configuration.latestPolicyChangeID == barrierID) + #expect(configuration.isEnabled == false) + #expect(configuration.isArchived == false) + #expect(await outbox.persistedSamples.isEmpty) + let checkIn = try #require(try await store.recordingDeviceCheckIns().first) + #expect(checkIn.lastAppliedPolicyChangeID == barrierID) + #expect(checkIn.lastDiscardedPolicyChangeID == barrierID) + } + + @Test func policyRevisionGapFailsClosedUntilTheMissingEventArrives() async throws { + let pending = Self.pendingSample() + let outbox = ScriptedLocationOutbox([pending]) + let (services, store) = try Self.makeServices( + authorization: .always, + initialEnabled: false, + outbox: outbox, + ) + _ = try await Self.register(services) + try await store.perform { + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: UUID(), + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [Self.initialPolicyID], + revision: 2, + issuedAt: Self.now, + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now, + state: .on, + reason: .userCommand, + )) + } + + await #expect(throws: RecordingPersistenceError.self) { + try await services.recording.reconcile(authorization: .always) + } + + #expect(await services.ingestor.isActive == false) + #expect(await outbox.persistedSamples == [pending]) + } + + @Test func oldInstallationDoesNotReplayInitialConsentInADestructiveEpoch() async throws { + let (services, store) = try Self.makeServices( + authorization: .always, initialEnabled: true, + ) + _ = try await Self.register(services) + let epoch = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: Self.remoteDeviceID, + at: Self.now.addingTimeInterval(60), + ) + } + + let configuration = try await Self.register(services) + + #expect(configuration.isArchived) + #expect(configuration.latestPolicyChangeID == epoch.id.rawValue) + #expect(configuration.device.status == .off) + #expect(await services.ingestor.isActive == false) + #expect(try await store.recordingPolicyChanges().isEmpty) + } + + @Test func profileArrivingAfterDestructiveEpochStartsArchivedUntilExplicitlyReenabled( + ) async throws { + let (services, store) = try Self.makeServices(authorization: .always) + _ = try await Self.register(services) + let epoch = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: CurrentRecordingDevice.preview.id, + at: Self.now.addingTimeInterval(60), + ) + } + try await store.perform { + try await store.addRecordingDeviceProfile(RecordingDeviceProfile( + id: Self.remoteDeviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: Self.now.addingTimeInterval(-3600), + registrationEpochID: .initial, + )) + } + + let before = try await services.recording.devices() + #expect(before.map(\.id) == [CurrentRecordingDevice.preview.id]) + #expect(before.first?.latestPolicyChangeID == epoch.id.rawValue) + + let after = try await services.recording.setEnabled(true, for: Self.remoteDeviceID) + let remote = try #require(after.first(where: { $0.id == Self.remoteDeviceID })) + #expect(remote.isEnabled == true) + #expect(remote.isArchived == false) + #expect(remote.isPending) + let policy = try #require( + try await store.recordingPolicyChanges().first(where: { + $0.deviceID == Self.remoteDeviceID + }), + ) + #expect(policy.revision == 0) + #expect(policy.reason == .userCommand) + } + + @Test func storeChangeRefreshesARecordingHeartbeatAfterTheInterval() async throws { + let clock = MutableRecordingTestClock(Self.now) + let (services, store) = try Self.makeServices( + authorization: .always, + now: { clock.value }, + ) + _ = try await Self.register(services) + await services.recording.startMonitoringPolicyChanges() + clock.value = Self.now.addingTimeInterval(60 * 60) + + try await store.perform { + try await store.setManualDay(DayPresence( + date: Self.now, + in: WhereCoreTestSupport.calendar(), + regions: [.california], + )) + } + try await waitUntil { + let checkIn = try? await store.recordingDeviceCheckIns().first + return checkIn?.lastSeenAt == clock.value + } + + let checkIn = try #require(try await store.recordingDeviceCheckIns().first) + #expect(checkIn.lastSeenAt == clock.value) + } + + @Test func storeChangeAppliesDestructiveEpochWithoutReplayingInitialConsent() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + _ = try await Self.register(services) + await services.recording.startMonitoringPolicyChanges() + let epoch = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: Self.remoteDeviceID, + at: Self.now.addingTimeInterval(60), + ) + } + + try await waitUntil { + let checkIn = try? await store.recordingDeviceCheckIns().first + return checkIn?.lastAppliedPolicyChangeID == epoch.id.rawValue + } + #expect(try await store.recordingPolicyChanges().isEmpty) + #expect(await services.ingestor.isActive == false) + } + + @Test func reconcileWithoutRegistrationFailsClosed() async throws { + let (services, _) = try Self.makeServices(authorization: .always) + + await #expect(throws: RecordingPersistenceError.self) { + try await services.recording.reconcile(authorization: .always) + } + + #expect(await services.ingestor.isActive == false) + } + + @Test func replaceRecoveryPreservesImportedPolicyWithoutReplayingLocalConsent() async throws { + let clock = MutableRecordingTestClock(Self.now) + let (services, store) = try Self.makeServices( authorization: .always, + now: { clock.value }, ) + _ = try await Self.register(services) + + try await services.recording.pause() + let importedPolicyID = try #require( + UUID(uuidString: "99999999-9999-9999-9999-999999999999"), + ) + let importedAt = Self.now.addingTimeInterval(60) + try await store.perform { + _ = try await store.rotateDataEpoch( + reason: .backupReplace, + changedBy: Self.remoteDeviceID, + at: importedAt, + ) + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: importedPolicyID, + deviceID: CurrentRecordingDevice.preview.id, + parentIDs: [], + revision: 0, + issuedAt: importedAt, + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: importedAt, + state: .off, + reason: .initialRegistration, + )) + } + clock.value = Self.now.addingTimeInterval(3600) - await services.recording.quiesce() - try await store.perform { try await store.clearAll() } + try await services.recording.resumeAfterImport(discardPendingSamples: true) + + let profile = try #require(try await store.recordingDeviceProfiles().first) + #expect(profile.registeredAt == Self.now) + let policies = try await store.recordingPolicyChanges() + #expect(policies.map(\.id) == [importedPolicyID]) + let checkIn = try #require(try await store.recordingDeviceCheckIns().first) + #expect(checkIn.lastAppliedPolicyChangeID == importedPolicyID) + #expect(checkIn.status == .off) + #expect(await services.ingestor.isActive == false) + } + + @Test func pausedControllerCannotMutateTheNewEpochAfterReset() async throws { + let (services, store) = try Self.makeServices(authorization: .always) + _ = try await Self.register(services) + + try await services.recording.pause() + try await store.perform { + _ = try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: CurrentRecordingDevice.preview.id, + at: Self.now.addingTimeInterval(60), + ) + } await #expect(throws: CancellationError.self) { - _ = try await services.recording.devices(initialEnabled: true) + _ = try await services.recording.devices() } - #expect(try await store.recordingDevices().isEmpty) + #expect(try await store.recordingDevices().count == 1) #expect(try await store.recordingPolicyChanges().isEmpty) } + + private func waitUntil( + timeout: Duration = .seconds(2), + condition: @escaping @Sendable () async -> Bool, + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if await condition() { return } + await Task.yield() + } + Issue.record("waitUntil timed out") + } +} + +private final class MutableRecordingTestClock: @unchecked Sendable { + private let lock = NSLock() + private var storedValue: Date + + init(_ value: Date) { + storedValue = value + } + + var value: Date { + get { lock.withLock { storedValue } } + set { lock.withLock { storedValue = newValue } } + } } diff --git a/Where/WhereCore/Tests/DismissedIssueStoreTests.swift b/Where/WhereCore/Tests/DismissedIssueStoreTests.swift index 5f01a119..a1234601 100644 --- a/Where/WhereCore/Tests/DismissedIssueStoreTests.swift +++ b/Where/WhereCore/Tests/DismissedIssueStoreTests.swift @@ -43,13 +43,17 @@ struct DismissedIssueStoreTests { #expect(ids == [id]) } - @Test func clearAll_wipesDismissals() async throws { + @Test func rotatingTheDataEpochWipesDismissals() async throws { let store = try SwiftDataStore.inMemory() try await store.perform { try await store.setIssueDismissed(true, id: Self.missingDays) } try await store.perform { - try await store.clearAll() + _ = try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: RecordingDeviceID(rawValue: UUID()), + at: Date(timeIntervalSinceReferenceDate: 1), + ) } let ids = try await store.dismissedIssueIDs() #expect(ids.isEmpty) diff --git a/Where/WhereCore/Tests/InstallationRecordingContextTests.swift b/Where/WhereCore/Tests/InstallationRecordingContextTests.swift new file mode 100644 index 00000000..65e76b57 --- /dev/null +++ b/Where/WhereCore/Tests/InstallationRecordingContextTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import WhereCore + +struct InstallationRecordingContextTests { + @Test func recommendationComesFromTheDeviceKind() { + let phone = context(kind: .phone) + let tablet = context(kind: .tablet) + + #expect(phone.recommendedRecordingEnabled) + #expect(tablet.recommendedRecordingEnabled == false) + } + + @Test func confirmationPreservesIdentityAndCarriesAStablePolicyToken() throws { + let proposed = context(kind: .tablet) + let policyChangeID = try #require( + UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"), + ) + + let confirmed = proposed.confirmingInitialRecording( + isEnabled: false, + policyChangeID: policyChangeID, + confirmedAt: Self.confirmedAt, + ) + + #expect(confirmed.currentDevice == proposed.currentDevice) + #expect(confirmed.registeredAt == proposed.registeredAt) + #expect(confirmed.initialRecordingChoice?.isEnabled == false) + #expect(confirmed.initialRecordingChoice?.policyChangeID == policyChangeID) + #expect(confirmed.initialRecordingChoice?.confirmedAt == Self.confirmedAt) + } + + private func context(kind: RecordingDeviceKind) -> InstallationRecordingContext { + InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: kind == .tablet ? "iPad" : "iPhone", + kind: kind, + ), + registeredAt: Self.registeredAt, + initialRecordingChoice: nil, + ) + } + + private static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) + private static let confirmedAt = Date(timeIntervalSinceReferenceDate: 200) +} diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index c475fae9..902c008c 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -6,6 +6,10 @@ import Testing /// Covers the GPS ingestion lifecycle, the post-persist hook, and the retry /// queue the controller delegates all of `startGPS`/`stopGPS`/auth to. struct LocationIngestorTests { + private enum OutboxFailure: Error { + case clear + } + private actor OutcomeRecorder { private(set) var outcomes: [LocationIngestor.IngestOutcome] = [] @@ -26,18 +30,29 @@ struct LocationIngestorTests { /// last `save`d, so two ingestors sharing one instance models the on-disk /// backlog surviving a relaunch. private actor SpyLocationOutbox: LocationOutbox { - private(set) var contents: [LocationSample] + private(set) var entries: [LocationOutboxEntry] + private let failsToClear: Bool + + init(_ contents: [LocationSample] = [], failsToClear: Bool = false) { + entries = contents.map { LocationOutboxEntry(sample: $0, dataEpochID: .initial) } + self.failsToClear = failsToClear + } - init(_ contents: [LocationSample] = []) { - self.contents = contents + func load() async throws -> [LocationOutboxEntry] { + entries } - func load() async -> [LocationSample] { - contents + func save(_ entries: [LocationOutboxEntry]) async { + self.entries = entries } - func save(_ samples: [LocationSample]) async { - contents = samples + func clear() async throws { + if failsToClear { throw OutboxFailure.clear } + entries = [] + } + + var contents: [LocationSample] { + entries.map(\.sample) } } @@ -65,7 +80,7 @@ struct LocationIngestorTests { let recorder = OutcomeRecorder() let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) - await ingestor.start() + try await ingestor.start() #expect(await ingestor.isActive) // The resume path fires a (drain-only) outcome even with an empty queue. #expect(await recorder.count == 1) @@ -106,6 +121,7 @@ struct LocationIngestorTests { let recorder = OutcomeRecorder() let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) source.setNextRequestedLocation(sample(at: "2026-03-15T08:05:00-07:00")) + try await ingestor.authorizeRecording() // No monitoring started (the When-In-Use case): the foreground fix is // the only way this user's data lands, and it still persists + reports. @@ -130,6 +146,7 @@ struct LocationIngestorTests { try await store .perform { try await store.add(sample: sample(at: "2026-03-15T02:00:00-07:00")) } source.setNextRequestedLocation(sample(at: "2026-03-15T08:05:00-07:00")) + try await ingestor.authorizeRecording() await ingestor .captureTodayIfNeeded(now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00")) @@ -156,6 +173,7 @@ struct LocationIngestorTests { )) } source.setNextRequestedLocation(sample(at: "2026-03-15T08:05:00-07:00")) + try await ingestor.authorizeRecording() await ingestor .captureTodayIfNeeded(now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00")) @@ -169,6 +187,7 @@ struct LocationIngestorTests { let recorder = OutcomeRecorder() let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) // No fix scripted → `requestCurrentLocation()` returns nil. + try await ingestor.authorizeRecording() await ingestor .captureTodayIfNeeded(now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00")) @@ -189,6 +208,7 @@ struct LocationIngestorTests { onPersisted: { outcome in await recorder.record(outcome) }, ) let now = WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00") + try await ingestor.authorizeRecording() // The first capture parks awaiting the gated fix, holding the // single-flight slot. @@ -206,12 +226,95 @@ struct LocationIngestorTests { #expect(source.requestCount == 1) } + @Test func cancelledFixCannotReviveAfterRecordingIsReenabled() async throws { + let store = try SwiftDataStore.inMemory() + let source = GatedLocationSource(fix: sample(at: "2026-03-15T08:05:00-07:00")) + let ingestor = LocationIngestor( + store: store, + locationSource: source, + recordingDeviceID: CurrentRecordingDevice.preview.id, + calendar: WhereCoreTestSupport.calendar(), + onPersisted: { _ in }, + ) + try await ingestor.authorizeRecording() + await ingestor.captureTodayIfNeeded( + now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00"), + ) + try await waitUntil { source.requestCount == 1 } + + await ingestor.revokeRecordingAuthorization() + try await ingestor.authorizeRecording() + // The cancelled, continuation-backed request remains the single-flight owner until it + // actually exits; re-enabling cannot replace it with a handle the old task could clear. + await ingestor.captureTodayIfNeeded( + now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00"), + ) + #expect(source.requestCount == 1) + source.openGate() + + // Once the cancelled task exits, a genuinely new capture can claim the slot and persist. + try await waitUntil { + await ingestor.captureTodayIfNeeded( + now: WhereCoreTestSupport.iso("2026-03-15T08:00:00-07:00"), + ) + return source.requestCount == 2 + } + try await waitUntil { await (try? store.allSamples().count) == 1 } + #expect(source.requestCount == 2) + } + + @Test func revokedAuthorizationDropsAOneShotFixEchoedOntoTheSampleStream() async throws { + let store = try SwiftDataStore.inMemory() + let source = EchoingLocationSource(fix: sample(at: "2026-03-15T08:05:00-07:00")) + let ingestor = LocationIngestor( + store: store, + locationSource: source, + recordingDeviceID: CurrentRecordingDevice.preview.id, + calendar: WhereCoreTestSupport.calendar(), + onPersisted: { _ in }, + ) + try await ingestor.start() + await ingestor.revokeRecordingAuthorization() + + #expect(await ingestor.currentLocation() != nil) + try await waitUntil { + guard source.didEchoFix else { return false } + return await ingestor.testingHasConsumedSample(id: source.fixID) + } + + #expect(try await store.allSamples().isEmpty) + } + + @Test func sampleBufferedDuringInitialOffIsNotAcceptedByALaterOn() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: .always) + let ingestor = Self.makeIngestor( + store: store, + source: source, + recorder: OutcomeRecorder(), + ) + let beforeConsent = sample(at: "2026-03-15T11:59:00-07:00") + let enabledAt = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00") + let afterConsent = sample(at: "2026-03-15T12:01:00-07:00") + + // The source can buffer before the first policy reconciliation. Initial Off installs a + // closed consumer; even if this row is not drained until after On, its timestamp remains + // outside the new authority window. + source.emit(beforeConsent) + await ingestor.revokeRecordingAuthorization() + try await ingestor.start(effectiveAt: enabledAt, dataEpochID: .initial) + source.emit(afterConsent) + + try await waitUntil { await (try? store.allSamples().count) == 1 } + #expect(try await store.allSamples().map(\.id) == [afterConsent.id]) + } + @Test func liveSampleIsPersistedAndReported() async throws { let store = try SwiftDataStore.inMemory() let source = ScriptedLocationSource(authorizationStatus: .always) let recorder = OutcomeRecorder() let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) - await ingestor.start() + try await ingestor.start() source.emit(LocationSample( timestamp: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00"), @@ -225,13 +328,38 @@ struct LocationIngestorTests { #expect(try await store.allSamples().count == 1) } + @Test func liveSampleFromSupersededEpochStopsWithoutEnteringRetryOutbox() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: .always) + let ingestor = Self.makeIngestor( + store: store, + source: source, + recorder: OutcomeRecorder(), + ) + try await ingestor.start(effectiveAt: .distantPast, dataEpochID: .initial) + _ = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: CurrentRecordingDevice.preview.id, + at: Date(timeIntervalSinceReferenceDate: 100), + ) + } + + source.emit(sample(at: "2026-03-15T12:00:00-07:00")) + + try await waitUntil { await !ingestor.testingIsAcceptingSamples } + #expect(await !ingestor.isActive) + #expect(await ingestor.retryQueueDepth == 0) + #expect(try await store.allSamples().isEmpty) + } + @Test func failedPersistEnqueuesThenLaterDrains() async throws { let backing = try SwiftDataStore.inMemory() let store = ToggleFailingStore(backing: backing) let source = ScriptedLocationSource(authorizationStatus: .always) let recorder = OutcomeRecorder() let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) - await ingestor.start() + try await ingestor.start() await store.setShouldFail(true) source.emit(sample(at: "2026-03-15T12:00:00-07:00")) @@ -254,7 +382,7 @@ struct LocationIngestorTests { let source = ScriptedLocationSource(authorizationStatus: .always) let recorder = OutcomeRecorder() let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) - await ingestor.start() + try await ingestor.start() await store.setShouldFail(true) source.emit(sample(at: "2026-03-15T12:00:00-07:00")) @@ -262,24 +390,56 @@ struct LocationIngestorTests { // Quiescing for a teardown drops the backlog so a later start() can't // re-drain those samples into the freshly wiped store. - await ingestor.quiesce() + try await ingestor.quiesce() #expect(await ingestor.retryQueueDepth == 0) #expect(await !(ingestor.isActive)) } + @Test func pausePreservesRetryBacklogForAResume() async throws { + let backing = try SwiftDataStore.inMemory() + let store = ToggleFailingStore(backing: backing) + let source = ScriptedLocationSource(authorizationStatus: .always) + let outbox = SpyLocationOutbox() + let ingestor = Self.makeIngestor( + store: store, + source: source, + recorder: OutcomeRecorder(), + outbox: outbox, + ) + try await ingestor.start() + + await store.setShouldFail(true) + source.emit(sample(at: "2026-03-15T12:00:00-07:00")) + try await waitUntil { await ingestor.retryQueueDepth == 1 } + try await waitUntil { await outbox.contents.count == 1 } + + await ingestor.pause() + + #expect(await !(ingestor.isActive)) + #expect(await ingestor.retryQueueDepth == 1) + #expect(await outbox.contents.count == 1) + + await store.setShouldFail(false) + try await ingestor.start() + + try await waitUntil { await (try? backing.allSamples().count) == 1 } + #expect(await ingestor.retryQueueDepth == 0) + #expect(await outbox.contents.isEmpty) + } + @Test func quiesceStopsPersistingFurtherSamples() async throws { let store = try SwiftDataStore.inMemory() let source = ScriptedLocationSource(authorizationStatus: .always) let recorder = OutcomeRecorder() let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) - await ingestor.start() + try await ingestor.start() source.emit(sample(at: "2026-03-15T12:00:00-07:00")) // start() fires one drain-only outcome; the live sample fires a second. try await waitUntil { await recorder.count >= 2 } #expect(try await store.allSamples().count == 1) - await ingestor.quiesce() + try await ingestor.quiesce() // A sample delivered after quiesce (e.g. a buffered event arriving mid // teardown) must not be persisted, so it can't clobber the wipe that @@ -300,7 +460,7 @@ struct LocationIngestorTests { recorder: OutcomeRecorder(), outbox: outbox, ) - await ingestor.start() + try await ingestor.start() await store.setShouldFail(true) source.emit(sample(at: "2026-03-15T12:00:00-07:00")) @@ -325,7 +485,7 @@ struct LocationIngestorTests { recorder: OutcomeRecorder(), outbox: outbox, ) - await ingestor1.start() + try await ingestor1.start() await failing.setShouldFail(true) source1.emit(sample(at: "2026-03-15T12:00:00-07:00")) try await waitUntil { await ingestor1.retryQueueDepth == 1 } @@ -340,13 +500,106 @@ struct LocationIngestorTests { recorder: OutcomeRecorder(), outbox: outbox, ) - await ingestor2.start() + try await ingestor2.start() try await waitUntil { await (try? backing.allSamples().count) == 1 } #expect(await ingestor2.retryQueueDepth == 0) #expect(await outbox.contents.isEmpty) } + @Test func durableBacklogPreservesExistingDeviceProvenance() async throws { + let store = try SwiftDataStore.inMemory() + let originalDeviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + let restoredSample = LocationSample( + timestamp: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00"), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + recordingDeviceID: originalDeviceID, + ) + let outbox = SpyLocationOutbox([restoredSample]) + let ingestor = Self.makeIngestor( + store: store, + source: ScriptedLocationSource(authorizationStatus: .always), + recorder: OutcomeRecorder(), + outbox: outbox, + ) + + try await ingestor.start() + + let stored = try await store.allSamples() + #expect(stored.count == 1) + #expect(stored.first?.recordingDeviceID == originalDeviceID) + } + + @Test func authorizingNewEpochDiscardsOldEpochBacklogWithoutPersistingIt() async throws { + let store = try SwiftDataStore.inMemory() + let newEpoch = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: CurrentRecordingDevice.preview.id, + at: Date(timeIntervalSinceReferenceDate: 100), + ) + } + let outbox = SpyLocationOutbox() + await outbox.save([LocationOutboxEntry( + sample: sample(at: "2026-03-15T12:00:00-07:00"), + dataEpochID: .initial, + )]) + let ingestor = Self.makeIngestor( + store: store, + source: ScriptedLocationSource(authorizationStatus: .always), + recorder: OutcomeRecorder(), + outbox: outbox, + ) + + try await ingestor.authorizeRecording( + effectiveAt: .distantPast, + dataEpochID: newEpoch.id, + ) + + #expect(await ingestor.retryQueueDepth == 0) + #expect(await outbox.entries.isEmpty) + #expect(try await store.allSamples().isEmpty) + } + + @Test func epochRotationDuringBacklogDrainFailsClosedWithoutStartingGPS() async throws { + let backing = try SwiftDataStore.inMemory() + let gatedStore = ToggleFailingStore(backing: backing) + let gate = OneShotGate() + await gatedStore.gateNextExpectedEpochPerform(with: gate) + let outbox = SpyLocationOutbox([sample(at: "2026-03-15T12:00:00-07:00")]) + let ingestor = Self.makeIngestor( + store: gatedStore, + source: ScriptedLocationSource(authorizationStatus: .always), + recorder: OutcomeRecorder(), + outbox: outbox, + ) + + let start = Task { + try await ingestor.start(effectiveAt: .distantPast, dataEpochID: .initial) + } + await gate.waitUntilEntered() + _ = try await backing.perform { + try await backing.rotateDataEpoch( + reason: .accountReset, + changedBy: CurrentRecordingDevice.preview.id, + at: Date(timeIntervalSinceReferenceDate: 100), + ) + } + await gate.release() + + await #expect(throws: RecordingPersistenceError.dataEpochChanged) { + try await start.value + } + #expect(await !ingestor.testingIsAcceptingSamples) + #expect(await !ingestor.isActive) + #expect(await ingestor.retryQueueDepth == 1) + #expect(try await backing.allSamples().isEmpty) + } + @Test func quiesceClearsTheDurableOutbox() async throws { let backing = try SwiftDataStore.inMemory() let store = ToggleFailingStore(backing: backing) @@ -358,7 +611,7 @@ struct LocationIngestorTests { recorder: OutcomeRecorder(), outbox: outbox, ) - await ingestor.start() + try await ingestor.start() await store.setShouldFail(true) source.emit(sample(at: "2026-03-15T12:00:00-07:00")) @@ -367,11 +620,61 @@ struct LocationIngestorTests { // A reset/erase teardown must wipe the durable backlog too, or it would // re-drain into the freshly erased store on the next launch. - await ingestor.quiesce() + try await ingestor.quiesce() + #expect(await ingestor.retryQueueDepth == 0) + #expect(await outbox.contents.isEmpty) + } + + @Test func discardRetryBacklogClearsTheDurableAndLiveCopies() async throws { + let backing = try SwiftDataStore.inMemory() + let store = ToggleFailingStore(backing: backing) + let source = ScriptedLocationSource(authorizationStatus: .always) + let outbox = SpyLocationOutbox() + let ingestor = Self.makeIngestor( + store: store, + source: source, + recorder: OutcomeRecorder(), + outbox: outbox, + ) + try await ingestor.start() + await store.setShouldFail(true) + source.emit(sample(at: "2026-03-15T12:00:00-07:00")) + try await waitUntil { await ingestor.retryQueueDepth == 1 } + try await waitUntil { await outbox.contents.count == 1 } + await ingestor.pause() + + try await ingestor.discardRetryBacklog() + #expect(await ingestor.retryQueueDepth == 0) #expect(await outbox.contents.isEmpty) } + @Test func failedRetryBacklogDiscardPreservesTheLiveAndDurableCopies() async throws { + let backing = try SwiftDataStore.inMemory() + let store = ToggleFailingStore(backing: backing) + let source = ScriptedLocationSource(authorizationStatus: .always) + let outbox = SpyLocationOutbox(failsToClear: true) + let ingestor = Self.makeIngestor( + store: store, + source: source, + recorder: OutcomeRecorder(), + outbox: outbox, + ) + try await ingestor.start() + await store.setShouldFail(true) + source.emit(sample(at: "2026-03-15T12:00:00-07:00")) + try await waitUntil { await ingestor.retryQueueDepth == 1 } + try await waitUntil { await outbox.contents.count == 1 } + await ingestor.pause() + + await #expect(throws: OutboxFailure.self) { + try await ingestor.discardRetryBacklog() + } + + #expect(await ingestor.retryQueueDepth == 1) + #expect(await outbox.contents.count == 1) + } + @Test func retryQueueEvictsOldestSampleAtCapacity() async throws { let store = try SwiftDataStore.inMemory() let source = ScriptedLocationSource(authorizationStatus: .always) @@ -395,7 +698,7 @@ struct LocationIngestorTests { coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), horizontalAccuracy: 0, source: .gpsSignificantChange, - )) + ), dataEpochID: .initial) } #expect(await ingestor.retryQueueDepth == 20) @@ -405,7 +708,7 @@ struct LocationIngestorTests { coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), horizontalAccuracy: 0, source: .gpsSignificantChange, - )) + ), dataEpochID: .initial) let queuedIDs = await ingestor.testingRetryQueueSampleIDs() #expect(queuedIDs.count == 20) @@ -458,6 +761,7 @@ private final class GatedLocationSource: LocationSource, @unchecked Sendable { private let lock = NSLock() private var waiters: [CheckedContinuation] = [] private var _requestCount = 0 + private var isOpen = false init(fix: LocationSample) { self.fix = fix @@ -477,11 +781,18 @@ private final class GatedLocationSource: LocationSource, @unchecked Sendable { func requestPermission() async throws {} func requestCurrentLocation() async -> LocationSample? { + let shouldWait = lock.withLock { + _requestCount += 1 + return !isOpen + } + guard shouldWait else { return fix } await withCheckedContinuation { continuation in - lock.withLock { - _requestCount += 1 + let openedBeforeRegistration = lock.withLock { + guard !isOpen else { return true } waiters.append(continuation) + return false } + if openedBeforeRegistration { continuation.resume() } } return fix } @@ -489,6 +800,7 @@ private final class GatedLocationSource: LocationSource, @unchecked Sendable { /// Resume every parked fix request with the scripted fix. func openGate() { let resumed = lock.withLock { + isOpen = true let current = waiters waiters.removeAll() return current @@ -499,13 +811,90 @@ private final class GatedLocationSource: LocationSource, @unchecked Sendable { } } +/// Models Core Location delivering a requested one-shot fix through both the direct callback and +/// the passive sample stream. The stream echo must still obey recording authority. +private final class EchoingLocationSource: LocationSource, @unchecked Sendable { + let sampleStream: AsyncStream + var authorizationUpdates: AsyncStream { + AsyncStream { _ in } + } + + private let fix: LocationSample + var fixID: UUID { + fix.id + } + + private let continuation: AsyncStream.Continuation + private let lock = NSLock() + private var _didEchoFix = false + + init(fix: LocationSample) { + self.fix = fix + let stream = AsyncStream.makeStream(of: LocationSample.self) + sampleStream = stream.stream + continuation = stream.continuation + } + + var didEchoFix: Bool { + lock.withLock { _didEchoFix } + } + + func start() async {} + func stop() async {} + func currentAuthorization() async -> LocationAuthorizationStatus { + .always + } + + func requestPermission() async throws {} + + func requestCurrentLocation() async -> LocationSample? { + lock.withLock { _didEchoFix = true } + continuation.yield(fix) + return fix + } +} + private struct ToggleFailingStoreError: Error {} +/// One-use suspension point whose entry can be awaited deterministically. +private actor OneShotGate { + private var didEnter = false + private var didRelease = false + private var enteredWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func suspend() async { + didEnter = true + let waiters = enteredWaiters + enteredWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + guard !didRelease else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func waitUntilEntered() async { + guard !didEnter else { return } + await withCheckedContinuation { enteredWaiters.append($0) } + } + + func release() { + didRelease = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } +} + /// `WhereStore` that lets a test toggle whether `add(sample:)` succeeds; every /// other API forwards to a real in-memory `SwiftDataStore`. private actor ToggleFailingStore: WhereStore { private let backing: SwiftDataStore private var shouldFail = false + private var nextExpectedEpochGate: OneShotGate? init(backing: SwiftDataStore) { self.backing = backing @@ -515,14 +904,62 @@ private actor ToggleFailingStore: WhereStore { shouldFail = value } + func gateNextExpectedEpochPerform(with gate: OneShotGate) { + nextExpectedEpochGate = gate + } + func perform(_ block: @Sendable () async throws -> T) async throws -> T { try await backing.perform(block) } + func perform( + expectedDataEpochID: WhereDataEpochID, + _ block: @Sendable () async throws -> T, + ) async throws -> T { + if let gate = nextExpectedEpochGate { + nextExpectedEpochGate = nil + await gate.suspend() + } + return try await backing.perform(expectedDataEpochID: expectedDataEpochID, block) + } + nonisolated func changes() -> AsyncStream { backing.changes() } + func dataEpoch() async throws -> WhereDataEpoch { + try await backing.dataEpoch() + } + + func rotateDataEpoch( + reason: WhereDataEpochReason, + changedBy deviceID: RecordingDeviceID, + at date: Date, + ) async throws -> WhereDataEpoch { + try await backing.rotateDataEpoch(reason: reason, changedBy: deviceID, at: date) + } + + func backupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws -> BackupImportReceipt? { + try await backing.backupImportReceipt(id: id, installationID: installationID) + } + + func addBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws { + try await backing.addBackupImportReceipt(id: id, installationID: installationID) + } + + func removeBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws { + try await backing.removeBackupImportReceipt(id: id, installationID: installationID) + } + func add(sample: LocationSample) async throws { if shouldFail { throw ToggleFailingStoreError() } try await backing.add(sample: sample) @@ -540,8 +977,28 @@ private actor ToggleFailingStore: WhereStore { try await backing.recordingDevices() } - func setRecordingDevice(_ device: RecordingDevice) async throws { - try await backing.setRecordingDevice(device) + func recordingDeviceProfiles() async throws -> [RecordingDeviceProfile] { + try await backing.recordingDeviceProfiles() + } + + func addRecordingDeviceProfile(_ profile: RecordingDeviceProfile) async throws { + try await backing.addRecordingDeviceProfile(profile) + } + + func recordingDeviceMetadataChanges() async throws -> [RecordingDeviceMetadataChange] { + try await backing.recordingDeviceMetadataChanges() + } + + func addRecordingDeviceMetadataChange(_ change: RecordingDeviceMetadataChange) async throws { + try await backing.addRecordingDeviceMetadataChange(change) + } + + func recordingDeviceCheckIns() async throws -> [RecordingDeviceCheckIn] { + try await backing.recordingDeviceCheckIns() + } + + func setRecordingDeviceCheckIn(_ checkIn: RecordingDeviceCheckIn) async throws { + try await backing.setRecordingDeviceCheckIn(checkIn) } func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { @@ -591,10 +1048,6 @@ private actor ToggleFailingStore: WhereStore { try await backing.clear(in: interval, manualDays: dayRange) } - func clearAll() async throws { - try await backing.clearAll() - } - func dismissedIssueIDs() async throws -> Set { try await backing.dismissedIssueIDs() } diff --git a/Where/WhereCore/Tests/LocationOutboxTests.swift b/Where/WhereCore/Tests/LocationOutboxTests.swift index 9fa82723..5089e7fd 100644 --- a/Where/WhereCore/Tests/LocationOutboxTests.swift +++ b/Where/WhereCore/Tests/LocationOutboxTests.swift @@ -1,12 +1,55 @@ import Foundation import RegionKit import Testing -@testable import WhereCore +@_spi(Testing) @testable import WhereCore struct LocationOutboxTests { + private enum StubReadError: Error { + case temporarilyUnavailable + } + + private enum StubExclusionError: Error { + case refused + } + private func tempURL() -> URL { FileManager.default.temporaryDirectory - .appending(path: "location-outbox-\(UUID().uuidString).json") + .appending( + path: "LocationOutboxTests.\(UUID().uuidString)", + directoryHint: .isDirectory, + ) + .appending(path: "outbox.json") + } + + private func cleanup(_ url: URL) { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } + + private func pendingURL(for url: URL) -> URL { + url.appendingPathExtension("pending") + } + + private func write(_ samples: [LocationSample], to url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + try JSONEncoder().encode(samples).write(to: url, options: .atomic) + } + + private func entries(_ samples: [LocationSample]) -> [LocationOutboxEntry] { + samples.map { LocationOutboxEntry(sample: $0, dataEpochID: .initial) } + } + + private func loadedSamples(from outbox: FileLocationOutbox) async throws -> [LocationSample] { + try await outbox.load().map(\.sample) + } + + private static func excludeFromBackup(_ url: URL) throws { + var secured = url + var values = URLResourceValues() + values.isExcludedFromBackup = true + try secured.setResourceValues(values) } private func sample(_ isoString: String) -> LocationSample { @@ -19,39 +62,217 @@ struct LocationOutboxTests { ) } - @Test func savesAndLoadsRoundTrip() async { + @Test func savesAndLoadsRoundTrip() async throws { let url = tempURL() - defer { try? FileManager.default.removeItem(at: url) } + defer { cleanup(url) } let outbox = FileLocationOutbox(fileURL: url) let samples = [sample("2026-03-15T12:00:00Z"), sample("2026-03-15T13:00:00Z")] - await outbox.save(samples) - #expect(await outbox.load() == samples) + await outbox.save(entries(samples)) + #expect(try await loadedSamples(from: outbox) == samples) + } + + @Test func roundTripPreservesNoninitialDataEpoch() async throws { + let url = tempURL() + defer { cleanup(url) } + let outbox = FileLocationOutbox(fileURL: url) + let entry = try LocationOutboxEntry( + sample: sample("2026-03-15T12:00:00Z"), + dataEpochID: WhereDataEpochID(rawValue: #require(UUID( + uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + ))), + ) + + await outbox.save([entry]) + + #expect(try await outbox.load() == [entry]) + } + + @Test func persistedBacklogIsExcludedFromDeviceBackup() async throws { + let url = tempURL() + defer { cleanup(url) } + let outbox = FileLocationOutbox(fileURL: url) + + await outbox.save(entries([sample("2026-03-15T12:00:00Z")])) + + let values = try url.resourceValues(forKeys: [.isExcludedFromBackupKey]) + #expect(values.isExcludedFromBackup == true) + } + + @Test func constructingTheOutboxSecuresAFileLeftByAnOlderBuild() throws { + let url = tempURL() + defer { cleanup(url) } + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + try JSONEncoder().encode([sample("2026-03-15T12:00:00Z")]).write(to: url) + + _ = FileLocationOutbox(fileURL: url) + + let values = try url.resourceValues(forKeys: [.isExcludedFromBackupKey]) + #expect(values.isExcludedFromBackup == true) + } + + @Test func constructionPromotesACompletePendingFirstWrite() async throws { + let url = tempURL() + defer { cleanup(url) } + let pendingURL = pendingURL(for: url) + let samples = [sample("2026-03-15T12:00:00Z")] + try write(samples, to: pendingURL) + + let outbox = FileLocationOutbox(fileURL: url) + + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(FileManager.default.fileExists(atPath: pendingURL.path) == false) + let values = try url.resourceValues(forKeys: [.isExcludedFromBackupKey]) + #expect(values.isExcludedFromBackup == true) + #expect(try await loadedSamples(from: outbox) == samples) + } + + @Test func constructionPromotesPendingOverThePreviousBacklog() async throws { + let url = tempURL() + defer { cleanup(url) } + let pendingURL = pendingURL(for: url) + let previous = [sample("2026-03-15T12:00:00Z")] + let pending = [ + sample("2026-03-15T12:00:00Z"), + sample("2026-03-15T13:00:00Z"), + ] + try write(previous, to: url) + try write(pending, to: pendingURL) + + let outbox = FileLocationOutbox(fileURL: url) + + #expect(try await loadedSamples(from: outbox) == pending) + #expect(FileManager.default.fileExists(atPath: pendingURL.path) == false) } - @Test func savingEmptyClearsThePersistedBacklog() async { + @Test func corruptPendingBacklogIsDroppedWithoutReplacingThePreviousCopy() async throws { let url = tempURL() - defer { try? FileManager.default.removeItem(at: url) } + defer { cleanup(url) } + let pendingURL = pendingURL(for: url) + let previous = [sample("2026-03-15T12:00:00Z")] + try write(previous, to: url) + try Data("not valid json".utf8).write(to: pendingURL, options: .atomic) + let outbox = FileLocationOutbox(fileURL: url) - await outbox.save([sample("2026-03-15T12:00:00Z")]) + #expect(try await loadedSamples(from: outbox) == previous) + #expect(FileManager.default.fileExists(atPath: pendingURL.path) == false) + } + + @Test func pendingExclusionFailureDiscardsOnlyTheInsecureCopy() async throws { + let url = tempURL() + defer { cleanup(url) } + let pendingURL = pendingURL(for: url) + let previous = [sample("2026-03-15T12:00:00Z")] + try write(previous, to: url) + try write([sample("2026-03-15T13:00:00Z")], to: pendingURL) + + let outbox = FileLocationOutbox( + fileURL: url, + readData: { try Data(contentsOf: $0) }, + excludeFromBackup: { candidate in + guard candidate != pendingURL else { throw StubExclusionError.refused } + try Self.excludeFromBackup(candidate) + }, + ) + + #expect(try await loadedSamples(from: outbox) == previous) + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(FileManager.default.fileExists(atPath: pendingURL.path) == false) + } + + @Test func finalExclusionFailureDiscardsThePromotedCopy() throws { + let url = tempURL() + defer { cleanup(url) } + let pendingURL = pendingURL(for: url) + try write([sample("2026-03-15T12:00:00Z")], to: pendingURL) + + _ = FileLocationOutbox( + fileURL: url, + readData: { try Data(contentsOf: $0) }, + excludeFromBackup: { candidate in + guard candidate != url else { throw StubExclusionError.refused } + try Self.excludeFromBackup(candidate) + }, + ) + + #expect(FileManager.default.fileExists(atPath: url.path) == false) + #expect(FileManager.default.fileExists(atPath: pendingURL.path) == false) + } + + @Test func savingEmptyClearsThePersistedBacklog() async throws { + let url = tempURL() + defer { cleanup(url) } + let outbox = FileLocationOutbox(fileURL: url) + + await outbox.save(entries([sample("2026-03-15T12:00:00Z")])) await outbox.save([]) - #expect(await outbox.load().isEmpty) + #expect(try await outbox.load().isEmpty) #expect(!FileManager.default.fileExists(atPath: url.path)) } - @Test func loadingAMissingFileReturnsEmpty() async { + @Test func clearAlsoRemovesALegacyBacklogLeftByFailedMigration() async throws { + let url = tempURL() + defer { cleanup(url) } + let legacyURL = url.deletingLastPathComponent().appending(path: "legacy.json") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + try JSONEncoder().encode([sample("2026-03-15T12:00:00Z")]).write(to: legacyURL) + let outbox = FileLocationOutbox(fileURL: url, legacyFileURL: legacyURL) + + try await outbox.clear() + + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + } + + @Test func loadingAMissingFileReturnsEmpty() async throws { let outbox = FileLocationOutbox(fileURL: tempURL()) - #expect(await outbox.load().isEmpty) + #expect(try await outbox.load().isEmpty) } - @Test func loadingACorruptFileReturnsEmptyRatherThanThrowing() async throws { + @Test func loadingACorruptFileThrowsAfterDiscardingIt() async throws { let url = tempURL() - defer { try? FileManager.default.removeItem(at: url) } + defer { cleanup(url) } + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) try Data("not valid json".utf8).write(to: url) let outbox = FileLocationOutbox(fileURL: url) - #expect(await outbox.load().isEmpty) + await #expect(throws: DecodingError.self) { + try await outbox.load() + } + #expect(FileManager.default.fileExists(atPath: url.path) == false) + } + + @Test func transientReadFailurePreservesBacklogForALaterRetry() async throws { + let url = tempURL() + defer { cleanup(url) } + let samples = [sample("2026-03-15T12:00:00Z")] + let writer = FileLocationOutbox(fileURL: url) + await writer.save(entries(samples)) + let unavailableMarkerURL = url.appendingPathExtension("unavailable") + try Data().write(to: unavailableMarkerURL) + let outbox = FileLocationOutbox(fileURL: url) { fileURL in + guard FileManager.default.fileExists(atPath: unavailableMarkerURL.path) == false else { + throw StubReadError.temporarilyUnavailable + } + return try Data(contentsOf: fileURL) + } + + await #expect(throws: StubReadError.self) { + try await outbox.load() + } + #expect(FileManager.default.fileExists(atPath: url.path)) + + try FileManager.default.removeItem(at: unavailableMarkerURL) + #expect(try await loadedSamples(from: outbox) == samples) } } diff --git a/Where/WhereCore/Tests/RecordingConfigurationBroadcasterTests.swift b/Where/WhereCore/Tests/RecordingConfigurationBroadcasterTests.swift new file mode 100644 index 00000000..0e6f3019 --- /dev/null +++ b/Where/WhereCore/Tests/RecordingConfigurationBroadcasterTests.swift @@ -0,0 +1,41 @@ +import Testing +@testable import WhereCore + +struct RecordingConfigurationBroadcasterTests { + @Test func eachSubscriberReceivesTheSameRuntimeUpdate() async { + let broadcaster = RecordingConfigurationBroadcaster() + var first = broadcaster.subscribe().makeAsyncIterator() + var second = broadcaster.subscribe().makeAsyncIterator() + let update = RecordingDeviceRuntimeUpdate(sequence: 1, state: .unavailable) + + broadcaster.send(update) + + #expect(await first.next() == update) + #expect(await second.next() == update) + broadcaster.finishAll() + } + + @Test func aSlowSubscriberKeepsOnlyTheNewestRuntimeUpdate() async { + let broadcaster = RecordingConfigurationBroadcaster() + var iterator = broadcaster.subscribe().makeAsyncIterator() + let older = RecordingDeviceRuntimeUpdate(sequence: 1, state: .unavailable) + let newest = RecordingDeviceRuntimeUpdate(sequence: 2, state: .unavailable) + + broadcaster.send(older) + broadcaster.send(newest) + + #expect(await iterator.next() == newest) + broadcaster.finishAll() + } + + @Test func finishAllEndsEveryExistingSubscription() async { + let broadcaster = RecordingConfigurationBroadcaster() + var first = broadcaster.subscribe().makeAsyncIterator() + var second = broadcaster.subscribe().makeAsyncIterator() + + broadcaster.finishAll() + + #expect(await first.next() == nil) + #expect(await second.next() == nil) + } +} diff --git a/Where/WhereCore/Tests/RecordingDeviceMetadataChangeTests.swift b/Where/WhereCore/Tests/RecordingDeviceMetadataChangeTests.swift new file mode 100644 index 00000000..171b283a --- /dev/null +++ b/Where/WhereCore/Tests/RecordingDeviceMetadataChangeTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import WhereCore + +struct RecordingDeviceMetadataChangeTests { + private static let deviceID = RecordingDeviceID( + rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + ) + private static let changeID = UUID( + uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + )! + + @Test func clearedNicknameRoundTripsAsANicknameEvent() throws { + let change = RecordingDeviceMetadataChange( + id: Self.changeID, + deviceID: Self.deviceID, + revision: 1, + changedAt: Date(timeIntervalSinceReferenceDate: 100), + changedByDeviceID: Self.deviceID, + nickname: nil, + ) + + let decoded = try JSONDecoder().decode( + RecordingDeviceMetadataChange.self, + from: JSONEncoder().encode(change), + ) + + #expect(decoded == change) + #expect(decoded.field == .nickname) + #expect(decoded.nickname == nil) + } + + @Test func decoderRejectsTheRetiredArchiveMetadataField() throws { + let change = RecordingDeviceMetadataChange( + id: Self.changeID, + deviceID: Self.deviceID, + revision: 0, + changedAt: Date(timeIntervalSinceReferenceDate: 100), + changedByDeviceID: Self.deviceID, + nickname: nil, + ) + var object = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(change)) as? [String: Any], + ) + object["field"] = "archive" + object["isArchived"] = true + let data = try JSONSerialization.data(withJSONObject: object) + + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(RecordingDeviceMetadataChange.self, from: data) + } + } +} diff --git a/Where/WhereCore/Tests/RecordingPolicyChangeTests.swift b/Where/WhereCore/Tests/RecordingPolicyChangeTests.swift new file mode 100644 index 00000000..06208738 --- /dev/null +++ b/Where/WhereCore/Tests/RecordingPolicyChangeTests.swift @@ -0,0 +1,371 @@ +import Foundation +import Testing +@testable import WhereCore + +struct RecordingPolicyChangeTests { + private static let deviceID = RecordingDeviceID( + rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + ) + private static let writerID = RecordingDeviceID( + rawValue: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, + ) + private static let date = Date(timeIntervalSinceReferenceDate: 1000) + + @Test func laterOffFromThePreviouslyLosingSiblingRemainsAuthoritative() throws { + let history = Self.historyWithRestrictiveDescendant( + descendantID: "00000000-0000-0000-0000-000000000004", + state: .off, + reason: .userCommand, + ) + + #expect(RecordingPolicyChange.formValidPersistedTimelines(history)) + let canonical = try #require(RecordingPolicyChange.canonicalTimeline(in: history)) + #expect(canonical.map(\.id) == [history[0].id, history[2].id, history[3].id]) + #expect(canonical.last?.state == .off) + } + + @Test func laterArchiveFromThePreviouslyLosingSiblingRemainsAuthoritative() throws { + let history = Self.historyWithRestrictiveDescendant( + descendantID: "00000000-0000-0000-0000-000000000005", + state: .archived, + reason: .archive, + ) + + #expect(RecordingPolicyChange.formValidPersistedTimelines(history)) + let canonical = try #require(RecordingPolicyChange.canonicalTimeline(in: history)) + #expect(canonical.map(\.id) == [history[0].id, history[2].id, history[3].id]) + #expect(canonical.last?.state == .archived) + } + + @Test func oneAppendingCommandJoinsAndClearsEveryObservedHead() throws { + let root = Self.change( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [], + revision: 0, + effectiveAt: Self.date, + state: .on, + reason: .initialRegistration, + ) + let first = Self.change( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: Self.date.addingTimeInterval(1), + state: .on, + reason: .userCommand, + ) + let second = Self.change( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: Self.date.addingTimeInterval(2), + state: .off, + reason: .userCommand, + ) + let observed = [second, root, first] + + let command = try RecordingPolicyChange.appendingCommand( + to: observed, + deviceID: Self.deviceID, + issuedAt: Self.date.addingTimeInterval(3), + issuedByDeviceID: Self.writerID, + effectiveAt: Self.date.addingTimeInterval(1), + state: .on, + reason: .userCommand, + ) + let joined = observed + [command] + + #expect(command.parentIDs == [first.id, second.id]) + #expect(command.revision == 2) + #expect(command.effectiveAt == second.effectiveAt) + #expect(joined.count(where: { $0.revision == 2 }) == 1) + #expect(RecordingPolicyChange.maximalHeads(in: joined) == [command]) + #expect(RecordingPolicyChange.canonicalHead(in: joined) == command) + } + + @Test func incompleteMultiParentCommandFailsClosed() { + let root = Self.change( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [], + revision: 0, + state: .on, + reason: .initialRegistration, + ) + let orphanedJoin = Self.change( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [ + root.id, + Self.id("20000000-0000-0000-0000-000000000000"), + ], + revision: 1, + state: .off, + reason: .userCommand, + ) + + #expect( + RecordingPolicyChange.formValidPersistedTimelines([root, orphanedJoin]) == false, + ) + #expect(RecordingPolicyChange.canonicalTimeline(in: [root, orphanedJoin]) == nil) + #expect(RecordingPolicyChange.maximalHeads(in: [root, orphanedJoin]) == nil) + } + + @Test func historicalAuthorityResolvesTheEligibleInducedDAG() { + let root = Self.change( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [], + revision: 0, + effectiveAt: Self.date, + state: .on, + reason: .initialRegistration, + ) + let off = Self.change( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: Self.date.addingTimeInterval(10), + state: .off, + reason: .userCommand, + ) + let concurrentOn = Self.change( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: Self.date.addingTimeInterval(20), + state: .on, + reason: .userCommand, + ) + let joinedOn = Self.change( + id: "40000000-0000-0000-0000-000000000000", + parentIDs: [off.id, concurrentOn.id], + revision: 2, + effectiveAt: Self.date.addingTimeInterval(30), + state: .on, + reason: .userCommand, + ) + let history = [joinedOn, concurrentOn, root, off] + + #expect(RecordingPolicyChange.effectiveHead( + in: history, + at: Self.date.addingTimeInterval(5), + ) == root) + #expect(RecordingPolicyChange.effectiveHead( + in: history, + at: Self.date.addingTimeInterval(15), + ) == off) + #expect(RecordingPolicyChange.effectiveHead( + in: history, + at: Self.date.addingTimeInterval(25), + ) == off) + #expect(RecordingPolicyChange.effectiveHead( + in: history, + at: Self.date.addingTimeInterval(35), + ) == joinedOn) + } + + @Test func concurrentDestructiveBarrierChangesTheCleanupTokenEvenWhenItsUUIDLoses() throws { + let root = Self.change( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [], + revision: 0, + state: .on, + reason: .initialRegistration, + ) + let previousUUIDWinner = Self.change( + id: "F0000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: Self.date.addingTimeInterval(1), + state: .off, + reason: .accountReset, + ) + let newLowerUUIDBarrier = Self.change( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: Self.date.addingTimeInterval(1), + state: .off, + reason: .accountReset, + ) + let prior = try #require(RecordingPolicyChange.destructiveCleanupToken( + in: [root, previousUUIDWinner], + )) + let joined = try #require(RecordingPolicyChange.destructiveCleanupToken( + in: [newLowerUUIDBarrier, root, previousUUIDWinner], + )) + + #expect(prior.rawValue == previousUUIDWinner.id) + #expect(joined != prior) + #expect(RecordingPolicyChange.destructiveCleanupToken( + in: [previousUUIDWinner, newLowerUUIDBarrier, root], + ) == joined) + } + + @Test func concurrentResetFloorJoinsLatestCutoffUntilADescendantReplace() { + let root = Self.change( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [], + revision: 0, + state: .on, + reason: .initialRegistration, + ) + let earlierReset = Self.change( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: Self.date.addingTimeInterval(10), + state: .off, + reason: .accountReset, + ) + let laterReset = Self.change( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: Self.date.addingTimeInterval(20), + state: .off, + reason: .accountReset, + ) + let reenabled = Self.change( + id: "40000000-0000-0000-0000-000000000000", + parentIDs: [earlierReset.id, laterReset.id], + revision: 2, + effectiveAt: Self.date.addingTimeInterval(30), + state: .on, + reason: .userCommand, + ) + let replacement = Self.change( + id: "50000000-0000-0000-0000-000000000000", + parentIDs: [reenabled.id], + revision: 3, + effectiveAt: Self.date.addingTimeInterval(40), + state: .off, + reason: .backupReplace, + ) + let throughReenable = [laterReset, root, reenabled, earlierReset] + + #expect( + RecordingPolicyChange.activeAccountResetFloor(in: throughReenable) + == laterReset.effectiveAt, + ) + #expect(RecordingPolicyChange.activeAccountResetFloor( + in: throughReenable + [replacement], + ) == nil) + } + + @Test func childCannotMoveItsHistoricalCutoffBeforeItsParent() { + let root = Self.change( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [], + revision: 0, + effectiveAt: Self.date, + state: .on, + reason: .initialRegistration, + ) + let off = Self.change( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: Self.date.addingTimeInterval(2), + state: .off, + reason: .userCommand, + ) + let backdatedEnable = Self.change( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [off.id], + revision: 2, + effectiveAt: Self.date.addingTimeInterval(1), + state: .on, + reason: .userCommand, + ) + let history = [root, off, backdatedEnable] + + #expect(RecordingPolicyChange.formValidPersistedTimelines(history) == false) + #expect(RecordingPolicyChange.canonicalTimeline(in: history) == nil) + } + + @Test func backupMergeCanPreserveEveryCompleteAuthorityState() { + let ids = [ + "10000000-0000-0000-0000-000000000000", + "20000000-0000-0000-0000-000000000000", + "30000000-0000-0000-0000-000000000000", + ] + for (id, state) in zip(ids, [ + RecordingPolicyState.on, + .off, + .archived, + ]) { + let barrier = Self.change( + id: id, + parentIDs: [], + revision: 0, + state: state, + reason: .backupMerge, + ) + #expect(barrier.hasValidReasonAndState) + #expect(barrier.reason.discardsPendingSamples == false) + } + } + + private static func historyWithRestrictiveDescendant( + descendantID: String, + state: RecordingPolicyState, + reason: RecordingPolicyReason, + ) -> [RecordingPolicyChange] { + let root = change( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [], + revision: 0, + state: .on, + reason: .initialRegistration, + ) + let previousWinner = change( + id: "F0000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: date.addingTimeInterval(1), + state: .on, + reason: .userCommand, + ) + let previousLoser = change( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [root.id], + revision: 1, + effectiveAt: date.addingTimeInterval(1), + state: .on, + reason: .userCommand, + ) + let restrictiveDescendant = change( + id: descendantID, + parentIDs: [previousLoser.id], + revision: 2, + effectiveAt: date.addingTimeInterval(2), + state: state, + reason: reason, + ) + return [root, previousWinner, previousLoser, restrictiveDescendant] + } + + private static func change( + id: String, + parentIDs: [UUID], + revision: Int64, + effectiveAt: Date = date, + state: RecordingPolicyState, + reason: RecordingPolicyReason, + ) -> RecordingPolicyChange { + RecordingPolicyChange( + id: self.id(id), + deviceID: deviceID, + parentIDs: parentIDs, + revision: revision, + issuedAt: date, + issuedByDeviceID: writerID, + effectiveAt: effectiveAt, + state: state, + reason: reason, + ) + } + + private static func id(_ value: String) -> UUID { + UUID(uuidString: value)! + } +} diff --git a/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift b/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift index a82e76ba..b490b81d 100644 --- a/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift +++ b/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift @@ -26,12 +26,19 @@ struct RecordingPolicyFilterTests { _ timestamp: String, enabled: Bool, id: String, + parentIDs: [String] = [], + revision: Int64 = 0, ) -> RecordingPolicyChange { RecordingPolicyChange( id: UUID(uuidString: id)!, deviceID: deviceID, + parentIDs: parentIDs.compactMap(UUID.init(uuidString:)), + revision: revision, + issuedAt: WhereCoreTestSupport.iso(timestamp), + issuedByDeviceID: deviceID, effectiveAt: WhereCoreTestSupport.iso(timestamp), - isEnabled: enabled, + state: enabled ? .on : .off, + reason: .userCommand, ) } @@ -40,15 +47,24 @@ struct RecordingPolicyFilterTests { let during = Self.sample("2026-03-02T08:00:00-08:00") let after = Self.sample("2026-03-03T08:00:00-08:00") let policies = [ + Self.policy( + "2026-03-01T00:00:00-08:00", + enabled: true, + id: "05000000-0000-0000-0000-000000000000", + ), Self.policy( "2026-03-02T00:00:00-08:00", enabled: false, id: "10000000-0000-0000-0000-000000000000", + parentIDs: ["05000000-0000-0000-0000-000000000000"], + revision: 1, ), Self.policy( "2026-03-03T00:00:00-08:00", enabled: true, id: "20000000-0000-0000-0000-000000000000", + parentIDs: ["10000000-0000-0000-0000-000000000000"], + revision: 2, ), ] @@ -95,7 +111,7 @@ struct RecordingPolicyFilterTests { #expect(visible.map(\.id) == [legacy.id, manual.id]) } - @Test func equalTimestampPoliciesConvergeByID() { + @Test func equalRevisionPoliciesPreferTheMoreRestrictiveState() { let disabled = Self.policy( "2026-03-02T00:00:00-08:00", enabled: false, @@ -111,6 +127,202 @@ struct RecordingPolicyFilterTests { #expect(RecordingPolicyFilter.visibleSamples( [sample], policyChanges: [enabled, disabled], - ) == [sample]) + ).isEmpty) + } + + @Test func causalWinnerIsNotReversedByAnotherDevicesLaterClock() { + let initial = Self.policy( + "2026-02-28T00:00:00-08:00", + enabled: true, + id: "10000000-0000-0000-0000-000000000000", + ) + let causallyLaterDisable = Self.policy( + "2026-03-02T00:00:00-08:00", + enabled: false, + id: "20000000-0000-0000-0000-000000000000", + parentIDs: ["30000000-0000-0000-0000-000000000000"], + revision: 2, + ) + let clockSkewedOlderEnable = Self.policy( + "2026-03-03T00:00:00-08:00", + enabled: true, + id: "30000000-0000-0000-0000-000000000000", + parentIDs: ["10000000-0000-0000-0000-000000000000"], + revision: 1, + ) + let sample = Self.sample("2026-03-04T08:00:00-08:00") + + #expect(RecordingPolicyFilter.visibleSamples( + [sample], + policyChanges: [initial, clockSkewedOlderEnable, causallyLaterDisable], + ).isEmpty) + } + + @Test func deviceStampedSampleFailsClosedUntilItsPolicyArrives() { + let stamped = Self.sample("2026-03-02T08:00:00-08:00") + let legacy = Self.sample("2026-03-02T09:00:00-08:00", deviceID: nil) + + let visible = RecordingPolicyFilter.visibleSamples( + [stamped, legacy], + policyChanges: [], + ) + + #expect(visible == [legacy]) + } + + @Test func deviceStampedSampleFailsClosedWhilePolicyRevisionsHaveAGap() { + let initial = Self.policy( + "2026-03-01T00:00:00-08:00", + enabled: true, + id: "10000000-0000-0000-0000-000000000000", + ) + let laterEnable = Self.policy( + "2026-03-03T00:00:00-08:00", + enabled: true, + id: "30000000-0000-0000-0000-000000000000", + parentIDs: ["10000000-0000-0000-0000-000000000000"], + revision: 2, + ) + let stamped = Self.sample("2026-03-04T08:00:00-08:00") + let legacy = Self.sample("2026-03-04T09:00:00-08:00", deviceID: nil) + + let visible = RecordingPolicyFilter.visibleSamples( + [stamped, legacy], + policyChanges: [initial, laterEnable], + ) + + #expect(visible == [legacy]) + } + + @Test func backdatedChildCannotReExposeSamplesBeforeItsOffParent() { + let initial = Self.policy( + "2026-03-01T00:00:00-08:00", + enabled: true, + id: "10000000-0000-0000-0000-000000000000", + ) + let off = Self.policy( + "2026-03-03T00:00:00-08:00", + enabled: false, + id: "20000000-0000-0000-0000-000000000000", + parentIDs: ["10000000-0000-0000-0000-000000000000"], + revision: 1, + ) + let backdatedEnable = Self.policy( + "2026-03-02T00:00:00-08:00", + enabled: true, + id: "30000000-0000-0000-0000-000000000000", + parentIDs: ["20000000-0000-0000-0000-000000000000"], + revision: 2, + ) + let sample = Self.sample("2026-03-02T08:00:00-08:00") + + #expect(RecordingPolicyFilter.visibleSamples( + [sample], + policyChanges: [initial, off, backdatedEnable], + ).isEmpty) + } + + @Test func archivedAuthorityExcludesLaterSamples() throws { + let sample = Self.sample("2026-03-03T08:00:00-08:00") + let archiveID = try #require( + UUID(uuidString: "20000000-0000-0000-0000-000000000000"), + ) + let archived = try RecordingPolicyChange( + id: archiveID, + deviceID: Self.deviceID, + parentIDs: [#require(UUID(uuidString: "10000000-0000-0000-0000-000000000000"))], + revision: 1, + issuedAt: WhereCoreTestSupport.iso("2026-03-02T00:00:00-08:00"), + issuedByDeviceID: Self.deviceID, + effectiveAt: WhereCoreTestSupport.iso("2026-03-02T00:00:00-08:00"), + state: .archived, + reason: .archive, + ) + + #expect(RecordingPolicyFilter.visibleSamples( + [sample], + policyChanges: [ + Self.policy( + "2026-03-01T00:00:00-08:00", + enabled: true, + id: "10000000-0000-0000-0000-000000000000", + ), + archived, + ], + ).isEmpty) + } + + @Test func accountResetKeepsLatePreResetSamplesErasedAfterReenable() throws { + let initial = Self.policy( + "2026-03-01T00:00:00-08:00", + enabled: true, + id: "10000000-0000-0000-0000-000000000000", + ) + let resetAt = WhereCoreTestSupport.iso("2026-03-03T00:00:00-08:00") + let reset = try RecordingPolicyChange( + id: #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")), + deviceID: Self.deviceID, + parentIDs: [initial.id], + revision: 1, + issuedAt: resetAt, + issuedByDeviceID: Self.deviceID, + effectiveAt: resetAt, + state: .off, + reason: .accountReset, + ) + let reenabled = Self.policy( + "2026-03-04T00:00:00-08:00", + enabled: true, + id: "30000000-0000-0000-0000-000000000000", + parentIDs: ["20000000-0000-0000-0000-000000000000"], + revision: 2, + ) + let latePreReset = Self.sample("2026-03-02T08:00:00-08:00") + let afterReenable = Self.sample("2026-03-05T08:00:00-08:00") + + let visible = RecordingPolicyFilter.visibleSamples( + [latePreReset, afterReenable], + policyChanges: [initial, reset, reenabled], + ) + + #expect(visible.map(\.id) == [afterReenable.id]) + } + + @Test func concurrentAccountResetOutranksBackupReplacement() throws { + let initial = Self.policy( + "2026-03-01T00:00:00-08:00", + enabled: true, + id: "05000000-0000-0000-0000-000000000000", + ) + let commandDate = WhereCoreTestSupport.iso("2026-03-03T00:00:00-08:00") + let reset = try RecordingPolicyChange( + id: #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")), + deviceID: Self.deviceID, + parentIDs: [initial.id], + revision: 1, + issuedAt: commandDate, + issuedByDeviceID: Self.deviceID, + effectiveAt: commandDate, + state: .off, + reason: .accountReset, + ) + // Its lexically later id would win the old UUID tie-break, dropping the reset floor. + let replacement = try RecordingPolicyChange( + id: #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")), + deviceID: Self.deviceID, + parentIDs: [initial.id], + revision: 1, + issuedAt: commandDate, + issuedByDeviceID: Self.deviceID, + effectiveAt: commandDate, + state: .off, + reason: .backupReplace, + ) + let latePreReset = Self.sample("2026-03-02T08:00:00-08:00") + + #expect(RecordingPolicyFilter.visibleSamples( + [latePreReset], + policyChanges: [replacement, initial, reset], + ).isEmpty) } } diff --git a/Where/WhereCore/Tests/RegionAttributionTests.swift b/Where/WhereCore/Tests/RegionAttributionTests.swift index a9dc489f..54126104 100644 --- a/Where/WhereCore/Tests/RegionAttributionTests.swift +++ b/Where/WhereCore/Tests/RegionAttributionTests.swift @@ -15,6 +15,7 @@ struct RegionAttributionTests { let illinois = try #require(Region(rawValue: "us-IL")) let attribution = RegionAttribution( store: store, + changes: store.changes(), initial: RegionAttributor(for: [.california]), trackedIDs: [Region.california.rawValue], ) @@ -39,6 +40,7 @@ struct RegionAttributionTests { let store = try SwiftDataStore.inMemory() let attribution = RegionAttribution( store: store, + changes: store.changes(), initial: RegionAttributor(for: Array(SwiftDataStore.defaultTrackedRegions)), trackedIDs: Set(SwiftDataStore.defaultTrackedRegions.map(\.rawValue)), ) diff --git a/Where/WhereCore/Tests/RemoteDataChangeReconcilerTests.swift b/Where/WhereCore/Tests/RemoteDataChangeReconcilerTests.swift new file mode 100644 index 00000000..57143a90 --- /dev/null +++ b/Where/WhereCore/Tests/RemoteDataChangeReconcilerTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import WhereCore + +struct RemoteDataChangeReconcilerTests { + @Test func remotePingRunsTheInjectedReconciliation() async throws { + let changes = StoreChangeBroadcaster() + let recorder = ReconcileRecorder() + let reconciler = RemoteDataChangeReconciler(changes: changes.subscribe()) { + await recorder.record() + } + + changes.send() + + try await waitUntil { await recorder.count == 1 } + _ = reconciler + } + + private func waitUntil( + timeout: Duration = .seconds(2), + condition: @escaping @Sendable () async -> Bool, + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if await condition() { return } + await Task.yield() + } + Issue.record("waitUntil timed out") + } +} + +private actor ReconcileRecorder { + private(set) var count = 0 + + func record() { + count += 1 + } +} diff --git a/Where/WhereCore/Tests/ReportReaderTests.swift b/Where/WhereCore/Tests/ReportReaderTests.swift index b0ff7179..6bf36b87 100644 --- a/Where/WhereCore/Tests/ReportReaderTests.swift +++ b/Where/WhereCore/Tests/ReportReaderTests.swift @@ -47,6 +47,17 @@ struct ReportReaderTests { rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) try await store.perform { + try await store.addRecordingPolicyChange(RecordingPolicyChange( + id: UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!, + deviceID: deviceID, + parentIDs: [], + revision: 0, + issuedAt: WhereCoreTestSupport.iso("2026-01-01T00:00:00-08:00"), + issuedByDeviceID: deviceID, + effectiveAt: WhereCoreTestSupport.iso("2026-01-01T00:00:00-08:00"), + state: .on, + reason: .initialRegistration, + )) try await store.add(sample: LocationSample( timestamp: WhereCoreTestSupport.iso("2026-01-10T12:00:00-08:00"), coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), @@ -64,8 +75,13 @@ struct ReportReaderTests { try await store.addRecordingPolicyChange(RecordingPolicyChange( id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, deviceID: deviceID, + parentIDs: [UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!], + revision: 1, + issuedAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"), + issuedByDeviceID: deviceID, effectiveAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"), - isEnabled: false, + state: .off, + reason: .userCommand, )) } diff --git a/Where/WhereCore/Tests/StoreRemoteChangeSourceTests.swift b/Where/WhereCore/Tests/StoreRemoteChangeSourceTests.swift index 20b05a8e..490b34f1 100644 --- a/Where/WhereCore/Tests/StoreRemoteChangeSourceTests.swift +++ b/Where/WhereCore/Tests/StoreRemoteChangeSourceTests.swift @@ -1,5 +1,6 @@ import CoreData import Foundation +import SwiftData import Testing @_spi(Testing) @testable import WhereCore @@ -20,11 +21,21 @@ struct StoreRemoteChangeSourceTests { /// The production source forwards a remote-change notification identifying /// the Where store it was built to observe. - @Test func persistentSourceForwardsChangeForItsStore() async { + @Test func persistentSourceForwardsExternalAuthorForItsStore() async throws { let center = NotificationCenter() - let storeURL = URL(fileURLWithPath: "/Where.store") - let source = PersistentStoreRemoteChangeSource(storeURL: storeURL, center: center) + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let storeURL = try #require(container.configurations.first?.url) + let source = try PersistentStoreRemoteChangeSource( + modelContainer: container, + storeURL: storeURL, + localTransactionAuthor: "where-local", + center: center, + ) let stream = source.remoteChanges + let external = ModelContext(container) + external.author = "where-other-process" + external.insert(SDTrackedRegion(regionID: "us-TX", epochID: .initial)) + try external.save() withExtendedLifetime(source) { center.post( @@ -37,13 +48,71 @@ struct StoreRemoteChangeSourceTests { #expect(await firstPing(stream, within: .seconds(2))) } + /// Observation starts after the initial history cursor is captured. An external commit in + /// that setup interval has already posted its notification to nobody, so the source must run + /// one history catch-up after registering rather than waiting for an unrelated later write. + @Test func persistentSourceCatchesCommitBetweenHistoryBaselineAndObservation() async throws { + let center = NotificationCenter() + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let storeURL = try #require(container.configurations.first?.url) + let source = try PersistentStoreRemoteChangeSource( + modelContainer: container, + storeURL: storeURL, + localTransactionAuthor: "where-local", + center: center, + testingAfterHistoryBaseline: { + let external = ModelContext(container) + external.author = "where-other-process" + external.insert(SDTrackedRegion(regionID: "us-TX", epochID: .initial)) + try external.save() + }, + ) + + #expect(await firstPing(source.remoteChanges, within: .seconds(2))) + } + + /// Core Data posts its remote-change notification for the app's own saves + /// too. The transaction author prevents those local commits from running a + /// second, full remote reconciliation after their focused one. + @Test func persistentSourceSuppressesItsLocalTransactionAuthor() async throws { + let center = NotificationCenter() + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let storeURL = try #require(container.configurations.first?.url) + let localAuthor = "where-local" + let source = try PersistentStoreRemoteChangeSource( + modelContainer: container, + storeURL: storeURL, + localTransactionAuthor: localAuthor, + center: center, + ) + let stream = source.remoteChanges + let local = ModelContext(container) + local.author = localAuthor + local.insert(SDTrackedRegion(regionID: "us-TX", epochID: .initial)) + try local.save() + + withExtendedLifetime(source) { + center.post( + name: .NSPersistentStoreRemoteChange, + object: nil, + userInfo: [NSPersistentStoreURLKey: storeURL], + ) + } + + #expect(await firstPing(stream, within: .milliseconds(200)) == false) + } + /// A second SwiftData store in the process (Periscope in the app) also posts /// `.NSPersistentStoreRemoteChange`; its commits must not invalidate Where's /// data or the resulting refresh spans feed back into more log-store writes. - @Test func persistentSourceIgnoresChangeForAnotherStore() async { + @Test func persistentSourceIgnoresChangeForAnotherStore() async throws { let center = NotificationCenter() - let source = PersistentStoreRemoteChangeSource( - storeURL: URL(fileURLWithPath: "/Where.store"), + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let storeURL = try #require(container.configurations.first?.url) + let source = try PersistentStoreRemoteChangeSource( + modelContainer: container, + storeURL: storeURL, + localTransactionAuthor: "where-local", center: center, ) let stream = source.remoteChanges @@ -60,6 +129,27 @@ struct StoreRemoteChangeSourceTests { #expect(await firstPing(stream, within: .milliseconds(200)) == false) } + + /// Target/selector observation must not make the notification center own + /// the source; otherwise `deinit` can never unregister or finish its tasks. + @Test func persistentSourceIsNotRetainedByNotificationCenter() throws { + let center = NotificationCenter() + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let storeURL = try #require(container.configurations.first?.url) + weak var weakSource: PersistentStoreRemoteChangeSource? + + try autoreleasepool { + let source = try PersistentStoreRemoteChangeSource( + modelContainer: container, + storeURL: storeURL, + localTransactionAuthor: "where-local", + center: center, + ) + weakSource = source + } + + #expect(weakSource == nil) + } } /// Awaits the first source emission, returning `false` if none arrives within diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 278bcb65..11c037c8 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -1,5 +1,6 @@ import Foundation import RegionKit +import SwiftData import Testing @_spi(Testing) @testable import WhereCore @@ -24,6 +25,9 @@ struct SwiftDataStoreTests { } private static let calendar = WhereCoreTestSupport.calendar() + private static let epochWriterID = RecordingDeviceID( + rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + ) private let day = DayPresence( date: Date(timeIntervalSince1970: 0), @@ -95,6 +99,40 @@ struct SwiftDataStoreTests { #expect(stored.count == count) } + @Test func unrelatedReadCannotSeeAnotherTasksPendingTransaction() async throws { + let store = try SwiftDataStore.inMemory() + let pending = LocationSample( + timestamp: Date(timeIntervalSinceReferenceDate: 100), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .manual, + ) + let (started, startedContinuation) = AsyncStream.makeStream(of: Void.self) + let (release, releaseContinuation) = AsyncStream.makeStream(of: Void.self) + let writer = Task { + try await store.perform { + try await store.add(sample: pending) + // Suspend while `writerContext` exists, widening the exact actor-reentrancy + // window where an unrelated read once selected that uncommitted peer. + startedContinuation.yield() + startedContinuation.finish() + for await _ in release { + break + } + } + } + for await _ in started { + break + } + + #expect(try await store.allSamples().isEmpty) + + releaseContinuation.yield() + releaseContinuation.finish() + try await writer.value + #expect(try await store.allSamples() == [pending]) + } + @Test func rolledBackWriteDoesNotPingChanges() async throws { let store = try SwiftDataStore.inMemory() let stream = store.changes() @@ -111,39 +149,73 @@ struct SwiftDataStoreTests { #expect(await !firstPing(stream, within: .milliseconds(200))) } - @Test func recordingDeviceAndPolicyRoundTripWithoutDuplicateLogicalRows() async throws { + @Test func recordingDeviceAndPolicyRowsRoundTripWithoutDuplicateLogicalRows() async throws { let store = try SwiftDataStore.inMemory() let deviceID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) let date = Date(timeIntervalSinceReferenceDate: 100) - let device = RecordingDevice( + let profile = RecordingDeviceProfile( id: deviceID, systemName: "iPad", - nickname: "Home iPad", kind: .tablet, registeredAt: date, + registrationEpochID: .initial, + ) + let nicknameMetadata = try RecordingDeviceMetadataChange( + id: #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")), + deviceID: deviceID, + revision: 0, + changedAt: date, + changedByDeviceID: deviceID, + nickname: "Home iPad", + ) + let checkIn = RecordingDeviceCheckIn( + deviceID: deviceID, + revision: 0, lastSeenAt: date, - archivedAt: nil, + appliedAt: date, lastAppliedPolicyChangeID: policyID, status: .off, ) let policy = RecordingPolicyChange( id: policyID, deviceID: deviceID, + parentIDs: [], + revision: 0, + issuedAt: date, + issuedByDeviceID: deviceID, effectiveAt: date, - isEnabled: false, + state: .off, + reason: .initialRegistration, ) try await store.perform { - try await store.setRecordingDevice(device) - try await store.setRecordingDevice(device) + try await store.addRecordingDeviceProfile(profile) + try await store.addRecordingDeviceProfile(profile) + try await store.addRecordingDeviceMetadataChange(nicknameMetadata) + try await store.addRecordingDeviceMetadataChange(nicknameMetadata) + try await store.setRecordingDeviceCheckIn(checkIn) + try await store.setRecordingDeviceCheckIn(checkIn) try await store.addRecordingPolicyChange(policy) try await store.addRecordingPolicyChange(policy) } - #expect(try await store.recordingDevices() == [device]) + #expect(try await store.recordingDeviceProfiles() == [profile]) + #expect(try await store.recordingDeviceMetadataChanges() == [nicknameMetadata]) + #expect(try await store.recordingDeviceCheckIns() == [checkIn]) + #expect(try await store.recordingDevices() == [RecordingDevice( + id: deviceID, + systemName: "iPad", + nickname: "Home iPad", + kind: .tablet, + registeredAt: date, + lastSeenAt: date, + archivedAt: nil, + lastAppliedPolicyChangeID: policyID, + status: .off, + )]) #expect(try await store.recordingPolicyChanges() == [policy]) } @@ -164,6 +236,862 @@ struct SwiftDataStoreTests { #expect(await firstPing(stream, within: .seconds(2))) } + @Test func simulatedRemoteRecordingImportIsReadableAfterRemoteChange() async throws { + let source = ScriptedStoreRemoteChangeSource() + let store = try SwiftDataStore.inMemory(remoteChangeSource: source) + let localWriteStream = store.changes() + let deviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) + let date = Date(timeIntervalSinceReferenceDate: 100) + let profile = RecordingDeviceProfile( + id: deviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: date, + registrationEpochID: .initial, + ) + let metadata = try RecordingDeviceMetadataChange( + id: #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")), + deviceID: deviceID, + revision: 0, + changedAt: date, + changedByDeviceID: deviceID, + nickname: "Travel iPad", + ) + let checkIn = RecordingDeviceCheckIn( + deviceID: deviceID, + revision: 0, + lastSeenAt: date, + appliedAt: date, + lastAppliedPolicyChangeID: policyID, + status: .recording, + ) + let policy = RecordingPolicyChange( + id: policyID, + deviceID: deviceID, + parentIDs: [], + revision: 0, + issuedAt: date, + issuedByDeviceID: deviceID, + effectiveAt: date, + state: .on, + reason: .initialRegistration, + ) + + try await store.simulateRemoteRecordingImport( + profiles: [profile], + metadataChanges: [metadata], + checkIns: [checkIn], + policyChanges: [policy], + ) + + // The seam suppresses the ordinary local-commit ping: observers must + // refresh through the same remote-change signal production CloudKit uses. + #expect(await !firstPing(localWriteStream, within: .milliseconds(200))) + let remoteChangeStream = store.changes() + source.yield() + #expect(await firstPing(remoteChangeStream, within: .seconds(2))) + + #expect(try await store.recordingDeviceProfiles() == [profile]) + #expect(try await store.recordingDeviceMetadataChanges() == [metadata]) + #expect(try await store.recordingDeviceCheckIns() == [checkIn]) + #expect(try await store.recordingPolicyChanges() == [policy]) + let device = try #require(try await store.recordingDevices().first) + #expect(device.nickname == "Travel iPad") + #expect(device.status == .recording) + #expect(device.lastAppliedPolicyChangeID == policyID) + } + + @Test func newerCheckInRevisionWinsEvenWhenItsWallClockMovedBackward() async throws { + let store = try SwiftDataStore.inMemory() + let deviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + let firstPolicyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) + let secondPolicyID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")) + let first = RecordingDeviceCheckIn( + deviceID: deviceID, + revision: 0, + lastSeenAt: Date(timeIntervalSinceReferenceDate: 200), + appliedAt: Date(timeIntervalSinceReferenceDate: 200), + lastAppliedPolicyChangeID: firstPolicyID, + status: .recording, + ) + let causallyLater = RecordingDeviceCheckIn( + deviceID: deviceID, + revision: 1, + lastSeenAt: Date(timeIntervalSinceReferenceDate: 100), + appliedAt: Date(timeIntervalSinceReferenceDate: 100), + lastAppliedPolicyChangeID: secondPolicyID, + status: .off, + ) + + try await store.perform { try await store.setRecordingDeviceCheckIn(first) } + try await store.perform { try await store.setRecordingDeviceCheckIn(causallyLater) } + + #expect(try await store.recordingDeviceCheckIns() == [causallyLater]) + } + + @Test func malformedSyncedAuthorityFailsClosedWhileOtherRowsAreDropped() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + let deviceID = try #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")) + let eventID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) + let date = Date(timeIntervalSinceReferenceDate: 100) + + let negativeMetadata = SDRecordingDeviceMetadataChange() + negativeMetadata.id = eventID + negativeMetadata.deviceID = deviceID + negativeMetadata.fieldRaw = RecordingDeviceMetadataField.nickname.rawValue + negativeMetadata.revision = -1 + negativeMetadata.changedAt = date + negativeMetadata.changedByDeviceID = deviceID + + let combinedMetadata = SDRecordingDeviceMetadataChange() + combinedMetadata.id = UUID() + combinedMetadata.deviceID = deviceID + combinedMetadata.fieldRaw = "removed-archive-field" + combinedMetadata.revision = 0 + combinedMetadata.changedAt = date + combinedMetadata.changedByDeviceID = deviceID + combinedMetadata.nickname = "iPad" + + let checkIn = SDRecordingDeviceCheckIn() + checkIn.deviceID = deviceID + checkIn.revision = -1 + checkIn.lastSeenAt = date + checkIn.appliedAt = date + checkIn.lastAppliedPolicyChangeID = eventID + checkIn.statusRaw = RecordingDeviceStatus.off.rawValue + + let policy = SDRecordingPolicyChange() + policy.id = eventID + policy.deviceID = deviceID + policy.revision = -1 + policy.issuedAt = date + policy.issuedByDeviceID = deviceID + policy.effectiveAt = date + policy.stateRaw = RecordingPolicyState.off.rawValue + policy.reasonRaw = RecordingPolicyReason.userCommand.rawValue + + context.insert(negativeMetadata) + context.insert(combinedMetadata) + context.insert(checkIn) + context.insert(policy) + try context.save() + let store = SwiftDataStore(modelContainer: container) + + #expect(try await store.recordingDeviceMetadataChanges().isEmpty) + #expect(try await store.recordingDeviceCheckIns().isEmpty) + await #expect(throws: RecordingPersistenceError.corruptRecordingPolicyHistory) { + try await store.recordingPolicyChanges() + } + } + + @Test func newMultiParentRowsFailClosedWhileTheirParentArrayIsUnavailable() throws { + let firstEpochParent = Self.epochID("10000000-0000-0000-0000-000000000000") + let secondEpochParent = Self.epochID("20000000-0000-0000-0000-000000000000") + let epoch = Self.epoch( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [firstEpochParent, secondEpochParent], + revision: 2, + changedAt: Date(timeIntervalSinceReferenceDate: 300), + reason: .backupReplace, + ) + let epochRow = SDWhereDataEpoch(value: epoch) + #expect(epochRow.parentID == nil) + #expect(epochRow.parentIDs == [ + firstEpochParent.rawValue, + secondEpochParent.rawValue, + ]) + epochRow.parentIDs = nil + + let policyParentIDs = try [ + #require(UUID(uuidString: "40000000-0000-0000-0000-000000000000")), + #require(UUID(uuidString: "50000000-0000-0000-0000-000000000000")), + ] + let policy = try RecordingPolicyChange( + id: #require(UUID(uuidString: "60000000-0000-0000-0000-000000000000")), + deviceID: Self.epochWriterID, + parentIDs: policyParentIDs, + revision: 2, + issuedAt: Date(timeIntervalSinceReferenceDate: 300), + issuedByDeviceID: Self.epochWriterID, + effectiveAt: Date(timeIntervalSinceReferenceDate: 300), + state: .off, + reason: .userCommand, + ) + let policyRow = SDRecordingPolicyChange(value: policy, epochID: .initial) + #expect(policyRow.parentID == nil) + #expect(policyRow.parentIDs == policyParentIDs) + policyRow.parentIDs = nil + + #expect(epochRow.toValue() == nil) + #expect(policyRow.toValue() == nil) + } + + @Test func legacyScalarParentsStillDecodeAsSingleParentArrays() throws { + let epochID = try #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")) + let epochRow = SDWhereDataEpoch() + epochRow.id = epochID + epochRow.parentID = WhereDataEpochID.initial.rawValue + epochRow.parentIDs = nil + epochRow.revision = 1 + epochRow.changedAt = Date(timeIntervalSinceReferenceDate: 100) + epochRow.changedByDeviceID = Self.epochWriterID.rawValue + epochRow.reasonRaw = WhereDataEpochReason.accountReset.rawValue + + let policyID = try #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")) + let policyParentID = try #require(UUID(uuidString: "30000000-0000-0000-0000-000000000000")) + let policyRow = SDRecordingPolicyChange() + policyRow.epochID = WhereDataEpochID.initial.rawValue + policyRow.id = policyID + policyRow.deviceID = Self.epochWriterID.rawValue + policyRow.parentID = policyParentID + policyRow.parentIDs = nil + policyRow.revision = 1 + policyRow.issuedAt = Date(timeIntervalSinceReferenceDate: 100) + policyRow.issuedByDeviceID = Self.epochWriterID.rawValue + policyRow.effectiveAt = Date(timeIntervalSinceReferenceDate: 100) + policyRow.stateRaw = RecordingPolicyState.off.rawValue + policyRow.reasonRaw = RecordingPolicyReason.userCommand.rawValue + + #expect(try #require(epochRow.toValue()).parentIDs == [.initial]) + #expect(try #require(policyRow.toValue()).parentIDs == [policyParentID]) + } + + @Test func lateRowsFromASupersededEpochCannotRepopulateAnySyncedUserData() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore(modelContainer: container) + let deviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) + let date = Date(timeIntervalSinceReferenceDate: 100) + let sample = LocationSample( + timestamp: date, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsVisit, + recordingDeviceID: deviceID, + ) + let evidence = try Evidence( + id: #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")), + kind: .boardingPass, + capturedAt: date, + region: .california, + note: nil, + contentType: .pdf, + ) + let manualDay = DayPresence( + date: date, + in: Self.calendar, + regions: [.california], + ) + let dismissal = DismissedIssue( + id: .borderDrift(day: manualDay.day), + dismissedAt: date, + ) + let profile = RecordingDeviceProfile( + id: deviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: date, + registrationEpochID: .initial, + ) + let metadata = try RecordingDeviceMetadataChange( + id: #require(UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")), + deviceID: deviceID, + revision: 0, + changedAt: date, + changedByDeviceID: deviceID, + nickname: "Home iPad", + ) + let checkIn = RecordingDeviceCheckIn( + deviceID: deviceID, + revision: 0, + lastSeenAt: date, + appliedAt: date, + lastAppliedPolicyChangeID: policyID, + status: .recording, + ) + let policy = RecordingPolicyChange( + id: policyID, + deviceID: deviceID, + parentIDs: [], + revision: 0, + issuedAt: date, + issuedByDeviceID: deviceID, + effectiveAt: date, + state: .on, + reason: .initialRegistration, + ) + + let epoch = try await store.perform { + try await store.addRecordingDeviceProfile(profile) + return try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: deviceID, + at: date.addingTimeInterval(1), + ) + } + + // Model a device that was offline during reset and uploads its complete old snapshot + // afterward. Remote CloudKit writes do not pass through WhereStore's mutation methods, + // so insert the old-generation records at the SwiftData boundary just as an import does. + let remoteContext = ModelContext(container) + remoteContext.insert(SDLocationSample(value: sample, epochID: .initial)) + remoteContext.insert(SDEvidence(value: evidence, blob: Data("old".utf8), epochID: .initial)) + remoteContext.insert(SDManualDay(value: manualDay, epochID: .initial)) + remoteContext.insert(SDDismissedIssue( + key: dismissal.id.storeURL.absoluteString, + dismissedAt: dismissal.dismissedAt, + epochID: .initial, + )) + remoteContext.insert(SDTrackedRegion(regionID: "us-TX", epochID: .initial)) + remoteContext.insert(SDRecordingDeviceMetadataChange(value: metadata, epochID: .initial)) + remoteContext.insert(SDRecordingDeviceCheckIn(value: checkIn, epochID: .initial)) + remoteContext.insert(SDRecordingPolicyChange(value: policy, epochID: .initial)) + try remoteContext.save() + + let reader = SwiftDataStore(modelContainer: container) + #expect(try await reader.dataEpoch() == epoch) + #expect(try await reader.allSamples().isEmpty) + #expect(try await reader.allEvidence().isEmpty) + #expect(try await reader.evidenceBlob(for: evidence.id) == nil) + #expect(try await reader.allManualDays().isEmpty) + #expect(try await reader.allDismissedIssues().isEmpty) + #expect(try await reader.trackedRegions() == SwiftDataStore.defaultTrackedRegions) + #expect(try await reader.recordingDeviceProfiles() == [profile]) + #expect(try await reader.recordingDeviceMetadataChanges().isEmpty) + #expect(try await reader.recordingDeviceCheckIns().isEmpty) + #expect(try await reader.recordingPolicyChanges().isEmpty) + } + + @Test func expectedEpochTransactionRejectsStaleAuthorityWithoutWriting() async throws { + let store = try SwiftDataStore.inMemory() + let staleEpochID = try await (store.dataEpoch()).id + let deviceID = RecordingDeviceID(rawValue: UUID()) + let currentEpoch = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: deviceID, + at: Date(timeIntervalSinceReferenceDate: 100), + ) + } + let sample = LocationSample( + timestamp: Date(timeIntervalSinceReferenceDate: 200), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .manual, + ) + + #expect(currentEpoch.id != staleEpochID) + await #expect(throws: RecordingPersistenceError.dataEpochChanged) { + try await store.perform(expectedDataEpochID: staleEpochID) { + try await store.add(sample: sample) + } + } + #expect(try await store.allSamples().isEmpty) + } + + @Test func epochRotationClampsABackwardClockToItsParentBoundary() async throws { + let store = try SwiftDataStore.inMemory() + let deviceID = RecordingDeviceID(rawValue: UUID()) + let parentDate = Date(timeIntervalSinceReferenceDate: 200) + let parent = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: deviceID, + at: parentDate, + ) + } + + let child = try await store.perform { + try await store.rotateDataEpoch( + reason: .backupReplace, + changedBy: deviceID, + at: parentDate.addingTimeInterval(-100), + ) + } + + #expect(child.parentIDs == [parent.id]) + #expect(child.changedAt == parentDate) + } + + @Test func syntheticEpochRowsRequireTheExactResetFrontierAndAJoinRetiresThem() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let first = Self.epoch( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [.initial], + revision: 1, + changedAt: Date(timeIntervalSinceReferenceDate: 100), + reason: .accountReset, + ) + let second = Self.epoch( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [.initial], + revision: 1, + changedAt: Date(timeIntervalSinceReferenceDate: 200), + reason: .accountReset, + ) + let firstResolution = try WhereDataEpoch.resolve(in: [first, second]) + let syntheticDay = DayPresence( + date: Date(timeIntervalSinceReferenceDate: 10000), + in: Self.calendar, + regions: [.california], + ) + let initialContext = ModelContext(container) + initialContext.insert(SDWhereDataEpoch(value: first)) + initialContext.insert(SDWhereDataEpoch(value: second)) + initialContext.insert(SDManualDay( + value: syntheticDay, + epochID: firstResolution.current.id, + )) + try initialContext.save() + + let firstReader = SwiftDataStore(modelContainer: container) + #expect(try await firstReader.dataEpoch() == firstResolution.current) + #expect(try await firstReader.allManualDays() == [syntheticDay]) + + let third = Self.epoch( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [.initial], + revision: 1, + changedAt: Date(timeIntervalSinceReferenceDate: 300), + reason: .accountReset, + ) + let secondResolution = try WhereDataEpoch.resolve(in: [first, second, third]) + let thirdResetContext = ModelContext(container) + thirdResetContext.insert(SDWhereDataEpoch(value: third)) + try thirdResetContext.save() + + let secondReader = SwiftDataStore(modelContainer: container) + #expect(secondResolution.current.id != firstResolution.current.id) + #expect(try await secondReader.dataEpoch() == secondResolution.current) + #expect(try await secondReader.allManualDays().isEmpty) + + let replacement = Self.epoch( + id: "40000000-0000-0000-0000-000000000000", + parentIDs: [first.id, second.id, third.id], + revision: 2, + changedAt: Date(timeIntervalSinceReferenceDate: 400), + reason: .backupReplace, + ) + let replacementDay = DayPresence( + date: Date(timeIntervalSinceReferenceDate: 20000), + in: Self.calendar, + regions: [.newYork], + ) + let replacementContext = ModelContext(container) + replacementContext.insert(SDWhereDataEpoch(value: replacement)) + replacementContext.insert(SDManualDay(value: replacementDay, epochID: replacement.id)) + try replacementContext.save() + + let replacementReader = SwiftDataStore(modelContainer: container) + #expect(try await replacementReader.dataEpoch() == replacement) + #expect(try await replacementReader.allManualDays() == [replacementDay]) + } + + @Test func rotationWritesOneCanonicalMultiParentNodeAndScopesFollowingRowsToIt() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let first = Self.epoch( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [.initial], + revision: 1, + changedAt: Date(timeIntervalSinceReferenceDate: 100), + reason: .accountReset, + ) + let second = Self.epoch( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [.initial], + revision: 1, + changedAt: Date(timeIntervalSinceReferenceDate: 200), + reason: .accountReset, + ) + let seedContext = ModelContext(container) + seedContext.insert(SDWhereDataEpoch(value: first)) + seedContext.insert(SDWhereDataEpoch(value: second)) + try seedContext.save() + + let replacementDay = DayPresence( + date: Date(timeIntervalSinceReferenceDate: 20000), + in: Self.calendar, + regions: [.newYork], + ) + let store = SwiftDataStore(modelContainer: container) + let replacement = try await store.perform { + let epoch = try await store.rotateDataEpoch( + reason: .backupReplace, + changedBy: Self.epochWriterID, + at: Date(timeIntervalSinceReferenceDate: 300), + ) + try await store.setManualDay(replacementDay) + return epoch + } + + let inspectionContext = ModelContext(container) + let epochRows = try inspectionContext.fetch(FetchDescriptor()) + let replacementRows = epochRows.filter { $0.id == replacement.id.rawValue } + let replacementRow = try #require(replacementRows.first) + let manualRows = try inspectionContext.fetch(FetchDescriptor()) + + #expect(replacement.parentIDs == [first.id, second.id]) + #expect(replacementRows.count == 1) + #expect(replacementRow.parentID == nil) + #expect(replacementRow.parentIDs == [first.id.rawValue, second.id.rawValue]) + #expect(manualRows.count == 1) + #expect(manualRows.first?.epochID == replacement.id.rawValue) + #expect(try await store.dataEpoch() == replacement) + #expect(try await store.allManualDays() == [replacementDay]) + } + + @Test func importReceiptRemainsDiscoverableAfterItsEpochIsSuperseded() async throws { + let store = try SwiftDataStore.inMemory() + let transactionID = UUID() + let installationID = RecordingDeviceID(rawValue: UUID()) + let originalEpochID = try await (store.dataEpoch()).id + try await store.perform { + try await store.addBackupImportReceipt( + id: transactionID, + installationID: installationID, + ) + } + + _ = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: installationID, + at: Date(timeIntervalSinceReferenceDate: 100), + ) + } + + let receipt = try #require(try await store.backupImportReceipt( + id: transactionID, + installationID: installationID, + )) + #expect(receipt.dataEpochID == originalEpochID) + #expect(try await store.backupImportReceipt( + id: transactionID, + installationID: RecordingDeviceID(rawValue: UUID()), + ) == nil) + } + + @Test func expectedEpochTransactionRejectsEpochImportedWhileBodyIsSuspended() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore(modelContainer: container) + let deviceID = RecordingDeviceID(rawValue: UUID()) + let sample = LocationSample( + timestamp: Date(timeIntervalSinceReferenceDate: 200), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .manual, + ) + let (started, startedContinuation) = AsyncStream.makeStream(of: Void.self) + let (release, releaseContinuation) = AsyncStream.makeStream(of: Void.self) + let writer = Task { + try await store.perform(expectedDataEpochID: .initial) { + startedContinuation.yield() + startedContinuation.finish() + for await _ in release { + break + } + try await store.add(sample: sample) + } + } + for await _ in started { + break + } + + let remoteContext = ModelContext(container) + remoteContext.insert(SDWhereDataEpoch(value: WhereDataEpoch( + id: WhereDataEpochID(rawValue: UUID()), + parentIDs: [.initial], + revision: 1, + changedAt: Date(timeIntervalSinceReferenceDate: 100), + changedByDeviceID: deviceID, + reason: .accountReset, + ))) + try remoteContext.save() + releaseContinuation.yield() + releaseContinuation.finish() + + await #expect(throws: RecordingPersistenceError.dataEpochChanged) { + try await writer.value + } + // The stale row may have committed before the post-save guard, but it + // belongs to the losing epoch and is never visible as active data. + #expect(try await store.allSamples().isEmpty) + } + + @Test func readSnapshotRejectsCommitBeforeNotification() async throws { + let source = ScriptedStoreRemoteChangeSource() + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore.inMemory( + modelContainer: container, + remoteChangeSource: source, + ) + let (started, startedContinuation) = AsyncStream.makeStream(of: Void.self) + let (release, releaseContinuation) = AsyncStream.makeStream(of: Void.self) + let readObserver = SnapshotReadObserver() + let snapshot = Task { + try await store.readSnapshot { + let first = try await store.allSamples() + await readObserver.recordFirst(first.count) + startedContinuation.yield() + startedContinuation.finish() + for await _ in release { + break + } + let second = try await store.allManualDays() + await readObserver.recordSecond(second.count) + return second + } + } + for await _ in started { + break + } + + let remoteContext = ModelContext(container) + remoteContext.insert(SDLocationSample( + value: LocationSample( + timestamp: Date(timeIntervalSince1970: 0), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .manual, + ), + epochID: .initial, + )) + remoteContext.insert(SDManualDay(value: day, epochID: .initial)) + try remoteContext.save() + // Deliberately do not deliver the corresponding remote-change signal: + // a store commit can be visible before Core Data posts its notification. + // Persistent history is committed alongside the row, so the snapshot + // must still reject the mixed pre/post-commit read. + releaseContinuation.yield() + releaseContinuation.finish() + + await #expect(throws: RecordingPersistenceError.dataEpochChanged) { + try await snapshot.value + } + #expect(await readObserver.counts == [0, 1]) + } + + @Test func readSnapshotAllowsDelayedNotificationForIncludedCommit() async throws { + let source = ScriptedStoreRemoteChangeSource() + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let remoteContext = ModelContext(container) + remoteContext.insert(SDManualDay(value: day, epochID: .initial)) + try remoteContext.save() + let store = SwiftDataStore.inMemory( + modelContainer: container, + remoteChangeSource: source, + ) + let (started, startedContinuation) = AsyncStream.makeStream(of: Void.self) + let (release, releaseContinuation) = AsyncStream.makeStream(of: Void.self) + let snapshot = Task { + try await store.readSnapshot { + _ = try await store.allManualDays() + startedContinuation.yield() + startedContinuation.finish() + for await _ in release { + break + } + return try await store.allManualDays() + } + } + for await _ in started { + break + } + + // The commit is already part of the snapshot's starting history head. + // Its delayed notification is refresh-only and must not invalidate a + // consistent read whose durable store generation has not changed. + source.yield() + releaseContinuation.yield() + releaseContinuation.finish() + + #expect(try await snapshot.value == [day]) + } + + @Test func inactiveEvidenceBlobIsNotResurrectedByMetadataOnlyActiveWrite() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore(modelContainer: container) + let deviceID = RecordingDeviceID(rawValue: UUID()) + let currentEpoch = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: deviceID, + at: Date(timeIntervalSinceReferenceDate: 100), + ) + } + let evidence = try Evidence( + id: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + kind: .boardingPass, + capturedAt: Date(timeIntervalSinceReferenceDate: 200), + region: .california, + note: "Restored metadata", + contentType: .pdf, + ) + let inactiveBlob = Data("inactive attachment".utf8) + let remoteContext = ModelContext(container) + remoteContext.insert(SDEvidence( + value: evidence, + blob: inactiveBlob, + epochID: .initial, + )) + try remoteContext.save() + + try await store.perform(expectedDataEpochID: currentEpoch.id) { + try await store.write(evidence: evidence, blob: nil) + } + + #expect(try await store.allEvidence() == [evidence]) + #expect(try await store.evidenceBlob(for: evidence.id) == nil) + } + + /// Reusing an inactive row would also reuse its CloudKit record identity. A delayed + /// tombstone from the old generation could then delete current restored data, so every + /// same-id write must preserve the inactive row and create a current-epoch record. + @Test func inactiveSameIDRowsRemainSeparateFromCurrentEpochUpserts() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore(modelContainer: container) + let deviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) + let metadataID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")) + let sampleID = try #require(UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")) + let date = Date(timeIntervalSinceReferenceDate: 200) + let sample = LocationSample( + id: sampleID, + timestamp: date, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsVisit, + recordingDeviceID: deviceID, + ) + let metadata = try RecordingDeviceMetadataChange( + id: metadataID, + deviceID: deviceID, + revision: 0, + changedAt: date, + changedByDeviceID: deviceID, + nickname: "Home iPad", + ) + let policy = RecordingPolicyChange( + id: policyID, + deviceID: deviceID, + parentIDs: [], + revision: 0, + issuedAt: date, + issuedByDeviceID: deviceID, + effectiveAt: date, + state: .off, + reason: .initialRegistration, + ) + let currentEpoch = try await store.perform { + try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: deviceID, + at: Date(timeIntervalSinceReferenceDate: 100), + ) + } + + let remoteContext = ModelContext(container) + remoteContext.insert(SDLocationSample(value: sample, epochID: .initial)) + remoteContext.insert(SDRecordingDeviceMetadataChange(value: metadata, epochID: .initial)) + remoteContext.insert(SDRecordingPolicyChange(value: policy, epochID: .initial)) + try remoteContext.save() + + try await store.perform(expectedDataEpochID: currentEpoch.id) { + try await store.add(sample: sample) + try await store.addRecordingDeviceMetadataChange(metadata) + try await store.addRecordingPolicyChange(policy) + } + + let inspectionContext = ModelContext(container) + let sampleRows = try inspectionContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == sampleID }), + ) + let metadataRows = try inspectionContext.fetch( + FetchDescriptor(predicate: #Predicate { + $0.id == metadataID + }), + ) + let policyRows = try inspectionContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == policyID }), + ) + let expectedEpochIDs = Set([ + WhereDataEpochID.initial.rawValue, + currentEpoch.id.rawValue, + ]) + + #expect(sampleRows.count == 2) + #expect(Set(sampleRows.compactMap(\.epochID)) == expectedEpochIDs) + #expect(metadataRows.count == 2) + #expect(Set(metadataRows.compactMap(\.epochID)) == expectedEpochIDs) + #expect(policyRows.count == 2) + #expect(Set(policyRows.compactMap(\.epochID)) == expectedEpochIDs) + #expect(try await store.allSamples() == [sample]) + #expect(try await store.recordingDeviceMetadataChanges() == [metadata]) + #expect(try await store.recordingPolicyChanges() == [policy]) + } + + @Test func duplicateProfilesResolveDeterministicallyByRegistrationEpoch() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + let deviceID = RecordingDeviceID(rawValue: UUID()) + let registeredAt = Date(timeIntervalSinceReferenceDate: 100) + let earlierCanonicalEpoch = try WhereDataEpochID(rawValue: #require(UUID( + uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + ))) + let laterCanonicalEpoch = try WhereDataEpochID(rawValue: #require(UUID( + uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + ))) + let winner = RecordingDeviceProfile( + id: deviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: registeredAt, + registrationEpochID: earlierCanonicalEpoch, + ) + let duplicate = RecordingDeviceProfile( + id: deviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: registeredAt, + registrationEpochID: laterCanonicalEpoch, + ) + context.insert(SDRecordingDeviceProfile(value: duplicate)) + context.insert(SDRecordingDeviceProfile(value: winner)) + try context.save() + + let store = SwiftDataStore(modelContainer: container) + #expect(try await store.recordingDeviceProfiles() == [winner]) + } + + @Test func incompleteEpochHistoryFailsClosed() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + context.insert(SDWhereDataEpoch(value: WhereDataEpoch( + id: WhereDataEpochID(rawValue: UUID()), + parentIDs: [.initial], + revision: 2, + changedAt: Date(timeIntervalSinceReferenceDate: 100), + changedByDeviceID: RecordingDeviceID(rawValue: UUID()), + reason: .accountReset, + ))) + try context.save() + + let store = SwiftDataStore(modelContainer: container) + await #expect(throws: RecordingPersistenceError.incompleteDataEpochHistory) { + try await store.dataEpoch() + } + } + /// Once `perform`'s `peer.save()` returns, the committed write must be /// visible to a later read through the main (read) context — the question /// raised in review (`send()` after `save()` is only useful if readers then @@ -200,6 +1128,24 @@ struct SwiftDataStoreTests { #expect(afterUpdate.first?.regions == [.newYork]) } + @Test func localCommitCarriesTheHistoryAuthorUsedByRemoteFiltering() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore(modelContainer: container) + + try await store.perform { + try await store.setManualDay(day) + } + + let historyContext = ModelContext(container) + let transactions = try historyContext.fetchHistory( + HistoryDescriptor(), + ) + let latest = try #require(transactions.max { + $0.transactionIdentifier < $1.transactionIdentifier + }) + #expect(latest.author?.hasPrefix("where-") == true) + } + @Test func auditRoundTripsThroughAManualDay() async throws { let store = try SwiftDataStore.inMemory() let date = Date(timeIntervalSince1970: 0) @@ -299,6 +1245,27 @@ struct SwiftDataStoreTests { // ...but the newer audit wins. #expect(stored.first?.audit == laterAudit) } + + private static func epoch( + id: String, + parentIDs: [WhereDataEpochID], + revision: Int64, + changedAt: Date, + reason: WhereDataEpochReason, + ) -> WhereDataEpoch { + WhereDataEpoch( + id: epochID(id), + parentIDs: parentIDs, + revision: revision, + changedAt: changedAt, + changedByDeviceID: epochWriterID, + reason: reason, + ) + } + + private static func epochID(_ value: String) -> WhereDataEpochID { + WhereDataEpochID(rawValue: UUID(uuidString: value)!) + } } /// Tracks the peak number of concurrently-executing transaction blocks so a @@ -318,6 +1285,21 @@ private actor ConcurrencyObserver { } } +/// Captures both table reads from a snapshot that is expected to throw during +/// its final generation validation, so the regression can prove the reads did +/// straddle one atomic external transaction. +private actor SnapshotReadObserver { + private(set) var counts: [Int] = [] + + func recordFirst(_ count: Int) { + counts = [count] + } + + func recordSecond(_ count: Int) { + counts.append(count) + } +} + /// Awaits the first `changes()` ping, returning `false` if none arrives within /// `budget`. Races the stream against a timeout so a missing ping fails fast /// instead of hanging the test. diff --git a/Where/WhereCore/Tests/TrackedRegionStoreTests.swift b/Where/WhereCore/Tests/TrackedRegionStoreTests.swift index 4cc4d28a..acdf5069 100644 --- a/Where/WhereCore/Tests/TrackedRegionStoreTests.swift +++ b/Where/WhereCore/Tests/TrackedRegionStoreTests.swift @@ -39,12 +39,18 @@ struct TrackedRegionStoreTests { #expect(try await store.trackedRegions() == [texas]) } - @Test func clearAllResetsToTheDefault() async throws { + @Test func rotatingTheDataEpochResetsToTheDefault() async throws { let store = try SwiftDataStore.inMemory() try await store.perform { try await store.setTrackedRegion(true, id: "us-TX") } - try await store.perform { try await store.clearAll() } + try await store.perform { + _ = try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: RecordingDeviceID(rawValue: UUID()), + at: Date(timeIntervalSinceReferenceDate: 1), + ) + } #expect(try await store.trackedRegions() == SwiftDataStore.defaultTrackedRegions) } } diff --git a/Where/WhereCore/Tests/WhereCoreTestSupport.swift b/Where/WhereCore/Tests/WhereCoreTestSupport.swift index 0103c405..6e1d6a58 100644 --- a/Where/WhereCore/Tests/WhereCoreTestSupport.swift +++ b/Where/WhereCore/Tests/WhereCoreTestSupport.swift @@ -1,4 +1,22 @@ import Foundation +@testable import WhereCore + +extension BackupCoordinator { + /// Settings-purpose convenience kept in the test target so production callers must always + /// name the import purpose explicitly. + func importBackup( + from url: URL, + strategy: ImportStrategy, + onProgress: @Sendable (Double) -> Void = { _ in }, + ) async throws -> ImportSummary { + try await importBackup( + from: url, + strategy: strategy, + purpose: .settings, + onProgress: onProgress, + ) + } +} enum WhereCoreTestSupport { static let pacific = TimeZone(identifier: "America/Los_Angeles")! @@ -34,3 +52,51 @@ final class MutableClock: @unchecked Sendable { lock.withLock { current += interval } } } + +/// Deterministic durable-location sidecar shared by controller/service lifecycle tests. +actor ScriptedLocationOutbox: LocationOutbox { + enum Failure: Error { + case load + case clear + } + + private var entries: [LocationOutboxEntry] + private var failsToLoad: Bool + private var failsToClear: Bool + + init( + _ samples: [LocationSample] = [], + failsToLoad: Bool = false, + failsToClear: Bool = false, + ) { + entries = samples.map { LocationOutboxEntry(sample: $0, dataEpochID: .initial) } + self.failsToLoad = failsToLoad + self.failsToClear = failsToClear + } + + func load() async throws -> [LocationOutboxEntry] { + guard !failsToLoad else { throw Failure.load } + return entries + } + + func save(_ entries: [LocationOutboxEntry]) async { + self.entries = entries + } + + func clear() async throws { + guard !failsToClear else { throw Failure.clear } + entries.removeAll() + } + + func setFailsToClear(_ value: Bool) { + failsToClear = value + } + + func setFailsToLoad(_ value: Bool) { + failsToLoad = value + } + + var persistedSamples: [LocationSample] { + entries.map(\.sample) + } +} diff --git a/Where/WhereCore/Tests/WhereCoreTests.swift b/Where/WhereCore/Tests/WhereCoreTests.swift index ef29025c..7ed1cc7c 100644 --- a/Where/WhereCore/Tests/WhereCoreTests.swift +++ b/Where/WhereCore/Tests/WhereCoreTests.swift @@ -23,16 +23,7 @@ struct YearReportTests { } } -struct StorageDefaultTests { - @Test func storageDefault_isInMemoryUnderTestRunner() { - // We're running under either XCTest or Swift Testing via - // `tuist test` / `xcodebuild test` / `swift test`, all of - // which set `XCTestConfigurationFilePath`. If this assertion - // ever fails, `Storage.default` would let a real test build - // write to the user's local SwiftData store — bad. - #expect(SwiftDataStore.Storage.default == .inMemory) - } - +struct SwiftDataStoreFactoryTests { @Test func make_inMemory_roundTripsASample() async throws { let store = try SwiftDataStore.make(storage: .inMemory) let sample = LocationSample( @@ -50,23 +41,29 @@ struct StorageDefaultTests { struct SDLocationSampleTests { @Test func missingSourceRawReturnsNil() { - let record = SDLocationSample(value: LocationSample( - timestamp: Date(timeIntervalSince1970: 1_700_000_000), - coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), - horizontalAccuracy: 0, - source: .manual, - )) + let record = SDLocationSample( + value: LocationSample( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 0, + source: .manual, + ), + epochID: .initial, + ) record.sourceRaw = nil #expect(record.toValue() == nil) } @Test func corruptSourceRawReturnsNil() { - let record = SDLocationSample(value: LocationSample( - timestamp: Date(timeIntervalSince1970: 1_700_000_000), - coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), - horizontalAccuracy: 0, - source: .manual, - )) + let record = SDLocationSample( + value: LocationSample( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 0, + source: .manual, + ), + epochID: .initial, + ) record.sourceRaw = "not-a-real-source" #expect(record.toValue() == nil) } diff --git a/Where/WhereCore/Tests/WhereDataEpochTests.swift b/Where/WhereCore/Tests/WhereDataEpochTests.swift new file mode 100644 index 00000000..4cd19043 --- /dev/null +++ b/Where/WhereCore/Tests/WhereDataEpochTests.swift @@ -0,0 +1,253 @@ +import Foundation +import Testing +@testable import WhereCore + +struct WhereDataEpochTests { + private static let deviceID = RecordingDeviceID( + rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + ) + private static let baseDate = Date(timeIntervalSinceReferenceDate: 100) + + @Test func noDestructiveChangesResolveToTheImplicitRoot() throws { + let resolution = try WhereDataEpoch.resolve(in: []) + + #expect(resolution.current == .initial) + #expect(resolution.realHeads == [.initial]) + } + + @Test func concurrentResetOutranksReplaceAtTheSameRevision() throws { + let replacement = Self.epoch( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [.initial], + revision: 1, + changedAt: Self.baseDate, + reason: .backupReplace, + ) + let reset = Self.epoch( + id: "10000000-0000-0000-0000-000000000000", + parentIDs: [.initial], + revision: 1, + changedAt: Self.baseDate, + reason: .accountReset, + ) + + #expect(try WhereDataEpoch.canonicalHead(in: [replacement, reset]) == reset) + } + + @Test func twoConcurrentResetsResolveToLockedSyntheticEmptyEpoch() throws { + let first = Self.reset( + id: "10000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate, + ) + let second = Self.reset( + id: "20000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate.addingTimeInterval(1), + ) + + let resolution = try WhereDataEpoch.resolve(in: [second, first]) + + #expect( + resolution.current.id.rawValue + == UUID(uuidString: "44DF774E-FC5C-8C4B-8742-04737BFCFED9"), + ) + #expect(resolution.current.parentIDs == [first.id, second.id]) + #expect(resolution.current.revision == 2) + #expect(resolution.current.changedAt == second.changedAt) + #expect(resolution.current.reason == .accountReset) + #expect(resolution.realHeads == [first, second]) + } + + @Test func anotherConcurrentResetChangesTheSyntheticEpochIdentity() throws { + let first = Self.reset( + id: "10000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate, + ) + let second = Self.reset( + id: "20000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate.addingTimeInterval(1), + ) + let third = Self.reset( + id: "30000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate.addingTimeInterval(2), + ) + + let twoResetID = try WhereDataEpoch.resolve(in: [first, second]).current.id + let threeResetID = try WhereDataEpoch.resolve(in: [third, second, first]).current.id + + #expect(twoResetID.rawValue == UUID( + uuidString: "44DF774E-FC5C-8C4B-8742-04737BFCFED9", + )) + #expect(threeResetID.rawValue == UUID( + uuidString: "E0710538-52EF-8169-8641-12BF823E00AB", + )) + #expect(threeResetID != twoResetID) + } + + @Test func weakerConcurrentReplaceDoesNotChangeTheResetConflictIdentity() throws { + let first = Self.reset( + id: "10000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate, + ) + let second = Self.reset( + id: "20000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate.addingTimeInterval(1), + ) + let replacement = Self.epoch( + id: "F0000000-0000-0000-0000-000000000000", + parentIDs: [.initial], + revision: 1, + changedAt: Self.baseDate.addingTimeInterval(2), + reason: .backupReplace, + ) + + let before = try WhereDataEpoch.resolve(in: [first, second]) + let after = try WhereDataEpoch.resolve(in: [replacement, second, first]) + + #expect(after.current.id == before.current.id) + #expect(after.current.parentIDs == before.current.parentIDs) + #expect(after.realHeads == [replacement, first, second]) + } + + @Test func oneMultiParentJoinRetiresEveryObservedRealHead() throws { + let first = Self.reset( + id: "10000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate, + ) + let second = Self.reset( + id: "20000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate.addingTimeInterval(1), + ) + let replacement = Self.epoch( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [second.id, first.id], + revision: 2, + changedAt: Self.baseDate.addingTimeInterval(2), + reason: .backupReplace, + ) + + let resolution = try WhereDataEpoch.resolve(in: [second, replacement, first]) + + #expect(resolution.current == replacement) + #expect(resolution.realHeads == [replacement]) + #expect(try WhereDataEpoch.maximalHeads(in: [replacement, first, second]) == [replacement]) + } + + @Test func missingOneNamedParentFailsClosed() { + let first = Self.reset( + id: "10000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate, + ) + let missing = Self.id("20000000-0000-0000-0000-000000000000") + let invalidJoin = Self.epoch( + id: "30000000-0000-0000-0000-000000000000", + parentIDs: [first.id, missing], + revision: 2, + changedAt: Self.baseDate.addingTimeInterval(1), + reason: .backupReplace, + ) + + #expect(throws: RecordingPersistenceError.incompleteDataEpochHistory) { + try WhereDataEpoch.resolve(in: [first, invalidJoin]) + } + } + + @Test func persistedEpochCannotReuseTheImplicitRootIdentity() { + let invalid = WhereDataEpoch( + id: .initial, + parentIDs: [Self.id("10000000-0000-0000-0000-000000000000")], + revision: 1, + changedAt: Self.baseDate, + changedByDeviceID: Self.deviceID, + reason: .accountReset, + ) + + #expect(throws: RecordingPersistenceError.incompleteDataEpochHistory) { + try WhereDataEpoch.canonicalHead(in: [invalid]) + } + } + + @Test func persistedEventCannotReuseTheSyntheticResetConflictIdentity() { + let first = Self.reset( + id: "10000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate, + ) + let second = Self.reset( + id: "20000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate.addingTimeInterval(1), + ) + let collision = Self.epoch( + id: "44DF774E-FC5C-8C4B-8742-04737BFCFED9", + parentIDs: [.initial], + revision: 1, + changedAt: Self.baseDate.addingTimeInterval(2), + reason: .backupReplace, + ) + + #expect(throws: RecordingPersistenceError.incompleteDataEpochHistory) { + try WhereDataEpoch.resolve(in: [collision, second, first]) + } + } + + @Test func persistedEventCannotUseAnyUUIDv8SyntheticIdentity() { + let invalid = Self.epoch( + id: "DEADBEEF-0000-8000-8000-000000000001", + parentIDs: [.initial], + revision: 1, + changedAt: Self.baseDate, + reason: .backupReplace, + ) + + #expect(throws: RecordingPersistenceError.incompleteDataEpochHistory) { + try WhereDataEpoch.resolve(in: [invalid]) + } + } + + @Test func causalChildCannotMoveTheEraseBoundaryBeforeItsParent() { + let parent = Self.reset( + id: "10000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate, + ) + let child = Self.epoch( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [parent.id], + revision: 2, + changedAt: Self.baseDate.addingTimeInterval(-1), + reason: .backupReplace, + ) + + #expect(throws: RecordingPersistenceError.incompleteDataEpochHistory) { + try WhereDataEpoch.canonicalHead(in: [parent, child]) + } + } + + private static func reset(id: String, changedAt: Date) -> WhereDataEpoch { + epoch( + id: id, + parentIDs: [.initial], + revision: 1, + changedAt: changedAt, + reason: .accountReset, + ) + } + + private static func epoch( + id: String, + parentIDs: [WhereDataEpochID], + revision: Int64, + changedAt: Date, + reason: WhereDataEpochReason, + ) -> WhereDataEpoch { + WhereDataEpoch( + id: self.id(id), + parentIDs: parentIDs, + revision: revision, + changedAt: changedAt, + changedByDeviceID: deviceID, + reason: reason, + ) + } + + private static func id(_ value: String) -> WhereDataEpochID { + WhereDataEpochID(rawValue: UUID(uuidString: value)!) + } +} diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift index 8fcc92c2..49e5991f 100644 --- a/Where/WhereCore/Tests/WherePreferencesTests.swift +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -2,16 +2,24 @@ import Testing @testable import WhereCore struct WherePreferencesTests { - @Test func recordingChoiceConfirmationIsPersistedAndReset() { - let store = InMemoryKeyValueStore() - let preferences = WherePreferences(store: store) - #expect(preferences.hasConfirmedRecordingChoice == false) + @Test func resetRestoresFirstInstallDefaults() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.hasOnboarded = true + preferences.remindersEnabled = false + preferences.reminderTime = ReminderTime(hour: 1, minute: 2) + preferences.summaryEnabled = false + preferences.summaryTime = ReminderTime(hour: 3, minute: 4) + preferences.issueAlertsEnabled = false + preferences.driftThresholdMeters = 123 - preferences.hasConfirmedRecordingChoice = true - let relaunched = WherePreferences(store: store) - #expect(relaunched.hasConfirmedRecordingChoice) + preferences.reset() - relaunched.reset() - #expect(relaunched.hasConfirmedRecordingChoice == false) + #expect(preferences.hasOnboarded == false) + #expect(preferences.remindersEnabled) + #expect(preferences.reminderTime == .defaultEvening) + #expect(preferences.summaryEnabled) + #expect(preferences.summaryTime == .defaultMorning) + #expect(preferences.issueAlertsEnabled) + #expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue) } } diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 6263ccea..6e8173d7 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -1,7 +1,7 @@ import Foundation import RegionKit import Testing -@_spi(Testing) import WhereCore +@_spi(Testing) @testable import WhereCore /// Integration coverage for the assembled `WhereServices`: the cross-collaborator /// wiring that no single focused suite owns — the ingestor's post-persist hook @@ -57,12 +57,13 @@ struct WhereServicesTests { let services = try await WhereServices.make( store: store, locationSource: ScriptedLocationSource(), - currentDevice: .preview, + installationContext: .testing, aggregator: Self.makeAggregator(), reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), + importRecoveryPersistence: .none, ) // Two samples on the same Pacific day: one in California, one in New York. try await store.perform { @@ -400,16 +401,16 @@ struct WhereServicesTests { } } - @Test func resetStopsTrackingAndWipesTheStore() async throws { + @Test func resetStopsTrackingAndErasesSyncedUserData() async throws { let (services, _, source) = try Self.makeServices() - await services.ingestor.start() + _ = try await services.recording.register(authorization: .always) source.emit(sample(at: "2026-03-15T12:00:00-07:00")) try await waitUntil { try await services.reports.yearReport(for: 2026).days.count == 1 } #expect(await services.ingestor.isActive) try await services.reset() - // reset() owns the full teardown: GPS stopped and every year wiped. + // reset() owns the full teardown: GPS stopped and every year's user data erased. #expect(await !(services.ingestor.isActive)) let report = try await services.reports.yearReport(for: 2026) #expect(report.days.isEmpty) @@ -425,7 +426,7 @@ struct WhereServicesTests { locationSource: source, aggregator: Self.makeAggregator(), ) - await services.ingestor.start() + _ = try await services.recording.register(authorization: .always) let sampleA = LocationSample( timestamp: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00"), @@ -465,7 +466,7 @@ struct WhereServicesTests { @Test func trackingResumesAfterPauseWithoutDroppingSamples() async throws { let (services, _, source) = try Self.makeServices() - await services.ingestor.start() + _ = try await services.recording.register(authorization: .always) source.emit(sample(at: "2026-03-15T12:00:00-07:00")) try await waitUntil { try await services.reports.yearReport(for: 2026).days.count == 1 } @@ -475,7 +476,7 @@ struct WhereServicesTests { await services.ingestor.stop() let pausedActive = await services.ingestor.isActive #expect(!pausedActive) - await services.ingestor.start() + try await services.ingestor.start() let resumedActive = await services.ingestor.isActive #expect(resumedActive) @@ -491,6 +492,7 @@ struct WhereServicesTests { /// manual edit does. @Test func liveGPSIngestPingsDataChangeUpdates() async throws { let (services, _, source) = try Self.makeServices() + _ = try await services.recording.register(authorization: .always) // Subscribe before the ingest; the broadcaster buffers the newest ping, // so a commit landing before the consumer iterates still delivers. let changes = services.dataChangeUpdates() @@ -501,8 +503,6 @@ struct WhereServicesTests { let consumer = Task { for await _ in changes { await recorder.record() } } - - await services.ingestor.start() source.emit(sample(at: "2026-03-15T12:00:00-07:00")) try await waitUntil { await recorder.pingCount >= 1 } @@ -516,6 +516,51 @@ struct WhereServicesTests { #expect(await recorder.pingCount == 1) } + @Test func remoteDayImportReconcilesNotificationsAndWidgets() async throws { + let remoteChanges = ScriptedStoreRemoteChangeSource() + let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) + let reminder = SpyReminderScheduler() + let summary = SpyDailySummaryScheduler() + let widget = SpyWidgetRefresher() + let now = WhereCoreTestSupport.iso("2026-03-15T20:00:00-07:00") + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + aggregator: Self.makeAggregator(), + reminderScheduler: reminder, + summaryScheduler: summary, + widgetRefresher: widget, + now: { now }, + ) + await services.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: false, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) + await services.summary.configure(enabled: true, time: .defaultMorning) + let reminderCount = await reminder.reconcileCount + let summaryCount = await summary.reconcileCount + + try await store.simulateRemoteDayImport( + samples: [], + manualDays: [DayPresence( + date: now, + in: Self.pacificCalendar, + regions: [.california], + )], + ) + remoteChanges.yield() + + try await waitUntil { + let didReconcileReminder = await reminder.reconcileCount > reminderCount + let didReconcileSummary = await summary.reconcileCount > summaryCount + let didPublishWidget = await widget.publishCount == 1 + return didReconcileReminder && didReconcileSummary && didPublishWidget + } + #expect(await widget.lastSnapshot?.dayRegions == [.california]) + } + @Test func performThrow_rollsBackEntireTransaction() async throws { let store = try SwiftDataStore.inMemory() let s1 = sample(at: "2026-04-10T08:00:00-07:00") @@ -568,6 +613,16 @@ struct WhereServicesTests { ) } + private func gpsSample(at isoString: String) -> LocationSample { + LocationSample( + id: UUID(), + timestamp: WhereCoreTestSupport.iso(isoString), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + ) + } + @Test func evidenceRoundTripsViaJournal() async throws { let (services, _, _) = try Self.makeServices() let evidence = Evidence( @@ -652,6 +707,320 @@ struct WhereServicesTests { #expect(ids.count == 2) } + @Test func backupMergePreservesAndDrainsAPendingLocation() async throws { + let (sourceServices, _, _) = try Self.makeServices() + let url = try await sourceServices.backup.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let backing = try SwiftDataStore.inMemory() + let store = ToggleFailingStore(backing: backing) + let source = ScriptedLocationSource(authorizationStatus: .always) + let outbox = ScriptedLocationOutbox() + let destination = WhereServices( + store: store, + locationSource: source, + locationOutbox: outbox, + ) + _ = try await destination.recording.register(authorization: .always) + let pending = gpsSample(at: "2026-03-15T12:00:00-07:00") + await store.setShouldFail(true) + source.emit(pending) + try await waitUntil { await destination.ingestor.retryQueueDepth == 1 } + await store.setShouldFail(false) + + _ = try await destination.backup.importBackup(from: url, strategy: .merge) + + try await waitUntil { + try await backing.allSamples().contains(where: { $0.id == pending.id }) + } + #expect(await destination.ingestor.retryQueueDepth == 0) + #expect(await outbox.persistedSamples.isEmpty) + } + + @Test func backupMergeCannotTurnAnOffInstallationOn() async throws { + let context = InstallationRecordingContext.testing + let currentDeviceID = context.currentDevice.id + let initialChoice = try #require(context.initialRecordingChoice) + let initial = RecordingPolicyChange( + id: initialChoice.policyChangeID, + deviceID: currentDeviceID, + parentIDs: [], + revision: 0, + issuedAt: initialChoice.confirmedAt, + issuedByDeviceID: currentDeviceID, + effectiveAt: initialChoice.confirmedAt, + state: .on, + reason: .initialRegistration, + ) + let off = try RecordingPolicyChange( + id: #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")), + deviceID: currentDeviceID, + parentIDs: [initial.id], + revision: 1, + issuedAt: Date(timeIntervalSinceReferenceDate: 2), + issuedByDeviceID: currentDeviceID, + effectiveAt: Date(timeIntervalSinceReferenceDate: 2), + state: .off, + reason: .userCommand, + ) + let importedOn = try RecordingPolicyChange( + id: #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")), + deviceID: currentDeviceID, + parentIDs: [off.id], + revision: 2, + issuedAt: Date(timeIntervalSinceReferenceDate: 3), + issuedByDeviceID: currentDeviceID, + effectiveAt: Date(timeIntervalSinceReferenceDate: 3), + state: .on, + reason: .userCommand, + ) + let profile = RecordingDeviceProfile( + id: currentDeviceID, + systemName: context.currentDevice.systemName, + kind: context.currentDevice.kind, + registeredAt: context.registeredAt, + registrationEpochID: .initial, + ) + let url = try BackupService().makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + recordingDeviceProfiles: [profile], + recordingDeviceMetadataChanges: [], + recordingDeviceCheckIns: [], + recordingPolicyChanges: [initial, off, importedOn], + blobs: [:], + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let store = try SwiftDataStore.inMemory() + try await store.perform { + try await store.addRecordingDeviceProfile(profile) + try await store.addRecordingPolicyChange(initial) + try await store.addRecordingPolicyChange(off) + } + let destination = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + installationContext: context, + ) + let before = try await destination.recording.register(authorization: .always) + #expect(before.isEnabled == false) + #expect(await destination.ingestor.isActive == false) + + _ = try await destination.backup.importBackup(from: url, strategy: .merge) + + let policies = try await store.recordingPolicyChanges() + let head = try #require(RecordingPolicyChange.canonicalHead(in: policies)) + #expect(head.parentIDs == [importedOn.id]) + #expect(head.state == .off) + #expect(head.reason == .backupMerge) + #expect(try await store.recordingDeviceCheckIns().first?.lastAppliedPolicyChangeID == head + .id) + #expect(await destination.ingestor.isActive == false) + } + + @Test func failedBackupMergePreservesThePendingLocationThroughRollback() async throws { + let (sourceServices, _, _) = try Self.makeServices() + try await seedBackupData(sourceServices) + let url = try await sourceServices.backup.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let backing = try SwiftDataStore.inMemory() + let store = ToggleFailingStore(backing: backing) + let source = ScriptedLocationSource(authorizationStatus: .always) + let outbox = ScriptedLocationOutbox() + let destination = WhereServices( + store: store, + locationSource: source, + locationOutbox: outbox, + ) + _ = try await destination.recording.register(authorization: .always) + let pending = gpsSample(at: "2026-03-15T12:00:00-07:00") + await store.setShouldFail(true) + source.emit(pending) + try await waitUntil { await destination.ingestor.retryQueueDepth == 1 } + + await #expect(throws: ToggleFailingStoreError.self) { + try await destination.backup.importBackup(from: url, strategy: .merge) + } + + #expect(await destination.ingestor.retryQueueDepth == 1) + #expect(await outbox.persistedSamples.map(\.id) == [pending.id]) + } + + @Test func backupReplaceDiscardsAPendingLocationOnlyAfterCommit() async throws { + let (sourceServices, _, _) = try Self.makeServices() + let url = try await sourceServices.backup.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let backing = try SwiftDataStore.inMemory() + let store = ToggleFailingStore(backing: backing) + let source = ScriptedLocationSource(authorizationStatus: .always) + let outbox = ScriptedLocationOutbox() + let destination = WhereServices( + store: store, + locationSource: source, + locationOutbox: outbox, + ) + _ = try await destination.recording.register(authorization: .always) + let pending = gpsSample(at: "2026-03-15T12:00:00-07:00") + await store.setShouldFail(true) + source.emit(pending) + try await waitUntil { await destination.ingestor.retryQueueDepth == 1 } + await store.setShouldFail(false) + + _ = try await destination.backup.importBackup(from: url, strategy: .replace) + + #expect(try await backing.allSamples().contains(where: { $0.id == pending.id }) == false) + #expect(await destination.ingestor.retryQueueDepth == 0) + #expect(await outbox.persistedSamples.isEmpty) + #expect(await destination.ingestor.isActive == false) + } + + @Test func backupReplaceNeverRestoresRecordingConsent() async throws { + let (source, _, _) = try Self.makeServices() + _ = try await source.recording.register(authorization: .always) + #expect(await source.ingestor.isActive) + let url = try await source.backup.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let (destination, store, _) = try Self.makeServices() + _ = try await destination.recording.register(authorization: .always) + #expect(await destination.ingestor.isActive) + _ = try await destination.backup.importBackup(from: url, strategy: .replace) + + let policies = try await store.recordingPolicyChanges() + #expect(policies.map(\.state) == [.on, .off]) + #expect(policies.last?.reason == .backupReplace) + #expect(try await store.recordingDeviceCheckIns().first?.status == .off) + #expect(await destination.ingestor.isActive == false) + } + + @Test func replaceCleanupFailureReportsCommittedPartialSuccessAndStaysOff() async throws { + let (sourceServices, _, _) = try Self.makeServices() + let url = try await sourceServices.backup.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let pending = gpsSample(at: "2026-03-15T12:00:00-07:00") + let outbox = ScriptedLocationOutbox([pending], failsToClear: true) + let store = try SwiftDataStore.inMemory() + let destination = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + locationOutbox: outbox, + ) + + await #expect(throws: BackupCoordinator.CommittedImportCleanupError.self) { + try await destination.backup.importBackup(from: url, strategy: .replace) + } + + #expect(await outbox.persistedSamples == [pending]) + #expect(await destination.ingestor.isActive == false) + #expect(try await store.dataEpoch().reason == .backupReplace) + #expect(try await store.recordingPolicyChanges().isEmpty) + } + + @Test func resetCleanupFailureKeepsTheOldInstallationForSafeRetry() async throws { + let pending = gpsSample(at: "2026-03-15T12:00:00-07:00") + let outbox = ScriptedLocationOutbox() + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + locationOutbox: outbox, + ) + _ = try await services.recording.register(authorization: .always) + await outbox.save([LocationOutboxEntry(sample: pending, dataEpochID: .initial)]) + await outbox.setFailsToClear(true) + + let error = await #expect(throws: WhereServices.ResetCleanupError.self) { + try await services.reset() + } + + #expect(error?.localizedDescription.contains("Close and reopen Where") == true) + #expect(await outbox.persistedSamples == [pending]) + #expect(await services.ingestor.isActive == false) + #expect(try await store.recordingDeviceProfiles().count == 1) + #expect(try await store.recordingDeviceCheckIns().isEmpty) + #expect(try await store.recordingPolicyChanges().isEmpty) + #expect(try await store.dataEpoch().reason == .accountReset) + } + + @Test func committedResetDiscardsPendingLocationsAndPreservesTheGlobalProfile() async throws { + let pending = gpsSample(at: "2026-03-15T12:00:00-07:00") + let outbox = ScriptedLocationOutbox() + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + locationOutbox: outbox, + ) + _ = try await services.recording.register(authorization: .always) + await outbox.save([LocationOutboxEntry(sample: pending, dataEpochID: .initial)]) + + try await services.reset() + + #expect(await outbox.persistedSamples.isEmpty) + #expect(try await store.recordingDeviceProfiles().count == 1) + #expect(try await store.recordingPolicyChanges().isEmpty) + #expect(try await store.dataEpoch().reason == .accountReset) + #expect(await services.ingestor.isActive == false) + } + + @Test func onboardingRestoreWaitsForTheLatestChoiceBeforeOpeningAuthority() async throws { + let (source, _, _) = try Self.makeServices() + let url = try await source.backup.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + // The sidecar's immutable first choice came from an earlier attempt, but the user has + // selected Off on this retry. Merely restoring the archive must not register that old On + // choice or start GPS in the gap before onboarding can append the latest selection. + let (destination, store, _) = try Self.makeServices() + _ = try await destination.backup.importBackup(from: url, strategy: .replace) + + #expect(await destination.ingestor.isActive == false) + #expect(try await store.recordingDeviceProfiles().isEmpty) + #expect(try await store.recordingDeviceCheckIns().isEmpty) + + let configuration = try await destination.recording.registerForOnboarding( + desiredEnabled: false, + authorization: .always, + ) + + #expect(configuration.isEnabled == false) + #expect(configuration.device.status == .off) + #expect(await destination.ingestor.isActive == false) + #expect(try await store.recordingPolicyChanges().map(\.isEnabled) == [true, false]) + } + + @Test func failedBackupTransactionRestoresThePreviousRecordingAuthority() async throws { + let (source, _, _) = try Self.makeServices() + try await seedBackupData(source) + let url = try await source.backup.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let store = try ToggleFailingStore(backing: SwiftDataStore.inMemory()) + let destination = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + aggregator: Self.makeAggregator(), + ) + _ = try await destination.recording.register(authorization: .always) + #expect(await destination.ingestor.isActive) + await store.setShouldFail(true) + + await #expect(throws: ToggleFailingStoreError.self) { + try await destination.backup.importBackup(from: url, strategy: .merge) + } + + #expect(await destination.ingestor.isActive) + + await store.setShouldFail(false) + _ = try await destination.backup.importBackup(from: url, strategy: .merge) + #expect(await destination.ingestor.isActive) + } + @Test func backupReplaceImportWipesPreexistingRows() async throws { let (source, sourceStore, _) = try Self.makeServices() try await seedBackupData(source) @@ -669,8 +1038,7 @@ struct WhereServicesTests { _ = try await destination.backup.importBackup(from: url, strategy: .replace) - // The store now mirrors the backup exactly — none of the pre-existing - // rows survive. + // Synced user history now mirrors the backup — none of these pre-existing rows survive. #expect(try await destinationStore.allSamples() == sourceStore.allSamples()) #expect(try await destinationStore.allManualDays() == sourceStore.allManualDays()) } @@ -721,7 +1089,53 @@ struct WhereServicesTests { #expect(await spy.lastBadgeCount == 0) } - @Test func clearAll_removesEveryTable() async throws { + @Test func backupImportReconcilesAttributionBeforePublishingDerivedData() async throws { + let texas = try #require(Region(rawValue: "us-TX")) + let austin = Self.sample( + "2026-03-15T12:00:00-07:00", + latitude: 30.2672, + longitude: -97.7431, + ) + let (source, _, _) = try Self.makeServices() + try await source.setPrimaryRegions([ + PrimaryRegion(region: texas, appearance: nil, order: 0), + ]) + try await source.journal.ingest(austin) + let url = try await source.backup.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let destinationStore = try SwiftDataStore.inMemory() + let defaultRegions = SwiftDataStore.defaultTrackedRegions + // Silence the autonomous observer so only the backup hook's explicit reconciliation can + // update this live attributor; the assertion therefore guards the required fan-out order. + let (ignoredChanges, ignoredChangesContinuation) = AsyncStream.makeStream(of: Void.self) + ignoredChangesContinuation.finish() + let attribution = RegionAttribution( + store: destinationStore, + changes: ignoredChanges, + initial: RegionAttributor(for: Region.inCanonicalOrder(defaultRegions)), + trackedIDs: Set(defaultRegions.map(\.rawValue)), + ) + let widget = SpyWidgetRefresher() + let destination = WhereServices( + store: destinationStore, + locationSource: ScriptedLocationSource(), + attributor: attribution, + aggregator: Self.makeAggregator(), + widgetRefresher: widget, + now: { austin.timestamp }, + ) + + #expect(attribution.region(at: austin.coordinate) == .other) + + _ = try await destination.backup.importBackup(from: url, strategy: .replace) + + #expect(attribution.region(at: austin.coordinate) == texas) + #expect(await widget.lastSnapshot?.dayRegions == [texas]) + #expect(await widget.lastSnapshot?.totals == [texas: 1]) + } + + @Test func rotatingDataEpochClearsSyncedStateButPreservesDeviceProfiles() async throws { let store = try SwiftDataStore.inMemory() let seedSample = sample(at: "2026-03-15T12:00:00-07:00") let seedDay = DayPresence( @@ -735,31 +1149,46 @@ struct WhereServicesTests { try await store.add(sample: seedSample) try await store.write(evidence: Self.backupEvidence, blob: Self.backupBlob) try await store.setManualDay(seedDay) - try await store.setRecordingDevice(RecordingDevice( + try await store.addRecordingDeviceProfile(RecordingDeviceProfile( id: deviceID, systemName: "iPhone", - nickname: nil, kind: .phone, registeredAt: seedSample.timestamp, + registrationEpochID: .initial, + )) + try await store.setRecordingDeviceCheckIn(RecordingDeviceCheckIn( + deviceID: deviceID, + revision: 0, lastSeenAt: seedSample.timestamp, - archivedAt: nil, + appliedAt: seedSample.timestamp, lastAppliedPolicyChangeID: policyID, status: .recording, )) try await store.addRecordingPolicyChange(RecordingPolicyChange( id: policyID, deviceID: deviceID, + parentIDs: [], + revision: 0, + issuedAt: seedSample.timestamp, + issuedByDeviceID: deviceID, effectiveAt: seedSample.timestamp, - isEnabled: true, + state: .on, + reason: .initialRegistration, )) } - try await store.perform { try await store.clearAll() } + try await store.perform { + _ = try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: deviceID, + at: seedSample.timestamp.addingTimeInterval(1), + ) + } #expect(try await store.allSamples().isEmpty) #expect(try await store.allEvidence().isEmpty) #expect(try await store.allManualDays().isEmpty) - #expect(try await store.recordingDevices().isEmpty) + #expect(try await store.recordingDevices().count == 1) #expect(try await store.recordingPolicyChanges().isEmpty) } @@ -823,7 +1252,7 @@ struct WhereServicesTests { #expect(await spy.lastBadgeCount == 4) #expect(await spy.lastScheduleDays.contains(today)) - await services.ingestor.start() + _ = try await services.recording.register(authorization: .always) source.emit(LocationSample( timestamp: WhereCoreTestSupport.iso("2026-01-05T12:00:00-08:00"), coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), @@ -849,7 +1278,7 @@ struct WhereServicesTests { driftThresholdMeters: Double(DriftThreshold.default.rawValue), ) - await services.ingestor.start() + _ = try await services.recording.register(authorization: .always) source.emit(LocationSample( timestamp: WhereCoreTestSupport.iso("2026-01-05T12:00:00-08:00"), coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), @@ -1135,7 +1564,7 @@ struct WhereServicesTests { let refresher = SpyWidgetRefresher() let (services, source) = try Self.makeWidgetServices(refresher: refresher) - await services.ingestor.start() + _ = try await services.recording.register(authorization: .always) source.emit(sample(at: "2026-03-15T12:00:00-07:00")) try await waitUntil { await refresher.publishCount == 1 } @@ -1150,7 +1579,7 @@ struct WhereServicesTests { store: store, ) - await services.ingestor.start() + _ = try await services.recording.register(authorization: .always) await store.setShouldFail(true) source.emit(sample(at: "2026-03-15T12:00:00-07:00")) try await waitUntil { await services.ingestor.retryQueueDepth == 1 } @@ -1376,6 +1805,39 @@ private actor ToggleFailingStore: WhereStore { backing.changes() } + func dataEpoch() async throws -> WhereDataEpoch { + try await backing.dataEpoch() + } + + func rotateDataEpoch( + reason: WhereDataEpochReason, + changedBy deviceID: RecordingDeviceID, + at date: Date, + ) async throws -> WhereDataEpoch { + try await backing.rotateDataEpoch(reason: reason, changedBy: deviceID, at: date) + } + + func backupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws -> BackupImportReceipt? { + try await backing.backupImportReceipt(id: id, installationID: installationID) + } + + func addBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws { + try await backing.addBackupImportReceipt(id: id, installationID: installationID) + } + + func removeBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws { + try await backing.removeBackupImportReceipt(id: id, installationID: installationID) + } + func add(sample: LocationSample) async throws { if shouldFail { throw ToggleFailingStoreError() } try await backing.add(sample: sample) @@ -1393,8 +1855,28 @@ private actor ToggleFailingStore: WhereStore { try await backing.recordingDevices() } - func setRecordingDevice(_ device: RecordingDevice) async throws { - try await backing.setRecordingDevice(device) + func recordingDeviceProfiles() async throws -> [RecordingDeviceProfile] { + try await backing.recordingDeviceProfiles() + } + + func addRecordingDeviceProfile(_ profile: RecordingDeviceProfile) async throws { + try await backing.addRecordingDeviceProfile(profile) + } + + func recordingDeviceMetadataChanges() async throws -> [RecordingDeviceMetadataChange] { + try await backing.recordingDeviceMetadataChanges() + } + + func addRecordingDeviceMetadataChange(_ change: RecordingDeviceMetadataChange) async throws { + try await backing.addRecordingDeviceMetadataChange(change) + } + + func recordingDeviceCheckIns() async throws -> [RecordingDeviceCheckIn] { + try await backing.recordingDeviceCheckIns() + } + + func setRecordingDeviceCheckIn(_ checkIn: RecordingDeviceCheckIn) async throws { + try await backing.setRecordingDeviceCheckIn(checkIn) } func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { @@ -1444,10 +1926,6 @@ private actor ToggleFailingStore: WhereStore { try await backing.clear(in: interval, manualDays: dayRange) } - func clearAll() async throws { - try await backing.clearAll() - } - func dismissedIssueIDs() async throws -> Set { try await backing.dismissedIssueIDs() } diff --git a/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift b/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift index e85019e9..a60b85ff 100644 --- a/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift +++ b/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift @@ -1,5 +1,6 @@ import Foundation import RegionKit +import SwiftData import Testing @testable import WhereCore @@ -50,6 +51,58 @@ struct WidgetSnapshotPublisherTests { #expect(await refresher.publishCount == 1) } + @Test func incompleteDestructiveEpochReplacesSensitiveSnapshotWithEmptyState() async throws { + let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00") + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore(modelContainer: container) + let aggregator = DayAggregator( + calendar: WhereCoreTestSupport.calendar(), + timeZone: WhereCoreTestSupport.pacific, + ) + let reader = WidgetDataReader( + store: store, + aggregator: aggregator, + attributor: RegionAttributor.shared, + ) + let refresher = SpyRefresher() + let publisher = WidgetSnapshotPublisher( + widgetReader: reader, + widgetRefresher: refresher, + attributor: RegionAttributor.shared, + calendar: WhereCoreTestSupport.calendar(), + now: { now }, + ) + try await store.perform { + try await store.add(sample: LocationSample( + timestamp: now, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + )) + } + await publisher.publish() + #expect(await refresher.lastSnapshot?.dayRegions == [.california]) + + // CloudKit can deliver a later destructive event before its parent. Once the store + // knows that history may have been erased, the widget must stop showing the prior data. + let remoteContext = ModelContext(container) + remoteContext.insert(SDWhereDataEpoch(value: WhereDataEpoch( + id: WhereDataEpochID(rawValue: UUID()), + parentIDs: [.initial], + revision: 2, + changedAt: now.addingTimeInterval(1), + changedByDeviceID: RecordingDeviceID(rawValue: UUID()), + reason: .accountReset, + ))) + try remoteContext.save() + + await publisher.publish() + + #expect(await refresher.publishCount == 2) + #expect(await refresher.lastSnapshot?.dayRegions.isEmpty == true) + #expect(await refresher.lastSnapshot?.totals.isEmpty == true) + } + @Test func refreshIfStaleSkipsWhenFresh() async throws { let now = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00") let (publisher, _, refresher) = try Self.makePublisher(now: { now }) diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index b6688385..ff082968 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -16,6 +16,23 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) - Composition is the one exception: `WhereScope` and `WhereModel` decide which world the app is logged in to and assemble it. That's launch wiring, not domain logic — see [Scopes and the launch](../AGENTS.md#scopes-and-the-launch). +- Keep `FileInstallationRecordingContextStore` as the UIKit/FileManager + adapter for Core's installation-context protocol; resolve one instance at + the app root and inject it into both `WhereModel` and `WhereBootstrap`. +- Persist the installation identity, confirmed first recording choice, stable + profile/policy IDs and timestamps, two-phase backup-import recovery, and the independent + terminal onboarding-import tombstone together in the excluded-from-backup sidecar; never infer + confirmation from backed-up preferences, rewrite the confirmed first event, or migrate it from + `UserDefaults`. +- Retire the installation sidecar with an atomic directory rename before + cleanup; retain the proposed replacement behind `ResetCleanupError` until + tombstone deletion succeeds (`InstallationRecordingContextStoreTests`). +- Reconcile every pending import after scope resolution but before session handoff or recording; + reconcile onboarding imports before offering Restore, acknowledge their preference independently + of cleanup, and retain the marker through any failure (`WhereLaunchTests`). +- Initialize `BackupModel` import availability from the scope's long-lived + `BackupCoordinator`; keep import disabled until committed cleanup recovery + reports ready (`BackupModelTests`). - The DEBUG developer accordion may only latch or clear `InspectorModeController` for the next launch. It must not host a live SwiftData inspector or switch the current runtime. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index dc0769b8..638a86e1 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -63,9 +63,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's opens, its bring-up is spanned (`openLogStore`) and history is trimmed with `LogHistoryPruner` (a 100-day window *and* a 50k-event ceiling, so the store is bounded however heavily the device logs). -- **`WhereModel`** — app-level state that outlives any one scope: the - onboarding and per-device recording-confirmation flags, the active - `WhereScope`, the owned `WhereSession`, and the lifecycle intents +- **`WhereModel`** — app-level state that outlives any one scope: the backed-up + onboarding flag, the separately injected non-backed-up installation + recording context (including stable first-profile/policy timestamps), the + active `WhereScope`, the owned `WhereSession`, and the lifecycle intents (`activate(scope:)`, `startSession(scope:)` — which *returns* the session the launch's `start-session` step threads onward — `endSession()`, `resetPreferences()`). @@ -76,7 +77,8 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's - **Scope-tiered models** — scene-scoped **`YearReportModel`** (the selected year's `YearReport`, its `LoadState`, and the manual-day edit intents), plus view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** - (export/import), **`RemindersSettingsModel`** (notification prefs), and + (export/import plus a mirror of the scope-owned committed-cleanup gate), + **`RemindersSettingsModel`** (notification prefs), and **`DevicesSettingsModel`** (synced installation names, policy, status, and archival). Each orchestrates `WhereServices`; none reimplements Core rules. @@ -88,15 +90,24 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's then picking up to five primary US regions (map or searchable list) and giving each a look, then verifying this installation's automatic-recording choice. Phones recommend On; tablets/other devices recommend Off, and only - an enabled confirmation requests location permission. An existing - installation without the new confirmation skips straight to that final - page. Finishing logs in to the real scope — the app's one store open — and - commits the picks as the tracked-region set + appearances before resolving - the gate. The intro also - offers **Restore from a backup**, which opens the store, imports a backup - (`.replace`), and skips the manual pick/customize steps straight to the - location ask; and **Explore a demo**, which builds a throwaway in-memory - world behind a captioned launch splash and enters it. + an enabled confirmation requests location permission. A restored device can + inherit the backed-up onboarding flag but not the installation sidecar, so it + skips straight to that final page. Finishing logs in to the real scope — the + app's one store open — and commits the picks as the tracked-region set + + appearances before resolving the gate. The intro also offers **Restore from + a backup**, which skips the manual pick/customize steps, verifies this + installation's recording choice, then opens the store and imports the backup + after asking whether to **Merge** (recommended, preserving existing data) or + **Replace** (destructive, starting from the backup); and **Explore a demo**, + which builds a throwaway in-memory world behind a captioned launch splash and + enters it. Once an onboarding import commits, its summary is retained and + a two-phase marker remains in the backup-excluded sidecar until onboarding is + acknowledged. A terminal tombstone remains after recovery is cleared so a + cold launch can repair an onboarding preference that had not reached disk, + but never offer the same archive for import again. Every cold launch also + resolves a Settings import marker before handing services to App Intents or + registering the recording device, so Replace cleanup finishes before GPS can + reopen or drain an obsolete outbox. - **`RegionPickerView` / `RegionCustomizeView`** — the shared primary-region picker (segmented map/list) and per-region color/emoji/icon customization, backed by `PrimaryRegionSelectionModel`. Reused by onboarding and the Settings diff --git a/Where/WhereUI/SnapshotTests/WhereLifecycleFailureViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/WhereLifecycleFailureViewSnapshotTests.swift new file mode 100644 index 00000000..7448d8bf --- /dev/null +++ b/Where/WhereUI/SnapshotTests/WhereLifecycleFailureViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct WhereLifecycleFailureViewSnapshotTests { + @Test func lifecycleFailure() async { + await assertSnapshots(of: WhereLifecycleFailureView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png index b853cb01..ca17aeaf 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:730a55edcdc21b7ae32a8f32aa55df7621d50853cd6edc5b719a405843c37654 -size 349730 +oid sha256:9da229067a54b929c7993617a545bec271554d8e90ba8429443334c04bb07ca3 +size 349736 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png index c05f9bb3..8285c386 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b426449b2324628421f359fe8c69e1389e5dc61ba913d1aa5fe5d5d13b212097 -size 663464 +oid sha256:67f2cb2dbaf0871f3b2d04dab472cab40e9147f1100407c2dff1a92771f40cf1 +size 663418 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png index 2564ed93..e1b4720d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4ba3aaf3475b3e7e709e1fa4f708fe424cc5533d012c9ef85c3f260f5df4d7cf -size 513684 +oid sha256:47a7e9a0685218a45579db4f910fa7bd04db330bd5c8f6695da6582c766f66ad +size 513741 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png index db8be004..755950fc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d12452c0cfb8e88c223fe4245f89f5975c611e6c4ca7c0c9d2d2b1d84e2f0bc1 -size 350719 +oid sha256:38c0370a858e567a21fdb7daed7ca343aa0604ebd4f449bbdafb5ddcd2669511 +size 350740 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png index ff8adf14..4b1c870a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e5f880dca469d55b2398cd5260754737604cba4e986071117852bda0a6327b4 -size 356171 +oid sha256:c4c461f018c87b503b7a5ca7abacb612cdad044d23156ec5620c61f0086b0578 +size 356166 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png index c7560f97..adaaf938 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e00b4ac92bbfbb28db2a7a8056c649d6a946abc92795cc9f48b52ec9d0cfbd9 -size 233745 +oid sha256:5e6d53ef0d2ae1deb7946fa7996ce43056843950aabd337574549df5ad8e0956 +size 233741 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png index 7a115a0e..f58ea4c3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e5afca7c7c18e20cce69d270937e88e1b471e9c2cf1a23a1d3b4100506baf6e -size 511087 +oid sha256:5894f347d5f29da3ec2aa7aac24d4eda95a2ae3fbc39d531315cb54904975353 +size 511027 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png index 79f6c07b..bb4b6d7c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:27c6b628896c1df322a1fa135ef114900129ab52b0b127ae14bfa43e5dbf8565 -size 225666 +oid sha256:296a0b4ffa1d368563d2ce470c75a37160e163e70b46042bfd6de9a38c0f7668 +size 234658 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png index 0b531c32..d4292f2e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:61dab2e91aa3bce0bf44450d06498d2ef35b4a13081eccd3d86736b531a9a1c5 -size 236378 +oid sha256:ff1c6de59f922f69890a6aca21e281f6f3566b5c6b68700de291c5f9f7b5999f +size 236369 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png index 7d016afb..948f320c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecb1c21c5007e8d944c361de70618e6aab0df328efc25cc1a0434bbafcdd2509 -size 237815 +oid sha256:778f2188172c88222632b3af6ab26c413d774b70be54b3fafff84d38abc54d9b +size 237784 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone.png index 621ff391..71969e87 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8e7a3554d84139aaabcf43bb6bd208ff3008e88640711f291df02e9ec12ce30 -size 532933 +oid sha256:6a79b900cf0c87aa59561f7bc678e35fe36fc2271f3ae6c1860f5e5798330304 +size 547026 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone_ax5.png new file mode 100644 index 00000000..2ebd795a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.PhoneRecordingChoice_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0dd77a89905a059243ae41ffa650d073062b273f8aa49f402d2596e9f2f35d7a +size 875088 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.TabletRecordingChoice_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.TabletRecordingChoice_iPad.png index 166b2421..6a927ff1 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.TabletRecordingChoice_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingViewSnapshotTests/onboarding.TabletRecordingChoice_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:71c12f8d34b8be34ad6122304d6c06e7c51bb541a5d9ad43001bc323b6d1f445 -size 1019732 +oid sha256:48c1c2102938c7aa375e121eecd135fa787dbe318a36ba5f26da2463cda6e96b +size 1039714 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereFlyoverViewSnapshotTests/seededEntryState.WhereFlyover_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereFlyoverViewSnapshotTests/seededEntryState.WhereFlyover_iPad.png index b6fab0b5..f5fc1526 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereFlyoverViewSnapshotTests/seededEntryState.WhereFlyover_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereFlyoverViewSnapshotTests/seededEntryState.WhereFlyover_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:97327fa669a48fee5faa4617ea0a523dc0d338edba01d7069b5b0c4deea57892 -size 2091399 +oid sha256:bea9576a7e49aebee90e0cd590a78ead66b8aff784247c450b0c39f1ea7798b5 +size 2096872 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportCleanup_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportCleanup_iPhone.png new file mode 100644 index 00000000..adb39351 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportCleanup_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3776352bf3abb71759acc5136699ec251ce831401474837ef256cc3f883dcc24 +size 203296 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportCleanup_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportCleanup_iPhone_dark.png new file mode 100644 index 00000000..30b041a3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportCleanup_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18c3546ee8fa5475196610705d40ed3401b4b2e191bc5f57745d1b6b7ce5b249 +size 189334 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportSetup_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportSetup_iPhone.png new file mode 100644 index 00000000..21637df7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportSetup_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:861b730d9a0fc3bbf68bcaf8fac2e2ff1bef90ec244bda7d1ed321bbada56796 +size 205160 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportSetup_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportSetup_iPhone_dark.png new file mode 100644 index 00000000..c7714e54 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedImportSetup_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b3fbec1cf79c16e16ed5b64c4e2d3518f78583c8e855e15468dc7f7eeec18c4 +size 191228 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad.png new file mode 100644 index 00000000..4ca8329c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2390bf33fef7aa2c15e3dec60e1cb77f722f299491f358f920084b6a16a953ee +size 313009 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_accessibility.png new file mode 100644 index 00000000..22382d9b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29e23e17e2ab911725216f2995cc4a8b4618ac045f8db9d84ac48f73d44c047e +size 468577 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_ax5.png new file mode 100644 index 00000000..78828726 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54b99c5d1248adfe6b9608ed2fd9e3055e7685a520f00d79a73a83bef390a4d7 +size 501953 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_contrast.png new file mode 100644 index 00000000..03e2e8d8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4861b6d9cac8d1fb5501b498d3fe9c9ad1d0de56dbc2dfae1a87e18ec591d599 +size 314422 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_dark.png new file mode 100644 index 00000000..894bb110 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f1824e4495bac2ec3e1cd241c71ea6beea71beb4adbdef32dba74cd67547813 +size 280066 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone.png new file mode 100644 index 00000000..d668c6f3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b15c4a79d675b12b78fe5261611fc149a02fb8a78854a123bd6a4059bf0ee6e4 +size 180470 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_accessibility.png new file mode 100644 index 00000000..635fdfcb --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfafd7c68b96f4a417e813bdb9fcc871afbd238dd1a89f8d3563f3074bb90aa9 +size 313231 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_ax5.png new file mode 100644 index 00000000..786e4665 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad9a09bf00f4a777ed669ff0c2b69dc3103e1ed7b14b0c5e09734e5ce5308b4a +size 366339 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_contrast.png new file mode 100644 index 00000000..e3a39247 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41ca4bd1e5432800834d68058480b44aed227630285c30a1665eca676f9b1a1f +size 182033 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_dark.png new file mode 100644 index 00000000..bf7b0337 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereLifecycleFailureViewSnapshotTests/lifecycleFailure.CommittedResetCleanup_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04946051a1278d53952be0d90cdb247784c19071c330ec56fcccf0ad5f0d7873 +size 165540 diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift index 60610d88..c3504dda 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift @@ -61,6 +61,7 @@ private static var appRegistrations: [WhereFlyoverData] { [ LaunchSplashView.flyoverData, + WhereLifecycleFailureView.flyoverData, OnboardingView.flyoverData, RegionPickerView.flyoverData, RegionCustomizeView.flyoverData, diff --git a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift b/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift deleted file mode 100644 index 7e0475fc..00000000 --- a/Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -import UIKit -import WhereCore - -/// Builds the local installation identity at the app composition boundary. -/// -/// The first available `identifierForVendor` (or a generated fallback before -/// first unlock) is persisted immediately in a device-local, non-backed-up -/// file. Every later launch reuses that choice, so a pre-unlock headless wake -/// cannot register one identity and the foreground launch silently switch to -/// another, while restoring a backup onto a second device cannot clone the -/// first installation's identity. -@MainActor -enum CurrentRecordingDeviceProvider { - private enum Key: String { - /// Pre-file-storage builds kept the identity here. `UserDefaults` is - /// backed up, so this key is migration input only and is removed after - /// the device-local file is created. - case recordingDeviceID = "where.recordingDeviceID" - } - - private static let identityFileName = "recording-device-id" - - /// Hardware family available before the scope/store is opened, so - /// onboarding can recommend a safe initial recording choice without - /// creating this installation's durable identity yet. - static var currentKind: RecordingDeviceKind { - kind(for: UIDevice.current.userInterfaceIdiom) - } - - static func current() throws -> CurrentRecordingDevice { - let device = UIDevice.current - let kind = kind(for: device.userInterfaceIdiom) - let directory = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true, - ) - return try current( - identityFileURL: directory.appending(path: identityFileName), - legacyDefaults: .standard, - vendorID: device.identifierForVendor, - systemName: device.model, - kind: kind, - ) - } - - static func kind(for idiom: UIUserInterfaceIdiom) -> RecordingDeviceKind { - switch idiom { - case .phone: .phone - case .pad: .tablet - case .unspecified, .tv, .carPlay, .mac, .vision: .other - @unknown default: .other - } - } - - /// Explicit-dependency factory used by the production composition method - /// above and by tests that exercise backup restoration and pre-unlock - /// identity resolution without touching the app sandbox. - static func current( - identityFileURL: URL, - legacyDefaults: UserDefaults, - vendorID: UUID?, - systemName: String, - kind: RecordingDeviceKind, - ) throws -> CurrentRecordingDevice { - let id = try identity( - at: identityFileURL, - legacyDefaults: legacyDefaults, - vendorID: vendorID, - ) - return CurrentRecordingDevice( - id: RecordingDeviceID(rawValue: id), - systemName: systemName, - kind: kind, - ) - } - - private static func identity( - at fileURL: URL, - legacyDefaults: UserDefaults, - vendorID: UUID?, - ) throws -> UUID { - if FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) { - let data = try Data(contentsOf: fileURL) - guard let value = String(data: data, encoding: .utf8) - .flatMap(UUID.init(uuidString:)) - else { - throw CocoaError( - .fileReadCorruptFile, - userInfo: [NSFilePathErrorKey: fileURL.path(percentEncoded: false)], - ) - } - return value - } - - // On the original device the vendor id matches the legacy preference; - // after a restore it does not. Making the current vendor id authoritative - // preserves the former and rotates the latter. Before first unlock there - // is no vendor id, so mint the fallback directly in non-backed-up storage. - let value = vendorID ?? UUID() - - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true, - ) - try Data(value.uuidString.utf8).write( - to: fileURL, - options: [.atomic, .noFileProtection], - ) - var persistedURL = fileURL - var resourceValues = URLResourceValues() - resourceValues.isExcludedFromBackup = true - try persistedURL.setResourceValues(resourceValues) - legacyDefaults.removeObject(forKey: Key.recordingDeviceID.rawValue) - return value - } -} diff --git a/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift new file mode 100644 index 00000000..aa72890f --- /dev/null +++ b/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift @@ -0,0 +1,78 @@ +import Foundation +import WhereCore + +/// In-memory installation context persistence used by previews and unit tests. +@_spi(Testing) +@MainActor +public final class InMemoryInstallationRecordingContextStore: + InstallationRecordingContextStoring +{ + public private(set) var onboardingContext: InstallationRecordingContext + public private(set) var backupImportRecovery: BackupCoordinator.DurableImportRecovery? + public private(set) var onboardingImportCompletion: + BackupCoordinator.OnboardingImportCompletion? + private let makeUUID: @MainActor () -> UUID + private let now: @MainActor () -> Date + + public convenience init(context: InstallationRecordingContext) { + self.init( + context: context, + makeUUID: { UUID() }, + now: { Date() }, + ) + } + + public init( + context: InstallationRecordingContext, + makeUUID: @escaping @MainActor () -> UUID, + now: @escaping @MainActor () -> Date, + ) { + onboardingContext = context + backupImportRecovery = nil + onboardingImportCompletion = nil + self.makeUUID = makeUUID + self.now = now + } + + public func resolve() throws -> InstallationRecordingContext { + onboardingContext + } + + public func confirmInitialRecording( + isEnabled: Bool, + ) throws -> InstallationRecordingContext { + if onboardingContext.initialRecordingChoice != nil { return onboardingContext } + onboardingContext = onboardingContext.confirmingInitialRecording( + isEnabled: isEnabled, + policyChangeID: makeUUID(), + confirmedAt: now(), + ) + return onboardingContext + } + + public func setBackupImportRecovery( + _ recovery: BackupCoordinator.DurableImportRecovery?, + ) { + backupImportRecovery = recovery + } + + public func recordOnboardingImportCompletion( + _ completion: BackupCoordinator.OnboardingImportCompletion, + ) { + onboardingImportCompletion = completion + } + + public func reset() throws { + backupImportRecovery = nil + onboardingImportCompletion = nil + onboardingContext = InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: makeUUID()), + systemName: onboardingContext.currentDevice.systemName, + kind: onboardingContext.currentDevice.kind, + ), + registeredAt: now(), + initialRecordingChoice: nil, + ) + } +} diff --git a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift new file mode 100644 index 00000000..ceb44388 --- /dev/null +++ b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift @@ -0,0 +1,696 @@ +import Foundation +import UIKit +import WhereCore + +/// File-backed installation context owned by the app composition root. +/// +/// The sidecar is excluded from backup, so restoring Where onto another device +/// cannot clone the source installation's identity or recording consent. A new +/// context stays in memory until onboarding confirms its first choice, which +/// keeps merely viewing onboarding or entering demo mode free of durable writes. +/// The sidecar also freezes the timestamps used by the first immutable device +/// profile and policy event, and retains active import recovery plus terminal +/// onboarding-import authority, so retries and cold-launch repair are deterministic. +@MainActor +public final class FileInstallationRecordingContextStore: + InstallationRecordingContextStoring +{ + private static let logger = WhereLog.root(OnboardingViewLog.self) + + private enum Resolution { + case resolved(InstallationRecordingContext) + case failed(any Error, proposed: InstallationRecordingContext) + /// The authoritative directory was atomically retired, but deleting that retired copy + /// failed. The new context is the logical result and must not rotate again on retry. + case resetCleanupRequired( + WhereServices.ResetCleanupError, + proposed: InstallationRecordingContext, + ) + + var onboardingContext: InstallationRecordingContext { + switch self { + case let .resolved(context): context + case let .failed(_, proposed), let .resetCleanupRequired(_, proposed): proposed + } + } + + func get() throws -> InstallationRecordingContext { + switch self { + case let .resolved(context): context + case let .failed(error, _): throw error + case let .resetCleanupRequired(error, _): throw error + } + } + } + + private struct StoredContext: Codable { + struct InitialRecordingChoice: Codable { + let isEnabled: Bool + let policyChangeID: UUID + let confirmedAt: Date + + enum CodingKeys: String, CodingKey { + case isEnabled + case policyChangeID + case confirmedAt + } + } + + struct BackupImportRecovery: Codable { + enum Strategy: String, Codable { + case merge = "backup-merge" + case replace = "backup-replace" + } + + enum Purpose: String, Codable { + case onboarding = "backup-onboarding" + case settings = "backup-settings" + } + + struct Summary: Codable { + let sampleCount: Int + let evidenceCount: Int + let manualDayCount: Int + let dismissedIssueCount: Int + let trackedRegionCount: Int + let recordingDeviceCount: Int + let recordingPolicyChangeCount: Int + + init(_ summary: BackupCoordinator.ImportSummary) { + sampleCount = summary.sampleCount + evidenceCount = summary.evidenceCount + manualDayCount = summary.manualDayCount + dismissedIssueCount = summary.dismissedIssueCount + trackedRegionCount = summary.trackedRegionCount + recordingDeviceCount = summary.recordingDeviceCount + recordingPolicyChangeCount = summary.recordingPolicyChangeCount + } + + var value: BackupCoordinator.ImportSummary { + BackupCoordinator.ImportSummary( + sampleCount: sampleCount, + evidenceCount: evidenceCount, + manualDayCount: manualDayCount, + dismissedIssueCount: dismissedIssueCount, + trackedRegionCount: trackedRegionCount, + recordingDeviceCount: recordingDeviceCount, + recordingPolicyChangeCount: recordingPolicyChangeCount, + ) + } + } + + enum Phase: String, Codable { + case prepared = "backup-prepared" + case committed = "backup-committed" + } + + let transactionID: UUID + let strategy: Strategy + let summary: Summary + let purpose: Purpose + let phase: Phase + let cleanupCompleted: Bool + let onboardingAcknowledged: Bool + + init(_ recovery: BackupCoordinator.DurableImportRecovery) { + let details = recovery.details + transactionID = details.transactionID + strategy = switch details.strategy { + case .merge: .merge + case .replace: .replace + } + summary = Summary(details.summary) + purpose = switch details.purpose { + case .onboarding: .onboarding + case .settings: .settings + } + switch recovery { + case .prepared: + phase = .prepared + cleanupCompleted = false + onboardingAcknowledged = false + case let .committed(_, completed, acknowledged): + phase = .committed + cleanupCompleted = completed + onboardingAcknowledged = acknowledged + } + } + + var value: BackupCoordinator.DurableImportRecovery { + let strategy: BackupCoordinator.ImportStrategy = switch strategy { + case .merge: .merge + case .replace: .replace + } + let purpose: BackupCoordinator.ImportPurpose = switch purpose { + case .onboarding: .onboarding + case .settings: .settings + } + let details = BackupCoordinator.ImportRecoveryDetails( + transactionID: transactionID, + strategy: strategy, + summary: summary.value, + purpose: purpose, + ) + return switch phase { + case .prepared: .prepared(details) + case .committed: .committed( + details, + cleanupCompleted: cleanupCompleted, + onboardingAcknowledged: onboardingAcknowledged, + ) + } + } + } + + let deviceID: UUID + let systemName: String + let kind: RecordingDeviceKind + let registeredAt: Date + let initialRecordingChoice: InitialRecordingChoice? + let backupImportRecovery: BackupImportRecovery? + let onboardingImportCompletionID: UUID? + + enum CodingKeys: String, CodingKey { + case deviceID + case systemName + case kind + case registeredAt + case initialRecordingChoice + case backupImportRecovery + case onboardingImportCompletionID + } + + init( + _ context: InstallationRecordingContext, + backupImportRecovery: BackupCoordinator.DurableImportRecovery?, + onboardingImportCompletion: BackupCoordinator.OnboardingImportCompletion?, + ) { + deviceID = context.currentDevice.id.rawValue + systemName = context.currentDevice.systemName + kind = context.currentDevice.kind + registeredAt = context.registeredAt + initialRecordingChoice = context.initialRecordingChoice.map { + InitialRecordingChoice( + isEnabled: $0.isEnabled, + policyChangeID: $0.policyChangeID, + confirmedAt: $0.confirmedAt, + ) + } + self.backupImportRecovery = backupImportRecovery.map(BackupImportRecovery.init) + onboardingImportCompletionID = onboardingImportCompletion?.transactionID + } + + var value: InstallationRecordingContext { + InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: deviceID), + systemName: systemName, + kind: kind, + ), + registeredAt: registeredAt, + initialRecordingChoice: initialRecordingChoice.map { + InstallationRecordingContext.InitialRecordingChoice( + isEnabled: $0.isEnabled, + policyChangeID: $0.policyChangeID, + confirmedAt: $0.confirmedAt, + ) + }, + ) + } + } + + private struct LoadedContext { + let context: InstallationRecordingContext + let backupImportRecovery: BackupCoordinator.DurableImportRecovery? + let onboardingImportCompletion: BackupCoordinator.OnboardingImportCompletion? + } + + private struct SecurityCleanupError: LocalizedError { + var errorDescription: String? { + String(localized: .onboardingInstallationSecurityError) + } + } + + private static let directoryName = "RecordingInstallationContext" + private static let fileName = "context.json" + + private let fileURL: URL + private let fileManager: FileManager + private let systemName: String + private let kind: RecordingDeviceKind + private let makeUUID: @MainActor () -> UUID + private let now: @MainActor () -> Date + private var resolution: Resolution + public private(set) var backupImportRecovery: BackupCoordinator.DurableImportRecovery? + public private(set) var onboardingImportCompletion: + BackupCoordinator.OnboardingImportCompletion? + + /// The production sidecar in Application Support, composed from the current + /// hardware without persisting the user-assigned device name. + public convenience init() { + let device = UIDevice.current + let directory: URL + do { + directory = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false, + ) + } catch { + // Preserve the exact resolution failure so onboarding can render + // and the launch can surface it when the user tries to continue. + self.init( + fileURL: URL(filePath: "/invalid/recording-installation-context.json"), + fileManager: .default, + systemName: device.model, + kind: Self.kind(for: device.userInterfaceIdiom), + makeUUID: { UUID() }, + now: { Date() }, + initialFailure: error, + ) + return + } + self.init( + fileURL: directory + .appending(path: Self.directoryName, directoryHint: .isDirectory) + .appending(path: Self.fileName), + fileManager: .default, + systemName: device.model, + kind: Self.kind(for: device.userInterfaceIdiom), + makeUUID: { UUID() }, + now: { Date() }, + ) + } + + /// Explicit-dependency initializer for tests and alternate composition. + @_spi(Testing) + public convenience init( + fileURL: URL, + fileManager: FileManager, + systemName: String, + kind: RecordingDeviceKind, + makeUUID: @escaping @MainActor () -> UUID, + now: @escaping @MainActor () -> Date, + ) { + self.init( + fileURL: fileURL, + fileManager: fileManager, + systemName: systemName, + kind: kind, + makeUUID: makeUUID, + now: now, + initialFailure: nil, + ) + } + + private init( + fileURL: URL, + fileManager: FileManager, + systemName: String, + kind: RecordingDeviceKind, + makeUUID: @escaping @MainActor () -> UUID, + now: @escaping @MainActor () -> Date, + initialFailure: (any Error)?, + ) { + self.fileURL = fileURL + self.fileManager = fileManager + self.systemName = systemName + self.kind = kind + self.makeUUID = makeUUID + self.now = now + backupImportRecovery = nil + onboardingImportCompletion = nil + + let proposed = Self.proposedContext( + systemName: systemName, + kind: kind, + id: makeUUID(), + registeredAt: now(), + ) + if let initialFailure { + resolution = .failed(initialFailure, proposed: proposed) + } else { + do { + try Self.finishInterruptedReset( + for: fileURL, + fileManager: fileManager, + ) + let loaded = try Self.load(from: fileURL, fileManager: fileManager) + resolution = .resolved(loaded?.context ?? proposed) + backupImportRecovery = loaded?.backupImportRecovery + onboardingImportCompletion = loaded?.onboardingImportCompletion + } catch { + let resetPendingURL = Self.resetPendingURL(for: fileURL) + if fileManager.fileExists( + atPath: resetPendingURL.path(percentEncoded: false), + ) { + resolution = .resetCleanupRequired( + WhereServices.ResetCleanupError(underlying: error), + proposed: proposed, + ) + } else { + resolution = .failed(error, proposed: proposed) + } + } + } + } + + public var onboardingContext: InstallationRecordingContext { + resolution.onboardingContext + } + + public func resolve() throws -> InstallationRecordingContext { + try resolution.get() + } + + public func confirmInitialRecording( + isEnabled: Bool, + ) throws -> InstallationRecordingContext { + let context = try resolution.get() + // Confirmation freezes one immutable policy event. A later UI retry cannot rewrite + // that event under the same id; subsequent changes belong in the synced policy stream. + if context.initialRecordingChoice != nil { return context } + + let confirmed = context.confirmingInitialRecording( + isEnabled: isEnabled, + policyChangeID: makeUUID(), + confirmedAt: now(), + ) + try persist( + confirmed, + backupImportRecovery: backupImportRecovery, + onboardingImportCompletion: onboardingImportCompletion, + ) + resolution = .resolved(confirmed) + return confirmed + } + + public func setBackupImportRecovery( + _ recovery: BackupCoordinator.DurableImportRecovery?, + ) throws { + let context = try resolution.get() + try persist( + context, + backupImportRecovery: recovery, + onboardingImportCompletion: onboardingImportCompletion, + ) + backupImportRecovery = recovery + } + + public func recordOnboardingImportCompletion( + _ completion: BackupCoordinator.OnboardingImportCompletion, + ) throws { + let context = try resolution.get() + try persist( + context, + backupImportRecovery: backupImportRecovery, + onboardingImportCompletion: completion, + ) + onboardingImportCompletion = completion + } + + public func reset() throws { + let directoryURL = fileURL.deletingLastPathComponent() + let resetPendingURL = Self.resetPendingURL(for: fileURL) + let proposed: InstallationRecordingContext + let wasAlreadyCommitted: Bool + switch resolution { + case let .resetCleanupRequired(_, pending): + proposed = pending + wasAlreadyCommitted = true + case .resolved, .failed: + proposed = Self.proposedContext( + systemName: systemName, + kind: kind, + id: makeUUID(), + registeredAt: now(), + ) + wasAlreadyCommitted = false + } + + if fileManager.fileExists(atPath: resetPendingURL.path(percentEncoded: false)) { + do { + try fileManager.removeItem(at: resetPendingURL) + } catch { + let cleanupError = WhereServices.ResetCleanupError(underlying: error) + resolution = .resetCleanupRequired(cleanupError, proposed: proposed) + throw cleanupError + } + if wasAlreadyCommitted { + backupImportRecovery = nil + onboardingImportCompletion = nil + resolution = .resolved(proposed) + return + } + } + + guard fileManager.fileExists(atPath: directoryURL.path(percentEncoded: false)) else { + backupImportRecovery = nil + onboardingImportCompletion = nil + resolution = .resolved(proposed) + return + } + + // Renaming inside Application Support is the commit point: either the old authoritative + // directory still exists, or it has the reset-pending name and can never be loaded as a + // live installation again. Deleting the retired copy is retryable cleanup after that. + try fileManager.moveItem(at: directoryURL, to: resetPendingURL) + backupImportRecovery = nil + onboardingImportCompletion = nil + do { + try fileManager.removeItem(at: resetPendingURL) + } catch { + let cleanupError = WhereServices.ResetCleanupError(underlying: error) + resolution = .resetCleanupRequired(cleanupError, proposed: proposed) + throw cleanupError + } + resolution = .resolved(proposed) + } + + /// Hardware-family mapping kept at the UIKit composition boundary. + @_spi(Testing) + public static func kind(for idiom: UIUserInterfaceIdiom) -> RecordingDeviceKind { + switch idiom { + case .phone: .phone + case .pad: .tablet + case .unspecified, .tv, .carPlay, .mac, .vision: .other + @unknown default: .other + } + } + + private static func proposedContext( + systemName: String, + kind: RecordingDeviceKind, + id: UUID, + registeredAt: Date, + ) -> InstallationRecordingContext { + InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: id), + systemName: systemName, + kind: kind, + ), + registeredAt: registeredAt, + initialRecordingChoice: nil, + ) + } + + private static func resetPendingURL(for fileURL: URL) -> URL { + fileURL.deletingLastPathComponent().appendingPathExtension("reset-pending") + } + + /// Finish deletion after a process stopped between the reset's atomic directory rename and + /// cleanup. A failure keeps the tombstone in place so the next construction retries it and + /// the old context is never loaded as authoritative again. + private static func finishInterruptedReset( + for fileURL: URL, + fileManager: FileManager, + ) throws { + let pendingURL = resetPendingURL(for: fileURL) + guard fileManager.fileExists(atPath: pendingURL.path(percentEncoded: false)) else { return } + try fileManager.removeItem(at: pendingURL) + } + + private static func load( + from fileURL: URL, + fileManager: FileManager, + ) throws -> LoadedContext? { + let directoryURL = fileURL.deletingLastPathComponent() + guard fileManager.fileExists(atPath: directoryURL.path(percentEncoded: false)) else { + return nil + } + // A prior process may have died at any point. Secure the directory before inspecting or + // deleting contents so even a pending file whose own attribute was never set is safe. + do { + try excludeFromBackup(directoryURL) + } catch { + try discardAfterExclusionFailure( + directoryURL: directoryURL, + fileManager: fileManager, + exclusionError: error, + ) + } + let pendingURL = fileURL.appendingPathExtension("pending") + if fileManager.fileExists(atPath: pendingURL.path(percentEncoded: false)) { + do { + try excludeFromBackup(pendingURL) + } catch { + try discardAfterExclusionFailure( + directoryURL: directoryURL, + fileManager: fileManager, + exclusionError: error, + ) + } + let pendingContext: LoadedContext + do { + pendingContext = try decodeContext(from: pendingURL) + } catch is DecodingError { + // An atomic write either produced a complete current value or unusable bytes. + // Keep an older authoritative context when one exists, but never retry a + // permanently malformed pending replacement on every launch. + Self.logger { .discardedCorruptInstallationContextPending } + try fileManager.removeItem(at: pendingURL) + return try loadAuthoritativeContext( + from: fileURL, + directoryURL: directoryURL, + fileManager: fileManager, + ) + } + if fileManager.fileExists(atPath: fileURL.path(percentEncoded: false)) { + _ = try fileManager.replaceItemAt( + fileURL, + withItemAt: pendingURL, + backupItemName: nil, + options: .usingNewMetadataOnly, + ) + } else { + try fileManager.moveItem(at: pendingURL, to: fileURL) + } + do { + try excludeFromBackup(fileURL) + } catch { + try discardAfterExclusionFailure( + directoryURL: directoryURL, + fileManager: fileManager, + exclusionError: error, + ) + } + return pendingContext + } + return try loadAuthoritativeContext( + from: fileURL, + directoryURL: directoryURL, + fileManager: fileManager, + ) + } + + private static func loadAuthoritativeContext( + from fileURL: URL, + directoryURL: URL, + fileManager: FileManager, + ) throws -> LoadedContext? { + guard fileManager.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + // Reassert this on every launch as defense against a restored/copied file whose + // extended attributes did not survive. Never accept an identity that can be backed up. + do { + try excludeFromBackup(fileURL) + } catch { + try discardAfterExclusionFailure( + directoryURL: directoryURL, + fileManager: fileManager, + exclusionError: error, + ) + } + return try decodeContext(from: fileURL) + } + + private static func decodeContext(from fileURL: URL) throws -> LoadedContext { + let stored = try JSONDecoder().decode( + StoredContext.self, + from: Data(contentsOf: fileURL), + ) + return LoadedContext( + context: stored.value, + backupImportRecovery: stored.backupImportRecovery?.value, + onboardingImportCompletion: stored.onboardingImportCompletionID.map { + BackupCoordinator.OnboardingImportCompletion(transactionID: $0) + }, + ) + } + + private func persist( + _ context: InstallationRecordingContext, + backupImportRecovery: BackupCoordinator.DurableImportRecovery?, + onboardingImportCompletion: BackupCoordinator.OnboardingImportCompletion?, + ) throws { + let directoryURL = fileURL.deletingLastPathComponent() + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + ) + // Secure the empty directory before any identity bytes are written. Atomic-write scratch + // files and our pending replacement therefore inherit a backup-excluded ancestor even if + // the process dies before their individual resource values are applied. + try Self.excludeFromBackup(directoryURL) + let pendingURL = fileURL.appendingPathExtension("pending") + if fileManager.fileExists(atPath: pendingURL.path(percentEncoded: false)) { + try fileManager.removeItem(at: pendingURL) + } + // Mark the replacement inode before it acquires the authoritative path as a second layer + // of defense beyond the already-excluded directory. + try JSONEncoder().encode(StoredContext( + context, + backupImportRecovery: backupImportRecovery, + onboardingImportCompletion: onboardingImportCompletion, + )).write( + to: pendingURL, + options: [.atomic, .noFileProtection], + ) + try Self.excludeFromBackup(pendingURL) + if fileManager.fileExists(atPath: fileURL.path(percentEncoded: false)) { + _ = try fileManager.replaceItemAt( + fileURL, + withItemAt: pendingURL, + backupItemName: nil, + options: .usingNewMetadataOnly, + ) + } else { + try fileManager.moveItem(at: pendingURL, to: fileURL) + } + // Verify/reapply after the rename too. The pending inode was already excluded, so a + // failure here does not expose its contents; a later launch retries before decoding. + try Self.excludeFromBackup(fileURL) + } + + private static func excludeFromBackup(_ fileURL: URL) throws { + var persistedURL = fileURL + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try persistedURL.setResourceValues(resourceValues) + } + + /// An identity whose backup exclusion cannot be proven is unusable and unsafe to retain. + /// Remove the dedicated directory, then surface the original exclusion failure. If removal + /// also fails, surface both failures so the privacy problem is never hidden. + private static func discardAfterExclusionFailure( + directoryURL: URL, + fileManager: FileManager, + exclusionError: any Error, + ) throws -> Never { + do { + try fileManager.removeItem(at: directoryURL) + } catch { + logger { + .installationContextSecurityCleanupFailed( + exclusionDescription: exclusionError.localizedDescription, + cleanupDescription: error.localizedDescription, + ) + } + throw SecurityCleanupError() + } + throw exclusionError + } +} diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 714eaea2..d86a2efc 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -43,10 +43,11 @@ public enum LaunchStepID: String, Sendable { /// Republish the widget snapshot from whatever is already on disk. case widgetSnapshot = "widget-snapshot" - /// Reset teardown: stop GPS, wipe the store, and drop the session. + /// Reset teardown: pause GPS, erase synced user data, retire recording authority, + /// discard pending fixes, and drop the session. case eraseData = "erase-data" - /// Reset teardown: clear the persisted preferences that gate the relaunch - /// (onboarding flag, tracking intent, reminder/summary schedules). + /// Reset teardown: clear the installation context and persisted preferences + /// that gate the relaunch (onboarding flag and reminder/summary schedules). case resetPreferences = "reset-preferences" /// Demo teardown: drop the demo world and hand the real one its durable /// log sink back. @@ -236,9 +237,20 @@ public protocol WhereScopeAssembling { public final class WhereBootstrap: WhereScopeAssembling { private static let logger = WhereLog.root(WhereLaunchLog.self) + private let installationContextStore: any InstallationRecordingContextStoring + private let storeStorage: SwiftDataStore.Storage + private let locationOutbox: any LocationOutbox private var locationSource: CoreLocationSource? - public init() {} + public init( + installationContextStore: any InstallationRecordingContextStoring, + storeStorage: SwiftDataStore.Storage, + locationOutbox: any LocationOutbox, + ) { + self.installationContextStore = installationContextStore + self.storeStorage = storeStorage + self.locationOutbox = locationOutbox + } /// Install the `CLLocationManager` + delegate right away, without touching /// the store. Idempotent. @@ -267,14 +279,19 @@ public final class WhereBootstrap: WhereScopeAssembling { let source = locationSource ?? CoreLocationSource() locationSource = nil do { - let currentDevice = try CurrentRecordingDeviceProvider.current() + let installationContext = try installationContextStore.resolve() + precondition( + installationContext.initialRecordingChoice != nil, + "A real scope cannot open before this installation confirms recording.", + ) + let storeStorage = storeStorage let store = try await Task.detached(priority: .userInitiated) { - try SwiftDataStore.make() + try SwiftDataStore.make(storage: storeStorage) }.value let services = try await WhereServices.make( store: store, locationSource: source, - currentDevice: currentDevice, + installationContext: installationContext, // The real world's seams, named here because this is the only // place that wants them: the demo scope builds the same stack // out of no-ops, and every test and preview gets no-ops by @@ -283,7 +300,9 @@ public final class WhereBootstrap: WhereScopeAssembling { summaryScheduler: UserNotificationDailySummaryScheduler(), issueAlertScheduler: UserNotificationDataIssueAlertScheduler(), widgetRefresher: WidgetCenterTimelineRefresher(), - locationOutbox: FileLocationOutbox.applicationSupport(), + locationOutbox: locationOutbox, + importRecoveryPersistence: installationContextStore + .backupImportRecoveryPersistence, ) Self.logger { .servicesAssembled } return services @@ -308,9 +327,8 @@ public final class WhereBootstrap: WhereScopeAssembling { ) } - /// Where a real scope's log store belongs, mirroring - /// `SwiftDataStore.Storage.default`'s test-runner guard: under a test host - /// it must stay in memory. A suite that logs in would otherwise write its + /// Where a real scope's log store belongs. Under a test host it must stay + /// in memory. A suite that logs in would otherwise write its /// records into the user's `Periscope.store`, and opening that from a test /// host's sandbox neither succeeds nor fails promptly — it stalls the /// bundle instead of failing it. diff --git a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift index 6e097338..fcc1e43e 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift @@ -22,7 +22,7 @@ import WhereCore // Each step also declares a span `budget` (see `BudgetedLaunchStep`), and // the plans compose them `.measured()` so every run is one Periscope span. -/// First-run onboarding and the one-time per-installation recording choice. +/// First-run onboarding and this installation's recording choice. /// Rooted at the trunk's head so that an install whose user hasn't chosen yet /// builds nothing: no store is opened, no CloudKit is contacted, and no session /// exists behind this. @@ -30,9 +30,10 @@ import WhereCore /// Unlike most gates it applies to **all** launch reasons rather than the /// foreground-only default. Parking a headless launch is the point here — the /// alternative is opening the user's store for a launch they can't see and may -/// never have consented to. An existing installation upgrading from before the -/// per-device choice may still receive a background wake; parking it safely -/// defers opening the store until the user verifies the choice in foreground. +/// never have consented to. The non-backed-up recording context is also absent +/// after a restore onto another device even when the backed-up onboarding flag +/// is present; parking safely defers opening the store until the user verifies +/// that new installation's choice in foreground. struct OnboardingGate: LifecycleGate { let model: WhereModel @@ -40,10 +41,14 @@ struct OnboardingGate: LifecycleGate { let modes: LifecycleModeSet = .all func isNeeded(_: Void) async -> Bool { + model.repairOnboardingFromCompletedImportIfNeeded() + if model.hasInterruptedOnboardingImport { + return await model.recoverInterruptedOnboardingImport() + } // An active scope means the choice has already been made — by // onboarding just now, or by a preview/test injecting one — so don't // ask again even though `hasOnboarded` may not be written yet. - model.activeScope == nil + return model.activeScope == nil && (!model.hasOnboarded || !model.hasConfirmedRecordingChoice) } } @@ -52,8 +57,10 @@ struct OnboardingGate: LifecycleGate { /// choice at the gate activated, or — for someone who onboarded on an earlier /// launch — their real scope, opening the app's **one** store on the way (see /// `WhereModel.resolveScope()`; everything else shares that store by -/// injection). Opening may run a lightweight migration; there's no separate UI -/// for it — the launch splash (shown throughout) fades in its own +/// injection). Before returning the scope, resolve any interrupted backup +/// import so neither App Intents nor recording can observe pre-cleanup state. +/// Opening may run a lightweight migration; there's no separate UI for it — the launch splash +/// (shown throughout) fades in its own /// launch-neutral "taking a moment" caption when any launch phase runs long. struct ResolveScopeStep: BudgetedLaunchStep { let model: WhereModel @@ -65,7 +72,12 @@ struct ResolveScopeStep: BudgetedLaunchStep { let budget: Duration = .seconds(1) func run(_: Void, _: LifecycleStepContext) async throws -> WhereScope { - try await model.resolveScope() + if let recoveryError = model.takeInterruptedOnboardingImportError() { + throw recoveryError + } + let scope = try await model.resolveScope() + try await model.preflightPendingImportRecovery(in: scope) + return scope } } @@ -191,25 +203,36 @@ struct WidgetSnapshotStep: BudgetedLaunchStep { // MARK: - Reset teardown steps -/// Stop GPS, wipe the store, and log out. Takes the session being erased as +/// Retire recording authority, erase synced user data, discard pending fixes, +/// and log out. Takes the session being erased as /// the teardown plan's root input — handed in by Settings, not re-read from an /// optional. If the erase throws the runner parks in `.failed` (terminally — -/// teardown runs fire-once) with the session and preferences *intact*, so the -/// reset is simply re-invocable from Settings after relaunching, rather than -/// stranding the user in onboarding atop un-erased data. +/// teardown runs fire-once). An ordinary erase failure leaves the session and +/// preferences intact and remains +/// re-invocable from Settings. A `ResetCleanupError` means the destructive transaction did +/// commit, so this step logs out before surfacing the dedicated partial-success failure. struct EraseDataStep: BudgetedLaunchStep { let model: WhereModel let id = LaunchStepID.eraseData - /// Quiescing GPS and wiping every table — the user is watching a - /// progress-free Settings row, so this is the reset's one slow step. + /// Coordinating the recording barrier, data transaction, and sidecar cleanup — + /// the user is watching a progress-free Settings row, so this is the reset's + /// one slow step. let budget: Duration = .seconds(3) func run(_ session: WhereSession, _: LifecycleStepContext) async throws { - try await session.eraseSession() - // Logging out drops the session and releases the scope, so the - // relaunch parks on the onboarding gate with nothing open; logging - // back in builds a fresh scope over the erased store. + do { + try await session.eraseSession() + } catch let error as WhereServices.ResetCleanupError { + // The destructive store transaction committed. Release the scope even though local + // cleanup remains, so App Intents cannot retain services over the erased store. The + // installation context is intentionally left for a later reset retry. + await model.endSession() + throw error + } + // Logging out drops the session and releases the scope, so the relaunch parks on the + // onboarding gate with nothing open; logging back in builds a fresh scope over the erased + // store. await model.endSession() } } @@ -235,17 +258,24 @@ struct ExitDemoStep: BudgetedLaunchStep { } } -/// Clear the persisted preferences that gate the relaunch (onboarding flag, -/// tracking intent, reminder/summary schedules), so the next launch behaves -/// like a fresh install. +/// Clear the non-backed-up installation context and persisted preferences that +/// gate the relaunch, so the next launch behaves like a fresh install. struct ResetPreferencesStep: BudgetedLaunchStep { let model: WhereModel let id = LaunchStepID.resetPreferences - /// A handful of key-value writes. + /// One sidecar removal plus a handful of key-value writes. let budget: Duration = .milliseconds(100) func run(_: Void, _: LifecycleStepContext) async throws { - model.resetPreferences() + do { + try model.resetPreferences() + } catch let error as WhereServices.ResetCleanupError { + throw error + } catch { + // EraseDataStep already committed synced erasure and released the old scope. A local + // installation-context failure is therefore partial success, not a generic rollback. + throw WhereServices.ResetCleanupError(underlying: error) + } } } diff --git a/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift b/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift new file mode 100644 index 00000000..8fa84f2d --- /dev/null +++ b/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift @@ -0,0 +1,149 @@ +import LifecycleKit +import LifecycleKitUI +import SnapshotKit +import SwiftUI +import WhereCore + +/// Where-specific terminal launch outcomes whose committed effects make the +/// shared “Couldn't finish launching” presentation misleading. +enum WhereLifecycleFailurePresentation: Equatable { + case committedImportCleanup(BackupCoordinator.ImportSummary) + case committedImportSetup(BackupCoordinator.ImportSummary) + case committedResetCleanup + + init?(failure: LifecycleFailure) { + if let error = failure.error as? BackupCoordinator.CommittedImportCleanupError { + self = .committedImportCleanup(error.summary) + } else if let error = failure.error as? OnboardingCommittedImportSetupError { + self = .committedImportSetup(error.summary) + } else if failure.error is WhereServices.ResetCleanupError { + self = .committedResetCleanup + } else { + return nil + } + } + + var title: String { + switch self { + case .committedImportCleanup: + String(localized: .backupImportCleanupTitle) + case .committedImportSetup: + String(localized: .backupImportSetupTitle) + case .committedResetCleanup: + String(localized: .launchResetCleanupTitle) + } + } + + var message: String { + switch self { + case let .committedImportCleanup(summary): + WhereFormat.backupImportCleanupMessage(summary) + case let .committedImportSetup(summary): + WhereFormat.backupImportSetupMessage(summary) + case .committedResetCleanup: + String(localized: .launchResetCleanupMessage) + } + } + + var systemImage: String { + switch self { + case .committedImportCleanup, .committedImportSetup: "exclamationmark.icloud" + case .committedResetCleanup: "trash.slash" + } + } +} + +/// Routes ordinary launch failures through LifecycleKit's shared terminal UI, +/// while committed backup/setup/reset failures explain what already succeeded +/// and the safe recovery that remains. +struct WhereLifecycleFailureView: View { + private enum Content { + case generic(LifecycleFailure) + case committed(WhereLifecycleFailurePresentation) + } + + @Environment(\.stylesheet) private var stylesheet + private let content: Content + + init(failure: LifecycleFailure) { + if let presentation = WhereLifecycleFailurePresentation(failure: failure) { + content = .committed(presentation) + } else { + content = .generic(failure) + } + } + + #if DEBUG + init(presentation: WhereLifecycleFailurePresentation) { + content = .committed(presentation) + } + #endif + + var body: some View { + switch content { + case let .generic(failure): + LifecycleFailureView(failure: failure) + case let .committed(presentation): + GeometryReader { geometry in + ScrollView { + VStack(spacing: stylesheet.spacing.large) { + Image(systemName: presentation.systemImage) + .font(.largeTitle) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(presentation.title) + .font(.title.bold()) + .accessibilityAddTraits(.isHeader) + Text(presentation.message) + .foregroundStyle(.secondary) + } + .multilineTextAlignment(.center) + .padding(stylesheet.spacing.xxxLarge) + .frame(maxWidth: .infinity, minHeight: geometry.size.height) + } + .scrollBounceBehavior(.basedOnSize) + } + .background(Color(.systemBackground).ignoresSafeArea()) + } + } +} + +#if DEBUG + extension WhereLifecycleFailureView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "CommittedResetCleanup", configurations: .screenDefaults) { + WhereLifecycleFailureView(presentation: .committedResetCleanup) + } + whereSnapshot(name: "CommittedImportCleanup", configurations: .phoneLightDark) { + WhereLifecycleFailureView(presentation: .committedImportCleanup(.preview)) + } + whereSnapshot(name: "CommittedImportSetup", configurations: .phoneLightDark) { + WhereLifecycleFailureView(presentation: .committedImportSetup(.preview)) + } + } + } + + extension BackupCoordinator.ImportSummary { + fileprivate static let preview = BackupCoordinator.ImportSummary( + sampleCount: 42, + evidenceCount: 3, + manualDayCount: 7, + dismissedIssueCount: 2, + trackedRegionCount: 5, + recordingDeviceCount: 2, + recordingPolicyChangeCount: 4, + ) + } + + #Preview { + WhereLifecycleFailureView.snapshotPreviews + } + + extension WhereLifecycleFailureView: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.snapshots( + WhereLifecycleFailureView.self, + title: "Committed Operation Failure", + navigationContainer: .none, + ) + } +#endif diff --git a/Where/WhereUI/Sources/Logging/BackupModelLog.swift b/Where/WhereUI/Sources/Logging/BackupModelLog.swift index b790fd6b..c3645ec0 100644 --- a/Where/WhereUI/Sources/Logging/BackupModelLog.swift +++ b/Where/WhereUI/Sources/Logging/BackupModelLog.swift @@ -13,13 +13,14 @@ enum BackupModelLog: LogEvent { trackedRegionCount: Int, ) case importFailed(description: String) + case importCleanupFailed(description: String) static let eventName = "Backup" var level: LogLevel { switch self { case .exported, .imported: .info - case .exportFailed, .importFailed: .warning + case .exportFailed, .importFailed, .importCleanupFailed: .warning } } @@ -39,6 +40,8 @@ enum BackupModelLog: LogEvent { "Imported backup (\(sampleCount) samples, \(evidenceCount) evidence, \(manualDayCount) manual days, \(dismissedIssueCount) dismissals, \(trackedRegionCount) tracked regions)" case let .importFailed(description): "Backup import failed: \(description)" + case let .importCleanupFailed(description): + "Backup import committed but recording cleanup failed: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift index 373da80a..1325fb8a 100644 --- a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift +++ b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift @@ -6,13 +6,27 @@ import PeriscopeCore enum OnboardingViewLog: LogEvent { case regionCommitFailed(description: String) case backupRestoreFailed(description: String) + case backupRestoreCleanupFailed(description: String) /// The user declined (or is restricted from) location access at the /// onboarding ask. Expected, not a failure: tracking stays /// intended-but-inactive and Settings offers the route to grant it. case locationPermissionDenied + /// The non-backed-up installation sidecar could not be persisted, so the + /// app cannot safely register a stable recording identity. + case installationContextWriteFailed(description: String) + /// Backup exclusion failed and the unsafe sidecar could not be removed either. + case installationContextSecurityCleanupFailed( + exclusionDescription: String, + cleanupDescription: String, + ) + /// A crash left an atomically written replacement that could not decode; the older + /// authoritative context remains usable and the corrupt pending copy was removed. + case discardedCorruptInstallationContextPending /// Opening the user's store failed, so onboarding can't hand the launch a /// world to run in. Fails the gate, landing on the failure surface. case scopeCreationFailed(description: String) + /// The stable device registration or selected recording command could not be persisted. + case recordingConfigurationFailed(description: String) /// Building the demo world failed. Recoverable: the intro comes back with /// an alert, and every other way forward still works. case demoBuildFailed(description: String) @@ -21,9 +35,12 @@ enum OnboardingViewLog: LogEvent { var level: LogLevel { switch self { - case .regionCommitFailed, .backupRestoreFailed, .demoBuildFailed: .warning + case .regionCommitFailed, .backupRestoreFailed, .demoBuildFailed, + .discardedCorruptInstallationContextPending: .warning case .locationPermissionDenied: .info - case .scopeCreationFailed: .error + case .installationContextWriteFailed, .installationContextSecurityCleanupFailed, + .scopeCreationFailed, + .recordingConfigurationFailed, .backupRestoreCleanupFailed: .error } } @@ -33,10 +50,21 @@ enum OnboardingViewLog: LogEvent { "Failed to commit onboarding region picks: \(description)" case let .backupRestoreFailed(description): "Onboarding backup restore failed: \(description)" + case let .backupRestoreCleanupFailed(description): + "Onboarding backup restore committed but recording cleanup failed: \(description)" case .locationPermissionDenied: "Location access declined during onboarding" + case let .installationContextWriteFailed(description): + "Failed to persist the installation recording context: \(description)" + case let .installationContextSecurityCleanupFailed(exclusion, cleanup): + "Failed to exclude the installation recording context from backup " + + "(\(exclusion)) and failed to remove it safely (\(cleanup))" + case .discardedCorruptInstallationContextPending: + "Discarded a corrupt pending installation recording context" case let .scopeCreationFailed(description): "Failed to open the store during onboarding: \(description)" + case let .recordingConfigurationFailed(description): + "Failed to apply the onboarding recording choice: \(description)" case let .demoBuildFailed(description): "Failed to build the demo world: \(description)" } diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 19bb02c6..d1dc510e 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -1,7 +1,7 @@ import Foundation import Observation import PeriscopeCore -import WhereCore +@_spi(Testing) import WhereCore /// The long-lived, app-level model: the onboarding gate, the persisted /// preferences, which `WhereScope` the app is logged in to, and the optional @@ -115,11 +115,19 @@ public final class WhereModel { /// holding it eagerly doesn't cost the logged-out window anything. let preferences: WherePreferences + /// The one device-local recording context store composed for this process. + /// It owns the non-backed-up installation sidecar and is shared with every + /// bootstrap this model creates, so onboarding and service assembly cannot + /// resolve different identities. + private let installationContextStore: any InstallationRecordingContextStoring + private var interruptedOnboardingImportError: (any Error)? + /// Makes the bootstrap a logged-out state carries. A factory rather than /// a stored instance, because a bootstrap is spent by the login it serves: /// logging out needs a fresh one for the next login, and holding the used /// one would keep a consumed location source alive beside the live scope. - private let makeBootstrap: @MainActor () -> any WhereScopeAssembling + private let makeBootstrap: + @MainActor (any InstallationRecordingContextStoring) -> any WhereScopeAssembling /// Called when the app logs out of a scope — a reset, or entering or /// leaving demo mode — so the composition root can release whatever it @@ -158,26 +166,114 @@ public final class WhereModel { set { preferences.hasOnboarded = newValue } } - /// Whether this installation has confirmed the recording recommendation. - /// Existing installations that predate the choice keep onboarding complete - /// but revisit its final page once to make this device-specific decision. - public private(set) var hasConfirmedRecordingChoice: Bool { - get { preferences.hasConfirmedRecordingChoice } - set { preferences.hasConfirmedRecordingChoice = newValue } + /// The context onboarding renders. A newly proposed value is kept in memory + /// until the user confirms it, so entering demo mode leaves no sidecar. + public var installationRecordingContext: InstallationRecordingContext { + installationContextStore.onboardingContext + } + + /// Confirmation lives beside the non-backed-up installation identity, not + /// in backed-up preferences. Restoring onto a new device therefore makes + /// this false even when `hasOnboarded` arrived in the backup. + public var hasConfirmedRecordingChoice: Bool { + installationRecordingContext.initialRecordingChoice != nil + } + + /// Whether the sidecar says onboarding crossed or may have crossed an import commit. The + /// launch gate uses this one narrow exception to open the store before offering Restore. + var hasInterruptedOnboardingImport: Bool { + installationContextStore.backupImportRecovery?.details.purpose == .onboarding + } + + /// Reassert the backed-up preference from the sidecar's terminal import authority. The + /// sidecar write is the durable boundary because `UserDefaults` can return from its setter + /// before the preference reaches disk. + func repairOnboardingFromCompletedImportIfNeeded() { + guard installationContextStore.onboardingImportCompletion != nil, + !hasOnboarded + else { return } + completeOnboarding() + } + + /// Resolve any import transaction left across a process death before the launch exposes this + /// scope to App Intents or starts recording. Settings imports do not pass through onboarding's + /// special gate, so this scope-level preflight is the common safety boundary for every import + /// purpose. + func preflightPendingImportRecovery(in scope: WhereScope) async throws { + guard let pendingRecovery = installationContextStore.backupImportRecovery else { return } + switch try await scope.services.backup.importRecoveryState() { + case .ready: + // Hydration proved a prepared transaction rolled back and cleared its marker. + return + case .cleanupRequired: + if pendingRecovery.details.purpose == .onboarding { + // Preserve onboarding's ordering if a marker appears after its gate check: + // persist the backed-up preference before acknowledging it in the sidecar. + completeOnboarding() + try await scope.services.backup.acknowledgeOnboardingImport() + } + try await scope.services.backup.retryImportCleanup() + case .onboardingAcknowledgementRequired: + completeOnboarding() + try await scope.services.backup.acknowledgeOnboardingImport() + } } - /// Mark first-run onboarding and this installation's recording choice - /// complete. Called after the optional permission prompt resolves. + /// Persist this installation's first recording choice. + @discardableResult + public func confirmInitialRecordingChoice( + isEnabled: Bool, + ) throws -> InstallationRecordingContext { + try installationContextStore.confirmInitialRecording( + isEnabled: isEnabled, + ) + } + + /// Mark the first-run app flow complete after its scope and selections have + /// been committed. Recording confirmation is persisted separately first. public func completeOnboarding() { hasOnboarded = true - hasConfirmedRecordingChoice = true Self.logger { .onboardingCompleted } } - /// Persist the one-time recording confirmation for an installation that - /// completed the rest of onboarding before per-device controls existed. - public func confirmRecordingChoice() { - hasConfirmedRecordingChoice = true + /// Reconcile a cold-launch onboarding import before the Restore UI can be presented. + /// Returns whether ordinary onboarding is still required. + func recoverInterruptedOnboardingImport() async -> Bool { + guard hasInterruptedOnboardingImport else { + return activeScope == nil && (!hasOnboarded || !hasConfirmedRecordingChoice) + } + do { + let scope = try await resolveScope() + switch try await scope.services.backup.importRecoveryState() { + case .ready: + // Prepared without a receipt: the store transaction never committed. Drop + // the temporarily opened world and offer the original onboarding flow. + await endSession() + return true + case .cleanupRequired: + // Write the backed-up preference before acknowledging it in the sidecar. + // Cleanup may still fail; acknowledgement is independent so Settings can + // finish it later without re-entering onboarding. + completeOnboarding() + try await scope.services.backup.acknowledgeOnboardingImport() + try await scope.services.backup.retryImportCleanup() + return false + case .onboardingAcknowledgementRequired: + completeOnboarding() + try await scope.services.backup.acknowledgeOnboardingImport() + return false + } + } catch { + // The sidecar remains authoritative. Skip the ordinary Restore surface and let the + // resolve step surface this exact failure with its normal retry affordance. + interruptedOnboardingImportError = error + return false + } + } + + func takeInterruptedOnboardingImportError() -> (any Error)? { + defer { interruptedOnboardingImportError = nil } + return interruptedOnboardingImportError } public static var currentYear: Int { @@ -190,27 +286,33 @@ public final class WhereModel { /// until something asks for a scope. /// /// - Parameters: - /// - makeBootstrap: makes the assembler a login builds its scope from. - /// Called once per logged-out state, so a test can hand back the same - /// instance and count what was asked of it. Deliberately has no - /// default, for the same reason `logSystem` doesn't: a test that - /// omitted it would open the app's real durable log store on the next - /// login — which in a test host neither succeeds nor fails quickly. + /// - installationContextStore: one non-backed-up installation context, + /// shared with every bootstrap created for this model. + /// - makeBootstrap: makes the assembler a login builds from that same + /// context store. Called once per logged-out state, so a test can hand + /// back the same instance and count what was asked of it. Deliberately + /// has no default, for the same reason `logSystem` doesn't: a test that + /// omitted it would open the app's real durable stores on the next + /// login. /// - logSystem: the logging system this model's scopes record into. /// Deliberately has no default: the app passes `Periscope.shared`, and /// a test that omitted it would silently attach its sinks to the /// process-wide pipeline. public init( preferences: WherePreferences, - makeBootstrap: @escaping @MainActor () -> any WhereScopeAssembling, + installationContextStore: any InstallationRecordingContextStoring, + makeBootstrap: @escaping @MainActor ( + any InstallationRecordingContextStoring, + ) -> any WhereScopeAssembling, logSystem: Periscope, now: @escaping @Sendable () -> Date = { Date() }, ) { self.preferences = preferences + self.installationContextStore = installationContextStore self.makeBootstrap = makeBootstrap self.logSystem = logSystem self.now = now - scopeState = .loggedOut(bootstrap: makeBootstrap()) + scopeState = .loggedOut(bootstrap: makeBootstrap(installationContextStore)) initialSelectedYear = WhereModel.currentYear initialReport = nil } @@ -232,6 +334,9 @@ public final class WhereModel { logSystem: Periscope, now: @escaping @Sendable () -> Date = { Date() }, ) { + let installationContextStore = InMemoryInstallationRecordingContextStore( + context: .testing, + ) let scope = WhereScope.fake( services: services, preferences: preferences, @@ -239,7 +344,8 @@ public final class WhereModel { ) scopeState = .real(scope) self.preferences = preferences - makeBootstrap = { InjectedServicesAssembler(services: services) } + self.installationContextStore = installationContextStore + makeBootstrap = { _ in InjectedServicesAssembler(services: services) } self.logSystem = logSystem self.now = now initialSelectedYear = selectedYear @@ -383,9 +489,9 @@ public final class WhereModel { } /// Drop the logged-in session and release the scope. Run by the reset - /// teardown after `eraseAllData()`: the relaunch parks on the onboarding - /// gate again (the teardown cleared `hasOnboarded`), and logging back in - /// builds a fresh scope over a newly-opened store. + /// teardown after `eraseAllData()` and when onboarding abandons a failed + /// restore attempt: the next login builds a fresh scope over a newly-opened + /// store and the installation context current at that attempt. public func endSession() async { await logOut() Self.logger { .endedSession } @@ -402,23 +508,32 @@ public final class WhereModel { private func logOut() async { await activeScope?.stopLogRouting() session = nil - scopeState = .loggedOut(bootstrap: makeBootstrap()) + scopeState = .loggedOut(bootstrap: makeBootstrap(installationContextStore)) logStoreState = .unavailable await onLoggedOut() } // MARK: - Reset / erase all - /// Clear every persisted preference so the next launch behaves like a fresh - /// install: onboarding shows again (`hasOnboarded` gone), background - /// tracking returns to its default intent, and the reminder/summary - /// schedules revert to their defaults. The preferences half of the - /// reset/erase teardown. + /// Clear the device-local installation context and every persisted + /// preference so the next launch behaves like a fresh install: onboarding + /// shows again, recording gets a new identity and explicit choice, and the + /// reminder/summary schedules revert to their defaults. /// /// `WherePreferences.reset()` removes the keys (rather than writing /// `false`/`0`) so the default-valued getters report first-install state /// again; the re-driven launch's fresh session reads those defaults back. - public func resetPreferences() { + public func resetPreferences() throws { + do { + try installationContextStore.reset() + } catch let error as WhereServices.ResetCleanupError { + // The old installation identity is already retired. Finish the logical reset even + // though deleting its tombstone still needs a retry, so a relaunch cannot combine a + // fresh unconfirmed identity with stale "already onboarded" preferences. + preferences.reset() + Self.logger { .resetPreferences } + throw error + } preferences.reset() Self.logger { .resetPreferences } } diff --git a/Where/WhereUI/Sources/Model/WhereScope.swift b/Where/WhereUI/Sources/Model/WhereScope.swift index 8ce1f8e1..f5619e33 100644 --- a/Where/WhereUI/Sources/Model/WhereScope.swift +++ b/Where/WhereUI/Sources/Model/WhereScope.swift @@ -232,7 +232,7 @@ public final class WhereScope { let services = try await WhereServices.make( store: store, locationSource: locationSource, - currentDevice: .preview, + installationContext: .demo, aggregator: aggregator, // Authorized, like the location source is: the demo presents a user // who has granted everything, so the alerts screen shows its real @@ -244,19 +244,18 @@ public final class WhereScope { issueAlertScheduler: NoopDataIssueAlertScheduler(authorized: true), widgetRefresher: NoopWidgetTimelineRefresher(), locationOutbox: NoOpLocationOutbox(), + importRecoveryPersistence: .none, now: now, ) try await DemoDataBuilder(now: now(), calendar: aggregator.calendar) .seed(into: services) let preferences = WherePreferences(store: InMemoryKeyValueStore()) - // Onboarded and tracking, so the demo opens on the logged-in app with - // live tracking shown rather than on a first-run prompt. These are the - // demo's own preferences: the user's real ones are untouched, which is - // what makes quitting mid-demo return to onboarding. + // Onboarded, so the demo opens on the logged-in app rather than on a + // first-run prompt. Recording starts from the demo installation context. + // These are the demo's own preferences: the user's real ones are untouched, + // which is what makes quitting mid-demo return to onboarding. preferences.hasOnboarded = true - preferences.hasConfirmedRecordingChoice = true - preferences.wantsTracking = true let scope = WhereScope( kind: .demo, diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index c57d3855..b1d5d01a 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -39,9 +39,17 @@ public final class WhereSession { /// gets a new token, so the scene can't fail to rebuild on a collision. public let id: SessionID - /// Whether background GPS ingestion is currently attached. Reflects reality - /// (authorization + the user's intent), not just the last button tap. - public private(set) var isTracking = false + /// The current installation's locally applied recording state. One value carries the + /// resolved policy, physical status, and durable acknowledgement together; `.unavailable` + /// means Core failed closed because it could not prove that agreement. + public private(set) var recordingRuntimeState: RecordingDeviceRuntimeState = .unavailable + + /// Whether background GPS ingestion is currently attached. Derived from the applied state, + /// so it cannot drift from the configuration Core durably acknowledged. + public var isTracking: Bool { + guard case let .applied(configuration) = recordingRuntimeState else { return false } + return configuration.device.status == .recording + } /// Stable installation identity used by the Devices settings screen to mark /// the current row and prevent archiving it. @@ -82,6 +90,10 @@ public final class WhereSession { /// access to race. @ObservationIgnored private nonisolated(unsafe) var authorizationTask: Task? + /// Mirrors only successfully applied current-device configurations emitted by Core. + @ObservationIgnored private nonisolated(unsafe) var recordingConfigurationTask: + Task? + /// Observes `dataChangeUpdates()` to keep ``regionStyles`` in sync with the /// store's picked region appearances. Same `nonisolated(unsafe)` rationale as /// `authorizationTask` — only touched on the main actor except `deinit`. @@ -108,12 +120,16 @@ public final class WhereSession { private var warnedSummaryUnauthorized = false private var warnedIssueAlertsUnauthorized = false - /// Persisted user intent to track in the background. Effective tracking is - /// this AND `.always` authorization; we default to `true` so that, once the - /// user grants Always, tracking resumes automatically on every launch. - private var wantsTracking: Bool { - get { preferences.wantsTracking } - set { preferences.wantsTracking = newValue } + /// Whether this session has performed its explicit, idempotent registration operation. + private var didRegisterRecordingDevice = false + /// Last controller-ordered runtime emission applied to presentation state. + private var lastRecordingRuntimeSequence: UInt64? + + /// Latest resolved desired policy for this installation, available only after Core applies + /// and acknowledges it. This gates foreground capture without a second mutable mirror. + private var recordingEnabled: Bool { + guard case let .applied(configuration) = recordingRuntimeState else { return false } + return configuration.isEnabled == true } /// A process-unique session identity. A typed token rather than a raw `Int` @@ -172,6 +188,7 @@ public final class WhereSession { /// until the next status change resumes it. deinit { authorizationTask?.cancel() + recordingConfigurationTask?.cancel() regionStyleTask?.cancel() } @@ -186,6 +203,7 @@ public final class WhereSession { public func start() async { await syncAuthorization() observeAuthorizationChanges() + observeRecordingConfigurationChanges() await seedRegionStyles() observeRegionStyleChanges() await reconcileTracking() @@ -298,11 +316,20 @@ public final class WhereSession { for await _ in updates { guard let self else { break } await seedRegionStyles() - // A CloudKit policy change for this installation arrives through - // the same store signal. Reconcile it even if the Devices screen - // is not open, so a left-behind device physically stops as soon - // as it receives the command. - await reconcileTracking() + } + } + } + + /// Observe Core's focused policy reconciliation output. The controller emits only after + /// physical GPS state and its target-owned acknowledgement agree, so this mirror never has + /// to infer state from an arbitrary store-change notification. + private func observeRecordingConfigurationChanges() { + guard recordingConfigurationTask == nil else { return } + let updates = services.recording.runtimeUpdates() + recordingConfigurationTask = Task { @MainActor [weak self] in + for await update in updates { + guard let self else { break } + applyRecordingRuntimeUpdate(update) } } } @@ -311,38 +338,64 @@ public final class WhereSession { /// current authorization. Tracking only runs with Always authorization. A /// launch step (see `WhereLaunch.plan(for:)`). func reconcileTracking() async { + observeRecordingConfigurationChanges() let wasTracking = isTracking do { - let configuration = try await services.recording.reconcile( - initialEnabled: wantsTracking, - authorization: authorizationStatus, - ) - // Keep the legacy local preference as the migration seed/fallback, - // but synced policy is authoritative once the device exists. - wantsTracking = configuration.isEnabled - isTracking = configuration.device.status == .recording + await services.recording.startMonitoringPolicyChanges() + if didRegisterRecordingDevice { + _ = try await services.recording.reconcile( + authorization: authorizationStatus, + ) + } else { + _ = try await services.recording.register( + authorization: authorizationStatus, + ) + didRegisterRecordingDevice = true + } + await synchronizeRecordingRuntimeState() if isTracking, !wasTracking { Self.logger { .backgroundTrackingStarted } } else if !isTracking, wasTracking { Self.logger { .backgroundTrackingStopped } } } catch { + // Core fails closed and stops its source. Keep the UI mirror equally honest. + didRegisterRecordingDevice = false + await synchronizeRecordingRuntimeState() Self.logger(attachments: [.error(error, name: "recording-reconcile-error")]) { .recordingReconcileFailed(description: error.localizedDescription) } } } + private func synchronizeRecordingRuntimeState() async { + guard let update = await services.recording.currentRuntimeUpdate() else { return } + applyRecordingRuntimeUpdate(update) + } + + private func applyRecordingRuntimeUpdate(_ update: RecordingDeviceRuntimeUpdate) { + if let lastRecordingRuntimeSequence, + update.sequence <= lastRecordingRuntimeSequence + { + return + } + lastRecordingRuntimeSequence = update.sequence + recordingRuntimeState = update.state + if case .unavailable = update.state { + didRegisterRecordingDevice = false + } + } + /// Fill in today with a one-shot GPS fix if the day has no GPS sample yet, /// so opening the app on a fresh morning doesn't leave the calendar blank - /// until passive tracking next fires. Gated on the user's tracking intent + /// until passive tracking next fires. Gated on the resolved recording policy /// and a usable authorization (When-In-Use is enough for a foreground fix — /// notably the only way When-In-Use users get any data). The ingestor is /// non-blocking and reconciles widgets / reminders + pings the read signal /// on persist. A launch step (see `WhereLaunch.plan(for:)`); also runs on /// every foreground. func captureTodayIfNeeded() async { - guard wantsTracking, authorizationStatus.allowsForegroundFix else { return } + guard recordingEnabled, authorizationStatus.allowsForegroundFix else { return } await services.ingestor.captureTodayIfNeeded(now: now()) } @@ -372,7 +425,7 @@ public final class WhereSession { /// hard denial the Settings alert is surfaced. public func startTracking() async { do { - _ = try await setRecordingEnabled(true, for: currentRecordingDeviceID) + try await setRecordingEnabled(true, for: currentRecordingDeviceID) } catch { Self.logger(attachments: [.error(error, name: "recording-enable-error")]) { .recordingReconcileFailed(description: error.localizedDescription) @@ -382,7 +435,7 @@ public final class WhereSession { public func stopTracking() async { do { - _ = try await setRecordingEnabled(false, for: currentRecordingDeviceID) + try await setRecordingEnabled(false, for: currentRecordingDeviceID) } catch { Self.logger(attachments: [.error(error, name: "recording-disable-error")]) { .recordingReconcileFailed(description: error.localizedDescription) @@ -390,24 +443,22 @@ public final class WhereSession { } } - /// Current synced device list, registering this installation on first use. + /// Current synced device list. Registration is an explicit launch operation. public func recordingDevices() async throws -> [RecordingDeviceConfiguration] { - try await services.recording.devices(initialEnabled: wantsTracking) + try await services.recording.devices() } /// Set automatic recording for any installation. The current device also /// runs the permission flow and updates the session's live tracking mirror. - @discardableResult public func setRecordingEnabled( _ enabled: Bool, for deviceID: RecordingDeviceID, - ) async throws -> [RecordingDeviceConfiguration] { + ) async throws { var devices = try await services.recording.setEnabled( enabled, for: deviceID, - initialEnabled: wantsTracking, ) - guard deviceID == currentRecordingDeviceID else { return devices } + guard deviceID == currentRecordingDeviceID else { return } var permissionRequestFailed = false if enabled { @@ -418,48 +469,39 @@ public final class WhereSession { } await syncAuthorization() _ = try await services.recording.reconcile( - initialEnabled: wantsTracking, authorization: authorizationStatus, ) // The permission prompt is an actor suspension point. Re-read after // it because a later Off action may have won while the prompt was // visible; reconciliation honors that latest policy rather than // appending another On event. - devices = try await services.recording.devices(initialEnabled: wantsTracking) + devices = try await services.recording.devices() } - guard let current = devices.first(where: { $0.id == deviceID }) else { - return devices + guard let current = devices.first(where: { $0.id == deviceID }) else { return } + guard let resolvedEnabled = current.isEnabled else { + throw RecordingPersistenceError.currentDevicePolicyUnknown(deviceID) } - wantsTracking = current.isEnabled - isTracking = current.device.status == .recording - permissionDenied = current.isEnabled && permissionRequestFailed - if current.isEnabled, isTracking { + await synchronizeRecordingRuntimeState() + permissionDenied = resolvedEnabled && permissionRequestFailed + if resolvedEnabled, isTracking { Self.logger { .trackingEnabled } - } else if !current.isEnabled { + } else if !resolvedEnabled { Self.logger { .stoppedBackgroundTracking } } - return devices } public func renameRecordingDevice( _ deviceID: RecordingDeviceID, to nickname: String, - ) async throws -> [RecordingDeviceConfiguration] { - try await services.recording.rename( - deviceID, - to: nickname, - initialEnabled: wantsTracking, - ) + ) async throws { + _ = try await services.recording.rename(deviceID, to: nickname) } public func archiveRecordingDevice( _ deviceID: RecordingDeviceID, - ) async throws -> [RecordingDeviceConfiguration] { - try await services.recording.archive( - deviceID, - initialEnabled: wantsTracking, - ) + ) async throws { + _ = try await services.recording.archive(deviceID) } /// Push the persisted reminder intent to the reminder reconciler and warn if @@ -530,10 +572,10 @@ public final class WhereSession { } } - /// Erase all persisted data and reset the coordinator's observable state to a + /// Erase synced user data and reset the coordinator's observable state to a /// clean slate. A thin pass-through to `WhereServices.reset()`, which owns - /// *what* gets cleared (GPS stop + store wipe + reminder/badge reconcile + - /// empty widget snapshot); the coordinator only mirrors the outcome. The + /// *what* gets cleared (recording authority + user-data transaction + pending + /// fixes + derived-state reconciliation); the coordinator only mirrors the outcome. The /// scene's `YearReportModel` is torn down and rebuilt by the relaunch, so no /// report/issue state needs clearing here. The data half of the reset/erase /// teardown (see `WhereLaunch.resetPlan(for:)`); throws on persistence failure @@ -551,17 +593,23 @@ public final class WhereSession { do { try await services.reset() + } catch let error as WhereServices.ResetCleanupError { + // Synced erasure already committed. Keep the old installation context available to + // a later cleanup retry, but never revive this session's observers or authority: the + // teardown step must release the scope and App Intents before surfacing the terminal + // partial-success state. + recordingRuntimeState = .unavailable + throw error } catch { - // A failed reset deliberately retains this session so the user can - // retry. Restore its live observers along with Core's operation - // gate rather than leaving the surviving UI stale. - isTracking = false + // The data transaction rolled back. This session remains valid for an explicit retry, + // so restore its live observers along with Core's operation gate. + recordingRuntimeState = .unavailable await reconcileTracking() observeAuthorizationChanges() observeRegionStyleChanges() throw error } - isTracking = false + recordingRuntimeState = .unavailable Self.logger { .erasedSession } } } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingRestoreSelection.swift b/Where/WhereUI/Sources/Onboarding/OnboardingRestoreSelection.swift new file mode 100644 index 00000000..ca208d4a --- /dev/null +++ b/Where/WhereUI/Sources/Onboarding/OnboardingRestoreSelection.swift @@ -0,0 +1,118 @@ +import Foundation +import WhereCore + +/// The complete onboarding-restore lifecycle, including its irreversible commit boundary. +/// +/// Keeping selection, strategy, and committed summary in one value prevents onboarding from +/// accidentally presenting the importer again after an archive has already changed the store. +struct OnboardingRestoreSelection { + struct ReadyImport { + let url: URL + let strategy: BackupCoordinator.ImportStrategy + } + + private struct ScopedArchive { + let url: URL + let hasScopedAccess: Bool + + func stopAccessingSecurityScopedResource() { + if hasScopedAccess { + url.stopAccessingSecurityScopedResource() + } + } + } + + private enum State { + case none + case choosingStrategy(ScopedArchive) + case ready(ScopedArchive, BackupCoordinator.ImportStrategy) + case committed(BackupCoordinator.ImportSummary) + } + + private var state: State = .none + + static let recommendedStrategy = BackupCoordinator.ImportStrategy.merge + + init() {} + + init(url: URL, hasScopedAccess: Bool) { + state = .choosingStrategy(ScopedArchive(url: url, hasScopedAccess: hasScopedAccess)) + } + + var selectedURL: URL? { + switch state { + case .none, .committed: nil + case let .choosingStrategy(archive), let .ready(archive, _): archive.url + } + } + + var strategy: BackupCoordinator.ImportStrategy? { + if case let .ready(_, strategy) = state { strategy } else { nil } + } + + var readyImport: ReadyImport? { + if case let .ready(archive, strategy) = state { + ReadyImport(url: archive.url, strategy: strategy) + } else { + nil + } + } + + var committedSummary: BackupCoordinator.ImportSummary? { + if case let .committed(summary) = state { summary } else { nil } + } + + mutating func select(url: URL, hasScopedAccess: Bool) { + discardUncommittedSelection() + guard committedSummary == nil else { + assertionFailure("A committed onboarding import cannot select another archive.") + return + } + state = .choosingStrategy(ScopedArchive(url: url, hasScopedAccess: hasScopedAccess)) + } + + mutating func choose(_ strategy: BackupCoordinator.ImportStrategy) { + switch state { + case let .choosingStrategy(archive), let .ready(archive, _): + state = .ready(archive, strategy) + case .none, .committed: + assertionFailure("A restore strategy requires an uncommitted archive selection.") + } + } + + /// Cross the irreversible import boundary, release the file, and retain its exact summary. + mutating func markCommitted(_ summary: BackupCoordinator.ImportSummary) { + switch state { + case let .ready(archive, _): + archive.stopAccessingSecurityScopedResource() + state = .committed(summary) + case let .committed(existing): + precondition(existing == summary, "A committed import summary cannot change.") + case .none, .choosingStrategy: + preconditionFailure("An import can commit only after its strategy is fixed.") + } + } + + /// Cancel or roll back only while the archive is still reversible. A committed summary is + /// deliberately retained so later onboarding work cannot reopen the importer. + mutating func discardUncommittedSelection() { + switch state { + case let .choosingStrategy(archive), let .ready(archive, _): + archive.stopAccessingSecurityScopedResource() + state = .none + case .none, .committed: + break + } + } +} + +/// A backup committed during onboarding, but a later device-setup operation failed. +/// The summary makes that irreversible boundary available to terminal launch presentation. +struct OnboardingCommittedImportSetupError: LocalizedError { + let summary: BackupCoordinator.ImportSummary + let underlying: any Error + + var errorDescription: String? { + String(localized: .backupImportSetupTitle) + } +} diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 8d6008f8..370ed292 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -3,7 +3,7 @@ import PeriscopeCore import SnapshotKit import SwiftUI import UniformTypeIdentifiers -import WhereCore +@_spi(Testing) import WhereCore /// First-run onboarding, run as the launch's opening gate. A short paged /// intro to the passport concept, then picking the primary regions you spend @@ -15,10 +15,11 @@ import WhereCore /// store is unopened and there is no session. Onboarding is what brings the /// user's world into being — restoring a backup or finishing the flow logs in /// to the real scope (`WhereModel.resolveScope()`, which performs the app's one -/// store open), commits the picked regions + appearances to it, persists -/// `hasOnboarded`, and resolves the `LifecycleGateHandle` so the launch -/// continues. The steps after the gate then build the session, seed region -/// styling, and pick up whatever permission was granted. +/// store open), commits the picked regions + appearances to it, persists the +/// confirmed recording choice beside this installation's non-backed-up +/// identity, and resolves the `LifecycleGateHandle` so launch continues. The +/// steps after the gate then build the session, seed region styling, and pick +/// up whatever permission was granted. public struct OnboardingView: View { // The model is onboarding's whole world: it persists the app-level // `hasOnboarded` flag and vends the scope this flow creates. There is no @@ -26,7 +27,11 @@ public struct OnboardingView: View { @Environment(WhereModel.self) private var model @Environment(\.stylesheet) private var stylesheet private let gate: LifecycleGateHandle - private let deviceKind: RecordingDeviceKind + private let installationContext: InstallationRecordingContext + + private var deviceKind: RecordingDeviceKind { + installationContext.currentDevice.kind + } /// The ordered onboarding phases. An explicit state machine (rather than /// loose flags) so only one screen is ever showing and the transitions are @@ -43,6 +48,7 @@ public struct OnboardingView: View { @State private var selection = PrimaryRegionSelectionModel() @State private var recordingEnabled: Bool @State private var isFinishing = false + @State private var restoreSelection = OnboardingRestoreSelection() /// What the intro is doing, and how it went — see ``OnboardingIntroState``. @State private var intro = OnboardingIntroState() @@ -52,6 +58,10 @@ public struct OnboardingView: View { /// dismissed without starting anything. @State private var showImporter = false + /// A selected backup has no import semantics until the user explicitly + /// chooses Merge or Replace. Merge is offered first as the safe default. + @State private var showRestoreStrategyDialog = false + /// How long the demo interstitial stays up at minimum. Seeding a year is /// fast enough to flash by, and a screen that appears and vanishes reads /// as a glitch rather than as work being done — so the wait is held long @@ -60,27 +70,32 @@ public struct OnboardingView: View { private static let logger = WhereLog.session(OnboardingViewLog.self) - public init(gate: LifecycleGateHandle) { + public init( + gate: LifecycleGateHandle, + installationContext: InstallationRecordingContext, + ) { self.init( gate: gate, - deviceKind: CurrentRecordingDeviceProvider.currentKind, + installationContext: installationContext, startsAtRecordingChoice: false, ) } - /// Internal composition/test initializer. A returning installation that - /// predates the per-device choice skips the first-run pages and verifies the - /// recommendation directly; snapshots inject the device kind explicitly. + /// Internal composition/test initializer. A restored installation whose + /// backed-up onboarding flag arrived without this non-backed-up context + /// skips to the device-specific verification page; snapshots use the same + /// route to capture that page directly. init( gate: LifecycleGateHandle, - deviceKind: RecordingDeviceKind, + installationContext: InstallationRecordingContext, startsAtRecordingChoice: Bool, ) { self.gate = gate - self.deviceKind = deviceKind + self.installationContext = installationContext _phase = State(initialValue: startsAtRecordingChoice ? .location : .intro) _recordingEnabled = State( - initialValue: deviceKind.recommendsAutomaticRecording, + initialValue: installationContext.initialRecordingChoice?.isEnabled + ?? installationContext.recommendedRecordingEnabled, ) } @@ -108,6 +123,7 @@ public struct OnboardingView: View { .ignoresSafeArea(), ) .animation(stylesheet.motion.reducedReveal, value: phase) + .onDisappear(perform: discardPendingRestore) // Log View Mode: reveal an inspect badge for onboarding events (region // commit / backup restore). A no-op in release. .debugLogInspectable(WhereLog.session(OnboardingViewLog.self)) @@ -156,6 +172,24 @@ public struct OnboardingView: View { allowedContentTypes: [.zip], onCompletion: handleRestoreSelection, ) + .confirmationDialog( + String(localized: .settingsBackupImportStrategyTitle), + isPresented: $showRestoreStrategyDialog, + titleVisibility: .visible, + presenting: restoreSelection.selectedURL, + ) { _ in + Button(String(localized: .onboardingRestoreMergeRecommended)) { + chooseRestoreStrategy(OnboardingRestoreSelection.recommendedStrategy) + } + Button(String(localized: .settingsBackupReplace), role: .destructive) { + chooseRestoreStrategy(.replace) + } + Button(String(localized: .settingsDataCancel), role: .cancel) { + discardPendingRestore() + } + } message: { _ in + Text(String(localized: .settingsBackupImportStrategyMessage)) + } .alert( failureTitle, isPresented: $intro.isShowingFailure, @@ -172,11 +206,7 @@ public struct OnboardingView: View { /// The alert's title, which names the task that failed. Empty when /// nothing has, in which case the alert isn't presented. private var failureTitle: String { - switch intro.failure?.flow { - case .restoreBackup: String(localized: .onboardingRestoreErrorTitle) - case .demo: String(localized: .onboardingDemoErrorTitle) - case nil: "" - } + intro.failure?.flow.title ?? "" } private func pageView(_ page: OnboardingPage) -> some View { @@ -205,6 +235,7 @@ public struct OnboardingView: View { if page < pages.count - 1 { withAnimation { page += 1 } } else { + discardPendingRestore() phase = .pickRegions } } label: { @@ -257,51 +288,57 @@ public struct OnboardingView: View { // MARK: - Location private var location: some View { - VStack(spacing: stylesheet.spacing.xxxLarge) { - Spacer(minLength: 0) - Image(systemName: deviceKind.systemImage) - .font(stylesheet.typography.onboardingIcon) - .foregroundStyle(Color.accentColor) - .accessibilityHidden(true) - VStack(spacing: stylesheet.spacing.large) { - Text(recordingTitle) - .font(.largeTitle.bold()) - .multilineTextAlignment(.center) - Text(String(localized: .onboardingRecordingDescription)) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - Spacer(minLength: 0) - - VStack(spacing: stylesheet.spacing.large) { - VStack(alignment: .leading, spacing: stylesheet.spacing.small) { - Toggle( - String(localized: .settingsDevicesAutomaticRecording), - isOn: $recordingEnabled, - ) - Text(recordingRecommendation) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - // Request Always-location only after the user confirms an - // enabled choice; the launch's reconcile step picks up - // whatever the system grants. - finish(enableLocation: recordingEnabled) - } label: { - Text(String(localized: .onboardingContinue)) - .frame(maxWidth: .infinity) + GeometryReader { geometry in + ScrollView { + VStack(spacing: stylesheet.spacing.xxxLarge) { + Spacer(minLength: 0) + Image(systemName: deviceKind.systemImage) + .font(stylesheet.typography.onboardingIcon) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + VStack(spacing: stylesheet.spacing.large) { + Text(recordingTitle) + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + Text(String(localized: .onboardingRecordingDescription)) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + Spacer(minLength: 0) + + VStack(spacing: stylesheet.spacing.large) { + VStack(alignment: .leading, spacing: stylesheet.spacing.small) { + Toggle( + String(localized: .settingsDevicesAutomaticRecording), + isOn: $recordingEnabled, + ) + Text(recordingRecommendation) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + // Request Always-location only after the user confirms an + // enabled choice; the launch's reconcile step picks up + // whatever the system grants. + finish(enableLocation: recordingEnabled) + } label: { + Text(String(localized: .onboardingContinue)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .disabled(isFinishing) } - .buttonStyle(.borderedProminent) - .controlSize(.large) + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.bottom, stylesheet.spacing.xxxLarge) + .frame(maxWidth: .infinity, minHeight: geometry.size.height) } - .disabled(isFinishing) + .scrollBounceBehavior(.basedOnSize) } - .padding(.horizontal, stylesheet.spacing.xxxLarge) - .padding(.bottom, stylesheet.spacing.xxxLarge) } private var recordingTitle: LocalizedStringResource { @@ -320,18 +357,43 @@ public struct OnboardingView: View { } } - /// Log in to the user's real world (opening the store, if the restore path - /// hasn't already), commit the picked regions + appearances, optionally - /// request location when enabled, then persist onboarding + the confirmed - /// per-device choice and resolve the gate so the launch continues. + /// Persist this installation's choice first, then log in to the user's real + /// world, optionally restore a selected backup, commit manual region picks, + /// request location when enabled, and resolve the gate. /// /// A store that won't open fails the gate rather than stranding the user /// on a dead intro: the runner lands on the failure surface, which is /// where an unopenable store has always surfaced. private func finish(enableLocation: Bool) { guard !isFinishing else { return } + let readyImport = restoreSelection.readyImport + if restoreSelection.selectedURL != nil { + guard readyImport != nil else { + assertionFailure("An onboarding restore must have an explicit import strategy.") + discardPendingRestore() + return + } + // Opening the real CloudKit-backed scope is part of restore work and + // can be slow. Move to the blocking progress surface before that + // first await rather than leaving a disabled Continue button behind. + intro.activity = .restoringBackup + phase = .intro + } isFinishing = true Task { + do { + let context = try model.confirmInitialRecordingChoice(isEnabled: enableLocation) + guard context.initialRecordingChoice != nil else { + preconditionFailure("A confirmed installation context must carry its choice.") + } + } catch { + Self.logger(attachments: [.error(error, name: "context-error")]) { + .installationContextWriteFailed(description: error.localizedDescription) + } + gate.fail(error) + return + } + let scope: WhereScope do { scope = try await model.resolveScope() @@ -342,11 +404,113 @@ public struct OnboardingView: View { gate.fail(error) return } - // This is also the initial synced policy for a newly registered - // installation. Persist the confirmed toggle explicitly so an Off - // recommendation cannot fall back to the old default-true intent - // on the next launch. - scope.preferences.wantsTracking = enableLocation + + if let readyImport { + do { + let summary = try await scope.services.backup.importBackup( + from: readyImport.url, + strategy: readyImport.strategy, + purpose: .onboarding, + ) + restoreSelection.markCommitted(summary) + // Persist the irreversible boundary immediately. If the process stops before + // device setup finishes, the next launch continues with the imported world + // instead of offering to apply the same archive again. + model.completeOnboarding() + do { + try await scope.services.backup.acknowledgeOnboardingImport() + } catch { + gate.fail(OnboardingCommittedImportSetupError( + summary: summary, + underlying: error, + )) + return + } + } catch let error as BackupCoordinator.CommittedImportCleanupError { + restoreSelection.markCommitted(error.summary) + // The archive is already committed. Complete onboarding and + // fail the gate into the terminal, relaunch-required partial- + // success surface; returning to the Restore button would lie + // about rollback and could apply the archive twice. + model.completeOnboarding() + do { + try await scope.services.backup.acknowledgeOnboardingImport() + } catch let acknowledgementError { + gate.fail(OnboardingCommittedImportSetupError( + summary: error.summary, + underlying: acknowledgementError, + )) + return + } + Self.logger(attachments: [.error(error.underlying, name: "cleanup-error")]) { + .backupRestoreCleanupFailed( + description: error.underlying.localizedDescription, + ) + } + gate.fail(error) + return + } catch let error as BackupCoordinator.CommittedImportSupersededError { + restoreSelection.markCommitted(error.summary) + model.completeOnboarding() + do { + try await scope.services.backup.acknowledgeOnboardingImport() + } catch let acknowledgementError { + gate.fail(OnboardingCommittedImportSetupError( + summary: error.summary, + underlying: acknowledgementError, + )) + return + } + gate.fail(error) + return + } catch let error as BackupCoordinator.ImportRecoveryResolutionError { + // The durable prepared marker remains authoritative, but receipt resolution + // failed. Never return to an importer that could apply the archive twice. + gate.fail(error) + return + } catch { + restoreSelection.discardUncommittedSelection() + // Drop the failed scope before retrying. The immutable first choice remains + // fixed; the retry creates a fresh scope over that same installation context. + await model.endSession() + intro.activity = .failed(.init(flow: .restoreBackup, error: error)) + phase = .intro + isFinishing = false + Self.logger(attachments: [.error(error, name: "restore-error")]) { + .backupRestoreFailed(description: error.localizedDescription) + } + return + } + } + + // A prior attempt may already have frozen a different immutable first choice. Honor + // what the user selected on this attempt by appending a causal follow-up before any + // physical authority opens; never silently snap back to the earlier value. + do { + let authorization = await scope.services.ingestor.authorizationStatus() + try await scope.services.recording.registerForOnboarding( + desiredEnabled: enableLocation, + authorization: authorization, + ) + } catch { + Self.logger(attachments: [.error(error, name: "recording-configuration-error")]) { + .recordingConfigurationFailed(description: error.localizedDescription) + } + if let summary = restoreSelection.committedSummary { + // The import cannot roll back with this later setup failure. Preserve its + // summary in the terminal result. Onboarding completed at the commit + // boundary, so a cold retry registers this installation without reapplying + // the archive. + gate.fail(OnboardingCommittedImportSetupError( + summary: summary, + underlying: error, + )) + } else { + gate.fail(error) + } + return + } + if enableLocation { await enableTracking(in: scope) } @@ -368,20 +532,17 @@ public struct OnboardingView: View { } } } - if model.hasOnboarded { - model.confirmRecordingChoice() - } else { + if !model.hasOnboarded { model.completeOnboarding() } gate.complete() } } - /// Record the tracking intent and drive the system prompt, so it maps 1:1 - /// to the tap that asked for it. Only these two halves happen here: the - /// `sync-auth` and `reconcile-tracking` steps run as soon as the gate - /// resolves, and they are what read the granted authorization back and - /// actually start GPS. + /// Drive the system prompt for the recording choice already persisted in + /// the installation context, so the prompt maps 1:1 to the tap that asked + /// for it. The `sync-auth` and `reconcile-tracking` steps run as soon as the + /// gate resolves; they read the granted authorization back and start GPS. private func enableTracking(in scope: WhereScope) async { do { try await scope.services.ingestor.requestPermission() @@ -432,34 +593,32 @@ public struct OnboardingView: View { private func handleRestoreSelection(_ result: Result) { switch result { case let .success(url): - restore(from: url) + restoreSelection.select( + url: url, + hasScopedAccess: url.startAccessingSecurityScopedResource(), + ) + showRestoreStrategyDialog = true case let .failure(error): + discardPendingRestore() intro.activity = .failed(.init(flow: .restoreBackup, error: error)) } } - /// Import the chosen backup (a fresh install, so `.replace` mirrors the file - /// exactly), then skip the manual pick/customize steps straight to the - /// location ask. Restoring is the user committing to their real data, so - /// this is one of the two places the store gets opened. On failure — - /// including a store that won't open — surface an alert and stay in the - /// intro, where they can retry or continue manually. - private func restore(from url: URL) { - guard !intro.isRestoringBackup else { return } - intro.activity = .restoringBackup - Task { - do { - let scope = try await model.resolveScope() - _ = try await scope.services.backup.importBackup(from: url, strategy: .replace) - intro.activity = .browsing - phase = .location - } catch { - intro.activity = .failed(.init(flow: .restoreBackup, error: error)) - Self.logger(attachments: [.error(error, name: "restore-error")]) { - .backupRestoreFailed(description: error.localizedDescription) - } - } + private func chooseRestoreStrategy(_ strategy: BackupCoordinator.ImportStrategy) { + guard restoreSelection.selectedURL != nil else { + assertionFailure("A restore strategy was chosen without a selected backup.") + return } + restoreSelection.choose(strategy) + phase = .location + } + + /// Keep the file importer's security-scoped URL available while the user + /// verifies this installation's recording choice, then balance access as + /// soon as the import finishes or onboarding leaves the hierarchy. + private func discardPendingRestore() { + showRestoreStrategyDialog = false + restoreSelection.discardUncommittedSelection() } } @@ -491,9 +650,16 @@ final class OnboardingIntroState { struct Failure { /// Which of the intro's two ways forward failed, since they say /// different things about it. - enum Flow { + enum Flow: Equatable { case restoreBackup case demo + + var title: String { + switch self { + case .restoreBackup: String(localized: .onboardingRestoreErrorTitle) + case .demo: String(localized: .onboardingDemoErrorTitle) + } + } } let flow: Flow @@ -567,19 +733,22 @@ struct OnboardingPage: Identifiable { id: LaunchStepID.onboarding, reason: .userForeground, ), + installationContext: .testing, ) .environment(PreviewSupport.onboardingModel()) }, whereSnapshot( name: "PhoneRecordingChoice", - configurations: SnapshotConfiguration.combinations(devices: [.iPhone]), + configurations: SnapshotConfiguration.combinations(devices: [.iPhone]) + [ + SnapshotConfiguration(dynamicType: .accessibility5, device: .iPhone), + ], ) { OnboardingView( gate: LifecycleGateHandle( id: LaunchStepID.onboarding, reason: .userForeground, ), - deviceKind: .phone, + installationContext: .testing, startsAtRecordingChoice: true, ) .environment(PreviewSupport.onboardingModel()) @@ -593,7 +762,19 @@ struct OnboardingPage: Identifiable { id: LaunchStepID.onboarding, reason: .userForeground, ), - deviceKind: .tablet, + installationContext: InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID( + rawValue: UUID( + uuidString: "00000000-0000-0000-0000-000000000003", + )!, + ), + systemName: "iPad", + kind: .tablet, + ), + registeredAt: InstallationRecordingContext.testing.registeredAt, + initialRecordingChoice: nil, + ), startsAtRecordingChoice: true, ) .environment(PreviewSupport.onboardingModel()) diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 77dd820a..91ceaae5 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -134,7 +134,7 @@ return [ RecordingDeviceConfiguration( device: RecordingDevice( - id: CurrentRecordingDevice.preview.id, + id: InstallationRecordingContext.testing.currentDevice.id, systemName: "iPhone", nickname: "My iPhone", kind: .phone, @@ -144,8 +144,12 @@ lastAppliedPolicyChangeID: currentPolicyID, status: .recording, ), - isEnabled: true, - latestPolicyChangeID: currentPolicyID, + policy: .resolved(ResolvedRecordingPolicy( + isEnabled: true, + isArchived: false, + changeID: currentPolicyID, + isAcknowledged: true, + )), ), RecordingDeviceConfiguration( device: RecordingDevice( @@ -159,8 +163,12 @@ lastAppliedPolicyChangeID: remoteAppliedPolicyID, status: .recording, ), - isEnabled: false, - latestPolicyChangeID: remoteLatestPolicyID, + policy: .resolved(ResolvedRecordingPolicy( + isEnabled: false, + isArchived: false, + changeID: remoteLatestPolicyID, + isAcknowledged: false, + )), ), ] } @@ -567,7 +575,6 @@ public static func loadedModel() -> WhereModel { let preferences = previewPreferences() preferences.hasOnboarded = true - preferences.hasConfirmedRecordingChoice = true return WhereModel( services: previewServices(), report: sampleReport(), diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 55564e28..903d6be3 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -139,6 +139,54 @@ } } }, + "backup.importCleanup.message" : { + "comment" : "Message shown after backup data committed but recording cleanup failed. The placeholder is the preserved import summary.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your backup data was imported successfully. Where couldn't finish recording cleanup, so automatic recording remains off. Close and reopen Where. Do not import this backup again.\n\n%@" + } + } + } + }, + "backup.importCleanup.title" : { + "comment" : "Title shown when a backup import committed but recording cleanup still requires an app relaunch.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Backup imported; cleanup incomplete" + } + } + } + }, + "backup.importSetup.message" : { + "comment" : "Terminal launch message after an onboarding backup committed but setup of the new installation failed. The placeholder is the preserved import summary.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your backup data was imported successfully, but Where couldn't finish setting up this device. Close and reopen Where; setup will retry automatically. Do not import this backup again.\n\n%@" + } + } + } + }, + "backup.importSetup.title" : { + "comment" : "Terminal launch title after an onboarding backup committed but setup of the new installation failed.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Backup imported; setup incomplete" + } + } + } + }, "calendar.day.accessibility" : { "comment" : "Accessibility label for a calendar day, including the day of the week and the names of the regions that day.", "extractionState" : "manual", @@ -2878,6 +2926,30 @@ } } }, + "launch.resetCleanup.message" : { + "comment" : "Terminal launch message after synced data was erased but local post-commit cleanup failed.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your synced data was erased, but Where couldn't finish local cleanup. Automatic recording remains off. Close and reopen Where. If Settings appears, retry Erase All Data & Reset; if onboarding appears, finish setup." + } + } + } + }, + "launch.resetCleanup.title" : { + "comment" : "Terminal launch title after reset committed but local cleanup failed.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Data erased; cleanup incomplete" + } + } + } + }, "locations.elsewhere.subtitle" : { "comment" : "Subtitle on the Locations tab's Elsewhere entry card: region count.", "extractionState" : "manual", @@ -3444,6 +3516,18 @@ } } }, + "onboarding.installationSecurityError" : { + "comment" : "Launch failure shown when Where cannot prove that the device-local recording identity is excluded from backup and also cannot remove it safely.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where couldn't secure this device's recording identity. Close and reopen Where, then try again." + } + } + } + }, "onboarding.next" : { "extractionState" : "manual", "localizations" : { @@ -3582,6 +3666,18 @@ } } }, + "onboarding.restoreMergeRecommended" : { + "comment" : "Recommended non-destructive backup strategy shown during onboarding.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Merge (Recommended)" + } + } + } + }, "onboarding.restoring" : { "extractionState" : "manual", "localizations" : { @@ -5334,6 +5430,18 @@ } } }, + "settings.backup.cleanupRequired" : { + "comment" : "Settings footer while a committed backup import still needs its post-commit cleanup retried.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A previous backup finished importing, but recording cleanup still needs attention. Finish cleanup before importing another backup." + } + } + } + }, "settings.backup.errorTitle" : { "extractionState" : "manual", "localizations" : { @@ -5406,7 +5514,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Merge keeps everything already on this device and adds the file's records. Replace erases this device first, then restores only what's in the file." + "value" : "Merge keeps the data already synced to your devices and adds the file's records. Replace restores the file's history and settings, retains device identities for safe syncing, and turns automatic recording off until you re-enable it. Offline devices receive the changes when they reconnect." } } } @@ -5477,6 +5585,30 @@ } } }, + "settings.backup.retryCleanup" : { + "comment" : "Button that retries only cleanup after a backup import already committed.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Retry Backup Cleanup" + } + } + } + }, + "settings.backup.retryingCleanup" : { + "comment" : "Progress label while retrying only cleanup after a backup import already committed.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Finishing Backup Cleanup…" + } + } + } + }, "settings.backup.share" : { "extractionState" : "manual", "localizations" : { @@ -5516,7 +5648,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "This removes every sample, manual day, and piece of evidence in %@. It can't be undone." + "value" : "This removes every sample, manual day, and piece of evidence in %@ across all synced devices, including offline devices when they next connect. It can't be undone." } } } @@ -5607,7 +5739,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Automatic recording will be turned off from now on and this device will be hidden. Its existing history is kept." + "value" : "Where will hide this device now. Automatic recording stops after that device next connects and syncs. Its existing history is kept." } } } @@ -5788,6 +5920,29 @@ } } }, + "settings.devices.status.syncing" : { + "comment" : "Status shown while a device profile has synced but its recording policy is still arriving.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Syncing Recording Setting" + } + } + } + }, + "settings.devices.status.unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unavailable" + } + } + } + }, "settings.devices.thisDevice" : { "extractionState" : "manual", "localizations" : { @@ -6346,7 +6501,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "This erases every sample, manual day, and piece of evidence on this device and returns you to first-run setup. It can't be undone." + "value" : "This erases every synced sample, manual day, and piece of evidence across all your devices, stops automatic recording on every known device, then returns this device to first-run setup. Offline devices discard pending fixes and receive the changes when they reconnect. It can't be undone." } } } diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index f93a139e..ba6b56cf 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -3,7 +3,7 @@ import LifecycleKitUI import PeriscopeUI import SnapshotKit import SwiftUI -import WhereCore +@_spi(Testing) import WhereCore #if DEBUG import Inspector import PeriscopeCore @@ -83,9 +83,19 @@ public struct RootView: View { // Mirrors the app root's wiring (see `AppDelegate`). Nothing here // attaches a sink unless a scope is actually resolved, which a preview // or the hosted UI test never gets to. + let installationContextStore = InMemoryInstallationRecordingContextStore( + context: .testing, + ) let model = WhereModel( preferences: WherePreferences(store: UserDefaults.standard), - makeBootstrap: { WhereBootstrap() }, + installationContextStore: installationContextStore, + makeBootstrap: { + WhereBootstrap( + installationContextStore: $0, + storeStorage: .inMemory, + locationOutbox: NoOpLocationOutbox(), + ) + }, logSystem: .shared, ) _model = State(initialValue: model) @@ -103,7 +113,7 @@ public struct RootView: View { animation: revealAnimation, minimumSplashDuration: stylesheet.launch.minimumSplashDuration, splash: { _ in LaunchSplashView() }, - failure: { LifecycleFailureView(failure: $0) }, + failure: { WhereLifecycleFailureView(failure: $0) }, gates: { // The gate roots the trunk, so there is no session (and no // open store) behind it yet — onboarding builds the scope @@ -111,7 +121,7 @@ public struct RootView: View { GateView(for: OnboardingGate.self) { handle, _ in OnboardingView( gate: handle, - deviceKind: CurrentRecordingDeviceProvider.currentKind, + installationContext: model.installationRecordingContext, startsAtRecordingChoice: model.hasOnboarded, ) } diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift index a5ee5164..7975b54f 100644 --- a/Where/WhereUI/Sources/Settings/BackupModel.swift +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -16,39 +16,97 @@ public final class BackupModel { case idle case exporting case importing + case recoveringImportCleanup + } + + /// UI mirror of the long-lived coordinator's committed-import recovery gate. A newly + /// created Settings model starts by checking rather than assuming imports are safe. + public enum ImportRecoveryState: Equatable { + case checking + case ready + case cleanupRequired(BackupCoordinator.ImportSummary) + } + + /// The honest result of an import attempt. A committed cleanup failure is + /// still a successful data import: keeping it distinct from `nil` prevents + /// callers from offering a retry that would apply the archive twice. + public enum ImportResult: Equatable { + case imported(BackupCoordinator.ImportSummary) + case committedWithCleanupFailure(BackupCoordinator.ImportSummary) + + public var summary: BackupCoordinator.ImportSummary { + switch self { + case let .imported(summary), let .committedWithCleanupFailure(summary): + summary + } + } + + public var requiresCleanupRecovery: Bool { + if case .committedWithCleanupFailure = self { true } else { false } + } + } + + /// One pending acknowledgment at a time. Import success, committed partial + /// success, and an operation failure cannot overlap in the presentation + /// layer even if a caller starts another operation programmatically. + private enum PresentedResult: Equatable { + case failed(String) + case importCompleted(ImportResult) } public private(set) var backupState: BackupState = .idle + public private(set) var importRecoveryState: ImportRecoveryState = .checking + + public var importCleanupRecoverySummary: BackupCoordinator.ImportSummary? { + if case let .cleanupRequired(summary) = importRecoveryState { summary } else { nil } + } + + public var canImport: Bool { + backupState == .idle && importRecoveryState == .ready + } /// Fraction (`0...1`) of the in-flight export/import that has completed, for /// a determinate progress bar. Reset to `0` whenever neither is running. public private(set) var backupProgress: Double = 0 - /// Last backup failure, surfaced as an alert. Mutable so the alert binding - /// can clear it on dismiss. - public var backupError: String? + private var presentedResult: PresentedResult? + + /// Last backup failure, surfaced as an alert. + public var backupError: String? { + guard case let .failed(message)? = presentedResult else { return nil } + return message + } /// Drives the backup-error alert. Reads `true` while `backupError` holds a /// message and clears it when dismissed, so the view can bind straight to it - /// (`$backup.isShowingBackupError`). `backupError` stays the single source of - /// truth. + /// (`$backup.isShowingBackupError`). public var isShowingBackupError: Bool { get { backupError != nil } - set { if !newValue { backupError = nil } } + set { + guard !newValue, case .failed? = presentedResult else { return } + presentedResult = nil + } } - /// Summary of the most recent successful import, surfaced as a confirmation - /// alert. Owned here (not on the view) so the acknowledgment survives the - /// backup screen being popped mid-import — mirroring `backupError`. Set by - /// `importBackup`; the alert binding clears it on dismiss. - public private(set) var lastImportSummary: BackupCoordinator.ImportSummary? + /// Result of the most recent committed import, surfaced as a confirmation + /// or partial-success alert. Owned here so acknowledgment survives the + /// backup screen being popped mid-import. + public var lastImportResult: ImportResult? { + guard case let .importCompleted(result)? = presentedResult else { return nil } + return result + } + + public var lastImportSummary: BackupCoordinator.ImportSummary? { + lastImportResult?.summary + } - /// Drives the import-success alert. Reads `true` while `lastImportSummary` - /// holds a value and clears it when dismissed, so the view can bind straight - /// to it (`$backup.isShowingImportSuccess`). - public var isShowingImportSuccess: Bool { - get { lastImportSummary != nil } - set { if !newValue { lastImportSummary = nil } } + /// Drives the import-result alert for both complete and partial success. + public var isShowingImportResult: Bool { + get { lastImportResult != nil } + set { + guard !newValue, case .importCompleted? = presentedResult else { return } + presentedResult = nil + } } private let services: WhereServices @@ -58,6 +116,23 @@ public final class BackupModel { self.services = services } + /// Synchronize the view-scoped mirror with the coordinator that outlives Settings. This is + /// called when the section appears and before programmatic imports, so recreating the model + /// cannot forget a committed cleanup failure. + public func refreshImportRecoveryState() async { + do { + importRecoveryState = switch try await services.backup.importRecoveryState() { + case .ready: .ready + case let .cleanupRequired(summary), + let .onboardingAcknowledgementRequired(summary): + .cleanupRequired(summary) + } + } catch { + importRecoveryState = .checking + presentBackupError(error) + } + } + /// Build a backup `.zip` of the entire database and return its URL for the /// share sheet, or `nil` if the export failed (in which case `backupError` is /// set). The `BackupCoordinator` owns the temporary file's lifecycle — it @@ -69,6 +144,7 @@ public final class BackupModel { public func exportBackup() async -> URL? { backupState = .exporting backupProgress = 0 + presentedResult = nil defer { backupState = .idle backupProgress = 0 @@ -90,7 +166,7 @@ public final class BackupModel { return url } catch { continuation.finish() - backupError = error.localizedDescription + presentBackupError(error) Self.logger { .exportFailed(description: error.localizedDescription) } return nil } @@ -103,16 +179,30 @@ public final class BackupModel { await services.backup.discardExport() } - /// Import a backup file with the chosen merge/replace strategy. Returns the - /// import summary on success, or `nil` on failure (with `backupError` set). + /// Import a backup file with the chosen merge/replace strategy. Returns a + /// committed result for complete or cleanup-partial success, or `nil` when + /// the data transaction failed (with `backupError` set). A partial success + /// is never flattened into failure: the archive is already applied and must + /// not be imported again. + /// /// The committed import pings the store-change signal, so the scene's /// `YearReportModel` re-pulls the report + badge count — no inline refresh here. public func importBackup( from url: URL, strategy: BackupCoordinator.ImportStrategy, - ) async -> BackupCoordinator.ImportSummary? { + ) async -> ImportResult? { + await refreshImportRecoveryState() + guard case .ready = importRecoveryState else { + if let summary = importCleanupRecoverySummary { + let result = ImportResult.committedWithCleanupFailure(summary) + presentedResult = .importCompleted(result) + return result + } + return nil + } backupState = .importing backupProgress = 0 + presentedResult = nil defer { backupState = .idle backupProgress = 0 @@ -130,27 +220,82 @@ public final class BackupModel { defer { observer.cancel() } do { - let summary = try await services.backup.importBackup(from: url, strategy: strategy) { + let summary = try await services.backup.importBackup( + from: url, + strategy: strategy, + purpose: .settings, + ) { continuation.yield($0) } continuation.finish() await observer.value - Self.logger { - .imported( - sampleCount: summary.sampleCount, - evidenceCount: summary.evidenceCount, - manualDayCount: summary.manualDayCount, - dismissedIssueCount: summary.dismissedIssueCount, - trackedRegionCount: summary.trackedRegionCount, - ) + logImported(summary) + let result = ImportResult.imported(summary) + importRecoveryState = .ready + presentedResult = .importCompleted(result) + return result + } catch let error as BackupCoordinator.CommittedImportCleanupError { + continuation.finish() + await observer.value + logImported(error.summary) + Self.logger(attachments: [.error(error.underlying, name: "cleanup-error")]) { + .importCleanupFailed(description: error.underlying.localizedDescription) } - lastImportSummary = summary - return summary + let result = ImportResult.committedWithCleanupFailure(error.summary) + importRecoveryState = .cleanupRequired(error.summary) + presentedResult = .importCompleted(result) + return result + } catch let error as BackupCoordinator.ImportRecoveryRequiredError { + continuation.finish() + await observer.value + let result = ImportResult.committedWithCleanupFailure(error.summary) + importRecoveryState = .cleanupRequired(error.summary) + presentedResult = .importCompleted(result) + return result } catch { continuation.finish() - backupError = error.localizedDescription + presentBackupError(error) Self.logger { .importFailed(description: error.localizedDescription) } return nil } } + + /// Retry only the post-commit cleanup retained by the coordinator. The archive transaction + /// is never replayed; success reopens importing and failure leaves the durable in-process + /// gate in place for another retry. + public func retryImportCleanup() async { + guard backupState == .idle, importCleanupRecoverySummary != nil else { return } + backupState = .recoveringImportCleanup + presentedResult = nil + defer { backupState = .idle } + + do { + try await services.backup.retryImportCleanup() + importRecoveryState = .ready + } catch { + await refreshImportRecoveryState() + presentBackupError(error) + Self.logger(attachments: [.error(error, name: "cleanup-retry-error")]) { + .importCleanupFailed(description: error.localizedDescription) + } + } + } + + /// Surface a file-selection or operation error through the model's single + /// presentation state. + public func presentBackupError(_ error: any Error) { + presentedResult = .failed(error.localizedDescription) + } + + private func logImported(_ summary: BackupCoordinator.ImportSummary) { + Self.logger { + .imported( + sampleCount: summary.sampleCount, + evidenceCount: summary.evidenceCount, + manualDayCount: summary.manualDayCount, + dismissedIssueCount: summary.dismissedIssueCount, + trackedRegionCount: summary.trackedRegionCount, + ) + } + } } diff --git a/Where/WhereUI/Sources/Settings/BackupSettingsSection.swift b/Where/WhereUI/Sources/Settings/BackupSettingsSection.swift index caaef012..91c24155 100644 --- a/Where/WhereUI/Sources/Settings/BackupSettingsSection.swift +++ b/Where/WhereUI/Sources/Settings/BackupSettingsSection.swift @@ -12,9 +12,9 @@ struct BackupSettingsSection: View { /// `ShareLink` once the background export finishes. @State private var exportedArchiveURL: URL? - // Backup import: the picked file and the merge/replace choice. The success - // confirmation lives on `backup` (the model), so it survives this screen - // being popped mid-import. + // Backup import: the picked file and the merge/replace choice. The committed + // result lives on `backup`, so its success/partial-success acknowledgment + // survives this screen being popped mid-import. @State private var showImporter = false @State private var pendingImportURL: URL? @State private var showStrategyDialog = false @@ -51,19 +51,18 @@ struct BackupSettingsSection: View { Text(String(localized: .settingsBackupImportStrategyMessage)) } .alert( - String(localized: .settingsBackupImportedTitle), - isPresented: $backup.isShowingImportSuccess, - presenting: backup.lastImportSummary, - ) { _ in + importResultTitle, + isPresented: $backup.isShowingImportResult, + presenting: backup.lastImportResult, + ) { result in + if result.requiresCleanupRecovery { + Button(String(localized: .settingsBackupRetryCleanup)) { + runImportCleanupRecovery() + } + } Button(String(localized: .commonOk), role: .cancel) {} - } message: { summary in - Text(WhereFormat.settingsBackupImportedMessage( - samples: summary.sampleCount, - evidence: summary.evidenceCount, - manualDays: summary.manualDayCount, - dismissedIssues: summary.dismissedIssueCount, - trackedRegions: summary.trackedRegionCount, - )) + } message: { result in + Text(importResultMessage(result)) } .alert( String(localized: .settingsBackupErrorTitle), @@ -112,6 +111,18 @@ struct BackupSettingsSection: View { } } + if backup.importCleanupRecoverySummary != nil { + Button { + runImportCleanupRecovery() + } label: { + Label( + importCleanupActionTitle, + systemImage: "arrow.clockwise", + ) + } + .disabled(backup.backupState != .idle) + } + Button { showImporter = true } label: { @@ -127,12 +138,16 @@ struct BackupSettingsSection: View { ) } } - .disabled(backup.backupState != .idle) + .disabled(!backup.canImport) .settingsRow(DataSettingsView.Item.importBackup) } header: { Text(String(localized: .settingsBackupHeader)) } footer: { - Text(String(localized: .settingsBackupFooter)) + if backup.importCleanupRecoverySummary != nil { + Text(String(localized: .settingsBackupCleanupRequired)) + } else { + Text(String(localized: .settingsBackupFooter)) + } } // A finished export lingers in the temp directory; stop offering it (and // reclaim the file) after a while so a stale link can't be shared. The @@ -141,6 +156,9 @@ struct BackupSettingsSection: View { .task(id: exportedArchiveURL) { await expireExportIfNeeded() } + .task { + await backup.refreshImportRecoveryState() + } // Log View Mode: reveal an inspect badge for backup export/import // events on this section. A no-op in release. .debugLogInspectable(WhereLog.session(BackupModelLog.self)) @@ -185,18 +203,52 @@ struct BackupSettingsSection: View { pendingImportURL = url showStrategyDialog = true case let .failure(error): - backup.backupError = error.localizedDescription + backup.presentBackupError(error) } } private func runImport(url: URL, strategy: BackupCoordinator.ImportStrategy) { Task { - // On success `backup` sets `lastImportSummary`, which drives the - // confirmation alert; the return value is unused here. + // A committed result drives either the success or partial-success + // alert; the return value is unused here. _ = await backup.importBackup(from: url, strategy: strategy) pendingImportURL = nil } } + + private func runImportCleanupRecovery() { + Task { + await backup.retryImportCleanup() + } + } + + private var importCleanupActionTitle: String { + if backup.backupState == .recoveringImportCleanup { + String(localized: .settingsBackupRetryingCleanup) + } else { + String(localized: .settingsBackupRetryCleanup) + } + } + + private var importResultTitle: String { + switch backup.lastImportResult { + case .imported: + String(localized: .settingsBackupImportedTitle) + case .committedWithCleanupFailure: + String(localized: .backupImportCleanupTitle) + case nil: + "" + } + } + + private func importResultMessage(_ result: BackupModel.ImportResult) -> String { + switch result { + case let .imported(summary): + WhereFormat.settingsBackupImportedMessage(summary) + case let .committedWithCleanupFailure(summary): + WhereFormat.backupImportCleanupMessage(summary) + } + } } #if DEBUG diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift index 16669b54..28793cc9 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift @@ -6,19 +6,54 @@ import WhereCore @MainActor @Observable final class DeviceSettingsRowModel: Identifiable { + /// Why the desired recording setting is not yet settled. A missing policy + /// is still arriving through CloudKit; a resolved policy can instead be + /// waiting for its target installation to acknowledge it. + enum PolicyPresentationState: Equatable { + case syncingPolicy + case resolved(isAcknowledged: Bool) + } + + struct EditableValues: Equatable { + var nickname: String + var isEnabled: Bool? + } + + enum Operation: Equatable { + case setRecordingEnabled(Bool) + case rename(String) + case archive + } + + struct OperationFailure: Identifiable, Equatable { + let id = UUID() + let operation: Operation + let message: String + } + + enum OperationState: Equatable { + case idle + case saving(Operation) + case failed(OperationFailure) + } + + private enum PendingAction: Hashable { + case saveNickname + case archive + } + let id: RecordingDeviceID let systemName: String let kind: RecordingDeviceKind let isCurrent: Bool - var nickname: String - private(set) var confirmedNickname: String - var isEnabled: Bool - private(set) var confirmedIsEnabled: Bool - var status: RecordingDeviceStatus - var lastSeenAt: Date - var isPending: Bool - var isBusy = false + private var confirmedValues: EditableValues + private var draftValues: EditableValues + private var pendingActions: Set = [] + private(set) var operationState: OperationState = .idle + private(set) var status: RecordingDeviceStatus + private(set) var lastSeenAt: Date + private(set) var policyPresentationState: PolicyPresentationState init(configuration: RecordingDeviceConfiguration, isCurrent: Bool) { id = configuration.id @@ -26,13 +61,84 @@ final class DeviceSettingsRowModel: Identifiable { kind = configuration.device.kind self.isCurrent = isCurrent let nickname = configuration.device.nickname ?? "" - self.nickname = nickname - confirmedNickname = nickname - isEnabled = configuration.isEnabled - confirmedIsEnabled = configuration.isEnabled + let editableValues = EditableValues( + nickname: nickname, + isEnabled: configuration.isEnabled, + ) + confirmedValues = editableValues + draftValues = editableValues status = configuration.device.status lastSeenAt = configuration.device.lastSeenAt - isPending = configuration.isPending + policyPresentationState = Self.policyPresentationState(for: configuration.policy) + } + + var nickname: String { + get { draftValues.nickname } + set { + guard draftValues.nickname != newValue else { return } + draftValues.nickname = newValue + clearFailure(for: .rename(newValue)) + } + } + + var isEnabled: Bool { + get { draftValues.isEnabled ?? false } + set { + guard draftValues.isEnabled != nil else { + assertionFailure("An unresolved recording policy cannot be edited.") + return + } + guard draftValues.isEnabled != newValue else { return } + draftValues.isEnabled = newValue + clearFailure(for: .setRecordingEnabled(newValue)) + } + } + + var hasResolvedRecordingPolicy: Bool { + draftValues.isEnabled != nil + } + + var isSyncingRecordingPolicy: Bool { + if case .syncingPolicy = policyPresentationState { true } else { false } + } + + var isPending: Bool { + switch policyPresentationState { + case .syncingPolicy: true + case let .resolved(isAcknowledged): !isAcknowledged + } + } + + var hasUnsavedNickname: Bool { + normalizedNickname != confirmedValues.nickname + } + + var canSaveNickname: Bool { + guard hasUnsavedNickname else { return false } + return switch operationState { + case .saving: false + case .idle, .failed: true + } + } + + var disablesRecordingControl: Bool { + switch operationState { + case .saving(.rename), .saving(.archive): true + case .idle, .saving(.setRecordingEnabled), .failed: false + } + } + + var disablesNicknameControl: Bool { + if case .saving = operationState { true } else { false } + } + + var disablesDestructiveActions: Bool { + guard hasResolvedRecordingPolicy else { return true } + return if case .saving = operationState { true } else { false } + } + + var isApplyingRecordingChange: Bool { + operationState.isSavingRecording } var displayName: String { @@ -44,17 +150,158 @@ final class DeviceSettingsRowModel: Identifiable { kind.systemImage } + /// Marks the current nickname draft as an explicit save request. Typing + /// alone never writes, while a request made during another save is retained + /// and processed when that operation finishes. + func requestNicknameSave() { + pendingActions.insert(.saveNickname) + } + + func requestArchive() { + pendingActions.insert(.archive) + } + + /// Claims the next accepted intent for the owning model's single writer + /// loop. Recording uses the live draft, so a second toggle made while the + /// first write is suspended becomes the next operation instead of being + /// discarded by a busy guard. + func beginNextOperation() -> Operation? { + if case .saving = operationState { return nil } + + if pendingActions.remove(.archive) != nil { + return begin(.archive) + } + + if let desiredEnabled = draftValues.isEnabled, + desiredEnabled != confirmedValues.isEnabled + { + return begin(.setRecordingEnabled(desiredEnabled)) + } + + if pendingActions.remove(.saveNickname) != nil { + let nickname = normalizedNickname + guard nickname != confirmedValues.nickname else { + draftValues.nickname = confirmedValues.nickname + operationState = .idle + return nil + } + return begin(.rename(nickname)) + } + + operationState = .idle + return nil + } + + func finish(_ operation: Operation) { + guard operationState == .saving(operation) else { + assertionFailure("Finished a device operation that was not active.") + return + } + if case let .rename(savedNickname) = operation, + normalizedNickname == savedNickname + { + draftValues.nickname = confirmedValues.nickname + } + operationState = .idle + } + + func fail(_ operation: Operation, error: any Error) -> OperationFailure { + guard operationState == .saving(operation) else { + assertionFailure("Failed a device operation that was not active.") + return OperationFailure( + operation: operation, + message: error.localizedDescription, + ) + } + + switch operation { + case .setRecordingEnabled: + // A failed toggle must display the last confirmed value rather + // than leave an optimistic value looking successfully saved. + draftValues.isEnabled = confirmedValues.isEnabled + case .rename: + // Preserve the draft so the explicit Save button is a reliable + // retry path and a failed write never destroys user input. + break + case .archive: + break + } + + let failure = OperationFailure( + operation: operation, + message: error.localizedDescription, + ) + operationState = .failed(failure) + return failure + } + + func dismiss(_ failure: OperationFailure) { + guard operationState == .failed(failure) else { return } + operationState = .idle + } + func update(from configuration: RecordingDeviceConfiguration) { + let previousConfirmedValues = confirmedValues let updatedNickname = configuration.device.nickname ?? "" - if nickname == confirmedNickname { - nickname = updatedNickname + let updatedValues = EditableValues( + nickname: updatedNickname, + isEnabled: configuration.isEnabled, + ) + + let preservesNicknameDraft = draftValues.nickname != previousConfirmedValues.nickname + || operationState.isSavingNickname + let preservesRecordingDraft = draftValues.isEnabled != previousConfirmedValues.isEnabled + || operationState.isSavingRecording + confirmedValues = updatedValues + if !preservesNicknameDraft { + draftValues.nickname = updatedValues.nickname + } + if !preservesRecordingDraft { + draftValues.isEnabled = updatedValues.isEnabled } - confirmedNickname = updatedNickname - isEnabled = configuration.isEnabled - confirmedIsEnabled = configuration.isEnabled status = configuration.device.status lastSeenAt = configuration.device.lastSeenAt - isPending = configuration.isPending + policyPresentationState = Self.policyPresentationState(for: configuration.policy) + } + + private var normalizedNickname: String { + nickname.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func begin(_ operation: Operation) -> Operation { + operationState = .saving(operation) + return operation + } + + private func clearFailure(for operation: Operation) { + guard case let .failed(failure) = operationState else { return } + switch (failure.operation, operation) { + case (.setRecordingEnabled, .setRecordingEnabled), (.rename, .rename): + operationState = .idle + case (.archive, _), (.setRecordingEnabled, _), (.rename, _): + break + } + } + + private static func policyPresentationState( + for resolution: RecordingPolicyResolution, + ) -> PolicyPresentationState { + switch resolution { + case .unknown: + .syncingPolicy + case let .resolved(policy): + .resolved(isAcknowledged: policy.isAcknowledged) + } + } +} + +extension DeviceSettingsRowModel.OperationState { + fileprivate var isSavingNickname: Bool { + if case .saving(.rename) = self { true } else { false } + } + + fileprivate var isSavingRecording: Bool { + if case .saving(.setRecordingEnabled) = self { true } else { false } } } diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift index 2f91c5cd..bd17c58d 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift @@ -9,32 +9,55 @@ struct DeviceSettingsSection: View { @Environment(WhereSession.self) private var session @Environment(\.openURL) private var openURL + @Environment(\.stylesheet) private var stylesheet @State private var isConfirmingArchive = false var body: some View { Section { - Toggle( - String(localized: .settingsDevicesAutomaticRecording), - isOn: $row.isEnabled, - ) - .settingsRow(DevicesSettingsView.Item.automaticRecording) - .disabled(row.isBusy) - .onChange(of: row.isEnabled) { oldValue, newValue in - guard oldValue != newValue else { return } - Task { - await model.setEnabled( - newValue, - row: row, - ) + if row.hasResolvedRecordingPolicy { + Toggle( + String(localized: .settingsDevicesAutomaticRecording), + isOn: $row.isEnabled, + ) + .settingsRow( + DevicesSettingsView.Item.automaticRecording, + when: row.isCurrent, + ) + .disabled(row.disablesRecordingControl) + .onChange(of: row.isEnabled) { oldValue, newValue in + guard oldValue != newValue else { return } + Task { + await model.recordingPreferenceChanged(for: row) + } + } + } else { + LabeledContent(String(localized: .settingsDevicesAutomaticRecording)) { + ProgressView() + .accessibilityLabel(String(localized: .settingsDevicesStatusSyncing)) } + .settingsRow( + DevicesSettingsView.Item.automaticRecording, + when: row.isCurrent, + ) } - TextField(String(localized: .settingsDevicesName), text: $row.nickname) - .settingsRow(DevicesSettingsView.Item.deviceName) - .disabled(row.isBusy) - .onSubmit { - Task { await model.rename(row) } + HStack { + TextField(String(localized: .settingsDevicesName), text: $row.nickname) + .submitLabel(.done) + .onSubmit { + Task { await model.saveNickname(row) } + } + + if row.hasUnsavedNickname { + Button(String(localized: .commonSave)) { + Task { await model.saveNickname(row) } + } + .buttonStyle(.borderless) + .disabled(!row.canSaveNickname) } + } + .disabled(row.disablesNicknameControl) + .settingsRow(DevicesSettingsView.Item.deviceName, when: row.isCurrent) LabeledContent(String(localized: .settingsDevicesStatus)) { HStack { @@ -93,7 +116,7 @@ struct DeviceSettingsSection: View { ) { isConfirmingArchive = true } - .disabled(row.isBusy) + .disabled(row.disablesDestructiveActions) .confirmationDialog( String(localized: .settingsDevicesArchiveConfirmTitle), isPresented: $isConfirmingArchive, @@ -108,11 +131,22 @@ struct DeviceSettingsSection: View { } } header: { Label { - HStack { - Text(row.displayName) - if row.isCurrent { - Text(String(localized: .settingsDevicesThisDevice)) - .foregroundStyle(.secondary) + ViewThatFits(in: .horizontal) { + HStack(spacing: stylesheet.spacing.small) { + Text(row.displayName) + if row.isCurrent { + Text(String(localized: .settingsDevicesThisDevice)) + .foregroundStyle(.secondary) + } + } + .fixedSize(horizontal: true, vertical: false) + + VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { + Text(row.displayName) + if row.isCurrent { + Text(String(localized: .settingsDevicesThisDevice)) + .foregroundStyle(.secondary) + } } } } icon: { @@ -128,10 +162,17 @@ struct DeviceSettingsSection: View { } private var statusTitle: String { - if row.isPending { + if row.isCurrent, case .unavailable = session.recordingRuntimeState { + return String(localized: .settingsDevicesStatusUnavailable) + } + if row.isSyncingRecordingPolicy { + return String(localized: .settingsDevicesStatusSyncing) + } + if row.isPending || row.isApplyingRecordingChange { return String(localized: .settingsDevicesStatusPending) } switch row.status { + case .unknown: return String(localized: .settingsDevicesStatusPending) case .recording: return String(localized: .settingsDevicesStatusRecording) case .off: return String(localized: .settingsDevicesStatusOff) case .permissionRequired: @@ -140,8 +181,17 @@ struct DeviceSettingsSection: View { } private var statusSymbol: String { - if row.isPending { return "clock.arrow.trianglehead.counterclockwise.rotate.90" } + if row.isCurrent, case .unavailable = session.recordingRuntimeState { + return "exclamationmark.triangle" + } + if row.isSyncingRecordingPolicy { + return "icloud.and.arrow.down" + } + if row.isPending || row.isApplyingRecordingChange { + return "clock.arrow.trianglehead.counterclockwise.rotate.90" + } return switch row.status { + case .unknown: "clock.arrow.trianglehead.counterclockwise.rotate.90" case .recording: "location.fill" case .off: "location.slash" case .permissionRequired: "exclamationmark.triangle" @@ -149,7 +199,15 @@ struct DeviceSettingsSection: View { } private var statusStyle: HierarchicalShapeStyle { - row.status == .recording && !row.isPending ? .primary : .secondary + let runtimeIsAvailable = if row.isCurrent { + if case .applied = session.recordingRuntimeState { true } else { false } + } else { + true + } + return row.status == .recording && runtimeIsAvailable + && !row.isPending && !row.isApplyingRecordingChange + ? .primary + : .secondary } private var showGrantButton: Bool { diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift index e6ceb74c..60817be0 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift @@ -2,49 +2,108 @@ import Foundation import Observation import WhereCore +/// The Settings-specific surface of the session. Commands report completion only; every row +/// snapshot is obtained through the model's single ordered refresh path. +@MainActor +protocol DevicesSettingsSession: AnyObject { + var currentRecordingDeviceID: RecordingDeviceID { get } + + func recordingDeviceUpdates() -> AsyncStream + func recordingDevices() async throws -> [RecordingDeviceConfiguration] + func setRecordingEnabled(_ enabled: Bool, for deviceID: RecordingDeviceID) async throws + func renameRecordingDevice(_ deviceID: RecordingDeviceID, to nickname: String) async throws + func archiveRecordingDevice(_ deviceID: RecordingDeviceID) async throws + func requestPermission() async +} + +extension WhereSession: DevicesSettingsSession { + func recordingDeviceUpdates() -> AsyncStream { + services.dataChangeUpdates() + } +} + /// View-scoped Devices settings state. All mutations await the serialized Core -/// controller and restore the last confirmed value when a write fails. +/// controller. Each row owns one operation state and this model drains its +/// accepted intents in order, including a newer toggle made while a write is +/// suspended. @MainActor @Observable final class DevicesSettingsModel { + struct Failure: Identifiable, Equatable { + enum Context: Equatable { + case initialLoad + case operation( + deviceID: RecordingDeviceID, + failure: DeviceSettingsRowModel.OperationFailure, + ) + case refresh + } + + let id = UUID() + let context: Context + let message: String + } + enum LoadState { case idle case loading + case empty case loaded - case failed(String) + case failed(Failure) + + var isReadyForSearchFocus: Bool { + if case .loaded = self { true } else { false } + } + } + + private struct RefreshFailure { + let generation: UInt64 + let error: any Error } - private let session: WhereSession + private let session: any DevicesSettingsSession private(set) var state: LoadState = .idle private(set) var rows: [DeviceSettingsRowModel] = [] - var errorMessage: String? + private(set) var presentedFailure: Failure? + @ObservationIgnored private var refreshTask: Task? + @ObservationIgnored private var requestedRefreshGeneration: UInt64 = 0 + @ObservationIgnored private var completedRefreshGeneration: UInt64 = 0 + @ObservationIgnored private var lastRefreshFailure: RefreshFailure? + @ObservationIgnored private var committedOperationsAwaitingRefresh: [ + RecordingDeviceID: DeviceSettingsRowModel.Operation + ] = [:] + @ObservationIgnored private var rowsNeedingOperationResume: Set = [] var isShowingError: Bool { - get { errorMessage != nil } + get { presentedFailure != nil } set { - if !newValue { errorMessage = nil } + if !newValue { dismissPresentedFailure() } } } - init(session: WhereSession) { + var presentedFailureCanRetry: Bool { + presentedFailure?.context == .refresh + } + + init(session: any DevicesSettingsSession) { self.session = session } #if DEBUG init( - session: WhereSession, + session: any DevicesSettingsSession, configurations: [RecordingDeviceConfiguration], ) { self.session = session - state = .loaded apply(configurations) + state = configurations.isEmpty ? .empty : .loaded } #endif /// Load once, then stay current with local commits and CloudKit imports /// until the owning view disappears and SwiftUI cancels the task. func run() async { - let updates = session.services.dataChangeUpdates() + let updates = session.recordingDeviceUpdates() await load(showLoading: true) for await _ in updates { await load(showLoading: false) @@ -55,51 +114,27 @@ final class DevicesSettingsModel { await load(showLoading: true) } - func setEnabled( - _ enabled: Bool, - row: DeviceSettingsRowModel, - ) async { - guard !row.isBusy, enabled != row.confirmedIsEnabled else { return } - row.isBusy = true - defer { row.isBusy = false } - do { - let configurations = try await session.setRecordingEnabled(enabled, for: row.id) - apply(configurations) - } catch { - row.isEnabled = row.confirmedIsEnabled - surface(error) - } + /// Persist the row's latest recording draft. If another write is already + /// active, that writer will observe this draft before it exits and submit + /// it next; no accepted toggle is discarded. + func recordingPreferenceChanged(for row: DeviceSettingsRowModel) async { + await processPendingOperations(for: row) } - func rename(_ row: DeviceSettingsRowModel) async { - guard !row.isBusy else { return } - row.isBusy = true - defer { row.isBusy = false } - do { - let nickname = row.nickname.trimmingCharacters(in: .whitespacesAndNewlines) - let configurations = try await session.renameRecordingDevice( - row.id, - to: nickname, - ) - row.nickname = nickname - apply(configurations) - } catch { - row.nickname = row.confirmedNickname - surface(error) - await load(showLoading: false) - } + /// Mark the current nickname draft for an explicit save. A request made + /// while another row operation is active remains queued. + func saveNickname(_ row: DeviceSettingsRowModel) async { + row.requestNicknameSave() + await processPendingOperations(for: row) } func archive(_ row: DeviceSettingsRowModel) async { - guard !row.isCurrent, !row.isBusy else { return } - row.isBusy = true - defer { row.isBusy = false } - do { - let configurations = try await session.archiveRecordingDevice(row.id) - apply(configurations) - } catch { - surface(error) + guard !row.isCurrent else { + assertionFailure("The current recording device cannot be archived.") + return } + row.requestArchive() + await processPendingOperations(for: row) } func requestPermission() async { @@ -108,19 +143,110 @@ final class DevicesSettingsModel { } private func load(showLoading: Bool) async { - if showLoading { state = .loading } + // Keep already rendered rows visible while a manual retry reconciles them. If that read + // fails again, the user gets another retryable alert instead of a permanent spinner. + if showLoading, rows.isEmpty { state = .loading } do { - try await apply(session.recordingDevices()) - state = .loaded + try await refreshConfigurations() + completeSuccessfulRefresh() + await resumeOperationsAfterRefresh() } catch { if rows.isEmpty { - state = .failed(error.localizedDescription) + state = .failed(Failure( + context: .initialLoad, + message: error.localizedDescription, + )) } else { - surface(error) + surfaceLoadRefreshFailure(error) } } } + private func processPendingOperations(for row: DeviceSettingsRowModel) async { + while let operation = row.beginNextOperation() { + do { + switch operation { + case let .setRecordingEnabled(enabled): + try await session.setRecordingEnabled(enabled, for: row.id) + case let .rename(nickname): + try await session.renameRecordingDevice(row.id, to: nickname) + case .archive: + try await session.archiveRecordingDevice(row.id) + } + } catch { + let failure = row.fail(operation, error: error) + surface(failure, for: row.id) + // A write can fail after committing. Re-read so the controls + // show persisted truth while preserving an unsaved nickname + // draft as the explicit retry path. + await load(showLoading: false) + return + } + + committedOperationsAwaitingRefresh[row.id] = operation + do { + // Never apply the snapshot a command happened to observe. A CloudKit import can + // land while the command is suspended, so all truth is re-read through the same + // ordered path used by data-change updates. A failed read does not turn a command + // that already committed into a failed command or cause it to be issued again. + try await refreshConfigurations() + completeSuccessfulRefresh() + rowsNeedingOperationResume.remove(row.id) + await resumeOperationsAfterRefresh() + if operation == .archive { return } + } catch { + surfaceLoadRefreshFailure(error) + return + } + } + } + + /// Coalesce concurrent command and data-change refreshes without allowing their actor hops + /// to apply out of order. A request arriving during a read schedules another pass, ensuring + /// that pass observes the commit which emitted the request. + private func refreshConfigurations() async throws { + let targetGeneration = requestRefresh() + while completedRefreshGeneration < targetGeneration { + guard let refreshTask else { continue } + await refreshTask.value + } + if let failure = lastRefreshFailure, + failure.generation >= targetGeneration + { + throw failure.error + } + } + + private func requestRefresh() -> UInt64 { + let (generation, overflow) = requestedRefreshGeneration.addingReportingOverflow(1) + precondition(!overflow, "Devices Settings refresh generation exhausted UInt64.") + requestedRefreshGeneration = generation + if refreshTask == nil { + refreshTask = Task { @MainActor [weak self] in + await self?.drainRefreshes() + } + } + return generation + } + + private func drainRefreshes() async { + while completedRefreshGeneration < requestedRefreshGeneration { + let generation = requestedRefreshGeneration + do { + let configurations = try await session.recordingDevices() + completedRefreshGeneration = generation + guard generation == requestedRefreshGeneration else { continue } + lastRefreshFailure = nil + apply(configurations) + } catch { + completedRefreshGeneration = generation + guard generation == requestedRefreshGeneration else { continue } + lastRefreshFailure = RefreshFailure(generation: generation, error: error) + } + } + refreshTask = nil + } + private func apply(_ configurations: [RecordingDeviceConfiguration]) { let existing = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) rows = configurations.map { configuration in @@ -133,9 +259,64 @@ final class DevicesSettingsModel { isCurrent: configuration.id == session.currentRecordingDeviceID, ) } + + let visibleDeviceIDs = Set(rows.map(\.id)) + let committedOperations = committedOperationsAwaitingRefresh + committedOperationsAwaitingRefresh.removeAll() + for (deviceID, operation) in committedOperations { + existing[deviceID]?.finish(operation) + if operation != .archive, visibleDeviceIDs.contains(deviceID) { + rowsNeedingOperationResume.insert(deviceID) + } + } + } + + private func completeSuccessfulRefresh() { + state = rows.isEmpty ? .empty : .loaded + if presentedFailure?.context == .refresh { + presentedFailure = nil + } + } + + private func resumeOperationsAfterRefresh() async { + let rowsToResume = rows.filter { rowsNeedingOperationResume.contains($0.id) } + rowsNeedingOperationResume.removeAll() + for row in rowsToResume { + await processPendingOperations(for: row) + } + } + + private func surface( + _ failure: DeviceSettingsRowModel.OperationFailure, + for deviceID: RecordingDeviceID, + ) { + presentedFailure = Failure( + context: .operation(deviceID: deviceID, failure: failure), + message: failure.message, + ) + } + + private func surfaceLoadRefreshFailure(_ error: any Error) { + guard presentedFailure == nil else { return } + presentedFailure = Failure( + context: .refresh, + message: error.localizedDescription, + ) } - private func surface(_ error: any Error) { - errorMessage = error.localizedDescription + private func dismissPresentedFailure() { + guard let presentedFailure else { return } + if case let .operation(deviceID, failure) = presentedFailure.context { + rows.first(where: { $0.id == deviceID })?.dismiss(failure) + } + self.presentedFailure = nil + if case .operation = presentedFailure.context, + let lastRefreshFailure + { + self.presentedFailure = Failure( + context: .refresh, + message: lastRefreshFailure.error.localizedDescription, + ) + } } } diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift index ef6ba39d..cebce412 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift @@ -38,7 +38,10 @@ struct DevicesSettingsView: View { var body: some View { @Bindable var session = session @Bindable var model = model - SettingsFocusScope(focus: focus) { + SettingsFocusScope( + focus: focus, + revealWhen: model.state.isReadyForSearchFocus, + ) { Form { switch model.state { case .idle, .loading: @@ -49,12 +52,22 @@ struct DevicesSettingsView: View { Spacer() } } - case let .failed(message): + case let .failed(failure): + Section { + ContentUnavailableView( + String(localized: .settingsDevicesLoadFailed), + systemImage: "exclamationmark.icloud", + description: Text(failure.message), + ) + Button(String(localized: .commonRetry)) { + Task { await model.retry() } + } + } + case .empty: Section { ContentUnavailableView( String(localized: .settingsDevicesLoadFailed), systemImage: "exclamationmark.icloud", - description: Text(message), ) Button(String(localized: .commonRetry)) { Task { await model.retry() } @@ -76,11 +89,16 @@ struct DevicesSettingsView: View { .alert( String(localized: .settingsDevicesErrorTitle), isPresented: $model.isShowingError, - presenting: model.errorMessage, + presenting: model.presentedFailure, ) { _ in + if model.presentedFailureCanRetry { + Button(String(localized: .commonRetry)) { + Task { await model.retry() } + } + } Button(String(localized: .commonOk), role: .cancel) {} - } message: { message in - Text(message) + } message: { failure in + Text(failure.message) } .alert( String(localized: .settingsPermissionAlertTitle), diff --git a/Where/WhereUI/Sources/Settings/SettingsRow.swift b/Where/WhereUI/Sources/Settings/SettingsRow.swift index 104ddd67..9a090d6b 100644 --- a/Where/WhereUI/Sources/Settings/SettingsRow.swift +++ b/Where/WhereUI/Sources/Settings/SettingsRow.swift @@ -24,6 +24,18 @@ extension View { func settingsRow(_ item: some SettingsItem) -> some View { modifier(SettingsRowModifier(focus: SettingsFocus(item))) } + + /// Tags this row only when it is the canonical search result for a setting. + /// Repeated device sections render the same labels, but search must have one + /// stable scroll destination rather than several views sharing one id. + @ViewBuilder + func settingsRow(_ item: some SettingsItem, when isSearchTarget: Bool) -> some View { + if isSearchTarget { + settingsRow(item) + } else { + self + } + } } /// Applies the scroll id + flash background for a tagged settings row. The flash @@ -59,6 +71,7 @@ struct SettingsRowModifier: ViewModifier { /// appearance so returning to the screen doesn't re-flash. struct SettingsFocusScope: View { let focus: SettingsFocus? + let isReady: Bool let content: Content @State private var highlighted: SettingsFocus? @@ -66,8 +79,13 @@ struct SettingsFocusScope: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.stylesheet) private var stylesheet - init(focus: SettingsFocus?, @ViewBuilder content: () -> Content) { + init( + focus: SettingsFocus?, + revealWhen isReady: Bool = true, + @ViewBuilder content: () -> Content, + ) { self.focus = focus + self.isReady = isReady self.content = content() } @@ -75,8 +93,8 @@ struct SettingsFocusScope: View { ScrollViewReader { proxy in content .environment(\.settingsHighlight, highlighted) - .task { - guard !didReveal else { return } + .task(id: isReady) { + guard isReady, !didReveal else { return } didReveal = true await reveal(using: proxy) } diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index 64f33d23..a8e48930 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -76,6 +76,38 @@ enum WhereFormat { )) } + static func settingsBackupImportedMessage( + _ summary: BackupCoordinator.ImportSummary, + ) -> String { + settingsBackupImportedMessage( + samples: summary.sampleCount, + evidence: summary.evidenceCount, + manualDays: summary.manualDayCount, + dismissedIssues: summary.dismissedIssueCount, + trackedRegions: summary.trackedRegionCount, + ) + } + + /// A committed import is not retryable: pair its preserved summary with + /// recovery guidance that cannot be mistaken for a rolled-back failure. + static func backupImportCleanupMessage( + _ summary: BackupCoordinator.ImportSummary, + ) -> String { + String(localized: .backupImportCleanupMessage( + settingsBackupImportedMessage(summary), + )) + } + + /// A later onboarding step cannot roll back an import that already committed. Preserve the + /// summary while directing the user to resume setup instead of applying the archive again. + static func backupImportSetupMessage( + _ summary: BackupCoordinator.ImportSummary, + ) -> String { + String(localized: .backupImportSetupMessage( + settingsBackupImportedMessage(summary), + )) + } + /// Result of a manual "Find issues now" scan — the current unresolved count, /// worded as present state (0 / 1 / many explicit, no catalog plural rule). static func settingsFindIssuesResult(count: Int) -> String { diff --git a/Where/WhereUI/Tests/BackupModelTests.swift b/Where/WhereUI/Tests/BackupModelTests.swift index eeb39c4c..82dbfa2f 100644 --- a/Where/WhereUI/Tests/BackupModelTests.swift +++ b/Where/WhereUI/Tests/BackupModelTests.swift @@ -4,8 +4,8 @@ import Testing @testable import WhereUI /// Exercises `BackupModel`'s export/import bridging: a successful round-trip -/// across two independent stores, and the failure path that surfaces -/// `backupError` without leaving the model stuck "working". +/// across two independent stores, a rolled-back failure, and the committed +/// cleanup-partial-success path that must preserve its import summary. @MainActor struct BackupModelTests { private func date(year: Int, month: Int, day: Int) -> Date { @@ -52,22 +52,24 @@ struct BackupModelTests { ) let destinationBackup = BackupModel(services: destination) - let summary = try #require( + let result = try #require( await destinationBackup.importBackup(from: url, strategy: .merge), ) + let summary = result.summary + #expect(result == .imported(summary)) #expect(summary.evidenceCount == 1) #expect(summary.manualDayCount == 1) #expect(summary.dismissedIssueCount == 1) #expect(destinationBackup.backupState == .idle) - // The success summary is also exposed on the model (not just returned), + // The committed result is also exposed on the model (not just returned), // so the confirmation alert survives the backup screen being popped - // mid-import. Dismissing (isShowingImportSuccess = false) clears it. + // mid-import. Dismissing the result clears it. #expect(destinationBackup.lastImportSummary?.evidenceCount == summary.evidenceCount) - #expect(destinationBackup.isShowingImportSuccess) - destinationBackup.isShowingImportSuccess = false + #expect(destinationBackup.isShowingImportResult) + destinationBackup.isShowingImportResult = false #expect(destinationBackup.lastImportSummary == nil) - #expect(!destinationBackup.isShowingImportSuccess) + #expect(!destinationBackup.isShowingImportResult) #expect(try await destinationStore.allEvidence() == sourceStore.allEvidence()) #expect(try await destinationStore.allManualDays() == sourceStore.allManualDays()) @@ -92,6 +94,89 @@ struct BackupModelTests { #expect(backup.backupState == .idle) // A failed import must not surface a success confirmation. #expect(backup.lastImportSummary == nil) - #expect(!backup.isShowingImportSuccess) + #expect(!backup.isShowingImportResult) + } + + @Test func committedCleanupFailurePreservesSummaryAsPartialSuccess() async throws { + let sourceStore = try SwiftDataStore.inMemory() + let source = WhereServices( + store: sourceStore, + locationSource: ScriptedLocationSource(), + ) + try await seed(source) + let sourceBackup = BackupModel(services: source) + let url = try #require(await sourceBackup.exportBackup()) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let destinationStore = try SwiftDataStore.inMemory() + let outbox = FailingClearLocationOutbox() + let destination = WhereServices( + store: destinationStore, + locationSource: ScriptedLocationSource(), + locationOutbox: outbox, + ) + let backup = BackupModel(services: destination) + + let result = try #require( + await backup.importBackup(from: url, strategy: .replace), + ) + let summary = result.summary + + #expect(result == .committedWithCleanupFailure(summary)) + #expect(result.requiresCleanupRecovery) + #expect(summary.evidenceCount == 1) + #expect(summary.manualDayCount == 1) + #expect(backup.lastImportResult == result) + #expect(backup.lastImportSummary == summary) + #expect(backup.isShowingImportResult) + #expect(backup.backupError == nil) + #expect(backup.backupState == .idle) + #expect(backup.importRecoveryState == .cleanupRequired(summary)) + #expect(!backup.canImport) + + // The warning represents committed data, not a rolled-back operation. + #expect(try await destinationStore.allEvidence().count == 1) + #expect(try await destinationStore.allManualDays().count == 1) + + // Recreating the view model over the same long-lived coordinator cannot forget the + // committed boundary. A second Replace is rejected before it can remove newer data. + let recreated = BackupModel(services: destination) + await recreated.refreshImportRecoveryState() + #expect(recreated.importRecoveryState == .cleanupRequired(summary)) + #expect(!recreated.canImport) + try await destination.journal.addManualDay( + date: date(year: 2026, month: 4, day: 2), + regions: [.newYork], + audit: nil, + ) + let rejected = await recreated.importBackup(from: url, strategy: .replace) + #expect(rejected == .committedWithCleanupFailure(summary)) + #expect(try await destinationStore.allManualDays().count == 2) + + await outbox.setFailsToClear(false) + await recreated.retryImportCleanup() + + #expect(recreated.importRecoveryState == .ready) + #expect(recreated.canImport) + #expect(recreated.backupError == nil) } } + +private actor FailingClearLocationOutbox: LocationOutbox { + private var failsToClear = true + + func load() async throws -> [LocationOutboxEntry] { + [] + } + + func save(_: [LocationOutboxEntry]) async {} + func clear() async throws { + guard !failsToClear else { throw CleanupFailure() } + } + + func setFailsToClear(_ value: Bool) { + failsToClear = value + } +} + +private struct CleanupFailure: Error {} diff --git a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift b/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift deleted file mode 100644 index 0b155cac..00000000 --- a/Where/WhereUI/Tests/CurrentRecordingDeviceProviderTests.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation -import Testing -import UIKit -import WhereCore -@testable import WhereUI - -@MainActor -struct CurrentRecordingDeviceProviderTests { - private static let vendorID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! - private static let restoredID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! - - @Test func mapsInterfaceIdiomsToRecordingKinds() { - #expect(CurrentRecordingDeviceProvider.kind(for: .phone) == .phone) - #expect(CurrentRecordingDeviceProvider.kind(for: .pad) == .tablet) - #expect(CurrentRecordingDeviceProvider.kind(for: .mac) == .other) - } - - @Test func persistsOneDeviceLocalInstallationIdentity() throws { - let fixture = try makeFixture() - defer { fixture.cleanup() } - - let first = try current(fixture: fixture, vendorID: Self.vendorID) - let second = try current(fixture: fixture, vendorID: Self.vendorID) - - #expect(first == second) - #expect(first.id.rawValue == Self.vendorID) - #expect(first.systemName == "iPhone") - #expect( - try fixture.identityFileURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) - .isExcludedFromBackup == true, - ) - } - - @Test func migratesAUserDefaultThatMatchesThisDevice() throws { - let fixture = try makeFixture(legacyID: Self.vendorID) - defer { fixture.cleanup() } - - let current = try current(fixture: fixture, vendorID: Self.vendorID) - - #expect(current.id.rawValue == Self.vendorID) - #expect(fixture.defaults.string(forKey: "where.recordingDeviceID") == nil) - } - - @Test func rejectsARestoredUserDefaultFromAnotherDevice() throws { - let fixture = try makeFixture(legacyID: Self.restoredID) - defer { fixture.cleanup() } - - let current = try current(fixture: fixture, vendorID: Self.vendorID) - - #expect(current.id.rawValue == Self.vendorID) - #expect(current.id.rawValue != Self.restoredID) - } - - @Test func preUnlockFallbackRemainsStableAfterUnlock() throws { - let fixture = try makeFixture(legacyID: Self.restoredID) - defer { fixture.cleanup() } - - let beforeUnlock = try current(fixture: fixture, vendorID: nil) - let afterUnlock = try current(fixture: fixture, vendorID: Self.vendorID) - - #expect(beforeUnlock == afterUnlock) - #expect(beforeUnlock.id.rawValue != Self.restoredID) - } - - private func current( - fixture: Fixture, - vendorID: UUID?, - ) throws -> CurrentRecordingDevice { - try CurrentRecordingDeviceProvider.current( - identityFileURL: fixture.identityFileURL, - legacyDefaults: fixture.defaults, - vendorID: vendorID, - systemName: "iPhone", - kind: .phone, - ) - } - - private func makeFixture(legacyID: UUID? = nil) throws -> Fixture { - let suiteName = "CurrentRecordingDeviceProviderTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - if let legacyID { - defaults.set(legacyID.uuidString, forKey: "where.recordingDeviceID") - } - let directory = FileManager.default.temporaryDirectory - .appending(path: suiteName, directoryHint: .isDirectory) - return Fixture( - directory: directory, - identityFileURL: directory.appending(path: "recording-device-id"), - defaults: defaults, - suiteName: suiteName, - ) - } - - private struct Fixture { - let directory: URL - let identityFileURL: URL - let defaults: UserDefaults - let suiteName: String - - func cleanup() { - try? FileManager.default.removeItem(at: directory) - defaults.removePersistentDomain(forName: suiteName) - } - } -} diff --git a/Where/WhereUI/Tests/DemoModeEnvironmentTests.swift b/Where/WhereUI/Tests/DemoModeEnvironmentTests.swift index 082579f2..f03d5462 100644 --- a/Where/WhereUI/Tests/DemoModeEnvironmentTests.swift +++ b/Where/WhereUI/Tests/DemoModeEnvironmentTests.swift @@ -64,7 +64,8 @@ struct DemoModeEnvironmentTests { let bootstrap = try ScriptedBootstrap(services: makeServices()) let model = WhereModel( preferences: makePreferences(), - makeBootstrap: { bootstrap }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in bootstrap }, logSystem: .isolated(), ) try await model.activateDemo(model.makeDemoScope()) diff --git a/Where/WhereUI/Tests/DemoModeTests.swift b/Where/WhereUI/Tests/DemoModeTests.swift index 61801156..a3a2f31e 100644 --- a/Where/WhereUI/Tests/DemoModeTests.swift +++ b/Where/WhereUI/Tests/DemoModeTests.swift @@ -36,7 +36,8 @@ struct DemoModeTests { return ( WhereModel( preferences: preferences, - makeBootstrap: { bootstrap }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in bootstrap }, logSystem: logSystem, ), bootstrap, @@ -65,10 +66,13 @@ struct DemoModeTests { let regions = report.days.flatMap(\.regions) #expect(Set(regions) == [.newYork, .california]) - // Onboarded and tracking, so the demo opens on the logged-in app. + // Onboarded and carrying the demo installation identity, so it opens on + // the logged-in app and Core owns its recording choice. #expect(scope.preferences.hasOnboarded) - #expect(scope.preferences.hasConfirmedRecordingChoice) - #expect(scope.preferences.wantsTracking) + #expect( + scope.services.recording.currentDevice + == InstallationRecordingContext.demo.currentDevice, + ) // Its log store is in memory, like everything else it owns — held but // not yet routed into, since the scope hasn't been activated. @@ -254,7 +258,8 @@ struct DemoModeTests { let bootstrap = try ScriptedBootstrap(services: makeServices(), logStore: realLogStore) let model = WhereModel( preferences: makePreferences(), - makeBootstrap: { bootstrap }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in bootstrap }, logSystem: logSystem, ) @@ -304,7 +309,8 @@ struct DemoModeTests { let bootstrap = try ScriptedBootstrap(services: makeServices(), logStore: realLogStore) let model = WhereModel( preferences: makePreferences(), - makeBootstrap: { bootstrap }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in bootstrap }, logSystem: logSystem, ) bootstrap.gateLogStore() @@ -336,7 +342,8 @@ struct DemoModeTests { let bootstrap = try ScriptedBootstrap(services: makeServices()) let model = WhereModel( preferences: makePreferences(), - makeBootstrap: { bootstrap }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in bootstrap }, logSystem: logSystem, ) let abandoned = try await model.makeDemoScope() diff --git a/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift index a4c800ce..3c270c55 100644 --- a/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift +++ b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift @@ -45,10 +45,11 @@ struct DeviceSettingsRowModelTests { #expect(row.id == Self.id) #expect(row.displayName == "Desk") - #expect(row.confirmedNickname == "Desk") #expect(row.status == .off) #expect(row.isPending) - #expect(row.confirmedIsEnabled == false) + #expect(row.isSyncingRecordingPolicy == false) + #expect(row.policyPresentationState == .resolved(isAcknowledged: false)) + #expect(row.isEnabled == false) } @Test func syncedRefreshDoesNotOverwriteAnUnsavedNickname() { @@ -69,7 +70,41 @@ struct DeviceSettingsRowModelTests { )) #expect(row.nickname == "Home iPad") - #expect(row.confirmedNickname == "Synced elsewhere") + #expect(row.hasUnsavedNickname) + } + + @Test func profileWithoutASyncedPolicyStaysUnresolved() { + let device = configuration( + nickname: "Home iPad", + status: .unknown, + appliedPolicyID: nil, + ).device + let row = DeviceSettingsRowModel( + configuration: RecordingDeviceConfiguration( + device: device, + policy: .unknown, + ), + isCurrent: false, + ) + + #expect(row.hasResolvedRecordingPolicy == false) + #expect(row.isPending) + #expect(row.isSyncingRecordingPolicy) + #expect(row.policyPresentationState == .syncingPolicy) + #expect(row.disablesDestructiveActions) + + row.update(from: configuration( + nickname: "Home iPad", + status: .off, + appliedPolicyID: Self.policyID, + )) + + #expect(row.hasResolvedRecordingPolicy) + #expect(row.isEnabled == false) + #expect(row.isPending == false) + #expect(row.isSyncingRecordingPolicy == false) + #expect(row.policyPresentationState == .resolved(isAcknowledged: true)) + #expect(row.disablesDestructiveActions == false) } private func configuration( @@ -89,8 +124,12 @@ struct DeviceSettingsRowModelTests { lastAppliedPolicyChangeID: appliedPolicyID, status: status, ), - isEnabled: status != .off, - latestPolicyChangeID: Self.policyID, + policy: .resolved(ResolvedRecordingPolicy( + isEnabled: status != .off, + isArchived: false, + changeID: Self.policyID, + isAcknowledged: appliedPolicyID == Self.policyID, + )), ) } } diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift index bc2ae591..53a3e9ea 100644 --- a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -6,6 +6,16 @@ import Testing @MainActor struct DevicesSettingsModelTests { private static let now = Date(timeIntervalSinceReferenceDate: 1000) + private static let disabledInitialPolicyID = UUID( + uuidString: "00000000-0000-0000-0000-000000000003", + )! + + @Test func searchFocusWaitsUntilDeviceRowsAreLoaded() { + #expect(DevicesSettingsModel.LoadState.idle.isReadyForSearchFocus == false) + #expect(DevicesSettingsModel.LoadState.loading.isReadyForSearchFocus == false) + #expect(DevicesSettingsModel.LoadState.empty.isReadyForSearchFocus == false) + #expect(DevicesSettingsModel.LoadState.loaded.isReadyForSearchFocus) + } private func makeSubject() throws -> ( model: DevicesSettingsModel, @@ -16,27 +26,41 @@ struct DevicesSettingsModelTests { let services = WhereServices( store: store, locationSource: ScriptedLocationSource(authorizationStatus: .always), - currentDevice: .preview, + installationContext: .testing, now: { Self.now }, ) let preferences = makePreferences() - preferences.wantsTracking = true let session = WhereSession(services: services, preferences: preferences) return (DevicesSettingsModel(session: session), session, store) } - private func makeSubject(store: any WhereStore) -> ( + private func makeSubject( + store: any WhereStore, + initialRecordingEnabled: Bool = true, + ) -> ( model: DevicesSettingsModel, session: WhereSession ) { + let installationContext = if initialRecordingEnabled { + InstallationRecordingContext.testing + } else { + InstallationRecordingContext( + currentDevice: InstallationRecordingContext.testing.currentDevice, + registeredAt: InstallationRecordingContext.testing.registeredAt, + initialRecordingChoice: .init( + isEnabled: false, + policyChangeID: Self.disabledInitialPolicyID, + confirmedAt: Date(timeIntervalSinceReferenceDate: 1), + ), + ) + } let services = WhereServices( store: store, locationSource: ScriptedLocationSource(authorizationStatus: .always), - currentDevice: .preview, + installationContext: installationContext, now: { Self.now }, ) let preferences = makePreferences() - preferences.wantsTracking = true let session = WhereSession(services: services, preferences: preferences) return (DevicesSettingsModel(session: session), session) } @@ -51,13 +75,105 @@ struct DevicesSettingsModelTests { #expect(row.status == .recording) row.isEnabled = false - await subject.model.setEnabled(false, row: row) + await subject.model.recordingPreferenceChanged(for: row) #expect(row.isEnabled == false) #expect(row.status == .off) #expect(row.isPending == false) #expect(subject.session.isTracking == false) - #expect(subject.session.preferences.wantsTracking == false) + } + + @Test func emptyDeviceResultsRemainVisibleAndRetryable() async { + let session = ScriptedDevicesSettingsSession(hasDevice: false) + let model = DevicesSettingsModel(session: session) + + await model.retry() + + guard case .empty = model.state else { + Issue.record("Expected an empty device result to have a visible state.") + return + } + #expect(model.rows.isEmpty) + + session.hasDevice = true + await model.retry() + + guard case .loaded = model.state else { + Issue.record("Expected retry to load the now-available device.") + return + } + #expect(model.rows.count == 1) + #expect(session.recordingDevicesCallCount == 2) + } + + @Test func refreshFailureAfterACommittedToggleDoesNotFailOrRepeatTheToggle() async throws { + let session = ScriptedDevicesSettingsSession(isEnabled: false) + let model = DevicesSettingsModel(session: session) + await model.retry() + let row = try #require(model.rows.first) + session.failRecordingDevicesCall(2) + + row.isEnabled = true + await model.recordingPreferenceChanged(for: row) + + #expect(session.setEnabledCalls == [true]) + #expect(row.isEnabled) + #expect(row.isApplyingRecordingChange) + #expect(model.presentedFailure?.context == .refresh) + #expect(model.presentedFailureCanRetry) + + await model.retry() + + #expect(session.setEnabledCalls == [true]) + #expect(row.isEnabled) + #expect(row.operationState == .idle) + #expect(model.presentedFailure == nil) + } + + @Test func repeatedRefreshFailureKeepsExistingRowsVisible() async throws { + let session = ScriptedDevicesSettingsSession() + let model = DevicesSettingsModel(session: session) + await model.retry() + _ = try #require(model.rows.first) + session.failRecordingDevicesCall(2) + session.failRecordingDevicesCall(3) + + await model.retry() + guard case .loaded = model.state else { + Issue.record("Expected a failed reconciliation to preserve the loaded rows.") + return + } + #expect(model.presentedFailure?.context == .refresh) + + model.isShowingError = false + await model.retry() + + guard case .loaded = model.state else { + Issue.record("Expected a repeated failure to preserve the loaded rows.") + return + } + #expect(model.rows.count == 1) + #expect(model.presentedFailure?.context == .refresh) + } + + @Test func refreshRetrySubmitsANewerToggleWithoutRepeatingTheCommittedToggle() async throws { + let session = ScriptedDevicesSettingsSession(isEnabled: false) + let model = DevicesSettingsModel(session: session) + await model.retry() + let row = try #require(model.rows.first) + session.failRecordingDevicesCall(2) + + row.isEnabled = true + await model.recordingPreferenceChanged(for: row) + row.isEnabled = false + await model.recordingPreferenceChanged(for: row) + + await model.retry() + + #expect(session.setEnabledCalls == [true, false]) + #expect(row.isEnabled == false) + #expect(row.operationState == .idle) + #expect(model.presentedFailure == nil) } @Test func renamesAndArchivesARemoteDevice() async throws { @@ -66,24 +182,23 @@ struct DevicesSettingsModelTests { let remoteID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) - try await subject.store.perform { - try await subject.store.setRecordingDevice(RecordingDevice( - id: remoteID, - systemName: "iPad", - nickname: nil, - kind: .tablet, - registeredAt: Self.now, - lastSeenAt: Self.now, - archivedAt: nil, - lastAppliedPolicyChangeID: nil, - status: .off, - )) - } + let remotePolicyID = try #require( + UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAB"), + ) + try await addRemoteDevice( + to: subject.store, + id: remoteID, + nickname: nil, + policyID: remotePolicyID, + enabled: false, + status: .off, + writerID: subject.session.currentRecordingDeviceID, + ) await subject.model.retry() let remote = try #require(subject.model.rows.first(where: { $0.id == remoteID })) remote.nickname = "Home iPad" - await subject.model.rename(remote) + await subject.model.saveNickname(remote) #expect(remote.displayName == "Home iPad") #expect(try await subject.store.recordingDevices() .first(where: { $0.id == remoteID })?.nickname == "Home iPad") @@ -94,6 +209,103 @@ struct DevicesSettingsModelTests { .first(where: { $0.id == remoteID })?.archivedAt == Self.now) } + @Test func newestToggleWinsWhileTheFirstWriteIsSuspended() async throws { + let store = try TestStore() + let subject = makeSubject(store: store, initialRecordingEnabled: false) + await subject.session.start() + await subject.model.retry() + let row = try #require(subject.model.rows.first) + #expect(row.isEnabled == false) + + await store.gateNextRecordingPolicyWrite() + row.isEnabled = true + let firstWrite = Task { + await subject.model.recordingPreferenceChanged(for: row) + } + await store.awaitRecordingPolicyWriteGate() + #expect(row.isApplyingRecordingChange) + + row.isEnabled = false + let newestIntent = Task { + await subject.model.recordingPreferenceChanged(for: row) + } + await newestIntent.value + await store.releaseRecordingPolicyWriteGate() + await firstWrite.value + + #expect(row.isEnabled == false) + #expect(row.operationState == .idle) + #expect(row.isApplyingRecordingChange == false) + let policyChanges = try await store.recordingPolicyChanges() + #expect(policyChanges.suffix(2).map(\.isEnabled) == [true, false]) + } + + @Test func remoteRefreshWinsWhileANicknameCommandIsSuspended() async throws { + let session = SuspendedDevicesSettingsSession() + let model = DevicesSettingsModel(session: session) + await model.retry() + let row = try #require(model.rows.first) + + row.nickname = "Local Name" + let save = Task { await model.saveNickname(row) } + await session.awaitRename() + + session.simulateRemoteNickname("Cloud Name") + await model.retry() + session.releaseRename() + await save.value + + #expect(row.nickname == "Cloud Name") + #expect(row.hasUnsavedNickname == false) + #expect(row.operationState == .idle) + } + + @Test func failedToggleRestoresConfirmedStateAndSurfacesTheFailure() async throws { + let store = try TestStore() + let subject = makeSubject(store: store) + await subject.session.start() + await subject.model.retry() + let row = try #require(subject.model.rows.first) + + await store.failNextRecordingPolicyWrite() + row.isEnabled = false + await subject.model.recordingPreferenceChanged(for: row) + + #expect(row.isEnabled) + guard case .failed = row.operationState else { + Issue.record("Expected the row to retain its failed operation state.") + return + } + #expect(subject.model.presentedFailure != nil) + + subject.model.isShowingError = false + #expect(row.operationState == .idle) + #expect(subject.model.presentedFailure == nil) + } + + @Test func failedNicknameSavePreservesTheDraftForAnExplicitRetry() async throws { + let store = try TestStore() + let subject = makeSubject(store: store) + await subject.session.start() + await subject.model.retry() + let row = try #require(subject.model.rows.first) + + row.nickname = "Travel Phone" + await store.failNextRecordingDeviceWrite() + await subject.model.saveNickname(row) + + #expect(row.nickname == "Travel Phone") + #expect(row.hasUnsavedNickname) + #expect(subject.model.presentedFailure != nil) + + subject.model.isShowingError = false + await subject.model.saveNickname(row) + + #expect(row.nickname == "Travel Phone") + #expect(row.hasUnsavedNickname == false) + #expect(try await store.recordingDevices().first?.nickname == "Travel Phone") + } + @Test func refreshesADeviceImportedFromAnotherDevice() async throws { let remoteChanges = ScriptedStoreRemoteChangeSource() let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) @@ -112,15 +324,33 @@ struct DevicesSettingsModelTests { UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"), ) try await store.simulateRemoteRecordingImport( - devices: [ - RecordingDevice( + profiles: [ + RecordingDeviceProfile( id: remoteID, systemName: "iPad", - nickname: "Home iPad", kind: .tablet, registeredAt: Self.now, + registrationEpochID: .initial, + ), + ], + metadataChanges: [ + RecordingDeviceMetadataChange( + id: #require( + UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCD"), + ), + deviceID: remoteID, + revision: 0, + changedAt: Self.now, + changedByDeviceID: subject.session.currentRecordingDeviceID, + nickname: "Home iPad", + ), + ], + checkIns: [ + RecordingDeviceCheckIn( + deviceID: remoteID, + revision: 0, lastSeenAt: Self.now, - archivedAt: nil, + appliedAt: Self.now, lastAppliedPolicyChangeID: policyID, status: .off, ), @@ -129,8 +359,13 @@ struct DevicesSettingsModelTests { RecordingPolicyChange( id: policyID, deviceID: remoteID, + parentIDs: [], + revision: 0, + issuedAt: Self.now, + issuedByDeviceID: subject.session.currentRecordingDeviceID, effectiveAt: Self.now, - isEnabled: false, + state: .off, + reason: .userCommand, ), ], ) @@ -154,11 +389,82 @@ struct DevicesSettingsModelTests { #expect(remote.isPending == false) } + @Test func remoteTargetAcknowledgementClearsThePendingRow() async throws { + let remoteChanges = ScriptedStoreRemoteChangeSource() + let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) + let subject = makeSubject(store: store) + await subject.session.start() + + let runTask = Task { await subject.model.run() } + defer { runTask.cancel() } + await waitUntil { + subject.model.rows.contains(where: \.isCurrent) + } + + let remoteID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "ABABABAB-ABAB-ABAB-ABAB-ABABABABABAB")), + ) + let initialPolicyID = try #require( + UUID(uuidString: "CDCDCDCD-CDCD-CDCD-CDCD-CDCDCDCDCDCD"), + ) + try await addRemoteDevice( + to: store, + id: remoteID, + nickname: "Travel iPad", + policyID: initialPolicyID, + enabled: true, + status: .recording, + writerID: remoteID, + ) + await waitUntil { + subject.model.rows.contains(where: { $0.id == remoteID }) + } + let remote = try #require(subject.model.rows.first(where: { $0.id == remoteID })) + + remote.isEnabled = false + await subject.model.recordingPreferenceChanged(for: remote) + + #expect(remote.isEnabled == false) + #expect(remote.isPending) + #expect(remote.status == .recording) + let disableID = try #require( + try await store.recordingPolicyChanges().first(where: { + $0.deviceID == remoteID && $0.state == .off + })?.id, + ) + + try await store.simulateRemoteRecordingImport( + profiles: [], + metadataChanges: [], + checkIns: [RecordingDeviceCheckIn( + deviceID: remoteID, + revision: 1, + lastSeenAt: Self.now.addingTimeInterval(60), + appliedAt: Self.now.addingTimeInterval(60), + lastAppliedPolicyChangeID: disableID, + status: .off, + )], + policyChanges: [], + ) + #expect(remote.isPending) + + remoteChanges.yield() + + await waitUntil { + remote.isPending == false && remote.status == .off + } + #expect(remote.isEnabled == false) + #expect(remote.isPending == false) + #expect(remote.status == .off) + runTask.cancel() + await runTask.value + } + @Test func observesADeviceAddedDuringInitialLoad() async throws { let store = try TestStore() let subject = makeSubject(store: store) await subject.session.start() - await store.gateRecordingDevices(afterCalls: 1) + await store.gateRecordingDevices(afterCalls: 0) let runTask = Task { await subject.model.run() } await store.awaitRecordingDevicesGate() @@ -166,19 +472,18 @@ struct DevicesSettingsModelTests { let remoteID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), ) - try await store.perform { - try await store.setRecordingDevice(RecordingDevice( - id: remoteID, - systemName: "iPad", - nickname: nil, - kind: .tablet, - registeredAt: Self.now, - lastSeenAt: Self.now, - archivedAt: nil, - lastAppliedPolicyChangeID: nil, - status: .off, - )) - } + let remotePolicyID = try #require( + UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBC"), + ) + try await addRemoteDevice( + to: store, + id: remoteID, + nickname: nil, + policyID: remotePolicyID, + enabled: false, + status: .off, + writerID: subject.session.currentRecordingDeviceID, + ) await store.releaseRecordingDevicesGate() await waitUntil { @@ -190,6 +495,61 @@ struct DevicesSettingsModelTests { #expect(subject.model.rows.contains(where: { $0.id == remoteID })) } + private func addRemoteDevice( + to store: any WhereStore, + id: RecordingDeviceID, + nickname: String?, + policyID: UUID, + enabled: Bool, + status: RecordingDeviceStatus, + writerID: RecordingDeviceID, + ) async throws { + let profile = RecordingDeviceProfile( + id: id, + systemName: "iPad", + kind: .tablet, + registeredAt: Self.now, + registrationEpochID: .initial, + ) + let metadata = nickname.map { + RecordingDeviceMetadataChange( + id: UUID(), + deviceID: id, + revision: 0, + changedAt: Self.now, + changedByDeviceID: writerID, + nickname: $0, + ) + } + let policy = RecordingPolicyChange( + id: policyID, + deviceID: id, + parentIDs: [], + revision: 0, + issuedAt: Self.now, + issuedByDeviceID: writerID, + effectiveAt: Self.now, + state: enabled ? .on : .off, + reason: .initialRegistration, + ) + let checkIn = RecordingDeviceCheckIn( + deviceID: id, + revision: 0, + lastSeenAt: Self.now, + appliedAt: Self.now, + lastAppliedPolicyChangeID: policyID, + status: status, + ) + try await store.perform { + try await store.addRecordingDeviceProfile(profile) + if let metadata { + try await store.addRecordingDeviceMetadataChange(metadata) + } + try await store.addRecordingPolicyChange(policy) + try await store.setRecordingDeviceCheckIn(checkIn) + } + } + private func waitUntil( timeout: Duration = .seconds(2), _ predicate: () -> Bool, @@ -202,3 +562,145 @@ struct DevicesSettingsModelTests { #expect(predicate(), "condition was not met before timeout") } } + +/// Deterministic command-vs-refresh race for the Settings session protocol. The command first +/// commits a local value, then suspends while a causally later remote value becomes readable. +@MainActor +private final class SuspendedDevicesSettingsSession: DevicesSettingsSession { + let currentRecordingDeviceID = CurrentRecordingDevice.preview.id + + private let policyID = UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")! + private var nickname = "iPhone" + private var renameReached = false + private var renameArrival: CheckedContinuation? + private var renameGate: CheckedContinuation? + + func recordingDeviceUpdates() -> AsyncStream { + AsyncStream { _ in } + } + + func recordingDevices() async throws -> [RecordingDeviceConfiguration] { + [configuration] + } + + func setRecordingEnabled(_: Bool, for _: RecordingDeviceID) async throws {} + + func renameRecordingDevice(_: RecordingDeviceID, to nickname: String) async throws { + self.nickname = nickname + renameReached = true + renameArrival?.resume() + renameArrival = nil + await withCheckedContinuation { renameGate = $0 } + } + + func archiveRecordingDevice(_: RecordingDeviceID) async throws {} + func requestPermission() async {} + + func awaitRename() async { + guard !renameReached else { return } + await withCheckedContinuation { renameArrival = $0 } + } + + func simulateRemoteNickname(_ nickname: String) { + self.nickname = nickname + } + + func releaseRename() { + renameGate?.resume() + renameGate = nil + } + + private var configuration: RecordingDeviceConfiguration { + RecordingDeviceConfiguration( + device: RecordingDevice( + id: currentRecordingDeviceID, + systemName: "iPhone", + nickname: nickname, + kind: .phone, + registeredAt: Date(timeIntervalSinceReferenceDate: 1000), + lastSeenAt: Date(timeIntervalSinceReferenceDate: 1000), + archivedAt: nil, + lastAppliedPolicyChangeID: policyID, + status: .recording, + ), + policy: .resolved(ResolvedRecordingPolicy( + isEnabled: true, + isArchived: false, + changeID: policyID, + isAcknowledged: true, + )), + ) + } +} + +@MainActor +private final class ScriptedDevicesSettingsSession: DevicesSettingsSession { + enum ReadError: LocalizedError { + case unavailable + + var errorDescription: String? { + "Device refresh unavailable" + } + } + + let currentRecordingDeviceID = CurrentRecordingDevice.preview.id + var hasDevice: Bool + private(set) var recordingDevicesCallCount = 0 + private(set) var setEnabledCalls: [Bool] = [] + + private let policyID = UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")! + private var isEnabled: Bool + private var failingRecordingDevicesCalls: Set = [] + + init(hasDevice: Bool = true, isEnabled: Bool = false) { + self.hasDevice = hasDevice + self.isEnabled = isEnabled + } + + func recordingDeviceUpdates() -> AsyncStream { + AsyncStream { _ in } + } + + func recordingDevices() async throws -> [RecordingDeviceConfiguration] { + recordingDevicesCallCount += 1 + if failingRecordingDevicesCalls.remove(recordingDevicesCallCount) != nil { + throw ReadError.unavailable + } + return hasDevice ? [configuration] : [] + } + + func setRecordingEnabled(_ enabled: Bool, for _: RecordingDeviceID) async throws { + setEnabledCalls.append(enabled) + isEnabled = enabled + } + + func renameRecordingDevice(_: RecordingDeviceID, to _: String) async throws {} + func archiveRecordingDevice(_: RecordingDeviceID) async throws {} + func requestPermission() async {} + + func failRecordingDevicesCall(_ call: Int) { + failingRecordingDevicesCalls.insert(call) + } + + private var configuration: RecordingDeviceConfiguration { + RecordingDeviceConfiguration( + device: RecordingDevice( + id: currentRecordingDeviceID, + systemName: "iPhone", + nickname: nil, + kind: .phone, + registeredAt: Date(timeIntervalSinceReferenceDate: 1000), + lastSeenAt: Date(timeIntervalSinceReferenceDate: 1000), + archivedAt: nil, + lastAppliedPolicyChangeID: policyID, + status: isEnabled ? .recording : .off, + ), + policy: .resolved(ResolvedRecordingPolicy( + isEnabled: isEnabled, + isArchived: false, + changeID: policyID, + isAcknowledged: true, + )), + ) + } +} diff --git a/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift new file mode 100644 index 00000000..4790faae --- /dev/null +++ b/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@_spi(Testing) import WhereCore +@_spi(Testing) import WhereUI + +@MainActor +struct InMemoryInstallationRecordingContextStoreTests { + @Test func confirmationStaysInMemory() throws { + let context = InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: Self.deviceID), + systemName: "iPad", + kind: .tablet, + ), + registeredAt: Self.registeredAt, + initialRecordingChoice: nil, + ) + let store = InMemoryInstallationRecordingContextStore( + context: context, + makeUUID: { Self.policyChangeID }, + now: { Self.confirmedAt }, + ) + + let confirmed = try store.confirmInitialRecording(isEnabled: false) + + #expect(confirmed.initialRecordingChoice?.policyChangeID == Self.policyChangeID) + #expect(confirmed.registeredAt == Self.registeredAt) + #expect(confirmed.initialRecordingChoice?.confirmedAt == Self.confirmedAt) + #expect(try store.resolve() == confirmed) + } + + @Test func resetCreatesANewUnconfirmedIdentity() throws { + let store = InMemoryInstallationRecordingContextStore( + context: .testing, + makeUUID: { Self.resetDeviceID }, + now: { Self.resetRegisteredAt }, + ) + + try store.reset() + + #expect(store.onboardingContext.currentDevice.id.rawValue == Self.resetDeviceID) + #expect(store.onboardingContext.registeredAt == Self.resetRegisteredAt) + #expect(store.onboardingContext.initialRecordingChoice == nil) + } + + @Test func laterConfirmationCannotRewriteTheInitialPolicyEvent() throws { + let store = InMemoryInstallationRecordingContextStore( + context: .testing, + makeUUID: { Self.resetDeviceID }, + now: { Self.confirmedAt }, + ) + + let repeated = try store.confirmInitialRecording(isEnabled: false) + + #expect(repeated == .testing) + #expect(repeated.initialRecordingChoice?.isEnabled == true) + } + + private static let deviceID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + private static let policyChangeID = UUID( + uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + )! + private static let resetDeviceID = UUID( + uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + )! + private static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) + private static let confirmedAt = Date(timeIntervalSinceReferenceDate: 200) + private static let resetRegisteredAt = Date(timeIntervalSinceReferenceDate: 300) +} diff --git a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift new file mode 100644 index 00000000..b1f405af --- /dev/null +++ b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift @@ -0,0 +1,405 @@ +import Foundation +import Testing +import UIKit +@_spi(Testing) import WhereCore +@_spi(Testing) @testable import WhereUI + +@MainActor +struct InstallationRecordingContextStoreTests { + @Test func mapsInterfaceIdiomsToRecordingKinds() { + #expect(FileInstallationRecordingContextStore.kind(for: .phone) == .phone) + #expect(FileInstallationRecordingContextStore.kind(for: .pad) == .tablet) + #expect(FileInstallationRecordingContextStore.kind(for: .mac) == .other) + } + + @Test func proposedContextLeavesNoDurableMark() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + + let store = fixture.makeStore() + + #expect(store.onboardingContext.currentDevice.id.rawValue == Self.deviceID) + #expect(store.onboardingContext.registeredAt == Self.registeredAt) + #expect(store.onboardingContext.initialRecordingChoice == nil) + #expect(fixture.fileExists == false) + } + + @Test func confirmationPersistsIdentityChoiceAndPolicyTokenTogether() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + let first = fixture.makeStore() + + let confirmed = try first.confirmInitialRecording(isEnabled: false) + let relaunched = fixture.makeStore() + let restored = try relaunched.resolve() + + #expect(restored == confirmed) + #expect(restored.currentDevice.id.rawValue == Self.deviceID) + #expect(restored.registeredAt == Self.registeredAt) + #expect(restored.initialRecordingChoice?.isEnabled == false) + #expect(restored.initialRecordingChoice?.policyChangeID == Self.policyChangeID) + #expect(restored.initialRecordingChoice?.confirmedAt == Self.confirmedAt) + #expect( + try fixture.fileURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) + .isExcludedFromBackup == true, + ) + #expect( + try fixture.directory.resourceValues(forKeys: [.isExcludedFromBackupKey]) + .isExcludedFromBackup == true, + ) + } + + @Test func repeatedResolutionAndConfirmationReuseTheSameContext() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + let store = fixture.makeStore() + + let first = try store.confirmInitialRecording(isEnabled: true) + let second = try store.confirmInitialRecording(isEnabled: true) + + #expect(try store.resolve() == first) + #expect(second == first) + } + + @Test func importRecoveryTransitionsSurviveStoreRecreation() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + let store = fixture.makeStore() + _ = try store.confirmInitialRecording(isEnabled: true) + let details = try BackupCoordinator.ImportRecoveryDetails( + transactionID: #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")), + strategy: .replace, + summary: BackupCoordinator.ImportSummary( + sampleCount: 3, + evidenceCount: 2, + manualDayCount: 1, + dismissedIssueCount: 0, + trackedRegionCount: 4, + ), + purpose: .onboarding, + ) + + try store.setBackupImportRecovery(.prepared(details)) + #expect(fixture.makeStore().backupImportRecovery == .prepared(details)) + + let committed = BackupCoordinator.DurableImportRecovery.committed( + details, + cleanupCompleted: false, + onboardingAcknowledged: true, + ) + try store.setBackupImportRecovery(committed) + + #expect(fixture.makeStore().backupImportRecovery == committed) + } + + @Test func completedOnboardingImportRepairsLostPreferenceAfterStoreRecreation() async throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + let installationStore = fixture.makeStore() + let installationContext = try installationStore.confirmInitialRecording(isEnabled: true) + let details = try BackupCoordinator.ImportRecoveryDetails( + transactionID: #require(UUID( + uuidString: "11111111-2222-3333-4444-555555555555", + )), + strategy: .replace, + summary: BackupCoordinator.ImportSummary( + sampleCount: 3, + evidenceCount: 2, + manualDayCount: 1, + dismissedIssueCount: 0, + trackedRegionCount: 4, + ), + purpose: .onboarding, + ) + try installationStore.setBackupImportRecovery(.committed( + details, + cleanupCompleted: true, + onboardingAcknowledged: false, + )) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + installationContext: installationContext, + importRecoveryPersistence: installationStore.backupImportRecoveryPersistence, + ) + let acknowledgedPreferences = makePreferences() + let acknowledgedModel = WhereModel( + preferences: acknowledgedPreferences, + installationContextStore: installationStore, + makeBootstrap: { _ in ScriptedBootstrap(services: services) }, + logSystem: .isolated(), + ) + acknowledgedModel.completeOnboarding() + + try await services.backup.acknowledgeOnboardingImport() + + #expect(installationStore.backupImportRecovery == nil) + #expect(installationStore.onboardingImportCompletion?.transactionID == details + .transactionID) + + // A later Settings transaction uses the active marker without replacing the terminal + // onboarding authority. + let settingsDetails = BackupCoordinator.ImportRecoveryDetails( + transactionID: UUID(), + strategy: .merge, + summary: details.summary, + purpose: .settings, + ) + try installationStore.setBackupImportRecovery(.prepared(settingsDetails)) + try installationStore.setBackupImportRecovery(nil) + + // Simulate a fresh process whose UserDefaults setter never reached disk. Recreating the + // file-backed sidecar retains the terminal proof, so the gate repairs the preference and + // cannot present Restore again. + let relaunchedStore = fixture.makeStore() + let lostPreferences = makePreferences() + let relaunchedModel = WhereModel( + preferences: lostPreferences, + installationContextStore: relaunchedStore, + makeBootstrap: { _ in UnusedBootstrap() }, + logSystem: .isolated(), + ) + + let isNeeded = await OnboardingGate(model: relaunchedModel).isNeeded(()) + + #expect(!isNeeded) + #expect(relaunchedModel.hasOnboarded) + #expect(relaunchedStore.backupImportRecovery == nil) + #expect(relaunchedStore.onboardingImportCompletion?.transactionID == details.transactionID) + } + + @Test func laterConfirmationCannotRewriteTheInitialPolicyEvent() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + let store = fixture.makeStore() + + let first = try store.confirmInitialRecording(isEnabled: false) + let repeated = try store.confirmInitialRecording(isEnabled: true) + + #expect(repeated == first) + #expect(repeated.initialRecordingChoice?.isEnabled == false) + #expect(try fixture.makeStore().resolve() == first) + } + + @Test func launchPromotesACompletePendingFirstWriteAfterACrash() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + let confirmed = try fixture.makeStore().confirmInitialRecording(isEnabled: false) + try FileManager.default.moveItem(at: fixture.fileURL, to: fixture.pendingURL) + + let restored = try fixture.makeStore().resolve() + + #expect(restored == confirmed) + #expect(fixture.fileExists) + #expect(fixture.pendingExists == false) + } + + @Test func completePendingReplacementWinsOverAnOlderAuthoritativeContext() throws { + let oldFixture = try makeFixture() + let newFixture = try makeFixture( + ids: [Self.resetDeviceID, Self.resetPolicyChangeID], + dates: [Self.resetRegisteredAt, Self.resetConfirmedAt], + ) + defer { + oldFixture.cleanup() + newFixture.cleanup() + } + _ = try oldFixture.makeStore().confirmInitialRecording(isEnabled: true) + let replacement = try newFixture.makeStore().confirmInitialRecording(isEnabled: false) + try FileManager.default.copyItem(at: newFixture.fileURL, to: oldFixture.pendingURL) + + let restored = try oldFixture.makeStore().resolve() + + #expect(restored == replacement) + #expect(oldFixture.pendingExists == false) + } + + @Test func corruptPendingReplacementIsDiscardedWithoutLosingTheLastGoodContext() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + let confirmed = try fixture.makeStore().confirmInitialRecording(isEnabled: true) + try Data("not-json".utf8).write(to: fixture.pendingURL) + + let restored = try fixture.makeStore().resolve() + + #expect(restored == confirmed) + #expect(fixture.pendingExists == false) + } + + @Test func resetRemovesTheSidecarAndRotatesTheInstallationIdentity() throws { + let fixture = try makeFixture(ids: [ + Self.deviceID, + Self.policyChangeID, + Self.resetDeviceID, + ], dates: [ + Self.registeredAt, + Self.confirmedAt, + Self.resetRegisteredAt, + ]) + defer { fixture.cleanup() } + let store = fixture.makeStore() + _ = try store.confirmInitialRecording(isEnabled: true) + try store.recordOnboardingImportCompletion(.init(transactionID: UUID())) + + try store.reset() + + #expect(fixture.fileExists == false) + #expect(store.onboardingImportCompletion == nil) + #expect(store.onboardingContext.currentDevice.id.rawValue == Self.resetDeviceID) + #expect(store.onboardingContext.registeredAt == Self.resetRegisteredAt) + #expect(store.onboardingContext.initialRecordingChoice == nil) + } + + @Test func committedResetCleanupRetriesWithoutRestoringTheOldContextOrRotatingAgain() throws { + let fixture = try makeFixture(ids: [ + Self.deviceID, + Self.policyChangeID, + Self.resetDeviceID, + ], dates: [ + Self.registeredAt, + Self.confirmedAt, + Self.resetRegisteredAt, + ]) + defer { fixture.cleanup() } + let fileManager = FailingResetCleanupFileManager() + let store = fixture.makeStore(fileManager: fileManager) + _ = try store.confirmInitialRecording(isEnabled: true) + + #expect(throws: WhereServices.ResetCleanupError.self) { + try store.reset() + } + + let proposedAfterCommit = store.onboardingContext + #expect(fixture.fileExists == false) + #expect(fixture.resetPendingExists) + #expect(proposedAfterCommit.currentDevice.id.rawValue == Self.resetDeviceID) + #expect(throws: WhereServices.ResetCleanupError.self) { + try store.resolve() + } + + try store.reset() + + #expect(fixture.resetPendingExists == false) + #expect(try store.resolve() == proposedAfterCommit) + #expect(store.onboardingContext.currentDevice.id.rawValue == Self.resetDeviceID) + } + + private nonisolated static let deviceID = UUID( + uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + )! + private nonisolated static let policyChangeID = UUID( + uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + )! + private nonisolated static let resetDeviceID = UUID( + uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + )! + private nonisolated static let resetPolicyChangeID = UUID( + uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD", + )! + private nonisolated static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) + private nonisolated static let confirmedAt = Date(timeIntervalSinceReferenceDate: 200) + private nonisolated static let resetRegisteredAt = Date(timeIntervalSinceReferenceDate: 300) + private nonisolated static let resetConfirmedAt = Date(timeIntervalSinceReferenceDate: 400) + + private func makeFixture( + ids: [UUID] = [Self.deviceID, Self.policyChangeID], + dates: [Date] = [Self.registeredAt, Self.confirmedAt], + ) throws -> Fixture { + let directory = FileManager.default.temporaryDirectory + .appending(path: "InstallationRecordingContextStoreTests.\(UUID().uuidString)") + return Fixture( + directory: directory, + fileURL: directory.appending(path: "recording-installation-context.json"), + ids: ids, + dates: dates, + ) + } + + private final class IDSequence { + private var ids: [UUID] + + init(_ ids: [UUID]) { + self.ids = ids + } + + func next() -> UUID { + precondition(ids.isEmpty == false, "Fixture requested more UUIDs than provided.") + return ids.removeFirst() + } + } + + private final class DateSequence { + private var dates: [Date] + + init(_ dates: [Date]) { + self.dates = dates + } + + func next() -> Date { + precondition(dates.isEmpty == false, "Fixture requested more dates than provided.") + return dates.removeFirst() + } + } + + private struct Fixture { + let directory: URL + let fileURL: URL + let ids: [UUID] + let dates: [Date] + + var fileExists: Bool { + FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) + } + + var pendingURL: URL { + fileURL.appendingPathExtension("pending") + } + + var pendingExists: Bool { + FileManager.default.fileExists(atPath: pendingURL.path(percentEncoded: false)) + } + + var resetPendingURL: URL { + directory.appendingPathExtension("reset-pending") + } + + var resetPendingExists: Bool { + FileManager.default.fileExists( + atPath: resetPendingURL.path(percentEncoded: false), + ) + } + + @MainActor + func makeStore( + fileManager: FileManager = .default, + ) -> FileInstallationRecordingContextStore { + let sequence = IDSequence(ids) + let clock = DateSequence(dates) + return FileInstallationRecordingContextStore( + fileURL: fileURL, + fileManager: fileManager, + systemName: "iPhone", + kind: .phone, + makeUUID: sequence.next, + now: clock.next, + ) + } + + func cleanup() { + try? FileManager.default.removeItem(at: directory) + try? FileManager.default.removeItem(at: resetPendingURL) + } + } +} + +private final class FailingResetCleanupFileManager: FileManager, @unchecked Sendable { + private var shouldFailResetCleanup = true + + override func removeItem(at url: URL) throws { + if shouldFailResetCleanup, url.pathExtension == "reset-pending" { + shouldFailResetCleanup = false + throw CocoaError(.fileWriteUnknown) + } + try super.removeItem(at: url) + } +} diff --git a/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift new file mode 100644 index 00000000..604d591b --- /dev/null +++ b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +import WhereCore +@testable import WhereUI + +struct OnboardingRestoreSelectionTests { + @Test func requiresAnExplicitStrategyAndRecommendsMerge() { + var selection = OnboardingRestoreSelection( + url: URL(fileURLWithPath: "/tmp/where-backup.zip"), + hasScopedAccess: false, + ) + + #expect(selection.strategy == nil) + #expect(OnboardingRestoreSelection.recommendedStrategy == .merge) + + selection.choose(.merge) + + #expect(selection.strategy == .merge) + } + + @Test func preservesAnExplicitReplaceChoice() { + var selection = OnboardingRestoreSelection( + url: URL(fileURLWithPath: "/tmp/where-backup.zip"), + hasScopedAccess: false, + ) + + selection.choose(.replace) + + #expect(selection.strategy == .replace) + } + + @Test func committedImportRetainsItsBoundaryAndCannotReturnToSelection() throws { + var selection = OnboardingRestoreSelection( + url: URL(fileURLWithPath: "/tmp/where-backup.zip"), + hasScopedAccess: false, + ) + selection.choose(.merge) + let ready = try #require(selection.readyImport) + + selection.markCommitted(Self.summary) + selection.discardUncommittedSelection() + + #expect(ready.url.lastPathComponent == "where-backup.zip") + #expect(ready.strategy == .merge) + #expect(selection.selectedURL == nil) + #expect(selection.strategy == nil) + #expect(selection.readyImport == nil) + #expect(selection.committedSummary == Self.summary) + } + + private static let summary = BackupCoordinator.ImportSummary( + sampleCount: 3, + evidenceCount: 2, + manualDayCount: 1, + dismissedIssueCount: 4, + trackedRegionCount: 5, + recordingDeviceCount: 2, + recordingPolicyChangeCount: 3, + ) +} diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index 4fe6f42a..3ad074a2 100644 --- a/Where/WhereUI/Tests/OnboardingTests.swift +++ b/Where/WhereUI/Tests/OnboardingTests.swift @@ -1,52 +1,78 @@ +import Foundation import Testing -@_spi(Testing) import WhereCore -import WhereUI +@_spi(Testing) @testable import WhereCore +@_spi(Testing) @testable import WhereUI @MainActor struct OnboardingModelTests { - @Test func hasOnboardedDefaultsFalse() { - let model = WhereModel( + @Test func freshInstallationStartsUnonboardedAndUnconfirmed() { + let model = makeModel( preferences: makePreferences(), - makeBootstrap: { UnusedBootstrap() }, - logSystem: .isolated(), + contextStore: unconfirmedContextStore(kind: .phone), ) + #expect(model.hasOnboarded == false) #expect(model.hasConfirmedRecordingChoice == false) + #expect(model.installationRecordingContext.recommendedRecordingEnabled) } - @Test func completeOnboardingPersists() { + @Test func confirmationPersistsChoiceAndPolicyTokenOutsidePreferences() throws { let preferences = makePreferences() - let model = WhereModel( - preferences: preferences, - makeBootstrap: { UnusedBootstrap() }, - logSystem: .isolated(), - ) + let contextStore = unconfirmedContextStore(kind: .tablet) + let model = makeModel(preferences: preferences, contextStore: contextStore) + + let confirmed = try model.confirmInitialRecordingChoice(isEnabled: false) model.completeOnboarding() + #expect(model.hasOnboarded) #expect(model.hasConfirmedRecordingChoice) + #expect(confirmed.initialRecordingChoice?.isEnabled == false) + #expect(confirmed.initialRecordingChoice?.policyChangeID != nil) - // A fresh model over the same preferences sees onboarding as done. - let relaunched = WhereModel( - preferences: preferences, - makeBootstrap: { UnusedBootstrap() }, - logSystem: .isolated(), - ) + let relaunched = makeModel(preferences: preferences, contextStore: contextStore) #expect(relaunched.hasOnboarded) #expect(relaunched.hasConfirmedRecordingChoice) + #expect(relaunched.installationRecordingContext == confirmed) } - @Test func recordingChoiceCanBeConfirmedWithoutRepeatingOnboarding() { - let preferences = makePreferences() - preferences.hasOnboarded = true - let model = WhereModel( + @Test func restoredOnboardingFlagDoesNotConfirmANewInstallation() { + let restoredPreferences = makePreferences() + restoredPreferences.hasOnboarded = true + let model = makeModel( + preferences: restoredPreferences, + contextStore: unconfirmedContextStore(kind: .tablet), + ) + + #expect(model.hasOnboarded) + #expect(model.hasConfirmedRecordingChoice == false) + #expect(model.installationRecordingContext.recommendedRecordingEnabled == false) + } + + private func makeModel( + preferences: WherePreferences, + contextStore: InMemoryInstallationRecordingContextStore, + ) -> WhereModel { + WhereModel( preferences: preferences, - makeBootstrap: { UnusedBootstrap() }, + installationContextStore: contextStore, + makeBootstrap: { _ in UnusedBootstrap() }, logSystem: .isolated(), ) + } - model.confirmRecordingChoice() - - #expect(model.hasOnboarded) - #expect(model.hasConfirmedRecordingChoice) + private func unconfirmedContextStore( + kind: RecordingDeviceKind, + ) -> InMemoryInstallationRecordingContextStore { + InMemoryInstallationRecordingContextStore( + context: InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: kind == .tablet ? "iPad" : "iPhone", + kind: kind, + ), + registeredAt: Date(timeIntervalSinceReferenceDate: 0), + initialRecordingChoice: nil, + ), + ) } } diff --git a/Where/WhereUI/Tests/Support/TestInstallationRecordingContext.swift b/Where/WhereUI/Tests/Support/TestInstallationRecordingContext.swift new file mode 100644 index 00000000..69ffc1c7 --- /dev/null +++ b/Where/WhereUI/Tests/Support/TestInstallationRecordingContext.swift @@ -0,0 +1,9 @@ +@_spi(Testing) import WhereCore +@_spi(Testing) import WhereUI + +@MainActor +func makeInstallationRecordingContextStore( + context: InstallationRecordingContext = .testing, +) -> InMemoryInstallationRecordingContextStore { + InMemoryInstallationRecordingContextStore(context: context) +} diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 80ebe676..78b77244 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -8,6 +8,10 @@ struct ManualSaveFailure: Error, Equatable {} /// year-report load can be forced to fail. struct SampleReadFailure: Error, Equatable {} +/// Thrown by the Devices settings save-failure hooks below. +struct RecordingDeviceSaveFailure: Error, Equatable {} +struct RecordingPolicySaveFailure: Error, Equatable {} + /// Test `WhereStore` that forwards to an in-memory `SwiftDataStore` but adds /// hooks the view-model tests need: /// @@ -16,6 +20,10 @@ struct SampleReadFailure: Error, Equatable {} /// of order (the stale-year race). /// - `gateRecordingDevices(afterCalls:)` suspends a selected device read after /// capturing its result, so a committed change can race an initial load. +/// - `gateNextRecordingPolicyWrite()` suspends one recording-policy write, so +/// the Devices model can accept a newer toggle while the first is in flight. +/// - `failNextRecordingDeviceWrite()` / `failNextRecordingPolicyWrite()` make +/// one Devices save fail without contaminating later retry assertions. /// - `failManualDays()` makes `setManualDay` throw, so manual-entry error /// handling is exercisable without a real persistence fault. /// @@ -33,8 +41,15 @@ actor TestStore: WhereStore { private var recordingDevicesGate: CheckedContinuation? private var recordingDevicesArrival: CheckedContinuation? + private var shouldGateNextRecordingPolicyWrite = false + private var recordingPolicyWriteGateReached = false + private var recordingPolicyWriteGate: CheckedContinuation? + private var recordingPolicyWriteArrival: CheckedContinuation? + private var shouldFailManualDay = false private var shouldFailSamples = false + private var shouldFailNextRecordingDeviceWrite = false + private var shouldFailNextRecordingPolicyWrite = false init() throws { backing = try SwiftDataStore.inMemory() @@ -74,6 +89,29 @@ actor TestStore: WhereStore { recordingDevicesGate = nil } + func gateNextRecordingPolicyWrite() { + shouldGateNextRecordingPolicyWrite = true + recordingPolicyWriteGateReached = false + } + + func awaitRecordingPolicyWriteGate() async { + guard !recordingPolicyWriteGateReached else { return } + await withCheckedContinuation { recordingPolicyWriteArrival = $0 } + } + + func releaseRecordingPolicyWriteGate() { + recordingPolicyWriteGate?.resume() + recordingPolicyWriteGate = nil + } + + func failNextRecordingDeviceWrite() { + shouldFailNextRecordingDeviceWrite = true + } + + func failNextRecordingPolicyWrite() { + shouldFailNextRecordingPolicyWrite = true + } + func failManualDays() { shouldFailManualDay = true } @@ -94,6 +132,39 @@ actor TestStore: WhereStore { backing.changes() } + func dataEpoch() async throws -> WhereDataEpoch { + try await backing.dataEpoch() + } + + func rotateDataEpoch( + reason: WhereDataEpochReason, + changedBy deviceID: RecordingDeviceID, + at date: Date, + ) async throws -> WhereDataEpoch { + try await backing.rotateDataEpoch(reason: reason, changedBy: deviceID, at: date) + } + + func backupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws -> BackupImportReceipt? { + try await backing.backupImportReceipt(id: id, installationID: installationID) + } + + func addBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws { + try await backing.addBackupImportReceipt(id: id, installationID: installationID) + } + + func removeBackupImportReceipt( + id: UUID, + installationID: RecordingDeviceID, + ) async throws { + try await backing.removeBackupImportReceipt(id: id, installationID: installationID) + } + func add(sample: LocationSample) async throws { try await backing.add(sample: sample) } @@ -129,8 +200,34 @@ actor TestStore: WhereStore { return devices } - func setRecordingDevice(_ device: RecordingDevice) async throws { - try await backing.setRecordingDevice(device) + func recordingDeviceProfiles() async throws -> [RecordingDeviceProfile] { + try await backing.recordingDeviceProfiles() + } + + func addRecordingDeviceProfile(_ profile: RecordingDeviceProfile) async throws { + try await backing.addRecordingDeviceProfile(profile) + } + + func recordingDeviceMetadataChanges() async throws -> [RecordingDeviceMetadataChange] { + try await backing.recordingDeviceMetadataChanges() + } + + func addRecordingDeviceMetadataChange( + _ change: RecordingDeviceMetadataChange, + ) async throws { + if shouldFailNextRecordingDeviceWrite { + shouldFailNextRecordingDeviceWrite = false + throw RecordingDeviceSaveFailure() + } + try await backing.addRecordingDeviceMetadataChange(change) + } + + func recordingDeviceCheckIns() async throws -> [RecordingDeviceCheckIn] { + try await backing.recordingDeviceCheckIns() + } + + func setRecordingDeviceCheckIn(_ checkIn: RecordingDeviceCheckIn) async throws { + try await backing.setRecordingDeviceCheckIn(checkIn) } func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { @@ -138,6 +235,17 @@ actor TestStore: WhereStore { } func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { + if shouldFailNextRecordingPolicyWrite { + shouldFailNextRecordingPolicyWrite = false + throw RecordingPolicySaveFailure() + } + if shouldGateNextRecordingPolicyWrite { + shouldGateNextRecordingPolicyWrite = false + recordingPolicyWriteGateReached = true + recordingPolicyWriteArrival?.resume() + recordingPolicyWriteArrival = nil + await withCheckedContinuation { recordingPolicyWriteGate = $0 } + } try await backing.addRecordingPolicyChange(change) } @@ -181,10 +289,6 @@ actor TestStore: WhereStore { try await backing.clear(in: interval, manualDays: dayRange) } - func clearAll() async throws { - try await backing.clearAll() - } - func dismissedIssueIDs() async throws -> Set { try await backing.dismissedIssueIDs() } diff --git a/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift b/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift index 5ea2ce85..c0a116d2 100644 --- a/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift +++ b/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift @@ -9,7 +9,6 @@ #expect(world.scope.logStore != nil) #expect(world.scope.preferences.hasOnboarded) - #expect(world.scope.preferences.wantsTracking) #expect(world.model.isInDemoMode == false) #expect(world.model.activeScope !== world.scope) #expect(world.report.report?.days.isEmpty == false) diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index c37de898..c995e83f 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -91,6 +91,24 @@ struct WhereFormatTests { ) } + @Test func backupCleanupMessagePreservesSummaryAndSafeRecoveryGuidance() { + let summary = BackupCoordinator.ImportSummary( + sampleCount: 3, + evidenceCount: 2, + manualDayCount: 5, + dismissedIssueCount: 4, + trackedRegionCount: 6, + recordingDeviceCount: 2, + recordingPolicyChangeCount: 7, + ) + + let message = WhereFormat.backupImportCleanupMessage(summary) + + #expect(message.contains("Imported 3 location samples")) + #expect(message.contains("Close and reopen Where")) + #expect(message.contains("Do not import this backup again.")) + } + @Test func yearTitlesFormatGroupingFree() { #expect(WhereFormat.evidenceListTitle(year: 2026) == "Evidence · 2026") #expect(WhereFormat.loggedDaysTitle(year: 2026) == "Logged Days · 2026") diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index b45789b8..699ba7b7 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -5,10 +5,30 @@ import RegionKit import TestHostSupport import Testing @_spi(Testing) import WhereCore -@_spi(Testing) import WhereUI +@_spi(Testing) @testable import WhereUI private struct WaitTimeout: Error {} +/// Records destructive outbox cleanup without relying on timing. Launch tests read the count +/// from the services-ready hook to prove recovery completed before the later recording step. +private actor LaunchImportOutbox: LocationOutbox { + private var clearCount = 0 + + func load() async throws -> [LocationOutboxEntry] { + [] + } + + func save(_: [LocationOutboxEntry]) async {} + + func clear() async throws { + clearCount += 1 + } + + func numberOfClears() -> Int { + clearCount + } +} + /// Polls `predicate` on the main actor until it holds or the timeout elapses, /// yielding to the launcher's drive task between checks. @MainActor @@ -104,12 +124,16 @@ struct WhereLaunchTests { private func makeLoggedOutModel( status: LocationAuthorizationStatus = .always, preferences: WherePreferences, + installationContextStore: InMemoryInstallationRecordingContextStore? = nil, ) throws -> (WhereModel, ScriptedBootstrap) { + let installationContextStore = installationContextStore + ?? makeInstallationRecordingContextStore() let bootstrap = try ScriptedBootstrap(services: makeServices(status: status)) return ( WhereModel( preferences: preferences, - makeBootstrap: { bootstrap }, + installationContextStore: installationContextStore, + makeBootstrap: { _ in bootstrap }, logSystem: .isolated(), ), bootstrap, @@ -242,6 +266,8 @@ struct WhereLaunchTests { #expect(model.session == nil) // Resolve the gate as OnboardingView would, letting the launch finish. + try model.confirmInitialRecordingChoice(isEnabled: true) + model.completeOnboarding() launcher.phase.gateHandle?.complete() await task.value #expect(launcher.phase.isReady) @@ -250,6 +276,217 @@ struct WhereLaunchTests { #expect(launcher.phase.readyValue === model.session) } + @Test func coldPreparedOnboardingImportWithoutReceiptReturnsToOnboarding() async throws { + let installationStore = makeInstallationRecordingContextStore() + let details = Self.onboardingRecoveryDetails() + try installationStore.setBackupImportRecovery(.prepared(details)) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + importRecoveryPersistence: installationStore.backupImportRecoveryPersistence, + ) + let bootstrap = ScriptedBootstrap(services: services) + let model = WhereModel( + preferences: makePreferences(), + installationContextStore: installationStore, + makeBootstrap: { _ in bootstrap }, + logSystem: .isolated(), + ) + + let isNeeded = await OnboardingGate(model: model).isNeeded(()) + + #expect(isNeeded) + #expect(!model.hasOnboarded) + #expect(model.activeScope == nil) + #expect(installationStore.backupImportRecovery == nil) + } + + @Test func coldCommittedOnboardingImportCompletesBeforeRestoreCanReappear() async throws { + let installationStore = makeInstallationRecordingContextStore() + let details = Self.onboardingRecoveryDetails() + try installationStore.setBackupImportRecovery(.committed( + details, + cleanupCompleted: true, + onboardingAcknowledged: false, + )) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + importRecoveryPersistence: installationStore.backupImportRecoveryPersistence, + ) + let bootstrap = ScriptedBootstrap(services: services) + let model = WhereModel( + preferences: makePreferences(), + installationContextStore: installationStore, + makeBootstrap: { _ in bootstrap }, + logSystem: .isolated(), + ) + + let isNeeded = await OnboardingGate(model: model).isNeeded(()) + + #expect(!isNeeded) + #expect(model.hasOnboarded) + #expect(model.activeScope != nil) + #expect(installationStore.backupImportRecovery == nil) + } + + @Test func coldOnboardingRecoveryOpenFailureNeverFallsThroughToRestore() async throws { + let installationStore = makeInstallationRecordingContextStore() + try installationStore.setBackupImportRecovery(.committed( + Self.onboardingRecoveryDetails(), + cleanupCompleted: false, + onboardingAcknowledged: false, + )) + let model = WhereModel( + preferences: makePreferences(), + installationContextStore: installationStore, + makeBootstrap: { _ in FailingBootstrap() }, + logSystem: .isolated(), + ) + + let isNeeded = await OnboardingGate(model: model).isNeeded(()) + + #expect(!isNeeded) + #expect(!model.hasOnboarded) + #expect(model.takeInterruptedOnboardingImportError() is FailingBootstrap.AssemblyFailure) + #expect(installationStore.backupImportRecovery != nil) + } + + @Test func coldPreparedSettingsImportRollsBackBeforeRecordingRegistration() async throws { + let installationStore = makeInstallationRecordingContextStore() + let details = Self.settingsRecoveryDetails(strategy: .replace) + let installationID = installationStore.onboardingContext.currentDevice.id + try installationStore.setBackupImportRecovery(.prepared(details)) + let store = try SwiftDataStore.inMemory() + let outbox = LaunchImportOutbox() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + installationContext: installationStore.onboardingContext, + locationOutbox: outbox, + importRecoveryPersistence: installationStore.backupImportRecoveryPersistence, + ) + let model = WhereModel( + preferences: makePreferences(), + installationContextStore: installationStore, + makeBootstrap: { _ in ScriptedBootstrap(services: services) }, + logSystem: .isolated(), + ) + model.completeOnboarding() + + var recoveryAtHandoff: BackupCoordinator.DurableImportRecovery? + var profilesAtHandoff: [RecordingDeviceProfile]? + var clearCountAtHandoff: Int? + let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) { _ in + recoveryAtHandoff = installationStore.backupImportRecovery + profilesAtHandoff = try? await store.recordingDeviceProfiles() + clearCountAtHandoff = await outbox.numberOfClears() + } + + await launcher.run() + + #expect(launcher.phase.isReady) + #expect(recoveryAtHandoff == nil) + #expect(profilesAtHandoff?.isEmpty == true) + // A prepared Replace with no receipt rolled back, so its old outbox was not destroyed. + #expect(clearCountAtHandoff == 0) + #expect(try await store.recordingDeviceProfiles().map(\.id) == [ + installationID, + ]) + #expect(model.session?.isTracking == true) + } + + @Test func coldCommittedSettingsReplaceCleansBeforeRecordingRegistration() async throws { + let installationStore = makeInstallationRecordingContextStore() + let details = Self.settingsRecoveryDetails(strategy: .replace) + let installationID = installationStore.onboardingContext.currentDevice.id + try installationStore.setBackupImportRecovery(.committed( + details, + cleanupCompleted: false, + onboardingAcknowledged: true, + )) + let store = try SwiftDataStore.inMemory() + try await store.perform { + try await store.addBackupImportReceipt( + id: details.transactionID, + installationID: installationID, + ) + } + let outbox = LaunchImportOutbox() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + installationContext: installationStore.onboardingContext, + locationOutbox: outbox, + importRecoveryPersistence: installationStore.backupImportRecoveryPersistence, + ) + let model = WhereModel( + preferences: makePreferences(), + installationContextStore: installationStore, + makeBootstrap: { _ in ScriptedBootstrap(services: services) }, + logSystem: .isolated(), + ) + model.completeOnboarding() + + var recoveryAtHandoff: BackupCoordinator.DurableImportRecovery? + var profilesAtHandoff: [RecordingDeviceProfile]? + var clearCountAtHandoff: Int? + var receiptAtHandoff: BackupImportReceipt? + let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) { _ in + recoveryAtHandoff = installationStore.backupImportRecovery + profilesAtHandoff = try? await store.recordingDeviceProfiles() + clearCountAtHandoff = await outbox.numberOfClears() + receiptAtHandoff = try? await store.backupImportReceipt( + id: details.transactionID, + installationID: installationID, + ) + } + + await launcher.run() + + #expect(launcher.phase.isReady) + #expect(recoveryAtHandoff == nil) + #expect(profilesAtHandoff?.isEmpty == true) + #expect(clearCountAtHandoff == 1) + #expect(receiptAtHandoff == nil) + #expect(try await store.recordingDeviceProfiles().map(\.id) == [ + installationID, + ]) + #expect(model.session?.isTracking == true) + } + + private static func onboardingRecoveryDetails() -> BackupCoordinator.ImportRecoveryDetails { + BackupCoordinator.ImportRecoveryDetails( + transactionID: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, + strategy: .merge, + summary: BackupCoordinator.ImportSummary( + sampleCount: 3, + evidenceCount: 2, + manualDayCount: 1, + dismissedIssueCount: 0, + trackedRegionCount: 4, + ), + purpose: .onboarding, + ) + } + + private static func settingsRecoveryDetails( + strategy: BackupCoordinator.ImportStrategy, + ) -> BackupCoordinator.ImportRecoveryDetails { + BackupCoordinator.ImportRecoveryDetails( + transactionID: UUID(), + strategy: strategy, + summary: BackupCoordinator.ImportSummary( + sampleCount: 3, + evidenceCount: 2, + manualDayCount: 1, + dismissedIssueCount: 0, + trackedRegionCount: 4, + ), + purpose: .settings, + ) + } + @Test func headlessFirstRunParksRatherThanOpeningTheStore() async throws { // A launch nobody can see must not open the user's store on their // behalf: the gate applies to every reason, so an `.undetermined` @@ -268,6 +505,8 @@ struct WhereLaunchTests { try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } #expect(bootstrap.makeServicesCount == 0) + try model.confirmInitialRecordingChoice(isEnabled: true) + model.completeOnboarding() launcher.phase.gateHandle?.complete() await promote.value await task.value @@ -286,10 +525,24 @@ struct WhereLaunchTests { #expect(bootstrap.makeServicesCount == 1) } - @Test func existingInstallationParksForItsRecordingChoiceBeforeOpening() async throws { + @Test func restoredInstallationParksForItsRecordingChoiceBeforeOpening() async throws { let preferences = makePreferences() preferences.hasOnboarded = true - let (model, bootstrap) = try makeLoggedOutModel(preferences: preferences) + let installationContextStore = InMemoryInstallationRecordingContextStore( + context: InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: "iPad", + kind: .tablet, + ), + registeredAt: Date(timeIntervalSinceReferenceDate: 0), + initialRecordingChoice: nil, + ), + ) + let (model, bootstrap) = try makeLoggedOutModel( + preferences: preferences, + installationContextStore: installationContextStore, + ) let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) let task = Task { @MainActor in await launcher.run() } @@ -298,7 +551,7 @@ struct WhereLaunchTests { #expect(model.hasConfirmedRecordingChoice == false) #expect(bootstrap.makeServicesCount == 0) - model.confirmRecordingChoice() + try model.confirmInitialRecordingChoice(isEnabled: false) launcher.phase.gateHandle?.complete() await task.value @@ -312,10 +565,10 @@ struct WhereLaunchTests { // reading as a launch that simply never finished. let preferences = makePreferences() preferences.hasOnboarded = true - preferences.hasConfirmedRecordingChoice = true let model = WhereModel( preferences: preferences, - makeBootstrap: { FailingBootstrap() }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in FailingBootstrap() }, logSystem: .isolated(), ) @@ -358,7 +611,8 @@ struct WhereLaunchTests { let logSystem = Periscope.isolated() let model = WhereModel( preferences: preferences, - makeBootstrap: { ScriptedBootstrap(services: services) }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in ScriptedBootstrap(services: services) }, logSystem: logSystem, ) model.activate(scope: .fake( @@ -396,7 +650,8 @@ struct WhereLaunchTests { let logSystem = Periscope.isolated() let model = WhereModel( preferences: preferences, - makeBootstrap: { ScriptedBootstrap(services: services) }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in ScriptedBootstrap(services: services) }, logSystem: logSystem, ) model.activate(scope: .fake( @@ -427,6 +682,7 @@ struct WhereLaunchTests { // so a relaunch parked in onboarding has handed nothing to consumers. #expect(hookFires == 1) + try model.confirmInitialRecordingChoice(isEnabled: true) model.completeOnboarding() launcher.phase.gateHandle?.complete() await teardown.value @@ -434,17 +690,39 @@ struct WhereLaunchTests { #expect(hookFires == 2) } - @Test func backgroundLaunchSkipsOnboardingAndReachesReady() async throws { - // Not onboarded — but a headless background launch must skip the - // foreground-only onboarding step (waiting for a tap with no UI would - // deadlock) and still run the rest. - let model = try makeModel(status: .always, preferences: makePreferences()) + @Test func backgroundLaunchParksUntilTheInstallationIsConfirmed() async throws { + // A headless launch must not open the store or infer consent for a new/restored + // installation. It parks until a later foreground UI confirms the choice. + let context = InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: "iPad", + kind: .tablet, + ), + registeredAt: Date(timeIntervalSinceReferenceDate: 0), + initialRecordingChoice: nil, + ) + let (model, bootstrap) = try makeLoggedOutModel( + status: .always, + preferences: makePreferences(), + installationContextStore: makeInstallationRecordingContextStore(context: context), + ) #expect(!model.hasOnboarded) let launcher = WhereLaunch.makeLauncher(model: model, reason: .background(.location)) - await launcher.run() - #expect(launcher.phase.isReady) + let run = Task { @MainActor in await launcher.run() } + + try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } + #expect(bootstrap.makeServicesCount == 0) + #expect(model.session == nil) #expect(launcher.reason.buildsNoViewTree) - // The minimal background steps still ran (reconcile-tracking resumed GPS). + + try model.confirmInitialRecordingChoice(isEnabled: true) + model.completeOnboarding() + launcher.phase.gateHandle?.complete() + await run.value + + #expect(launcher.phase.isReady) + #expect(bootstrap.makeServicesCount == 1) #expect(model.session?.isTracking == true) } } diff --git a/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift b/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift new file mode 100644 index 00000000..c739177c --- /dev/null +++ b/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift @@ -0,0 +1,79 @@ +import Foundation +import LifecycleKit +import Testing +@_spi(Testing) @testable import WhereCore +@testable import WhereUI + +struct WhereLifecycleFailureViewTests { + private static let summary = BackupCoordinator.ImportSummary( + sampleCount: 3, + evidenceCount: 2, + manualDayCount: 5, + dismissedIssueCount: 4, + trackedRegionCount: 6, + recordingDeviceCount: 2, + recordingPolicyChangeCount: 7, + ) + + @Test func committedImportCleanupPreservesSummaryInADedicatedPresentation() throws { + let failure = LifecycleFailure( + stepID: "onboarding", + error: BackupCoordinator.CommittedImportCleanupError( + strategy: .replace, + summary: Self.summary, + underlying: TestFailure(), + ), + ) + + let presentation = try #require(WhereLifecycleFailurePresentation(failure: failure)) + + #expect(presentation == .committedImportCleanup(Self.summary)) + #expect(presentation.title == "Backup imported; cleanup incomplete") + #expect(presentation.message.contains("Imported 3 location samples")) + #expect(presentation.message.contains("Do not import this backup again.")) + } + + @Test func committedResetCleanupUsesDedicatedCommittedResetCopy() throws { + let failure = LifecycleFailure( + stepID: "erase-data", + error: WhereServices.ResetCleanupError(underlying: TestFailure()), + ) + + let presentation = try #require(WhereLifecycleFailurePresentation(failure: failure)) + + #expect(presentation == .committedResetCleanup) + #expect(presentation.title == "Data erased; cleanup incomplete") + #expect(presentation.message.contains("Your synced data was erased")) + #expect(presentation.message.contains("Close and reopen Where")) + } + + @Test func committedOnboardingImportSetupFailurePreservesTheBoundary() throws { + let failure = LifecycleFailure( + stepID: "onboarding", + error: OnboardingCommittedImportSetupError( + summary: Self.summary, + underlying: TestFailure(), + ), + ) + + let presentation = try #require(WhereLifecycleFailurePresentation(failure: failure)) + + #expect(presentation == .committedImportSetup(Self.summary)) + #expect(presentation.title == "Backup imported; setup incomplete") + #expect(presentation.message.contains("Imported 3 location samples")) + #expect(presentation.message.contains("setup will retry automatically")) + #expect(presentation.message.contains("Do not import this backup again.")) + } + + @Test func ordinaryLaunchFailureUsesLifecycleKitsGenericPresentation() { + let failure = LifecycleFailure(stepID: "open-store", error: TestFailure()) + + #expect(WhereLifecycleFailurePresentation(failure: failure) == nil) + } +} + +private struct TestFailure: LocalizedError { + var errorDescription: String? { + "Test failure" + } +} diff --git a/Where/WhereUI/Tests/WhereModelTests.swift b/Where/WhereUI/Tests/WhereModelTests.swift index 9ec3f683..23df8716 100644 --- a/Where/WhereUI/Tests/WhereModelTests.swift +++ b/Where/WhereUI/Tests/WhereModelTests.swift @@ -44,7 +44,8 @@ struct WhereModelTests { bootstrap.gateLogStore() let model = WhereModel( preferences: makePreferences(), - makeBootstrap: { bootstrap }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in bootstrap }, logSystem: .isolated(), ) @@ -67,7 +68,8 @@ struct WhereModelTests { let bootstrap = try FailingLogStoreBootstrap(services: makeServices()) let model = WhereModel( preferences: makePreferences(), - makeBootstrap: { bootstrap }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in bootstrap }, logSystem: .isolated(), ) @@ -89,7 +91,8 @@ struct WhereModelTests { let bootstrap = try ScriptedBootstrap(services: makeServices()) let model = WhereModel( preferences: makePreferences(), - makeBootstrap: { bootstrap }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in bootstrap }, logSystem: .isolated(), ) diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 31ed876b..d1882431 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -1,5 +1,6 @@ import Foundation import LifecycleKit +import RegionKit import TestHostSupport import Testing @_spi(Testing) import WhereCore @@ -89,15 +90,17 @@ struct WhereResetTests { // (the editing surface, `RemindersSettingsModel`, writes here). preferences.remindersEnabled = false preferences.summaryEnabled = false + let originalInstallationID = model.installationRecordingContext.currentDevice.id #expect(model.hasOnboarded) #expect(model.hasConfirmedRecordingChoice) - model.resetPreferences() + try model.resetPreferences() - // Removing the keys lets the default-valued getters report first-install - // state again: onboarding returns and reminders/summary default back on. + // Removing the sidecar and keys restores a real first-install state: + // onboarding returns with a new identity and schedules default back on. #expect(model.hasOnboarded == false) #expect(model.hasConfirmedRecordingChoice == false) + #expect(model.installationRecordingContext.currentDevice.id != originalInstallationID) #expect(preferences.remindersEnabled) #expect(preferences.summaryEnabled) } @@ -155,7 +158,8 @@ struct WhereResetTests { let bootstrap = try ScriptedBootstrap(services: makeServices()) let model = WhereModel( preferences: preferences, - makeBootstrap: { bootstrap }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in bootstrap }, logSystem: .isolated(), ) model.completeOnboarding() @@ -175,6 +179,8 @@ struct WhereResetTests { #expect(model.activeScope == nil) #expect(bootstrap.makeServicesCount == 1) + try model.confirmInitialRecordingChoice(isEnabled: true) + model.completeOnboarding() launcher.phase.gateHandle?.complete() await task.value @@ -223,6 +229,8 @@ struct WhereResetTests { } } try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } + try model.confirmInitialRecordingChoice(isEnabled: true) + model.completeOnboarding() launcher.phase.gateHandle?.complete() await task.value #expect(launcher.phase.isReady) @@ -272,10 +280,12 @@ struct WhereResetTests { #expect(model.hasConfirmedRecordingChoice == false) #expect(model.session == nil) #expect(launcher.phase.gateHandle != nil) - // The erase quiesced GPS before wiping, so the torn-down session is no - // longer tracking and can't write into the store as it's cleared. + // The erase paused GPS before its transaction, so the torn-down session is no + // longer tracking and can't write while user data is cleared. #expect(!session.isTracking) + try model.confirmInitialRecordingChoice(isEnabled: true) + model.completeOnboarding() launcher.phase.gateHandle?.complete() await task.value @@ -283,7 +293,7 @@ struct WhereResetTests { // Resolving the gate rebuilt a fresh session over the erased scope. let rebuilt = try #require(model.session) #expect(rebuilt !== session) - // The store was wiped: a fresh report read against it finds nothing. + // Synced user data was erased: a fresh report read against it finds nothing. await report.refresh() #expect(report.trackedDayCount == 0) } @@ -322,12 +332,14 @@ struct WhereResetTests { #expect(preferences.remindersEnabled) #expect(preferences.summaryEnabled) + try model.confirmInitialRecordingChoice(isEnabled: true) + model.completeOnboarding() launcher.phase.gateHandle?.complete() await task.value #expect(launcher.phase.isReady) let rebuilt = try #require(model.session) #expect(rebuilt !== original) - // The store was wiped: a fresh report read against it is empty. + // Synced user data was erased: a fresh report read against it is empty. await report.refresh() #expect(report.trackedDayCount == 0) } @@ -352,6 +364,87 @@ struct WhereResetTests { #expect(model.hasOnboarded) // reset-preferences never ran #expect(model.hasConfirmedRecordingChoice) } + + @Test func committedCleanupFailureLogsOutWhileKeepingInstallationContextForRetry() async throws { + let outbox = ResetLocationOutbox() + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(authorizationStatus: .always), + reminderScheduler: NoopLoggingReminderScheduler(), + summaryScheduler: NoopDailySummaryScheduler(), + issueAlertScheduler: NoopDataIssueAlertScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + locationOutbox: outbox, + ) + let model = WhereModel( + services: services, + preferences: makePreferences(), + logSystem: .isolated(), + ) + model.completeOnboarding() + var logOuts = 0 + model.onLoggedOut = { logOuts += 1 } + let installationID = model.installationRecordingContext.currentDevice.id + let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) + await launcher.run() + let session = try #require(model.session) + await outbox.save([LocationOutboxEntry( + sample: LocationSample( + timestamp: Date(), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsVisit, + recordingDeviceID: installationID, + ), + dataEpochID: .initial, + )]) + await outbox.setFailsToClear(true) + + await launcher.teardown(WhereLaunch.resetPlan(for: model), input: session) + + #expect(launcher.phase.failed(at: LaunchStepID.eraseData)) + #expect(launcher.phase.failure?.error is WhereServices.ResetCleanupError) + #expect(model.hasOnboarded) + #expect(model.hasConfirmedRecordingChoice) + #expect(model.installationRecordingContext.currentDevice.id == installationID) + #expect(model.session == nil) + #expect(model.activeScope == nil) + #expect(logOuts == 1) + #expect(await outbox.samples.count == 1) + } + + @Test func committedInstallationCleanupFailureLogsOutAndUsesResetCleanupError() async throws { + let services = try makeServices() + let preferences = makePreferences() + let contextStore = CommittedFailingResetInstallationContextStore(context: .testing) + let bootstrap = ScriptedBootstrap(services: services) + let model = WhereModel( + preferences: preferences, + installationContextStore: contextStore, + makeBootstrap: { _ in bootstrap }, + logSystem: .isolated(), + ) + model.completeOnboarding() + var logOuts = 0 + model.onLoggedOut = { logOuts += 1 } + let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) + await launcher.run() + let session = try #require(model.session) + let report = YearReportModel(services: services, preferences: preferences) + try await report.setManualDay(date: Date(), regions: [.california]) + + await launcher.teardown(WhereLaunch.resetPlan(for: model), input: session) + + #expect(launcher.phase.failed(at: LaunchStepID.resetPreferences)) + #expect(launcher.phase.failure?.error is WhereServices.ResetCleanupError) + #expect(model.session == nil) + #expect(model.activeScope == nil) + #expect(logOuts == 1) + #expect(model.hasOnboarded == false) + #expect(model.hasConfirmedRecordingChoice == false) + await report.refresh() + #expect(report.trackedDayCount == 0) + } } /// An erase stand-in that always throws, so the teardown-failure path can be @@ -372,6 +465,81 @@ private struct ResetPreferencesProbeStep: LifecycleStep { let id = LaunchStepID.resetPreferences func run(_: Void, _: LifecycleStepContext) async throws { - model.resetPreferences() + try model.resetPreferences() + } +} + +private actor ResetLocationOutbox: LocationOutbox { + private(set) var entries: [LocationOutboxEntry] = [] + private var failsToClear = false + + func load() async throws -> [LocationOutboxEntry] { + entries + } + + func save(_ entries: [LocationOutboxEntry]) async { + self.entries = entries + } + + func clear() async throws { + guard !failsToClear else { throw CocoaError(.fileWriteUnknown) } + entries.removeAll() + } + + func setFailsToClear(_ value: Bool) { + failsToClear = value + } + + var samples: [LocationSample] { + entries.map(\.sample) + } +} + +@MainActor +private final class CommittedFailingResetInstallationContextStore: + InstallationRecordingContextStoring +{ + private(set) var onboardingContext: InstallationRecordingContext + private(set) var backupImportRecovery: BackupCoordinator.DurableImportRecovery? + private(set) var onboardingImportCompletion: + BackupCoordinator.OnboardingImportCompletion? + + init(context: InstallationRecordingContext) { + onboardingContext = context + } + + func resolve() throws -> InstallationRecordingContext { + onboardingContext + } + + func confirmInitialRecording(isEnabled _: Bool) throws -> InstallationRecordingContext { + onboardingContext + } + + func setBackupImportRecovery( + _ recovery: BackupCoordinator.DurableImportRecovery?, + ) { + backupImportRecovery = recovery + } + + func recordOnboardingImportCompletion( + _ completion: BackupCoordinator.OnboardingImportCompletion, + ) { + onboardingImportCompletion = completion + } + + func reset() throws { + backupImportRecovery = nil + onboardingImportCompletion = nil + onboardingContext = InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: onboardingContext.currentDevice.systemName, + kind: onboardingContext.currentDevice.kind, + ), + registeredAt: Date(), + initialRecordingChoice: nil, + ) + throw WhereServices.ResetCleanupError(underlying: CocoaError(.fileWriteUnknown)) } } diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index a44ba71b..a7d1ae9a 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -7,9 +7,14 @@ import WhereUI /// Covers the launch-time reconciliation that fixes the "toggle is always off" /// and "Grant does nothing" bugs: tracking and the authorization indicator must -/// reflect real authorization + persisted intent, not just the last tap. +/// reflect real authorization plus the synced recording policy, not just the +/// last tap. @MainActor struct WhereSessionTrackingTests { + private static let disabledInitialPolicyChangeID = UUID( + uuidString: "00000000-0000-0000-0000-000000000003", + )! + private func makeSession( status: LocationAuthorizationStatus, preferences: WherePreferences, @@ -21,12 +26,15 @@ struct WhereSessionTrackingTests { private func makeSessionAndStore( status: LocationAuthorizationStatus, preferences: WherePreferences, + store: SwiftDataStore? = nil, + installationContext: InstallationRecordingContext = .testing, ) throws -> (WhereSession, ScriptedLocationSource, SwiftDataStore) { - let store = try SwiftDataStore.inMemory() + let store = try store ?? SwiftDataStore.inMemory() let source = ScriptedLocationSource(authorizationStatus: status) let services = WhereServices( store: store, locationSource: source, + installationContext: installationContext, reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), issueAlertScheduler: NoopDataIssueAlertScheduler(), @@ -36,6 +44,21 @@ struct WhereSessionTrackingTests { return (session, source, store) } + private func installationContext( + initialRecordingEnabled: Bool, + ) -> InstallationRecordingContext { + guard initialRecordingEnabled == false else { return .testing } + return InstallationRecordingContext( + currentDevice: InstallationRecordingContext.testing.currentDevice, + registeredAt: InstallationRecordingContext.testing.registeredAt, + initialRecordingChoice: .init( + isEnabled: false, + policyChangeID: Self.disabledInitialPolicyChangeID, + confirmedAt: Date(timeIntervalSinceReferenceDate: 1), + ), + ) + } + /// A one-shot fix stamped "now", so it lands on today's calendar day /// regardless of when the test runs. private func todayFix() -> LocationSample { @@ -73,16 +96,23 @@ struct WhereSessionTrackingTests { @Test func stoppingTrackingPersistsAcrossLaunches() async throws { let preferences = makePreferences() - let (session, _) = try makeSession(status: .always, preferences: preferences) + let (session, _, store) = try makeSessionAndStore( + status: .always, + preferences: preferences, + ) await session.start() #expect(session.isTracking) await session.stopTracking() #expect(!session.isTracking) - // A fresh session sharing the same preferences should stay paused even - // though authorization is still Always. - let (relaunched, _) = try makeSession(status: .always, preferences: preferences) + // A fresh session sharing the same store should stay paused even though + // authorization is still Always. + let (relaunched, _, _) = try makeSessionAndStore( + status: .always, + preferences: preferences, + store: store, + ) await relaunched.start() #expect(!relaunched.isTracking) } @@ -92,10 +122,11 @@ struct WhereSessionTrackingTests { let services = try WhereServices( store: SwiftDataStore.inMemory(), locationSource: source, + installationContext: installationContext(initialRecordingEnabled: false), ) let preferences = makePreferences() - preferences.wantsTracking = false let session = WhereSession(services: services, preferences: preferences) + await session.start() let enabling = Task { try await session.setRecordingEnabled( @@ -105,12 +136,12 @@ struct WhereSessionTrackingTests { } await waitUntil { source.isAwaitingPermission } - _ = try await session.setRecordingEnabled( + try await session.setRecordingEnabled( false, for: session.currentRecordingDeviceID, ) source.resolvePermission(as: .always) - _ = try await enabling.value + try await enabling.value let current = try #require( try await session.recordingDevices() @@ -119,7 +150,6 @@ struct WhereSessionTrackingTests { #expect(current.isEnabled == false) #expect(current.device.status == .off) #expect(session.isTracking == false) - #expect(preferences.wantsTracking == false) } @Test func grantingLaterStartsTrackingViaLiveUpdates() async throws { @@ -146,11 +176,10 @@ struct WhereSessionTrackingTests { let services = WhereServices( store: store, locationSource: source, - currentDevice: .preview, + installationContext: .testing, now: { now }, ) let preferences = makePreferences() - preferences.wantsTracking = true let session = WhereSession(services: services, preferences: preferences) await session.start() @@ -160,14 +189,26 @@ struct WhereSessionTrackingTests { let policyID = try #require( UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"), ) + let parentID = try #require(try await store.recordingPolicyChanges().first?.id) try await store.simulateRemoteRecordingImport( - devices: [], + profiles: [], + metadataChanges: [], + checkIns: [], policyChanges: [ RecordingPolicyChange( id: policyID, deviceID: session.currentRecordingDeviceID, + parentIDs: [parentID], + revision: 1, + issuedAt: now.addingTimeInterval(1), + issuedByDeviceID: RecordingDeviceID( + rawValue: #require(UUID( + uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", + )), + ), effectiveAt: now.addingTimeInterval(1), - isEnabled: false, + state: .off, + reason: .userCommand, ), ], ) @@ -188,7 +229,6 @@ struct WhereSessionTrackingTests { ) #expect(source.startCount == 1) #expect(source.stopCount == 1) - #expect(preferences.wantsTracking == false) #expect(current.status == .off) #expect(current.lastAppliedPolicyChangeID == policyID) } @@ -213,6 +253,7 @@ struct WhereSessionTrackingTests { preferences: makePreferences(), ) // The user turned tracking off; opening the app must not silently log. + await session.start() await session.stopTracking() source.setNextRequestedLocation(todayFix()) diff --git a/Where/install b/Where/install index 67bd84e8..1bb12ee4 100755 --- a/Where/install +++ b/Where/install @@ -30,6 +30,7 @@ OPTIMIZE=true # force compiler optimizations on regardless of configuration DEVICE="" # name, UDID, or identifier; empty = auto-pick the sole device LAUNCH=true ASSUME_YES=false +CLOUDKIT=false usage() { cat <<'USAGE' @@ -47,12 +48,15 @@ Options: --optimize Force compiler optimizations on (default) --no-optimize Build without forcing optimizations (use the configuration's own optimization level) + --cloudkit Build Debug against CloudKit instead of its normal + local-only store (persists across every relaunch) --no-launch Install only; don't launch the app afterwards -y, --yes Skip the "unlock your device" confirmation prompt -h, --help Show this help Examples: ./Where/install + ./Where/install --cloudkit ./Where/install --device "Kai's iPhone" ./Where/install --configuration Release ./Where/install --no-optimize --no-launch @@ -74,6 +78,7 @@ while [ $# -gt 0 ]; do --configuration) shift; require_value --configuration "${1:-}"; CONFIGURATION="$1" ;; --optimize) OPTIMIZE=true ;; --no-optimize) OPTIMIZE=false ;; + --cloudkit) CLOUDKIT=true ;; --no-launch) LAUNCH=false ;; -y|--yes) ASSUME_YES=true ;; -h|--help) usage; exit 0 ;; @@ -123,6 +128,14 @@ if [ "$OPTIMIZE" = true ]; then OPTIMIZATION_OVERRIDES=(SWIFT_OPTIMIZATION_LEVEL=-O GCC_OPTIMIZATION_LEVEL=s) fi +# CloudKit validation is a property of the installed Debug binary, not its first +# process invocation. iOS does not preserve custom argv for later foreground, +# background, or push-driven relaunches, while this compilation condition does. +CLOUDKIT_OVERRIDES=() +if [ "$CLOUDKIT" = true ]; then + CLOUDKIT_OVERRIDES=("SWIFT_ACTIVE_COMPILATION_CONDITIONS=\$(inherited) WHERE_CLOUDKIT_VALIDATION") +fi + # Build + sign for a generic iOS device. -allowProvisioningUpdates lets xcodebuild # create/download the profiles for the app and its extensions (App Groups, # location) instead of requiring them to exist already. @@ -134,7 +147,8 @@ mise exec -- xcodebuild build \ -destination 'generic/platform=iOS' \ -derivedDataPath "$DERIVED" \ -allowProvisioningUpdates \ - ${OPTIMIZATION_OVERRIDES[@]+"${OPTIMIZATION_OVERRIDES[@]}"} + ${OPTIMIZATION_OVERRIDES[@]+"${OPTIMIZATION_OVERRIDES[@]}"} \ + ${CLOUDKIT_OVERRIDES[@]+"${CLOUDKIT_OVERRIDES[@]}"} APP="$DERIVED/Build/Products/$CONFIGURATION-iphoneos/Where.app" if [ ! -d "$APP" ]; then @@ -228,7 +242,11 @@ echo "==> installing to device" xcrun devicectl device install app --device "$DEVICE_UDID" "$APP" if [ "$LAUNCH" = true ]; then - echo "==> launching $BUNDLE_ID" + if [ "$CLOUDKIT" = true ]; then + echo "==> launching $BUNDLE_ID with CloudKit validation enabled" + else + echo "==> launching $BUNDLE_ID" + fi xcrun devicectl device process launch \ --device "$DEVICE_UDID" \ --terminate-existing \ diff --git a/test b/test index 21bb5596..6af0b86d 100755 --- a/test +++ b/test @@ -82,7 +82,8 @@ Usage: ./test [options] [BundleName ...] Runs this repo's tests against the simulator this checkout owns, streaming progress as it goes. With no arguments it runs only the bundles affected by the -working tree's changes. +working tree's changes. Every test run also runs the fast host-side backup +upgrader regression suite before selecting an iOS test scope. Scope: (no arguments) Bundles affected by the diff against origin/main, including @@ -162,6 +163,9 @@ case "$RECORD" in exit 1 ;; esac +echo "==> Testing backup upgrader" +mise exec -- ruby Where/Tools/Tests/upgrade_backup_test.rb + WORKSPACE="Stuff.xcworkspace" UNIT_SCHEME="Stuff-iOS-Tests" SNAPSHOT_SCHEME="StuffSnapshotTests" From 8aa00fdbc7aab17c46547c361455fcac0dbe95ec Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 08:30:37 -0700 Subject: [PATCH 10/31] Model one global recording assignment --- .../Devices/RecordingAssignmentChange.swift | 256 ++++++++++++++++++ .../Devices/RecordingDeviceArchive.swift | 21 ++ .../Devices/RecordingPersistenceError.swift | 6 + .../RecordingAssignmentChangeTests.swift | 133 +++++++++ .../Tests/RecordingDeviceArchiveTests.swift | 21 ++ 5 files changed, 437 insertions(+) create mode 100644 Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceArchive.swift create mode 100644 Where/WhereCore/Tests/RecordingAssignmentChangeTests.swift create mode 100644 Where/WhereCore/Tests/RecordingDeviceArchiveTests.swift diff --git a/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift b/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift new file mode 100644 index 00000000..8483b658 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift @@ -0,0 +1,256 @@ +import Foundation + +/// The account-wide automatic-recording assignment. +/// +/// A missing device means recording is explicitly Off. Exactly one installation can otherwise +/// hold the assignment; every installation remains able to edit history and attach evidence. +public struct RecordingAssignment: Sendable, Hashable { + public let deviceID: RecordingDeviceID? + + public static let off = RecordingAssignment(deviceID: nil) + + public static func device(_ deviceID: RecordingDeviceID) -> RecordingAssignment { + RecordingAssignment(deviceID: deviceID) + } + + private init(deviceID: RecordingDeviceID?) { + self.deviceID = deviceID + } +} + +/// Why an account-wide recording assignment was appended. +public enum RecordingAssignmentReason: String, Codable, Sendable, Hashable { + case onboarding + case userCommand + case backupMerge + case accountReset + case backupReplace +} + +/// One append-only command in the account-wide automatic-recording assignment DAG. +/// +/// Commands name every maximal event observed by their writer. This permits CloudKit writers to +/// converge without comparing clocks: an Off head wins concurrent assignment, identical +/// assignments coalesce, and concurrent assignments to different devices fail closed. +public struct RecordingAssignmentChange: Identifiable, Codable, Sendable, Hashable { + public let id: UUID + public let parentIDs: [UUID] + public let revision: Int64 + public let issuedAt: Date + public let issuedByDeviceID: RecordingDeviceID + public let effectiveAt: Date + public let assignedDeviceID: RecordingDeviceID? + public let reason: RecordingAssignmentReason + + public var assignment: RecordingAssignment { + assignedDeviceID.map(RecordingAssignment.device) ?? .off + } + + public init( + id: UUID, + parentIDs: [UUID], + revision: Int64, + issuedAt: Date, + issuedByDeviceID: RecordingDeviceID, + effectiveAt: Date, + assignedDeviceID: RecordingDeviceID?, + reason: RecordingAssignmentReason, + ) { + precondition(revision >= 0, "A recording-assignment revision cannot be negative.") + precondition( + (revision == 0) == parentIDs.isEmpty, + "Only a recording-assignment root may omit its parents.", + ) + let canonicalParentIDs = parentIDs.sorted { $0.uuidString < $1.uuidString } + precondition( + Set(canonicalParentIDs).count == canonicalParentIDs.count, + "A recording-assignment command cannot name the same parent twice.", + ) + precondition( + canonicalParentIDs.contains(id) == false, + "A recording-assignment command cannot parent itself.", + ) + precondition( + Self.isValid(reason: reason, assignedDeviceID: assignedDeviceID), + "The recording assignment is invalid for its reason.", + ) + self.id = id + self.parentIDs = canonicalParentIDs + self.revision = revision + self.issuedAt = issuedAt + self.issuedByDeviceID = issuedByDeviceID + self.effectiveAt = effectiveAt + self.assignedDeviceID = assignedDeviceID + self.reason = reason + } +} + +/// Fail-closed resolution of the global assignment graph. +public enum RecordingAssignmentResolution: Sendable, Hashable { + case unconfigured + case resolved(RecordingAssignment) + case conflict(Set) + case invalid + + public var assignment: RecordingAssignment? { + guard case let .resolved(assignment) = self else { return nil } + return assignment + } + + public func permitsRecording(on deviceID: RecordingDeviceID) -> Bool { + assignment?.deviceID == deviceID + } +} + +extension RecordingAssignmentChange { + private struct CausalGraph { + let heads: [RecordingAssignmentChange] + } + + public static func resolve( + _ changes: [RecordingAssignmentChange], + ) -> RecordingAssignmentResolution { + resolveValidated(changes) + } + + public static func resolve( + _ changes: [RecordingAssignmentChange], + at date: Date, + ) -> RecordingAssignmentResolution { + guard changes.isEmpty || causalGraph(in: changes) != nil else { return .invalid } + return resolveValidated(changes.filter { $0.effectiveAt <= date }) + } + + public static func maximalHeads( + in changes: [RecordingAssignmentChange], + ) -> [RecordingAssignmentChange]? { + guard changes.isEmpty == false else { return [] } + return causalGraph(in: changes)?.heads.sorted(by: isOrderedBefore) + } + + public static func appendingCommand( + to changes: [RecordingAssignmentChange], + assignment: RecordingAssignment, + issuedAt: Date, + issuedByDeviceID: RecordingDeviceID, + effectiveAt: Date, + reason: RecordingAssignmentReason, + ) throws -> RecordingAssignmentChange { + guard let heads = maximalHeads(in: changes) else { + throw RecordingPersistenceError.incompleteAssignmentHistory + } + let revision: Int64 + if let maximumRevision = heads.map(\.revision).max() { + let (next, overflow) = maximumRevision.addingReportingOverflow(1) + guard overflow == false else { + throw RecordingPersistenceError.assignmentRevisionExhausted + } + revision = next + } else { + revision = 0 + } + return RecordingAssignmentChange( + id: UUID(), + parentIDs: heads.map(\.id), + revision: revision, + issuedAt: issuedAt, + issuedByDeviceID: issuedByDeviceID, + effectiveAt: heads.reduce(effectiveAt) { max($0, $1.effectiveAt) }, + assignedDeviceID: assignment.deviceID, + reason: reason, + ) + } + + static func formValidPersistedTimeline(_ changes: [RecordingAssignmentChange]) -> Bool { + changes.isEmpty || causalGraph(in: changes) != nil + } + + static func isCanonicalBefore( + _ lhs: RecordingAssignmentChange, + _ rhs: RecordingAssignmentChange, + ) -> Bool { + if lhs.parentIDs != rhs.parentIDs { + return lhs.parentIDs.map(\.uuidString).joined(separator: ",") + < rhs.parentIDs.map(\.uuidString).joined(separator: ",") + } + if lhs.revision != rhs.revision { return lhs.revision < rhs.revision } + if lhs.issuedAt != rhs.issuedAt { return lhs.issuedAt < rhs.issuedAt } + if lhs.issuedByDeviceID != rhs.issuedByDeviceID { + return lhs.issuedByDeviceID.storeURL.absoluteString + < rhs.issuedByDeviceID.storeURL.absoluteString + } + if lhs.effectiveAt != rhs.effectiveAt { return lhs.effectiveAt < rhs.effectiveAt } + if lhs.assignedDeviceID != rhs.assignedDeviceID { + return (lhs.assignedDeviceID?.storeURL.absoluteString ?? "") + < (rhs.assignedDeviceID?.storeURL.absoluteString ?? "") + } + return lhs.reason.rawValue < rhs.reason.rawValue + } + + private static func resolveValidated( + _ changes: [RecordingAssignmentChange], + ) -> RecordingAssignmentResolution { + guard changes.isEmpty == false else { return .unconfigured } + guard let heads = causalGraph(in: changes)?.heads else { return .invalid } + if heads.contains(where: { $0.assignedDeviceID == nil }) { + return .resolved(.off) + } + let targets = Set(heads.compactMap(\.assignedDeviceID)) + guard targets.count == 1, let target = targets.first else { + return .conflict(targets) + } + return .resolved(.device(target)) + } + + private static func causalGraph( + in changes: [RecordingAssignmentChange], + ) -> CausalGraph? { + let groupedByID = Dictionary(grouping: changes, by: \.id) + guard groupedByID.values.allSatisfy({ $0.count == 1 }) else { return nil } + let byID = groupedByID.compactMapValues(\.first) + var parentIDs = Set() + for change in changes { + guard change.revision >= 0, + isValid(reason: change.reason, assignedDeviceID: change.assignedDeviceID) + else { return nil } + if change.revision == 0 { + guard change.parentIDs.isEmpty else { return nil } + continue + } + guard change.parentIDs.isEmpty == false, + change.parentIDs == change.parentIDs + .sorted(by: { $0.uuidString < $1.uuidString }), + Set(change.parentIDs).count == change.parentIDs.count, + change.parentIDs.contains(change.id) == false + else { return nil } + let parents = change.parentIDs.compactMap { byID[$0] } + guard parents.count == change.parentIDs.count, + let maximumRevision = parents.map(\.revision).max(), + maximumRevision < Int64.max, + change.revision == maximumRevision + 1, + parents.allSatisfy({ change.effectiveAt >= $0.effectiveAt }) + else { return nil } + parentIDs.formUnion(change.parentIDs) + } + let heads = changes.filter { parentIDs.contains($0.id) == false } + return heads.isEmpty ? nil : CausalGraph(heads: heads) + } + + private static func isValid( + reason: RecordingAssignmentReason, + assignedDeviceID: RecordingDeviceID?, + ) -> Bool { + switch reason { + case .onboarding, .userCommand, .backupMerge: true + case .accountReset, .backupReplace: assignedDeviceID == nil + } + } + + private static func isOrderedBefore( + _ lhs: RecordingAssignmentChange, + _ rhs: RecordingAssignmentChange, + ) -> Bool { + if lhs.revision != rhs.revision { return lhs.revision < rhs.revision } + return lhs.id.uuidString < rhs.id.uuidString + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceArchive.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceArchive.swift new file mode 100644 index 00000000..44ca76d3 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceArchive.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Irreversible, append-only tombstone hiding an installation from active device UI. +public struct RecordingDeviceArchive: Identifiable, Codable, Sendable, Hashable { + public let id: UUID + public let deviceID: RecordingDeviceID + public let archivedAt: Date + public let archivedByDeviceID: RecordingDeviceID + + public init( + id: UUID, + deviceID: RecordingDeviceID, + archivedAt: Date, + archivedByDeviceID: RecordingDeviceID, + ) { + self.id = id + self.deviceID = deviceID + self.archivedAt = archivedAt + self.archivedByDeviceID = archivedByDeviceID + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift b/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift index a16c4c40..932fbbbd 100644 --- a/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift +++ b/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift @@ -2,6 +2,8 @@ import Foundation /// Honest failures from the append-only recording persistence boundary. public enum RecordingPersistenceError: Error, LocalizedError, Sendable, Hashable { + case incompleteAssignmentHistory + case assignmentRevisionExhausted case conflictingImmutableRecord(id: UUID) case deviceNotFound(RecordingDeviceID) case devicePolicyUnknown(RecordingDeviceID) @@ -17,6 +19,10 @@ public enum RecordingPersistenceError: Error, LocalizedError, Sendable, Hashable public var errorDescription: String? { switch self { + case .incompleteAssignmentHistory: + String(localized: .recordingErrorIncompletePolicyHistory) + case .assignmentRevisionExhausted: + String(localized: .recordingErrorRevisionExhausted) case .conflictingImmutableRecord: String(localized: .recordingErrorConflictingImmutableRecord) case .deviceNotFound: diff --git a/Where/WhereCore/Tests/RecordingAssignmentChangeTests.swift b/Where/WhereCore/Tests/RecordingAssignmentChangeTests.swift new file mode 100644 index 00000000..5008451f --- /dev/null +++ b/Where/WhereCore/Tests/RecordingAssignmentChangeTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing +@testable import WhereCore + +struct RecordingAssignmentChangeTests { + private static let phone = device("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") + private static let tablet = device("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB") + private static let writer = device("CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC") + private static let date = Date(timeIntervalSinceReferenceDate: 1000) + + @Test func emptyHistoryIsUnconfigured() { + #expect(RecordingAssignmentChange.resolve([]) == .unconfigured) + } + + @Test func concurrentAssignmentsToTheSameDeviceCoalesce() { + let first = Self.change(id: 1, assignedDeviceID: Self.phone) + let second = Self.change(id: 2, assignedDeviceID: Self.phone) + + #expect( + RecordingAssignmentChange.resolve([first, second]) + == .resolved(.device(Self.phone)), + ) + } + + @Test func concurrentAssignmentsToDifferentDevicesFailClosed() { + let first = Self.change(id: 1, assignedDeviceID: Self.phone) + let second = Self.change(id: 2, assignedDeviceID: Self.tablet) + + #expect( + RecordingAssignmentChange.resolve([first, second]) + == .conflict([Self.phone, Self.tablet]), + ) + #expect(RecordingAssignmentChange.resolve([first, second]) + .permitsRecording(on: Self.phone) == false) + #expect(RecordingAssignmentChange.resolve([first, second]) + .permitsRecording(on: Self.tablet) == false) + } + + @Test func concurrentOffWinsAnAssignment() { + let assigned = Self.change(id: 1, assignedDeviceID: Self.phone) + let off = Self.change(id: 2, assignedDeviceID: nil) + + #expect(RecordingAssignmentChange.resolve([assigned, off]) == .resolved(.off)) + } + + @Test func commandJoinsEveryObservedHeadAndUsesTheLatestCutoff() throws { + let first = Self.change(id: 1, effectiveAt: Self.date, assignedDeviceID: Self.phone) + let second = Self.change( + id: 2, + effectiveAt: Self.date.addingTimeInterval(10), + assignedDeviceID: Self.tablet, + ) + + let command = try RecordingAssignmentChange.appendingCommand( + to: [first, second], + assignment: .device(Self.phone), + issuedAt: Self.date.addingTimeInterval(20), + issuedByDeviceID: Self.writer, + effectiveAt: Self.date.addingTimeInterval(5), + reason: .userCommand, + ) + + #expect(command.parentIDs == [first.id, second.id]) + #expect(command.revision == 1) + #expect(command.effectiveAt == second.effectiveAt) + #expect(RecordingAssignmentChange + .resolve([first, second, command]) == .resolved(.device(Self.phone))) + } + + @Test func historicalResolutionUsesTheAssignmentAtTheSampleTime() throws { + let root = Self.change(id: 1, assignedDeviceID: Self.phone) + let transfer = try RecordingAssignmentChange.appendingCommand( + to: [root], + assignment: .device(Self.tablet), + issuedAt: Self.date.addingTimeInterval(10), + issuedByDeviceID: Self.writer, + effectiveAt: Self.date.addingTimeInterval(10), + reason: .userCommand, + ) + + #expect( + RecordingAssignmentChange.resolve([root, transfer], at: Self.date.addingTimeInterval(5)) + == .resolved(.device(Self.phone)), + ) + #expect( + RecordingAssignmentChange.resolve( + [root, transfer], + at: Self.date.addingTimeInterval(10), + ) + == .resolved(.device(Self.tablet)), + ) + } + + @Test func aMissingParentInvalidatesTheWholeGraph() { + let invalid = RecordingAssignmentChange( + id: Self.uuid(2), + parentIDs: [Self.uuid(1)], + revision: 1, + issuedAt: Self.date, + issuedByDeviceID: Self.writer, + effectiveAt: Self.date, + assignedDeviceID: Self.phone, + reason: .userCommand, + ) + + #expect(RecordingAssignmentChange.resolve([invalid]) == .invalid) + } + + private static func change( + id: Int, + effectiveAt: Date = date, + assignedDeviceID: RecordingDeviceID?, + ) -> RecordingAssignmentChange { + RecordingAssignmentChange( + id: uuid(id), + parentIDs: [], + revision: 0, + issuedAt: effectiveAt, + issuedByDeviceID: writer, + effectiveAt: effectiveAt, + assignedDeviceID: assignedDeviceID, + reason: .userCommand, + ) + } + + private static func device(_ value: String) -> RecordingDeviceID { + RecordingDeviceID(rawValue: UUID(uuidString: value)!) + } + + private static func uuid(_ value: Int) -> UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", value))! + } +} diff --git a/Where/WhereCore/Tests/RecordingDeviceArchiveTests.swift b/Where/WhereCore/Tests/RecordingDeviceArchiveTests.swift new file mode 100644 index 00000000..8c5c7104 --- /dev/null +++ b/Where/WhereCore/Tests/RecordingDeviceArchiveTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import WhereCore + +struct RecordingDeviceArchiveTests { + @Test func archiveRetainsItsIndependentWriterAndTarget() { + let target = RecordingDeviceID(rawValue: UUID()) + let writer = RecordingDeviceID(rawValue: UUID()) + let date = Date(timeIntervalSinceReferenceDate: 1000) + let archive = RecordingDeviceArchive( + id: UUID(), + deviceID: target, + archivedAt: date, + archivedByDeviceID: writer, + ) + + #expect(archive.deviceID == target) + #expect(archive.archivedByDeviceID == writer) + #expect(archive.archivedAt == date) + } +} From ca97e8b90b1187fb5c7f7918947a65d7a025340d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 08:39:22 -0700 Subject: [PATCH 11/31] Reconcile one account-wide recorder --- .../Devices/DeviceRecordingController.swift | 221 +++++++++++++++++- .../Devices/LocationHistoryReader.swift | 10 +- .../Devices/RecordingAssignmentChange.swift | 31 +++ .../Devices/RecordingAuthoritySnapshot.swift | 16 ++ .../Devices/RecordingPolicyFilter.swift | 15 ++ .../Sources/Persistence/SwiftDataStore.swift | 176 ++++++++++++++ .../Sources/Persistence/WhereStore.swift | 12 + .../Tests/LocationIngestorTests.swift | 16 ++ .../WhereCore/Tests/WhereServicesTests.swift | 16 ++ Where/WhereUI/Tests/Support/TestStore.swift | 16 ++ 10 files changed, 518 insertions(+), 11 deletions(-) create mode 100644 Where/WhereCore/Sources/Devices/RecordingAuthoritySnapshot.swift diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index 3756489b..807758ce 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -60,6 +60,8 @@ public actor DeviceRecordingController { let metadataChanges: [RecordingDeviceMetadataChange] let checkIns: [RecordingDeviceCheckIn] let policyChanges: [RecordingPolicyChange] + let assignmentChanges: [RecordingAssignmentChange] + let archives: [RecordingDeviceArchive] } init( @@ -170,6 +172,22 @@ public actor DeviceRecordingController { } let commandDate = now() let desiredState: RecordingPolicyState = desiredEnabled ? .on : .off + let desiredAssignment: RecordingAssignment = desiredEnabled + ? .device(currentDevice.id) : .off + let assignmentChange: RecordingAssignmentChange? = if RecordingAssignmentChange + .resolve(snapshot.assignmentChanges).assignment == desiredAssignment + { + nil + } else { + try RecordingAssignmentChange.appendingCommand( + to: snapshot.assignmentChanges, + assignment: desiredAssignment, + issuedAt: commandDate, + issuedByDeviceID: currentDevice.id, + effectiveAt: max(commandDate, snapshot.epoch.changedAt), + reason: .userCommand, + ) + } let policyChange: RecordingPolicyChange? = if latestPolicy.state == desiredState { nil } else { @@ -186,9 +204,14 @@ public actor DeviceRecordingController { reason: .userCommand, ) } - if let policyChange { + if policyChange != nil || assignmentChange != nil { try await store.perform(expectedDataEpochID: snapshot.epoch.id) { - try await self.store.addRecordingPolicyChange(policyChange) + if let policyChange { + try await self.store.addRecordingPolicyChange(policyChange) + } + if let assignmentChange { + try await self.store.addRecordingAssignmentChange(assignmentChange) + } } // Historical visibility changed as soon as the authority event committed. Do // not make derived reconciliation depend on a later physical/check-in success. @@ -239,6 +262,40 @@ public actor DeviceRecordingController { return try await configurationsLocked(includeArchived: false) } + /// Read the one account-wide assignment and every installation eligible to receive it. + public func authoritySnapshot() async throws -> RecordingAuthoritySnapshot { + await beginExclusive() + defer { endExclusive() } + try requireActive() + return try await store.readSnapshot { + async let devices = store.recordingDevices() + async let changes = store.recordingAssignmentChanges() + async let archives = store.recordingDeviceArchives() + let values = try await (devices, changes, archives) + return RecordingAuthoritySnapshot( + resolution: RecordingAssignmentChange.resolve(values.1), + devices: values.0, + archivedDeviceIDs: Set(values.2.map(\.deviceID)), + ) + } + } + + /// Transfer automatic recording immediately to one installation. + @discardableResult + public func assignAutomaticRecording( + to deviceID: RecordingDeviceID, + ) async throws -> RecordingAuthoritySnapshot { + _ = try await setEnabled(true, for: deviceID) + return try await authoritySnapshot() + } + + /// Turn account-wide automatic recording Off. + @discardableResult + public func turnOffAutomaticRecording() async throws -> RecordingAuthoritySnapshot { + _ = try await setEnabled(false, for: currentDevice.id) + return try await authoritySnapshot() + } + /// Append a desired-state command. A command for this installation is physically reconciled /// and acknowledged before returning; a remote command remains pending until its target syncs. @discardableResult @@ -266,6 +323,21 @@ public actor DeviceRecordingController { throw RecordingPersistenceError.devicePolicyUnknown(deviceID) } let issuedAt = now() + let desiredAssignment: RecordingAssignment = enabled ? .device(deviceID) : .off + let assignmentChange: RecordingAssignmentChange? = if RecordingAssignmentChange + .resolve(snapshot.assignmentChanges).assignment == desiredAssignment + { + nil + } else { + try RecordingAssignmentChange.appendingCommand( + to: snapshot.assignmentChanges, + assignment: desiredAssignment, + issuedAt: issuedAt, + issuedByDeviceID: currentDevice.id, + effectiveAt: max(issuedAt, epoch.changedAt), + reason: .userCommand, + ) + } let desiredState: RecordingPolicyState = enabled ? .on : .off let causalHead = RecordingPolicyChange.canonicalHead(in: timeline) let policyChange: RecordingPolicyChange? = if latestPolicy.state == desiredState { @@ -285,7 +357,7 @@ public actor DeviceRecordingController { ) } - guard let policyChange else { + guard policyChange != nil || assignmentChange != nil else { if deviceID == currentDevice.id { if !enabled { await ingestor.revokeRecordingAuthorization() @@ -296,7 +368,12 @@ public actor DeviceRecordingController { } try await store.perform(expectedDataEpochID: epoch.id) { - try await self.store.addRecordingPolicyChange(policyChange) + if let policyChange { + try await self.store.addRecordingPolicyChange(policyChange) + } + if let assignmentChange { + try await self.store.addRecordingAssignmentChange(assignmentChange) + } } // Close local physical authority before any potentially slow derived-data rebuild. The // durable cutoff already hides history, but raw fixes must not continue entering the @@ -373,6 +450,27 @@ public actor DeviceRecordingController { throw RecordingPersistenceError.devicePolicyUnknown(deviceID) } let causalHead = RecordingPolicyChange.canonicalHead(in: timeline) + let archive = snapshot.archives.contains(where: { $0.deviceID == deviceID }) ? nil : + RecordingDeviceArchive( + id: UUID(), + deviceID: deviceID, + archivedAt: date, + archivedByDeviceID: currentDevice.id, + ) + let assignmentChange: RecordingAssignmentChange? = if RecordingAssignmentChange + .resolve(snapshot.assignmentChanges).assignment?.deviceID == deviceID + { + try RecordingAssignmentChange.appendingCommand( + to: snapshot.assignmentChanges, + assignment: .off, + issuedAt: date, + issuedByDeviceID: currentDevice.id, + effectiveAt: max(date, epoch.changedAt), + reason: .userCommand, + ) + } else { + nil + } let policyChange: RecordingPolicyChange? = if latestPolicy.state != .archived { try RecordingPolicyChange.appendingCommand( to: timeline, @@ -389,11 +487,19 @@ public actor DeviceRecordingController { } else { nil } - guard let policyChange else { + guard policyChange != nil || archive != nil || assignmentChange != nil else { return try await configurationsLocked(includeArchived: false) } try await store.perform(expectedDataEpochID: epoch.id) { - try await self.store.addRecordingPolicyChange(policyChange) + if let policyChange { + try await self.store.addRecordingPolicyChange(policyChange) + } + if let archive { + try await self.store.addRecordingDeviceArchive(archive) + } + if let assignmentChange { + try await self.store.addRecordingAssignmentChange(assignmentChange) + } } await onPolicyChanged() return try await configurationsLocked(includeArchived: false) @@ -545,10 +651,11 @@ public actor DeviceRecordingController { ) let existingInitialPolicy = snapshot.policyChanges .first(where: { $0.id == initialPolicyChangeID }) + let needsInitialAssignment = snapshot.assignmentChanges.isEmpty let needsProfileWrite = existingProfile != profile let needsInitialPolicyWrite = ownsInitialPolicyInThisEpoch && existingInitialPolicy != initialPolicy - guard needsProfileWrite || needsInitialPolicyWrite else { return } + guard needsProfileWrite || needsInitialPolicyWrite || needsInitialAssignment else { return } // The add APIs validate identical immutable retries and reject conflicting payloads. // An installation first seen in this epoch also retries its immutable initial policy. @@ -559,6 +666,18 @@ public actor DeviceRecordingController { if ownsInitialPolicyInThisEpoch { try await self.store.addRecordingPolicyChange(initialPolicy) } + if needsInitialAssignment { + try await self.store.addRecordingAssignmentChange(RecordingAssignmentChange( + id: initialPolicyChangeID, + parentIDs: [], + revision: 0, + issuedAt: self.initialRecordingChoice.confirmedAt, + issuedByDeviceID: self.currentDevice.id, + effectiveAt: max(self.initialRecordingChoice.confirmedAt, epoch.changedAt), + assignedDeviceID: initialEnabled ? self.currentDevice.id : nil, + reason: .onboarding, + )) + } } } @@ -589,13 +708,46 @@ public actor DeviceRecordingController { guard Self.hasCompleteRevisionHistory(timeline) else { throw RecordingPersistenceError.incompletePolicyHistory(currentDevice.id) } - guard let latest = Self.effectivePolicy( + guard let legacyPolicy = Self.effectivePolicy( for: currentDevice.id, epoch: epoch, timeline: timeline, ) else { throw RecordingPersistenceError.currentDevicePolicyUnknown(currentDevice.id) } + let latest: RecordingPolicyChange + if snapshot.assignmentChanges.count <= 1 { + latest = legacyPolicy + } else { + let resolution = RecordingAssignmentChange.resolve(snapshot.assignmentChanges) + guard let assignment = resolution.assignment, + let frontierID = RecordingAssignmentChange.frontierToken( + in: snapshot.assignmentChanges, + ), + let heads = RecordingAssignmentChange.maximalHeads( + in: snapshot.assignmentChanges, + ) + else { + throw RecordingPersistenceError.incompleteAssignmentHistory + } + let assignedDeviceIsArchived = assignment.deviceID.map { assignedID in + snapshot.archives.contains(where: { $0.deviceID == assignedID }) + } ?? false + guard assignedDeviceIsArchived == false else { + throw RecordingPersistenceError.incompleteAssignmentHistory + } + latest = RecordingPolicyChange( + id: frontierID, + deviceID: currentDevice.id, + parentIDs: [], + revision: 0, + issuedAt: heads.map(\.issuedAt).max() ?? epoch.changedAt, + issuedByDeviceID: heads.last?.issuedByDeviceID ?? currentDevice.id, + effectiveAt: heads.map(\.effectiveAt).max() ?? epoch.changedAt, + state: assignment.deviceID == currentDevice.id ? .on : .off, + reason: .userCommand, + ) + } let nickname = Self.latestMetadata( for: currentDevice.id, field: .nickname, @@ -702,14 +854,55 @@ public actor DeviceRecordingController { try await store.readSnapshot { async let devices = store.recordingDevices() async let policies = store.recordingPolicyChanges() + async let assignments = store.recordingAssignmentChanges() + async let archives = store.recordingDeviceArchives() async let epoch = store.dataEpoch() - let (resolvedDevices, resolvedPolicies, resolvedEpoch) = try await ( + let ( + resolvedDevices, + resolvedPolicies, + resolvedAssignments, + resolvedArchives, + resolvedEpoch + ) = try await ( devices, policies, + assignments, + archives, epoch, ) + let assignment = RecordingAssignmentChange.resolve(resolvedAssignments).assignment + let assignmentFrontierID = RecordingAssignmentChange.frontierToken( + in: resolvedAssignments, + ) + let assignmentHeads = RecordingAssignmentChange.maximalHeads(in: resolvedAssignments) + let archivedIDs = Set(resolvedArchives.map(\.deviceID)) return resolvedDevices .map { device in + if resolvedAssignments.count > 1, + let assignment, + let assignmentFrontierID, + let assignmentHeads + { + let change = RecordingPolicyChange( + id: assignmentFrontierID, + deviceID: device.id, + parentIDs: [], + revision: 0, + issuedAt: assignmentHeads.map(\.issuedAt).max() ?? resolvedEpoch + .changedAt, + issuedByDeviceID: assignmentHeads.last? + .issuedByDeviceID ?? currentDevice.id, + effectiveAt: assignmentHeads.map(\.effectiveAt).max() ?? resolvedEpoch + .changedAt, + state: assignment.deviceID == device.id ? .on : .off, + reason: .userCommand, + ) + return RecordingDeviceConfiguration( + device: device, + policyChange: change, + requiredCleanupToken: nil, + ) + } let timeline = Self.policyTimeline(for: device.id, in: resolvedPolicies) guard Self.hasCompleteRevisionHistory(timeline), let latest = Self.effectivePolicy( @@ -732,7 +925,9 @@ public actor DeviceRecordingController { ) } .filter { - includeArchived || !$0.isArchived || $0.id == currentDevice.id + includeArchived + || (!archivedIDs.contains($0.id) && !$0.isArchived) + || $0.id == currentDevice.id } .sorted { lhs, rhs in if lhs.id == currentDevice.id { return true } @@ -824,12 +1019,16 @@ public actor DeviceRecordingController { async let metadataChanges = store.recordingDeviceMetadataChanges() async let checkIns = store.recordingDeviceCheckIns() async let policyChanges = store.recordingPolicyChanges() + async let assignmentChanges = store.recordingAssignmentChanges() + async let archives = store.recordingDeviceArchives() let values = try await ( epoch, profiles, metadataChanges, checkIns, policyChanges, + assignmentChanges, + archives, ) return StoreSnapshot( epoch: values.0, @@ -837,6 +1036,8 @@ public actor DeviceRecordingController { metadataChanges: values.2, checkIns: values.3, policyChanges: values.4, + assignmentChanges: values.5, + archives: values.6, ) } } diff --git a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift index 442c38a7..bfb93d6e 100644 --- a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift +++ b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift @@ -14,10 +14,18 @@ public struct LocationHistoryReader: Sendable { try await store.readSnapshot { async let samples = store.samples(in: interval) async let policyChanges = store.recordingPolicyChanges() - let (resolvedSamples, resolvedPolicyChanges) = try await ( + async let assignmentChanges = store.recordingAssignmentChanges() + let (resolvedSamples, resolvedPolicyChanges, resolvedAssignmentChanges) = try await ( samples, policyChanges, + assignmentChanges, ) + if resolvedAssignmentChanges.isEmpty == false { + return RecordingPolicyFilter.visibleSamples( + resolvedSamples, + assignmentChanges: resolvedAssignmentChanges, + ) + } return RecordingPolicyFilter.visibleSamples( resolvedSamples, policyChanges: resolvedPolicyChanges, diff --git a/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift b/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift index 8483b658..e78fecd0 100644 --- a/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift +++ b/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation /// The account-wide automatic-recording assignment. @@ -128,6 +129,36 @@ extension RecordingAssignmentChange { return causalGraph(in: changes)?.heads.sorted(by: isOrderedBefore) } + /// Stable acknowledgement identity for the complete maximal frontier. + static func frontierToken(in changes: [RecordingAssignmentChange]) -> UUID? { + guard let heads = maximalHeads(in: changes), heads.isEmpty == false else { return nil } + guard heads.count > 1 else { return heads[0].id } + var hasher = SHA256() + hasher.update(data: Data("com.stuff.where.recording-assignment-frontier.v1".utf8)) + for head in heads { + hasher.update(data: Data("\n\(head.id.uuidString)".utf8)) + } + let digest = Array(hasher.finalize().prefix(16)) + return UUID(uuid: ( + digest[0], + digest[1], + digest[2], + digest[3], + digest[4], + digest[5], + digest[6], + digest[7], + digest[8], + digest[9], + digest[10], + digest[11], + digest[12], + digest[13], + digest[14], + digest[15], + )) + } + public static func appendingCommand( to changes: [RecordingAssignmentChange], assignment: RecordingAssignment, diff --git a/Where/WhereCore/Sources/Devices/RecordingAuthoritySnapshot.swift b/Where/WhereCore/Sources/Devices/RecordingAuthoritySnapshot.swift new file mode 100644 index 00000000..1202c1c7 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingAuthoritySnapshot.swift @@ -0,0 +1,16 @@ +/// Account-wide recording authority plus the installations that can be assigned. +public struct RecordingAuthoritySnapshot: Sendable, Hashable { + public let resolution: RecordingAssignmentResolution + public let devices: [RecordingDevice] + public let archivedDeviceIDs: Set + + public init( + resolution: RecordingAssignmentResolution, + devices: [RecordingDevice], + archivedDeviceIDs: Set, + ) { + self.resolution = resolution + self.devices = devices + self.archivedDeviceIDs = archivedDeviceIDs + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift index 646d0b3d..05fcb0ff 100644 --- a/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift +++ b/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift @@ -6,6 +6,21 @@ import Foundation /// Legacy samples without a device ID remain visible because no device policy /// can be attributed to them safely. public enum RecordingPolicyFilter { + public static func visibleSamples( + _ samples: [LocationSample], + assignmentChanges: [RecordingAssignmentChange], + ) -> [LocationSample] { + samples.filter { sample in + guard sample.source.isGPS, let deviceID = sample.recordingDeviceID else { + return true + } + return RecordingAssignmentChange.resolve( + assignmentChanges, + at: sample.timestamp, + ).permitsRecording(on: deviceID) + } + } + public static func visibleSamples( _ samples: [LocationSample], policyChanges: [RecordingPolicyChange], diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 5973c1f4..fa1dbb4f 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -258,6 +258,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { SDRecordingDeviceMetadataChange.self, SDRecordingDeviceCheckIn.self, SDRecordingPolicyChange.self, + SDRecordingAssignmentChange.self, + SDRecordingDeviceArchive.self, ] } @@ -844,6 +846,16 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { { context.delete(record) } + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where belongs(record.epochID, to: epochID) + { + context.delete(record) + } } public func add(sample: LocationSample) async throws { @@ -1160,6 +1172,87 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } + public func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { + let context = readContext() + let epochID = try readEpochID(in: context) + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.revision), SortDescriptor(\.id)], + ) + descriptor.includePendingChanges = true + let values = try context.fetch(descriptor) + .filter { Self.belongs($0.epochID, to: epochID) } + .compactMap { record -> RecordingAssignmentChange? in + guard let value = record.toValue() else { + Self.logFault(forCorrupt: record) + return nil + } + return value + } + guard RecordingAssignmentChange.formValidPersistedTimeline(values) else { + throw RecordingPersistenceError.incompleteAssignmentHistory + } + return Dictionary(grouping: values, by: \.id) + .compactMap { _, duplicates in + duplicates.min(by: RecordingAssignmentChange.isCanonicalBefore) + } + .sorted { + $0.revision == $1.revision + ? $0.id.uuidString < $1.id.uuidString + : $0.revision < $1.revision + } + } + + public func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { + let context = mutationContext() + let epochID = mutationEpochID() + let id = change.id + let existing = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id }), + ).filter { Self.belongs($0.epochID, to: epochID) } + guard existing.isEmpty == false else { + context.insert(SDRecordingAssignmentChange(value: change, epochID: epochID)) + return + } + guard existing.allSatisfy({ $0.toValue() == change }) else { + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + } + for duplicate in existing.dropFirst() { + context.delete(duplicate) + } + } + + public func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] { + let context = readContext() + let epochID = try readEpochID(in: context) + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.archivedAt), SortDescriptor(\.id)], + ) + descriptor.includePendingChanges = true + let records: [SDRecordingDeviceArchive] = try context.fetch(descriptor) + return records + .filter { Self.belongs($0.epochID, to: epochID) } + .compactMap { $0.toValue() } + } + + public func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws { + let context = mutationContext() + let epochID = mutationEpochID() + let id = archive.id + let existing = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id }), + ).filter { Self.belongs($0.epochID, to: epochID) } + guard existing.isEmpty == false else { + context.insert(SDRecordingDeviceArchive(value: archive, epochID: epochID)) + return + } + guard existing.allSatisfy({ $0.toValue() == archive }) else { + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + } + for duplicate in existing.dropFirst() { + context.delete(duplicate) + } + } + public func write(evidence: Evidence, blob: Data?) async throws { let context = mutationContext() let epochID = mutationEpochID() @@ -2222,3 +2315,86 @@ final class SDRecordingPolicyChange { return value.hasValidReasonAndState ? value : nil } } + +/// Account-wide automatic-recording assignment command. +@Model +final class SDRecordingAssignmentChange { + var epochID: UUID? + var id: UUID? + var parentIDs: [UUID]? + var revision: Int64? + var issuedAt: Date? + var issuedByDeviceID: UUID? + var effectiveAt: Date? + var assignedDeviceID: UUID? + var reasonRaw: String? + + init() {} + + convenience init(value: RecordingAssignmentChange, epochID: WhereDataEpochID) { + self.init() + self.epochID = epochID.rawValue + id = value.id + parentIDs = value.parentIDs + revision = value.revision + issuedAt = value.issuedAt + issuedByDeviceID = value.issuedByDeviceID.rawValue + effectiveAt = value.effectiveAt + assignedDeviceID = value.assignedDeviceID?.rawValue + reasonRaw = value.reason.rawValue + } + + func toValue() -> RecordingAssignmentChange? { + guard let id, + let parentIDs, + let revision, + let issuedAt, + let issuedByDeviceID, + let effectiveAt, + let reasonRaw, + let reason = RecordingAssignmentReason(rawValue: reasonRaw), + (revision == 0) == parentIDs.isEmpty + else { return nil } + return RecordingAssignmentChange( + id: id, + parentIDs: parentIDs, + revision: revision, + issuedAt: issuedAt, + issuedByDeviceID: RecordingDeviceID(rawValue: issuedByDeviceID), + effectiveAt: effectiveAt, + assignedDeviceID: assignedDeviceID.map(RecordingDeviceID.init(rawValue:)), + reason: reason, + ) + } +} + +/// Irreversible installation archive tombstone. +@Model +final class SDRecordingDeviceArchive { + var epochID: UUID? + var id: UUID? + var deviceID: UUID? + var archivedAt: Date? + var archivedByDeviceID: UUID? + + init() {} + + convenience init(value: RecordingDeviceArchive, epochID: WhereDataEpochID) { + self.init() + self.epochID = epochID.rawValue + id = value.id + deviceID = value.deviceID.rawValue + archivedAt = value.archivedAt + archivedByDeviceID = value.archivedByDeviceID.rawValue + } + + func toValue() -> RecordingDeviceArchive? { + guard let id, let deviceID, let archivedAt, let archivedByDeviceID else { return nil } + return RecordingDeviceArchive( + id: id, + deviceID: RecordingDeviceID(rawValue: deviceID), + archivedAt: archivedAt, + archivedByDeviceID: RecordingDeviceID(rawValue: archivedByDeviceID), + ) + } +} diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index ed02c2c7..f5688526 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -135,6 +135,18 @@ public protocol WhereStore: Sendable { /// `perform { ... }`. func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws + /// Complete account-wide automatic-recording assignment history for the active epoch. + func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] + + /// Insert one immutable global assignment command. Must run inside `perform { ... }`. + func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws + + /// Irreversible device tombstones in the active epoch. + func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] + + /// Insert an immutable archive tombstone. Must run inside `perform { ... }`. + func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws + func write(evidence: Evidence, blob: Data?) async throws func evidence(in interval: DateInterval) async throws -> [Evidence] /// Every evidence record in the store, regardless of `capturedAt`. Used diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index 902c008c..c4422f59 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -1009,6 +1009,22 @@ private actor ToggleFailingStore: WhereStore { try await backing.addRecordingPolicyChange(change) } + func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { + try await backing.recordingAssignmentChanges() + } + + func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { + try await backing.addRecordingAssignmentChange(change) + } + + func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] { + try await backing.recordingDeviceArchives() + } + + func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws { + try await backing.addRecordingDeviceArchive(archive) + } + func write(evidence: Evidence, blob: Data?) async throws { try await backing.write(evidence: evidence, blob: blob) } diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 6e8173d7..30e0b26c 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -1887,6 +1887,22 @@ private actor ToggleFailingStore: WhereStore { try await backing.addRecordingPolicyChange(change) } + func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { + try await backing.recordingAssignmentChanges() + } + + func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { + try await backing.addRecordingAssignmentChange(change) + } + + func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] { + try await backing.recordingDeviceArchives() + } + + func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws { + try await backing.addRecordingDeviceArchive(archive) + } + func write(evidence: Evidence, blob: Data?) async throws { try await backing.write(evidence: evidence, blob: blob) } diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 78b77244..6b4d94c2 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -249,6 +249,22 @@ actor TestStore: WhereStore { try await backing.addRecordingPolicyChange(change) } + func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { + try await backing.recordingAssignmentChanges() + } + + func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { + try await backing.addRecordingAssignmentChange(change) + } + + func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] { + try await backing.recordingDeviceArchives() + } + + func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws { + try await backing.addRecordingDeviceArchive(archive) + } + func write(evidence: Evidence, blob: Data?) async throws { try await backing.write(evidence: evidence, blob: blob) } From 125feb268fb6f3e22c1022bf4f970182d6e7cf9b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 08:45:36 -0700 Subject: [PATCH 12/31] Discover recording authority during onboarding --- .../Devices/DeviceRecordingController.swift | 23 ++++--- .../WhereUI/Sources/Launch/WhereLaunch.swift | 31 +++++++-- Where/WhereUI/Sources/Model/WhereModel.swift | 10 +++ .../Sources/Onboarding/OnboardingView.swift | 68 +++++++++++++++++-- 4 files changed, 114 insertions(+), 18 deletions(-) diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index 807758ce..ad4e512e 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -148,7 +148,7 @@ public actor DeviceRecordingController { /// causal follow-up command, instead of silently snapping the UI back to the earlier choice. @discardableResult public func registerForOnboarding( - desiredEnabled: Bool, + desiredEnabled: Bool?, authorization: LocationAuthorizationStatus, ) async throws -> RecordingDeviceConfiguration { await beginExclusive() @@ -171,17 +171,19 @@ public actor DeviceRecordingController { throw RecordingPersistenceError.currentDevicePolicyUnknown(currentDevice.id) } let commandDate = now() - let desiredState: RecordingPolicyState = desiredEnabled ? .on : .off - let desiredAssignment: RecordingAssignment = desiredEnabled - ? .device(currentDevice.id) : .off - let assignmentChange: RecordingAssignmentChange? = if RecordingAssignmentChange - .resolve(snapshot.assignmentChanges).assignment == desiredAssignment + let desiredState: RecordingPolicyState = desiredEnabled == true ? .on : .off + let desiredAssignment: RecordingAssignment? = desiredEnabled.map { + $0 ? .device(currentDevice.id) : .off + } + let assignmentChange: RecordingAssignmentChange? = if desiredAssignment == nil + || RecordingAssignmentChange.resolve(snapshot.assignmentChanges).assignment + == desiredAssignment { nil } else { try RecordingAssignmentChange.appendingCommand( to: snapshot.assignmentChanges, - assignment: desiredAssignment, + assignment: desiredAssignment!, issuedAt: commandDate, issuedByDeviceID: currentDevice.id, effectiveAt: max(commandDate, snapshot.epoch.changedAt), @@ -716,7 +718,9 @@ public actor DeviceRecordingController { throw RecordingPersistenceError.currentDevicePolicyUnknown(currentDevice.id) } let latest: RecordingPolicyChange - if snapshot.assignmentChanges.count <= 1 { + let usesGlobalAssignment = snapshot.assignmentChanges.count > 1 + || snapshot.assignmentChanges.first?.id != initialRecordingChoice.policyChangeID + if usesGlobalAssignment == false { latest = legacyPolicy } else { let resolution = RecordingAssignmentChange.resolve(snapshot.assignmentChanges) @@ -878,7 +882,8 @@ public actor DeviceRecordingController { let archivedIDs = Set(resolvedArchives.map(\.deviceID)) return resolvedDevices .map { device in - if resolvedAssignments.count > 1, + if device.id == currentDevice.id, + resolvedAssignments.count > 1, let assignment, let assignmentFrontierID, let assignmentHeads diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index d86a2efc..aff81f54 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -216,6 +216,10 @@ public protocol WhereScopeAssembling { /// **one** store open. func makeServices() async throws -> WhereServices + /// Open and retain the real store while onboarding remains dormant, then read the synced + /// recording assignment without constructing services or activating location/App Intents. + func discoverRecordingAssignment() async throws -> RecordingAssignmentResolution + /// Open the durable log store the scope's records persist to, or `nil` for /// an assembly with no durable logging — previews and tests, which log /// through the in-memory pipeline and must leave no sink attached to the @@ -223,6 +227,12 @@ public protocol WhereScopeAssembling { func makeLogStore() async throws -> PeriscopeStore? } +extension WhereScopeAssembling { + public func discoverRecordingAssignment() async throws -> RecordingAssignmentResolution { + .unconfigured + } +} + /// Owns the assembly of the user's real world, so `WhereScope` and /// `WhereModel` consume finished pieces rather than wiring up persistence and /// CoreLocation themselves. @@ -241,6 +251,7 @@ public final class WhereBootstrap: WhereScopeAssembling { private let storeStorage: SwiftDataStore.Storage private let locationOutbox: any LocationOutbox private var locationSource: CoreLocationSource? + private var preparedStore: SwiftDataStore? public init( installationContextStore: any InstallationRecordingContextStoring, @@ -284,10 +295,7 @@ public final class WhereBootstrap: WhereScopeAssembling { installationContext.initialRecordingChoice != nil, "A real scope cannot open before this installation confirms recording.", ) - let storeStorage = storeStorage - let store = try await Task.detached(priority: .userInitiated) { - try SwiftDataStore.make(storage: storeStorage) - }.value + let store = try await prepareStore() let services = try await WhereServices.make( store: store, locationSource: source, @@ -314,6 +322,21 @@ public final class WhereBootstrap: WhereScopeAssembling { } } + public func discoverRecordingAssignment() async throws -> RecordingAssignmentResolution { + let store = try await prepareStore() + return try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) + } + + private func prepareStore() async throws -> SwiftDataStore { + if let preparedStore { return preparedStore } + let storeStorage = storeStorage + let store = try await Task.detached(priority: .userInitiated) { + try SwiftDataStore.make(storage: storeStorage) + }.value + preparedStore = store + return store + } + /// Open the app's durable log store: `Periscope.store` on disk, plus this /// launch's crash journal beside it. Opened per scope rather than per /// process, because what a session persists depends on which world it is diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index d1dc510e..51ac7649 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -179,6 +179,16 @@ public final class WhereModel { installationRecordingContext.initialRecordingChoice != nil } + /// Discover existing synced authority while the app remains logged out. The bootstrap keeps + /// this exact store instance for `resolveScope()`, so onboarding never opens two containers. + public func discoverRecordingAssignment() async throws -> RecordingAssignmentResolution { + guard case let .loggedOut(bootstrap) = scopeState else { + guard let scope = activeScope else { return .unconfigured } + return try await scope.services.recording.authoritySnapshot().resolution + } + return try await bootstrap.discoverRecordingAssignment() + } + /// Whether the sidecar says onboarding crossed or may have crossed an import commit. The /// launch gate uses this one narrow exception to open the store before offering Restore. var hasInterruptedOnboardingImport: Bool { diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 370ed292..ac06b5bc 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -47,6 +47,8 @@ public struct OnboardingView: View { @State private var page = 0 @State private var selection = PrimaryRegionSelectionModel() @State private var recordingEnabled: Bool + @State private var preserveExistingAssignment = false + @State private var assignmentDiscovery: AssignmentDiscovery = .idle @State private var isFinishing = false @State private var restoreSelection = OnboardingRestoreSelection() @@ -70,6 +72,13 @@ public struct OnboardingView: View { private static let logger = WhereLog.session(OnboardingViewLog.self) + private enum AssignmentDiscovery: Equatable { + case idle + case loading + case ready(RecordingAssignmentResolution) + case failed(String) + } + public init( gate: LifecycleGateHandle, installationContext: InstallationRecordingContext, @@ -309,10 +318,38 @@ public struct OnboardingView: View { VStack(spacing: stylesheet.spacing.large) { VStack(alignment: .leading, spacing: stylesheet.spacing.small) { + switch assignmentDiscovery { + case .idle, .loading: + ProgressView("Checking your other devices…") + case let .ready(.resolved(existing)): + if existing.deviceID != nil { + Toggle( + "Keep the current recorder", + isOn: $preserveExistingAssignment, + ) + Text( + "Choose this to leave the device already recording unchanged.", + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + case let .ready(.conflict(deviceIDs)): + Label( + "Choose one recorder to resolve a conflict between \(deviceIDs.count) devices.", + systemImage: "exclamationmark.triangle.fill", + ) + .foregroundStyle(.orange) + case .ready(.unconfigured), .ready(.invalid): + EmptyView() + case let .failed(description): + Label(description, systemImage: "icloud.slash") + .foregroundStyle(.secondary) + } Toggle( String(localized: .settingsDevicesAutomaticRecording), isOn: $recordingEnabled, ) + .disabled(preserveExistingAssignment) Text(recordingRecommendation) .font(.subheadline) .foregroundStyle(.secondary) @@ -323,7 +360,10 @@ public struct OnboardingView: View { // Request Always-location only after the user confirms an // enabled choice; the launch's reconcile step picks up // whatever the system grants. - finish(enableLocation: recordingEnabled) + finish( + enableLocation: recordingEnabled, + preserveExistingAssignment: preserveExistingAssignment, + ) } label: { Text(String(localized: .onboardingContinue)) .frame(maxWidth: .infinity) @@ -331,7 +371,7 @@ public struct OnboardingView: View { .buttonStyle(.borderedProminent) .controlSize(.large) } - .disabled(isFinishing) + .disabled(isFinishing || assignmentDiscovery == .loading) } .padding(.horizontal, stylesheet.spacing.xxxLarge) .padding(.bottom, stylesheet.spacing.xxxLarge) @@ -339,6 +379,7 @@ public struct OnboardingView: View { } .scrollBounceBehavior(.basedOnSize) } + .task { await discoverRecordingAssignment() } } private var recordingTitle: LocalizedStringResource { @@ -364,7 +405,10 @@ public struct OnboardingView: View { /// A store that won't open fails the gate rather than stranding the user /// on a dead intro: the runner lands on the failure surface, which is /// where an unopenable store has always surfaced. - private func finish(enableLocation: Bool) { + private func finish( + enableLocation: Bool, + preserveExistingAssignment: Bool = false, + ) { guard !isFinishing else { return } let readyImport = restoreSelection.readyImport if restoreSelection.selectedURL != nil { @@ -489,7 +533,7 @@ public struct OnboardingView: View { do { let authorization = await scope.services.ingestor.authorizationStatus() try await scope.services.recording.registerForOnboarding( - desiredEnabled: enableLocation, + desiredEnabled: preserveExistingAssignment ? nil : enableLocation, authorization: authorization, ) } catch { @@ -511,7 +555,7 @@ public struct OnboardingView: View { return } - if enableLocation { + if enableLocation, preserveExistingAssignment == false { await enableTracking(in: scope) } // Only commit when the user actually picked regions in the manual @@ -539,6 +583,20 @@ public struct OnboardingView: View { } } + private func discoverRecordingAssignment() async { + guard assignmentDiscovery == .idle else { return } + assignmentDiscovery = .loading + do { + let resolution = try await model.discoverRecordingAssignment() + assignmentDiscovery = .ready(resolution) + if case let .resolved(assignment) = resolution, assignment.deviceID != nil { + preserveExistingAssignment = true + } + } catch { + assignmentDiscovery = .failed(error.localizedDescription) + } + } + /// Drive the system prompt for the recording choice already persisted in /// the installation context, so the prompt maps 1:1 to the tap that asked /// for it. The `sync-auth` and `reconcile-tracking` steps run as soon as the From eb99e2bb6dc7223064857f6f60d54054f53177ae Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 08:50:17 -0700 Subject: [PATCH 13/31] Show one automatic recorder in Settings --- .../Devices/DeviceRecordingController.swift | 5 +- .../WhereUI/Sources/Model/WhereSession.swift | 23 ++++++ .../Settings/DeviceSettingsSection.swift | 30 +------- .../Settings/DevicesSettingsModel.swift | 70 +++++++++++++++++++ .../Settings/DevicesSettingsView.swift | 5 +- .../Settings/RecordingAuthoritySection.swift | 59 ++++++++++++++++ 6 files changed, 160 insertions(+), 32 deletions(-) create mode 100644 Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index ad4e512e..d6e7ac99 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -383,10 +383,13 @@ public actor DeviceRecordingController { if deviceID == currentDevice.id, !enabled { await ingestor.revokeRecordingAuthorization() } + if let assignmentChange, assignmentChange.assignedDeviceID != currentDevice.id { + await ingestor.revokeRecordingAuthorization() + } // The cutoff is already durable even if physical acknowledgement below fails. await onPolicyChanged() - if deviceID == currentDevice.id { + if deviceID == currentDevice.id || assignmentChange != nil { try await reconcileCurrentAfterCommandLocked() } return try await configurationsLocked(includeArchived: false) diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index b1d5d01a..ef405668 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -448,6 +448,29 @@ public final class WhereSession { try await services.recording.devices() } + public func recordingAuthoritySnapshot() async throws -> RecordingAuthoritySnapshot { + try await services.recording.authoritySnapshot() + } + + public func assignAutomaticRecording(to deviceID: RecordingDeviceID) async throws { + _ = try await services.recording.assignAutomaticRecording(to: deviceID) + if deviceID == currentRecordingDeviceID { + do { + try await services.ingestor.requestPermission() + } catch { + permissionDenied = true + } + await syncAuthorization() + _ = try await services.recording.reconcile(authorization: authorizationStatus) + } + await synchronizeRecordingRuntimeState() + } + + public func turnOffAutomaticRecording() async throws { + _ = try await services.recording.turnOffAutomaticRecording() + await synchronizeRecordingRuntimeState() + } + /// Set automatic recording for any installation. The current device also /// runs the permission flow and updates the session's live tracking mirror. public func setRecordingEnabled( diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift index bd17c58d..5db2fc74 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift @@ -1,8 +1,7 @@ import SwiftUI import WhereCore -/// Form section for one device. It binds directly to the row model and sends -/// async effects through the owning Devices model. +/// Form section for one installation's identity, activity, permission, and archive controls. struct DeviceSettingsSection: View { let model: DevicesSettingsModel @Bindable var row: DeviceSettingsRowModel @@ -14,33 +13,6 @@ struct DeviceSettingsSection: View { var body: some View { Section { - if row.hasResolvedRecordingPolicy { - Toggle( - String(localized: .settingsDevicesAutomaticRecording), - isOn: $row.isEnabled, - ) - .settingsRow( - DevicesSettingsView.Item.automaticRecording, - when: row.isCurrent, - ) - .disabled(row.disablesRecordingControl) - .onChange(of: row.isEnabled) { oldValue, newValue in - guard oldValue != newValue else { return } - Task { - await model.recordingPreferenceChanged(for: row) - } - } - } else { - LabeledContent(String(localized: .settingsDevicesAutomaticRecording)) { - ProgressView() - .accessibilityLabel(String(localized: .settingsDevicesStatusSyncing)) - } - .settingsRow( - DevicesSettingsView.Item.automaticRecording, - when: row.isCurrent, - ) - } - HStack { TextField(String(localized: .settingsDevicesName), text: $row.nickname) .submitLabel(.done) diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift index 60817be0..0f2b69be 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift @@ -10,6 +10,9 @@ protocol DevicesSettingsSession: AnyObject { func recordingDeviceUpdates() -> AsyncStream func recordingDevices() async throws -> [RecordingDeviceConfiguration] + func recordingAuthoritySnapshot() async throws -> RecordingAuthoritySnapshot + func assignAutomaticRecording(to deviceID: RecordingDeviceID) async throws + func turnOffAutomaticRecording() async throws func setRecordingEnabled(_ enabled: Bool, for deviceID: RecordingDeviceID) async throws func renameRecordingDevice(_ deviceID: RecordingDeviceID, to nickname: String) async throws func archiveRecordingDevice(_ deviceID: RecordingDeviceID) async throws @@ -22,6 +25,31 @@ extension WhereSession: DevicesSettingsSession { } } +extension DevicesSettingsSession { + func recordingAuthoritySnapshot() async throws -> RecordingAuthoritySnapshot { + let configurations = try await recordingDevices() + let enabled = configurations.filter { $0.isEnabled == true }.map(\.id) + let resolution: RecordingAssignmentResolution = switch enabled.count { + case 0: .resolved(.off) + case 1: .resolved(.device(enabled[0])) + default: .conflict(Set(enabled)) + } + return RecordingAuthoritySnapshot( + resolution: resolution, + devices: configurations.map(\.device), + archivedDeviceIDs: [], + ) + } + + func assignAutomaticRecording(to deviceID: RecordingDeviceID) async throws { + try await setRecordingEnabled(true, for: deviceID) + } + + func turnOffAutomaticRecording() async throws { + try await setRecordingEnabled(false, for: currentRecordingDeviceID) + } +} + /// View-scoped Devices settings state. All mutations await the serialized Core /// controller. Each row owns one operation state and this model drains its /// accepted intents in order, including a newer toggle made while a write is @@ -64,6 +92,8 @@ final class DevicesSettingsModel { private let session: any DevicesSettingsSession private(set) var state: LoadState = .idle private(set) var rows: [DeviceSettingsRowModel] = [] + private(set) var authorityResolution: RecordingAssignmentResolution = .unconfigured + var selectedRecordingDeviceID: RecordingDeviceID? private(set) var presentedFailure: Failure? @ObservationIgnored private var refreshTask: Task? @ObservationIgnored private var requestedRefreshGeneration: UInt64 = 0 @@ -96,6 +126,9 @@ final class DevicesSettingsModel { ) { self.session = session apply(configurations) + let authority = Self.compatibilityAuthority(from: configurations) + authorityResolution = authority.resolution + selectedRecordingDeviceID = authority.resolution.assignment?.deviceID state = configurations.isEmpty ? .empty : .loaded } #endif @@ -142,6 +175,20 @@ final class DevicesSettingsModel { await load(showLoading: false) } + func recordingAssignmentChanged() async { + do { + if let selectedRecordingDeviceID { + try await session.assignAutomaticRecording(to: selectedRecordingDeviceID) + } else { + try await session.turnOffAutomaticRecording() + } + await load(showLoading: false) + } catch { + surfaceLoadRefreshFailure(error) + await load(showLoading: false) + } + } + private func load(showLoading: Bool) async { // Keep already rendered rows visible while a manual retry reconciles them. If that read // fails again, the user gets another retryable alert instead of a permanent spinner. @@ -234,10 +281,17 @@ final class DevicesSettingsModel { let generation = requestedRefreshGeneration do { let configurations = try await session.recordingDevices() + let authority = if let liveSession = session as? WhereSession { + try await liveSession.recordingAuthoritySnapshot() + } else { + Self.compatibilityAuthority(from: configurations) + } completedRefreshGeneration = generation guard generation == requestedRefreshGeneration else { continue } lastRefreshFailure = nil apply(configurations) + authorityResolution = authority.resolution + selectedRecordingDeviceID = authority.resolution.assignment?.deviceID } catch { completedRefreshGeneration = generation guard generation == requestedRefreshGeneration else { continue } @@ -247,6 +301,22 @@ final class DevicesSettingsModel { refreshTask = nil } + private static func compatibilityAuthority( + from configurations: [RecordingDeviceConfiguration], + ) -> RecordingAuthoritySnapshot { + let enabled = configurations.filter { $0.isEnabled == true }.map(\.id) + let resolution: RecordingAssignmentResolution = switch enabled.count { + case 0: .resolved(.off) + case 1: .resolved(.device(enabled[0])) + default: .conflict(Set(enabled)) + } + return RecordingAuthoritySnapshot( + resolution: resolution, + devices: configurations.map(\.device), + archivedDeviceIDs: [], + ) + } + private func apply(_ configurations: [RecordingDeviceConfiguration]) { let existing = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) rows = configurations.map { configuration in diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift index cebce412..a2a244e4 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift @@ -2,8 +2,8 @@ import SnapshotKit import SwiftUI import WhereCore -/// Synced device-management screen. Each installation has its own automatic -/// recording intent, editable nickname, acknowledgement state, and last check-in. +/// Synced device-management screen. One authority card assigns automatic recording account-wide; +/// installation rows retain editable identity, acknowledgement, permission, and activity state. struct DevicesSettingsView: View { var focus: SettingsFocus? @@ -74,6 +74,7 @@ struct DevicesSettingsView: View { } } case .loaded: + RecordingAuthoritySection(model: model) ForEach(model.rows) { row in DeviceSettingsSection(model: model, row: row) } diff --git a/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift b/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift new file mode 100644 index 00000000..44f0cc23 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift @@ -0,0 +1,59 @@ +import SwiftUI +import WhereCore + +/// The one account-wide automatic recorder. Device rows remain editors for identity, activity, +/// permission, and archival; assignment changes happen only here. +struct RecordingAuthoritySection: View { + @Bindable var model: DevicesSettingsModel + + var body: some View { + Section { + Picker("Automatic recording", selection: $model.selectedRecordingDeviceID) { + Text("Off").tag(RecordingDeviceID?.none) + ForEach(model.rows) { row in + Label(row.displayName, systemImage: row.systemImage) + .tag(Optional(row.id)) + } + } + .onChange(of: model.selectedRecordingDeviceID) { oldValue, newValue in + guard oldValue != newValue else { return } + Task { await model.recordingAssignmentChanged() } + } + + switch model.authorityResolution { + case .unconfigured: + Label("Choose the one device that stays with you.", systemImage: "location") + .foregroundStyle(.secondary) + case let .resolved(assignment): + if assignment.deviceID == nil { + Label("Automatic recording is Off.", systemImage: "location.slash") + .foregroundStyle(.secondary) + } else { + Label( + "Only this device records location automatically.", + systemImage: "checkmark.shield", + ) + .foregroundStyle(.secondary) + } + case let .conflict(deviceIDs): + Label( + "Recording is paused because \(deviceIDs.count) devices were chosen at the same time. Pick one to resolve it.", + systemImage: "exclamationmark.triangle.fill", + ) + .foregroundStyle(.orange) + case .invalid: + Label( + "Recording is paused while device changes finish syncing.", + systemImage: "icloud.and.arrow.down", + ) + .foregroundStyle(.secondary) + } + } header: { + Text("Recorder") + } footer: { + Text( + "Every device can still correct your history and add evidence. Transferring the recorder takes effect immediately.", + ) + } + } +} From 56e0ddb424befb01fb01c0e148601b622f2c3fc3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 09:12:58 -0700 Subject: [PATCH 14/31] Preserve recording authority across sync and backup --- Where/AGENTS.md | 23 +++---- Where/Tools/upgrade-backup.rb | 4 +- Where/WhereCore/AGENTS.md | 18 +++--- Where/WhereCore/README.md | 14 ++--- .../Sources/Backup/BackupArchive.swift | 13 +++- .../Sources/Backup/BackupCoordinator.swift | 43 +++++++++++++ .../Sources/Backup/BackupService.swift | 8 +++ .../Persistence/CloudKitImportReadiness.swift | 61 +++++++++++++++++++ .../WhereCore/Tests/BackupServiceTests.swift | 20 ++++++ .../WhereCore/Tests/WhereServicesTests.swift | 2 +- Where/WhereUI/README.md | 14 +++-- .../devices.Default_iPad.png | 4 +- .../devices.Default_iPad_accessibility.png | 4 +- .../devices.Default_iPad_ax5.png | 4 +- .../devices.Default_iPad_contrast.png | 4 +- .../devices.Default_iPad_dark.png | 4 +- .../devices.Default_iPhone.png | 4 +- .../devices.Default_iPhone_accessibility.png | 4 +- .../devices.Default_iPhone_ax5.png | 4 +- .../devices.Default_iPhone_contrast.png | 4 +- .../devices.Default_iPhone_dark.png | 4 +- .../WhereUI/Sources/Launch/WhereLaunch.swift | 5 ++ .../Sources/Onboarding/OnboardingView.swift | 5 +- .../Settings/RecordingAuthoritySection.swift | 12 ++++ 24 files changed, 220 insertions(+), 62 deletions(-) create mode 100644 Where/WhereCore/Sources/Persistence/CloudKitImportReadiness.swift diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 1697764b..f0a445fb 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -67,15 +67,13 @@ Rules the code enforces and agents must preserve: `CoreLocationSource` in production, `ScriptedLocationSource` in tests/previews. The one-shot `requestCurrentLocation()` returns `nil` rather than throwing when no fix is available. -- **Automatic location policy is per installation and append-only.** Stamp - every automatic GPS sample with its `RecordingDeviceID`; write enable/disable - events with an effective timestamp; route every user-facing sample read - through `LocationHistoryReader`. A synced cutoff hides later raw samples - immediately while the target device is still pending; device-stamped samples - fail closed until an effective On exists; archive is a state in that same - multi-parent causal policy DAG, and legacy/manual history remains visible. - Persist immutable profiles, nickname events, target-owned check-ins, and - desired-policy events separately. Keep the confirmed choice and +- **Automatic location authority is one account-wide append-only assignment.** Stamp every + automatic GPS sample with its `RecordingDeviceID` and route every user-facing sample read + through `LocationHistoryReader`. Resolve Off or exactly one installation; concurrent claims, + incomplete history, and an archived assignee fail closed. Show samples only when their source + held the assignment at capture time; legacy/manual history remains visible. Persist immutable + profiles, nickname events, archive tombstones, target-owned check-ins, and assignment events + separately. Keep the confirmed choice and immutable first profile/policy IDs and timestamps beside the backup-excluded installation identity; phone recommends On, while tablet/other recommends Off. - **Manual entries carry a `ManualEntryAudit`**; `DayJournal`'s write methods @@ -130,10 +128,9 @@ slow. `WhereServices`, the `WherePreferences` driving it, and the durable log store they record into. Created whole; `WhereSession` is built from one, so a surface can't read one world's store against another's preferences. -- **Nothing opens until the user picks a world.** The trunk is rooted at the - onboarding gate, so an install that never onboards creates no store file, - contacts no CloudKit, and opens no log store. Guard: - `WhereLaunchTests.firstRunForegroundLaunchParksOnTheOnboardingGateBeforeOpeningAnything`. +- **Onboarding may prepare the real store only for recording-authority discovery.** Retain that + exact store for scope resolution; do not construct services, expose App Intents, start GPS, or + open the log store until the user finishes choosing a world. - **At most one scope is active and log-routing at a time.** Logging out — a reset, or leaving a demo — releases and tears down the scope; logging back in builds a fresh one. Flyover is the narrow exception to "one open world": it diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 0a78471d..a136f544 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -53,7 +53,7 @@ require "digest" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 7 +CURRENT_FORMAT_VERSION = 8 INITIAL_DATA_EPOCH_ID = "00000000-0000-0000-0000-0000000000E0" SUPPORTED_SOURCE_FORMAT_VERSIONS = (1..CURRENT_FORMAT_VERSION).freeze @@ -457,6 +457,8 @@ def upgrade_manifest(manifest) sample["recordingDeviceID"] = nil unless sample.key?("recordingDeviceID") end manifest["recordingPolicyChanges"] ||= [] + manifest["recordingAssignmentChanges"] ||= [] + manifest["recordingDeviceArchives"] ||= [] upgrade_recording_devices!(manifest, source_version) manifest["formatVersion"] = CURRENT_FORMAT_VERSION warnings.uniq.each { |message| warn "warning: #{message}" } diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 701f4362..4a9ef3ba 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -52,10 +52,9 @@ internal shape. lossless.** Add persisted user-data shapes end-to-end and cover both import strategies, but export no target-owned recording check-ins and ignore any in an imported archive (`BackupServiceTests` / `BackupCoordinatorTests`). -- **Backup import never changes live recording authority.** Merge reasserts each pre-import - state (including the destructive epoch's implicit archive, defaulting every new imported - device Off); Replace rotates to a child epoch and appends an Off/archive barrier to every - imported device before discarding the local outbox (`BackupCoordinatorTests`). +- **Backup import never adopts restored recording authority.** Merge reasserts the pre-import + global assignment; Replace rotates to a child epoch and appends global Off before discarding + the local outbox (`BackupCoordinatorTests`). - **Gate import recovery with a two-phase sidecar plus an atomic store receipt.** Never clear a committed onboarding marker before its independent terminal completion tombstone (`BackupCoordinatorTests` / `WhereLaunchTests`). @@ -105,17 +104,16 @@ internal shape. `ScriptedLocationSource` in tests/previews; `requestCurrentLocation()` returns `nil`, never throws, and backs `LocationIngestor.captureTodayIfNeeded(now:)`. -- **`DeviceRecordingController` owns automatic-recording policy and physical - GPS state.** Keep policy events append-only, serialize mutations across +- **`DeviceRecordingController` owns the account-wide recording assignment and physical GPS + state.** Keep assignment events append-only, serialize mutations across awaits, fail closed when authority or acknowledgement is unavailable, stamp every ingested GPS sample with the current installation id, seed the first policy from `InstallationRecordingContext`'s explicitly confirmed choice plus its stable profile/policy IDs and timestamps, and apply `LocationHistoryReader` - to every user-facing projection. Require an effective On event for every - device-stamped sample, and represent On, Off, and archive in one multi-parent causal authority - DAG. Make each command name every observed maximal head, resolve concurrent heads + to every user-facing projection. Require the source installation to hold the effective + assignment for every device-stamped sample. Make each command name every observed maximal head, resolve concurrent heads safety-first, and derive cleanup/reset floors from the independent destructive frontier. - Persist immutable profiles, nickname events, target-owned check-ins, and policy events separately. + Persist immutable profiles, nickname events, archive tombstones, target-owned check-ins, and assignment events separately. Stamp every durable location-outbox entry with its authorizing data epoch and never replay it into another generation; backups alone read lossless raw samples and policy/device timelines, excluding non-restorable check-ins. diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 65e3bde0..3f0bd8fd 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -45,8 +45,8 @@ one it belongs to rather than to a god-object: token, emoji, SF Symbol — and pick order alongside the synced rows) — one row per region, defaulting to the four until the user chooses in the onboarding / Settings region picker. Recording identity and authority are split into - immutable profiles, append-only nickname and policy events, and target-owned - check-ins rather than one mutable device row. + immutable profiles, append-only nickname events and archive tombstones, one global assignment + history, and target-owned check-ins rather than one mutable device row. - **`WhereDataEpoch`** — the account-wide logical generation that keeps late uploads from an offline device from repopulating data after Reset or Replace. Each destructive operation appends one immutable node naming every real @@ -105,11 +105,11 @@ one it belongs to rather than to a god-object: the current installation's `RecordingDeviceID`. Every durable retry entry also carries the data epoch that authorized it, so a pre-reset fix can be discarded but never written into the replacement generation. -- **`DeviceRecordingController`** — serializes per-device enable/disable - policy with the current installation's physical `LocationIngestor`. Immutable +- **`DeviceRecordingController`** — serializes one account-wide Off-or-one-device assignment + with the current installation's physical `LocationIngestor`. Immutable profiles, nickname events, target-owned check-ins, and complete-authority - desired-policy events sync independently so one writer cannot roll another - field backward. Each policy command names every maximal event it observed; + assignment events sync independently so one writer cannot roll another field backward. Each + assignment command names every maximal event it observed; concurrent unjoined heads resolve to the most restrictive authority, while a later command joins them with one identity. A remote disable or archive affects history at its timestamp @@ -120,7 +120,7 @@ one it belongs to rather than to a god-object: GPS samples during disabled/archived intervals while keeping raw storage, backups, legacy samples without provenance, and user-asserted samples lossless. A device-stamped sample remains invisible until its matching - effective On policy arrives, so partial CloudKit delivery fails closed. + effective assignment arrives, so partial CloudKit delivery and concurrent claims fail closed. ### Detection, notifications & the rest diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 54af0ce1..2d1d3586 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -23,12 +23,13 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// target-owned check-ins, and append-only complete-authority policy events. v5 adds the /// logical data epoch in which each immutable profile registered. v6 adds causal parent /// metadata and state-preserving Merge barriers. v7 expands that metadata to a sorted parent - /// set so one semantic command can causally join every observed concurrent head. There's no + /// set so one semantic command can causally join every observed concurrent head. v8 adds the + /// account-wide recording assignment and irreversible device archive tombstones. There's no /// in-app decode /// fallback for an older archive — it is reshaped out of band by /// `Tools/upgrade-backup.rb`, matching the module's no-migration-on-read rule (see /// `AGENTS.md`). - public static let currentFormatVersion = 7 + public static let currentFormatVersion = 8 public let formatVersion: Int public let exportedAt: Date @@ -56,6 +57,10 @@ public struct BackupArchive: Codable, Sendable, Hashable { public let recordingDeviceCheckIns: [RecordingDeviceCheckIn] /// The full append-only policy timeline for every device. public let recordingPolicyChanges: [RecordingPolicyChange] + /// Account-wide automatic-recording assignment history. + public let recordingAssignmentChanges: [RecordingAssignmentChange] + /// Irreversible installation archive tombstones. + public let recordingDeviceArchives: [RecordingDeviceArchive] /// One entry per evidence record that has blob bytes in the archive. /// Evidence without bytes simply has no entry here. public let assets: [BackupAssetEntry] @@ -73,6 +78,8 @@ public struct BackupArchive: Codable, Sendable, Hashable { recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], recordingDeviceCheckIns: [RecordingDeviceCheckIn], recordingPolicyChanges: [RecordingPolicyChange], + recordingAssignmentChanges: [RecordingAssignmentChange] = [], + recordingDeviceArchives: [RecordingDeviceArchive] = [], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -87,6 +94,8 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.recordingDeviceMetadataChanges = recordingDeviceMetadataChanges self.recordingDeviceCheckIns = recordingDeviceCheckIns self.recordingPolicyChanges = recordingPolicyChanges + self.recordingAssignmentChanges = recordingAssignmentChanges + self.recordingDeviceArchives = recordingDeviceArchives self.assets = assets } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index d6f91df3..2315eac4 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -169,6 +169,8 @@ public actor BackupCoordinator { recordingDeviceMetadataChanges: store.recordingDeviceMetadataChanges(), recordingDeviceCheckIns: [], recordingPolicyChanges: store.recordingPolicyChanges(), + recordingAssignmentChanges: store.recordingAssignmentChanges(), + recordingDeviceArchives: store.recordingDeviceArchives(), ) } let evidence = tables.evidence @@ -204,6 +206,8 @@ public actor BackupCoordinator { recordingDeviceMetadataChanges: tables.recordingDeviceMetadataChanges, recordingDeviceCheckIns: tables.recordingDeviceCheckIns, recordingPolicyChanges: tables.recordingPolicyChanges, + recordingAssignmentChanges: tables.recordingAssignmentChanges, + recordingDeviceArchives: tables.recordingDeviceArchives, blobs: snapshot.blobs, ) }.value @@ -225,6 +229,8 @@ public actor BackupCoordinator { let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] let recordingDeviceCheckIns: [RecordingDeviceCheckIn] let recordingPolicyChanges: [RecordingPolicyChange] + let recordingAssignmentChanges: [RecordingAssignmentChange] + let recordingDeviceArchives: [RecordingDeviceArchive] } private struct ExportSnapshot { @@ -426,6 +432,8 @@ public actor BackupCoordinator { + archive.recordingDeviceMetadataChanges.count + archive.recordingDeviceCheckIns.count + archive.recordingPolicyChanges.count + + archive.recordingAssignmentChanges.count + + archive.recordingDeviceArchives.count // Decode and validate before touching live authority. Once the archive is known-good, // close ingestion before either merge or replace: both can change this installation's @@ -458,6 +466,21 @@ public actor BackupCoordinator { } else { [RecordingDeviceID: RecordingPolicyState]() } + let existingAssignments = try await store.recordingAssignmentChanges() + let usesGlobalAssignment = existingAssignments.count > 1 + || archive.recordingAssignmentChanges.isEmpty == false + let preservedAssignment: RecordingAssignment = if strategy == .merge { + if existingAssignments.count <= 1, + let legacyState = mergeAuthority[currentDeviceID] + { + legacyState == .on ? .device(currentDeviceID) : .off + } else { + RecordingAssignmentChange.resolve(existingAssignments).assignment + ?? .off + } + } else { + .off + } let replacementEpoch: WhereDataEpoch? = if strategy == .replace { try await store.rotateDataEpoch( reason: .backupReplace, @@ -514,6 +537,26 @@ public actor BackupCoordinator { try await store.addRecordingPolicyChange(change) report() } + for change in archive.recordingAssignmentChanges { + try await store.addRecordingAssignmentChange(change) + report() + } + for archive in archive.recordingDeviceArchives { + try await store.addRecordingDeviceArchive(archive) + report() + } + if usesGlobalAssignment { + let joinedAssignments = try await store.recordingAssignmentChanges() + let assignmentBarrier = try RecordingAssignmentChange.appendingCommand( + to: joinedAssignments, + assignment: preservedAssignment, + issuedAt: importDate, + issuedByDeviceID: currentDeviceID, + effectiveAt: importDate, + reason: strategy == .merge ? .backupMerge : .backupReplace, + ) + try await store.addRecordingAssignmentChange(assignmentBarrier) + } if let replacementEpoch { try await Self.appendReplacementSafetyBarriers( to: store, diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 3a744b96..514524ef 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -120,6 +120,8 @@ public struct BackupService: Sendable { recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], recordingDeviceCheckIns: [RecordingDeviceCheckIn], recordingPolicyChanges: [RecordingPolicyChange], + recordingAssignmentChanges: [RecordingAssignmentChange] = [], + recordingDeviceArchives: [RecordingDeviceArchive] = [], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, @@ -128,6 +130,7 @@ public struct BackupService: Sendable { metadataChanges: recordingDeviceMetadataChanges, checkIns: recordingDeviceCheckIns, policyChanges: recordingPolicyChanges, + assignmentChanges: recordingAssignmentChanges, ) let fileManager = FileManager.default let workRoot = fileManager.temporaryDirectory @@ -163,6 +166,8 @@ public struct BackupService: Sendable { recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, recordingDeviceCheckIns: recordingDeviceCheckIns, recordingPolicyChanges: recordingPolicyChanges, + recordingAssignmentChanges: recordingAssignmentChanges, + recordingDeviceArchives: recordingDeviceArchives, assets: assetEntries, ) try Self.logger.measure(.encodeManifest) { @@ -276,6 +281,7 @@ public struct BackupService: Sendable { metadataChanges: archive.recordingDeviceMetadataChanges, checkIns: archive.recordingDeviceCheckIns, policyChanges: archive.recordingPolicyChanges, + assignmentChanges: archive.recordingAssignmentChanges, ) } @@ -283,11 +289,13 @@ public struct BackupService: Sendable { metadataChanges: [RecordingDeviceMetadataChange], checkIns: [RecordingDeviceCheckIn], policyChanges: [RecordingPolicyChange], + assignmentChanges: [RecordingAssignmentChange], ) throws { guard metadataChanges.allSatisfy({ $0.revision >= 0 }), checkIns.allSatisfy({ $0.revision >= 0 && $0.status != .unknown }), + RecordingAssignmentChange.formValidPersistedTimeline(assignmentChanges), RecordingPolicyChange.formValidPersistedTimelines( policyChanges, ) diff --git a/Where/WhereCore/Sources/Persistence/CloudKitImportReadiness.swift b/Where/WhereCore/Sources/Persistence/CloudKitImportReadiness.swift new file mode 100644 index 00000000..52b46674 --- /dev/null +++ b/Where/WhereCore/Sources/Persistence/CloudKitImportReadiness.swift @@ -0,0 +1,61 @@ +import CoreData +import Foundation + +/// Waits for SwiftData's initial CloudKit import without constructing application services. +@MainActor +public final class CloudKitImportReadiness: NSObject { + public struct Timeout: LocalizedError { + public init() {} + + public var errorDescription: String? { + "Where couldn’t finish checking iCloud. Choose this device or Off to continue safely." + } + } + + private var continuation: CheckedContinuation? + private var finishedValue: Bool? + private var timeoutTask: Task? + + public func start() { + NotificationCenter.default.removeObserver(self) + NotificationCenter.default.addObserver( + self, + selector: #selector(eventChanged(_:)), + name: NSPersistentCloudKitContainer.eventChangedNotification, + object: nil, + ) + } + + public func waitForImport() async -> Bool { + if let finishedValue { return finishedValue } + timeoutTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(10)) + self?.finish(false) + } + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation = $0 } + } onCancel: { + Task { @MainActor [weak self] in self?.finish(false) } + } + } + + @objc private nonisolated func eventChanged(_ notification: Notification) { + guard let event = notification + .userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] + as? NSPersistentCloudKitContainer.Event + else { return } + let imported = event.type == .import && event.endDate != nil && event.succeeded + guard imported else { return } + Task { @MainActor [weak self] in self?.finish(true) } + } + + private func finish(_ value: Bool) { + guard finishedValue == nil else { return } + finishedValue = value + timeoutTask?.cancel() + timeoutTask = nil + NotificationCenter.default.removeObserver(self) + continuation?.resume(returning: value) + continuation = nil + } +} diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index 424a0e32..8544038f 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -168,6 +168,22 @@ struct BackupServiceTests { let recordingDeviceMetadataChanges = Self.recordingDeviceMetadataFixtures() let recordingDeviceCheckIns = Self.recordingDeviceCheckInFixtures() let recordingPolicies = Self.recordingPolicyFixtures() + let assignment = RecordingAssignmentChange( + id: UUID(), + parentIDs: [], + revision: 0, + issuedAt: Self.exportDate, + issuedByDeviceID: Self.recordingDeviceID, + effectiveAt: Self.exportDate, + assignedDeviceID: Self.recordingDeviceID, + reason: .userCommand, + ) + let deviceArchive = RecordingDeviceArchive( + id: UUID(), + deviceID: Self.recordingDeviceID, + archivedAt: Self.exportDate, + archivedByDeviceID: Self.recordingDeviceID, + ) let url = try service.makeArchiveFile( samples: samples, @@ -178,6 +194,8 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, recordingDeviceCheckIns: recordingDeviceCheckIns, recordingPolicyChanges: recordingPolicies, + recordingAssignmentChanges: [assignment], + recordingDeviceArchives: [deviceArchive], blobs: blobs, exportedAt: Self.exportDate, ) @@ -199,6 +217,8 @@ struct BackupServiceTests { #expect(result.archive.recordingDeviceMetadataChanges == recordingDeviceMetadataChanges) #expect(result.archive.recordingDeviceCheckIns == recordingDeviceCheckIns) #expect(result.archive.recordingPolicyChanges == recordingPolicies) + #expect(result.archive.recordingAssignmentChanges == [assignment]) + #expect(result.archive.recordingDeviceArchives == [deviceArchive]) let encodedManifest = try #require(String( data: BackupService.makeEncoder().encode(result.archive), encoding: .utf8, diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 30e0b26c..5e2abb2e 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -1014,7 +1014,7 @@ struct WhereServicesTests { try await destination.backup.importBackup(from: url, strategy: .merge) } - #expect(await destination.ingestor.isActive) + try await waitUntil { await destination.ingestor.isActive } await store.setShouldFail(false) _ = try await destination.backup.importBackup(from: url, strategy: .merge) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 638a86e1..5f4e2533 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -79,21 +79,23 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** (export/import plus a mirror of the scope-owned committed-cleanup gate), **`RemindersSettingsModel`** (notification prefs), and - **`DevicesSettingsModel`** (synced installation names, policy, status, and + **`DevicesSettingsModel`** (one recorder assignment plus synced installation names, status, and archival). Each orchestrates `WhereServices`; none reimplements Core rules. ### Reusable views & styling - **`OnboardingView`** — the first-run flow, registered for the launch's `OnboardingGate` and handed its `LifecycleGateHandle`. The gate roots the - trunk, so there is no session (and no open store) behind it: a paged intro, + trunk, so there is no session behind it: a paged intro, then picking up to five primary US regions (map or searchable list) and giving each a look, then verifying this installation's automatic-recording - choice. Phones recommend On; tablets/other devices recommend Off, and only + choice. The final page opens and retains the real store in a dormant state to discover synced + authority, offering to preserve the existing recorder or resolve a conflict before any services, + App Intents, or GPS are active. Phones recommend On; tablets/other devices recommend Off, and only an enabled confirmation requests location permission. A restored device can inherit the backed-up onboarding flag but not the installation sidecar, so it skips straight to that final page. Finishing logs in to the real scope — the - app's one store open — and commits the picks as the tracked-region set + + app promotes that same store into its one real scope — and commits the picks as the tracked-region set + appearances before resolving the gate. The intro also offers **Restore from a backup**, which skips the manual pick/customize steps, verifies this installation's recording choice, then opens the store and imports the backup @@ -112,8 +114,8 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's picker (segmented map/list) and per-region color/emoji/icon customization, backed by `PrimaryRegionSelectionModel`. Reused by onboarding and the Settings `RegionsSettingsView` editor. -- **`DevicesSettingsView`** — Settings’ per-installation automatic-recording - controls. It distinguishes desired policy from acknowledged physical state, +- **`DevicesSettingsView`** — Settings’ one account-wide automatic-recorder card plus installation + rows for names, activity, permission, and archive. It distinguishes assignment from acknowledged physical state, labels the current installation, permits synced nicknames, and archives only remote devices while preserving their history. - **Widget views** — the shared renderers the **WhereWidgets** extension draws diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png index ca17aeaf..124fb257 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9da229067a54b929c7993617a545bec271554d8e90ba8429443334c04bb07ca3 -size 349736 +oid sha256:b3d5b853dc818db56c3d852381859f5be1ee3c740b813bd22c299957b09f6d70 +size 375403 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png index 8285c386..40ec1fc8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67f2cb2dbaf0871f3b2d04dab472cab40e9147f1100407c2dff1a92771f40cf1 -size 663418 +oid sha256:df8281dda395118ab5122e9f048e4807c7d39caa9e9dd32de674fdb134b23661 +size 697625 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png index e1b4720d..e54db458 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47a7e9a0685218a45579db4f910fa7bd04db330bd5c8f6695da6582c766f66ad -size 513741 +oid sha256:d26cfb9f59e1ff2e3e02cb49101f304775c2647f4670685990fb02105e3f90a9 +size 512094 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png index 755950fc..f0ce136d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38c0370a858e567a21fdb7daed7ca343aa0604ebd4f449bbdafb5ddcd2669511 -size 350740 +oid sha256:8e2770c08e06701b3440abd6bb7b8cbf27a9d06b557b530621f35a951a865b41 +size 377030 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png index 4b1c870a..44fd0a5c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4c461f018c87b503b7a5ca7abacb612cdad044d23156ec5620c61f0086b0578 -size 356166 +oid sha256:d41dcbbb6ba4179e5f4b89097daf1c138871ae8f190efae8858adfda7de0fb91 +size 382815 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png index adaaf938..d4ed16d7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e6d53ef0d2ae1deb7946fa7996ce43056843950aabd337574549df5ad8e0956 -size 233741 +oid sha256:b5e5d24c1b225a48e576b0f92e800af16d7c25d9bfa0b75d6c1df8eb45a9cf09 +size 242321 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png index f58ea4c3..d88f3026 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5894f347d5f29da3ec2aa7aac24d4eda95a2ae3fbc39d531315cb54904975353 -size 511027 +oid sha256:485419f9b4d31fa8eecfe6bc62879f5608d864b53315f0d2b821bb8bf4264bfb +size 501089 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png index bb4b6d7c..4d0dadbe 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:296a0b4ffa1d368563d2ce470c75a37160e163e70b46042bfd6de9a38c0f7668 -size 234658 +oid sha256:a0de4750b943e38ba1ce15c555a27b4df9e0d351da3cd591f1fcca2e195cf47b +size 234989 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png index d4292f2e..31dbbb17 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff1c6de59f922f69890a6aca21e281f6f3566b5c6b68700de291c5f9f7b5999f -size 236369 +oid sha256:0932bdf9bae3ae9b0e6e3b185cdc6bdc2fc5f0e0d267a690017c9517eaadf265 +size 246330 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png index 948f320c..6a34281e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:778f2188172c88222632b3af6ab26c413d774b70be54b3fafff84d38abc54d9b -size 237784 +oid sha256:dbc1d8a954947f8ddef3f0ac59a0d5511b7156ad3378a730e07a3807d58a66b4 +size 247658 diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index aff81f54..aeb7f679 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -323,7 +323,12 @@ public final class WhereBootstrap: WhereScopeAssembling { } public func discoverRecordingAssignment() async throws -> RecordingAssignmentResolution { + let readiness = CloudKitImportReadiness() + if storeStorage == .cloudKit { readiness.start() } let store = try await prepareStore() + if storeStorage == .cloudKit, await readiness.waitForImport() == false { + throw CloudKitImportReadiness.Timeout() + } return try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index ac06b5bc..8d70af7e 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -11,8 +11,9 @@ import UniformTypeIdentifiers /// record automatically. Enabling it requests background-location permission /// here, rather than burying that decision in Settings. /// -/// Nothing exists behind this screen yet: the gate roots the trunk, so the -/// store is unopened and there is no session. Onboarding is what brings the +/// No session exists behind this screen: the gate roots the trunk. The final choice may prepare +/// and retain the real store solely to discover synced authority; services and GPS remain dormant. +/// Onboarding is what brings the /// user's world into being — restoring a backup or finishing the flow logs in /// to the real scope (`WhereModel.resolveScope()`, which performs the app's one /// store open), commits the picked regions + appearances to it, persists the diff --git a/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift b/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift index 44f0cc23..c07eead5 100644 --- a/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift +++ b/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift @@ -57,3 +57,15 @@ struct RecordingAuthoritySection: View { } } } + +#if DEBUG + #Preview { + let session = PreviewSupport.loadedSession() + let model = DevicesSettingsModel( + session: session, + configurations: PreviewSupport.recordingDeviceConfigurations(), + ) + Form { RecordingAuthoritySection(model: model) } + .environment(session) + } +#endif From 620645bba523439b264c3186e85dc3592cca2027 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 09:59:47 -0700 Subject: [PATCH 15/31] Remove obsolete per-device recording policies --- Where/TODOs.md | 4 +- Where/Tools/Tests/upgrade_backup_test.rb | 269 +---- Where/Tools/upgrade-backup.rb | 419 +------- Where/WhereCore/README.md | 25 +- .../Sources/Backup/BackupArchive.swift | 32 +- .../Sources/Backup/BackupCoordinator.swift | 194 +--- .../Sources/Backup/BackupService.swift | 24 +- .../Devices/DeviceRecordingController.swift | 562 +++-------- .../InstallationRecordingContext.swift | 18 +- .../InstallationRecordingContextStoring.swift | 2 +- .../Devices/LocationHistoryReader.swift | 14 +- .../Devices/RecordingAssignmentFilter.swift | 23 + .../Sources/Devices/RecordingDevice.swift | 28 +- .../Devices/RecordingDeviceCheckIn.swift | 38 +- .../RecordingDeviceConfiguration.swift | 72 +- .../RecordingDeviceMetadataChange.swift | 4 +- .../Devices/RecordingPersistenceError.swift | 13 +- .../Devices/RecordingPolicyChange.swift | 445 --------- .../Devices/RecordingPolicyFilter.swift | 66 -- .../Devices/RecordingPolicyResolution.swift | 6 - .../Devices/ResolvedRecordingPolicy.swift | 21 - .../Sources/Location/LocationIngestor.swift | 2 +- .../DeviceRecordingControllerLog.swift | 4 +- .../Sources/Persistence/SwiftDataStore.swift | 185 +--- .../Sources/Persistence/WhereStore.swift | 16 +- Where/WhereCore/Sources/WhereServices.swift | 3 +- .../Tests/BackupCoordinatorTests.swift | 297 +----- .../WhereCore/Tests/BackupServiceTests.swift | 144 +-- .../DeviceRecordingControllerTests.swift | 945 ++---------------- .../InstallationRecordingContextTests.swift | 6 +- .../Tests/LocationIngestorTests.swift | 8 - .../RecordingAssignmentFilterTests.swift | 108 ++ .../Tests/RecordingPolicyChangeTests.swift | 371 ------- .../Tests/RecordingPolicyFilterTests.swift | 328 ------ Where/WhereCore/Tests/ReportReaderTests.swift | 12 +- .../WhereCore/Tests/SwiftDataStoreTests.swift | 138 +-- .../WhereCore/Tests/WhereServicesTests.swift | 75 +- .../devices.Default_iPhone.png | 4 +- .../devices.Default_iPhone_accessibility.png | 4 +- .../devices.Default_iPhone_contrast.png | 4 +- .../devices.Default_iPhone_dark.png | 4 +- ...oryInstallationRecordingContextStore.swift | 2 +- .../InstallationRecordingContextStore.swift | 22 +- .../Launch/WhereLifecycleFailureView.swift | 2 +- .../WhereUI/Sources/Model/WhereSession.swift | 4 +- .../Sources/Preview/PreviewSupport.swift | 29 +- .../Settings/DeviceSettingsRowModel.swift | 36 +- .../Settings/DeviceSettingsSection.swift | 4 +- .../Tests/DeviceSettingsRowModelTests.swift | 37 +- .../Tests/DevicesSettingsModelTests.swift | 90 +- ...stallationRecordingContextStoreTests.swift | 6 +- ...stallationRecordingContextStoreTests.swift | 14 +- .../OnboardingRestoreSelectionTests.swift | 2 +- Where/WhereUI/Tests/OnboardingTests.swift | 2 +- Where/WhereUI/Tests/Support/TestStore.swift | 68 +- Where/WhereUI/Tests/WhereFormatTests.swift | 2 +- .../WhereLifecycleFailureViewTests.swift | 2 +- .../Tests/WhereSessionTrackingTests.swift | 20 +- 58 files changed, 894 insertions(+), 4385 deletions(-) create mode 100644 Where/WhereCore/Sources/Devices/RecordingAssignmentFilter.swift delete mode 100644 Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift delete mode 100644 Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift delete mode 100644 Where/WhereCore/Sources/Devices/RecordingPolicyResolution.swift delete mode 100644 Where/WhereCore/Sources/Devices/ResolvedRecordingPolicy.swift create mode 100644 Where/WhereCore/Tests/RecordingAssignmentFilterTests.swift delete mode 100644 Where/WhereCore/Tests/RecordingPolicyChangeTests.swift delete mode 100644 Where/WhereCore/Tests/RecordingPolicyFilterTests.swift diff --git a/Where/TODOs.md b/Where/TODOs.md index e98cc97b..5176eeb7 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -22,7 +22,7 @@ The item format and the placement rule live in the root - perf(WhereCore) [needs-design]: Performance pass — how often is the app booting? Can we only do it on changes of, say, 1 km or more? (human) ## P1s (Should do) -- fix(WhereCore) [needs-design]: Replace cross-device wall-clock recording cutoffs with a target-applied or server-time boundary. Causal revisions converge which policy wins, but `RecordingPolicyFilter` still compares sample timestamps against `effectiveAt` authored on the issuing device, so substantial clock skew can hide pre-disable samples or expose post-disable samples. Preserve the immediate remote-history cutoff while making its boundary independent of peer clock agreement. (`RecordingPolicyChange.swift`, `RecordingPolicyFilter.swift`; PR #160 review) +- fix(WhereCore) [needs-design]: Replace cross-device wall-clock recording cutoffs with a target-applied or server-time boundary. Causal revisions converge which assignment wins, but `RecordingAssignmentFilter` still compares sample timestamps against `effectiveAt` authored on the issuing device, so substantial clock skew can hide pre-transfer samples or expose post-transfer samples. Preserve the immediate remote-history cutoff while making its boundary independent of peer clock agreement. (`RecordingAssignmentChange.swift`, `RecordingAssignmentFilter.swift`; PR #160 review) - refactor(WhereCore) [needs-design]: Scope diagnostic emission so Flyover's unactivated sibling demo world cannot write its activity through the process-global `WhereLog` / `Periscope.shared` facade into the active real scope's durable diagnostic store. `WhereFlyoverWorld.build()` correctly gives the sibling a private `Periscope` and never starts its sink, but static `WhereLog` channels still bypass that injection; carry the scope's logging system through services/models or add a task-/environment-scoped routing context before treating Flyover's diagnostic activity as isolated. Domain data, preferences, widgets, notifications, and location remain in memory/no-op already. (`WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift`, `WhereCore/Sources/Logging/WhereLog.swift`; agent 2026-07-29) - fix(WhereUI) [quick-win]: `CalendarDay.displayDate` resolves through `Calendar.current` (`DateRangeFormatting.swift:33`), so every day label that flows through it — relabel, logged days, resolution details, the region drill-in — renders a wrong date on a non-Gregorian device: `startOfDay(in:)` interprets the day's Gregorian Y-M-D as *that* calendar's components, so a Buddhist-era device resolves 2026-07-26 to a date ~543 years off. `DateRangeFormatting.abbreviated` (`:6`, `:19`) and `PresenceTimeline.stints` (`PresenceTimeline.swift:37`) also *default* to `.current`, and `PresenceTimelineList` (`:12`) doesn't pass `report.calendar`. Take an explicit calendar (Gregorian + current time zone) in the helper and thread the report's calendar from the call sites. The `where.gregorian_calendar` Bumper rule that should catch this is blind to the implicit-member form — filed in the root [`TODOs.md`](../TODOs.md). (audit 2026-07-26) - fix(WhereCore) [needs-design]: `WhereServices.setPrimaryRegions(_:)` (`:285`) commits atomically but skips `DayJournal.reconcileAfterDayChange()` — widgets/reminders/summary don't refresh until foreground/configure. Route picker commits through the unified fan-out, or document the intentional deferral. (audit 2026-07-26) @@ -62,7 +62,7 @@ The item format and the placement rule live in the root - fix(WhereUI): broken-snapshots: `locations.Loaded_iPad.png` bakes in raw inflection markup — the Elsewhere card's subtitle renders literally as `^[3 region](inflect: true)`. This is the `locations.elsewhere.subtitle` P1 filed above, now pinned as a reference; recorded here so the image isn't mistaken for correct output, and so that reference is re-recorded when the fix lands. (pr#101 review) ## P2s (Nice to have) -- perf(WhereCore) [needs-design]: Measure and bound the append-only recording-policy and device-metadata timelines. Compaction must preserve the current causal winner, historical cutoff semantics, backup round trips, and enough audit history to diagnose cross-device commands; do not delete events merely because a newer one exists. (`DeviceRecordingController.swift`, `RecordingPolicyFilter.swift`; PR #160 review) +- perf(WhereCore) [needs-design]: Measure and bound the append-only recording-assignment and device-metadata timelines. Compaction must preserve the current causal winner, historical cutoff semantics, backup round trips, and enough audit history to diagnose cross-device commands; do not delete events merely because a newer one exists. (`DeviceRecordingController.swift`, `RecordingAssignmentFilter.swift`; PR #160 review) - feat(Where): Consider the user-assigned device-name entitlement and matching provisioning-profile support so the Devices screen can offer a better initial label than the generic hardware family. Keep the current generic name until the entitlement is intentionally provisioned; never silently depend on an entitlement absent from developer signing. (`FileInstallationRecordingContextStore`; PR #160 review) - feat(WhereUI) [needs-design]: Give the app a branded launch screen. `UILaunchScreen` is an empty dictionary (`Project.swift`), so the pre-main frame is plain white. Measured from a fresh-install simulator recording, a first run reads as ~1.7s of white → ~0.25s of the dark `LaunchSplashView` → the light onboarding screen, so the splash registers as a quarter-second dark blip between two light screens rather than as the app opening. A launch screen matching the splash's background + icon would make that continuous. Note this is the right layer to fix it at: the splash's own `minimumSplashDuration` hold deliberately gates only the `.ready` reveal, not a gate transition like onboarding, so lengthening the hold would just delay interactive UI. (agent) - refactor(WhereUI) [needs-design]: Make the scene-scoped model wiring compiler-checked rather than an `@Environment` lookup that fails silently. `WhereSession` (the always-on coordinator) is read from the environment, so a screen mounted without a parent injecting it resolves to a runtime fallback/precondition instead of a compile error. The scoped models (`YearReportModel`, `ResolveModel`, `BackupModel`, `RemindersSettingsModel`) are already constructor-injected; explore threading the coordinator the same way (or a non-defaulting typed `EnvironmentKey`) so a broken wiring can't build. Follow-up from the `WhereSession` split. (agent) diff --git a/Where/Tools/Tests/upgrade_backup_test.rb b/Where/Tools/Tests/upgrade_backup_test.rb index f66f023e..7e373ba0 100644 --- a/Where/Tools/Tests/upgrade_backup_test.rb +++ b/Where/Tools/Tests/upgrade_backup_test.rb @@ -4,241 +4,48 @@ require_relative "../upgrade-backup" class UpgradeBackupTest < Minitest::Test - DEVICE_ID = "store://devices/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + def test_v1_adds_current_tables_without_inventing_recording_consent + upgraded = upgrade_manifest(base_manifest(1)) + + assert_equal 3, upgraded.fetch("formatVersion") + assert_equal [], upgraded.fetch("recordingDeviceProfiles") + assert_equal [], upgraded.fetch("recordingDeviceMetadataChanges") + assert_equal [], upgraded.fetch("recordingAssignmentChanges") + assert_equal [], upgraded.fetch("recordingDeviceArchives") + assert_nil upgraded.fetch("samples").first.fetch("recordingDeviceID") + end - def test_v3_final_archive_state_out_ranks_its_later_monotonic_off_cutoff - manifest = { - "formatVersion" => 3, - "samples" => [], - "evidence" => [], - "manualDays" => [], - "dismissedIssues" => [], - "trackedRegions" => [], - "primaryRegions" => [], - "assets" => [], - "recordingDevices" => [ - { - "id" => DEVICE_ID, - "systemName" => "iPad", - "kind" => "tablet", - "registeredAt" => 100.0, - "lastSeenAt" => 200.0, - "archivedAt" => 200.0, - "lastAppliedPolicyChangeID" => "22222222-2222-2222-2222-222222222222", - "status" => "off", - "nickname" => nil, - }, - ], - "recordingPolicyChanges" => [ - { - "id" => "11111111-1111-1111-1111-111111111111", - "deviceID" => DEVICE_ID, - "effectiveAt" => 100.0, - "isEnabled" => true, - }, - { - "id" => "22222222-2222-2222-2222-222222222222", - "deviceID" => DEVICE_ID, - # The v3 writer advanced equal/backward cutoffs by one microsecond. - "effectiveAt" => 200.000001, - "isEnabled" => false, - }, - ], - } + def test_v1_synthesizes_primary_regions_and_rekeys_legacy_ids + manifest = base_manifest(1).merge("trackedRegions" => ["california", "newYork"]) upgraded = upgrade_manifest(manifest) - policies = upgraded.fetch("recordingPolicyChanges") - assert_equal [0, 1, 2], policies.map { |policy| policy.fetch("revision") } + assert_equal ["us-CA", "us-NY"], upgraded.fetch("trackedRegions") assert_equal [ - [], - [policies[0].fetch("id")], - [policies[1].fetch("id")], - ], policies.map { |policy| policy.fetch("parentIDs") } - refute policies.any? { |policy| policy.key?("parentID") } - assert_equal "archived", policies.last.fetch("state") - assert_equal "archive", policies.last.fetch("reason") + { "region" => "us-CA", "appearance" => nil, "order" => 0 }, + { "region" => "us-NY", "appearance" => nil, "order" => 1 }, + ], upgraded.fetch("primaryRegions") end - def test_v3_active_device_expands_into_current_tables_idempotently - policy_id = "11111111-1111-1111-1111-111111111111" - manifest = base_manifest(3).merge( - "recordingDevices" => [ - { - "id" => DEVICE_ID, - "systemName" => "iPhone", - "kind" => "phone", - "registeredAt" => 100.0, - "lastSeenAt" => 200.0, - "archivedAt" => nil, - "lastAppliedPolicyChangeID" => policy_id, - "status" => "recording", - "nickname" => "Travel phone", - }, - ], - "recordingPolicyChanges" => [ - { - "id" => policy_id, - "deviceID" => DEVICE_ID, - "effectiveAt" => 100.0, - "isEnabled" => true, - }, + def test_v2_preserves_primary_region_appearance + appearance = { "color" => "orange", "emoji" => "🌴", "symbolName" => nil } + manifest = base_manifest(2).merge( + "primaryRegions" => [ + { "region" => "us-CA", "appearance" => appearance, "order" => 0 }, ], ) - upgraded = upgrade_manifest(deep_copy(manifest)) - - assert_equal CURRENT_FORMAT_VERSION, upgraded.fetch("formatVersion") - refute upgraded.key?("recordingDevices") - assert_equal [ - { - "id" => DEVICE_ID, - "systemName" => "iPhone", - "kind" => "phone", - "registeredAt" => 100.0, - "registrationEpochID" => { "rawValue" => INITIAL_DATA_EPOCH_ID }, - }, - ], upgraded.fetch("recordingDeviceProfiles") - - metadata = upgraded.fetch("recordingDeviceMetadataChanges") - assert_equal 1, metadata.length - assert_equal DEVICE_ID, metadata.first.fetch("deviceID") - assert_equal "nickname", metadata.first.fetch("field") - assert_equal 0, metadata.first.fetch("revision") - assert_equal 200.0, metadata.first.fetch("changedAt") - assert_equal DEVICE_ID, metadata.first.fetch("changedByDeviceID") - assert_equal "Travel phone", metadata.first.fetch("nickname") - - assert_equal [ - { - "deviceID" => DEVICE_ID, - "revision" => 0, - "lastSeenAt" => 200.0, - "appliedAt" => 200.0, - "lastAppliedPolicyChangeID" => policy_id, - "status" => "recording", - }, - ], upgraded.fetch("recordingDeviceCheckIns") - - policy = upgraded.fetch("recordingPolicyChanges").fetch(0) - assert_equal policy_id, policy.fetch("id") - assert_equal DEVICE_ID, policy.fetch("deviceID") - assert_equal [], policy.fetch("parentIDs") - refute policy.key?("parentID") - assert_equal 0, policy.fetch("revision") - assert_equal 100.0, policy.fetch("issuedAt") - assert_equal DEVICE_ID, policy.fetch("issuedByDeviceID") - assert_equal 100.0, policy.fetch("effectiveAt") - assert_equal "on", policy.fetch("state") - assert_equal "initialRegistration", policy.fetch("reason") - - assert_equal upgraded, upgrade_manifest(deep_copy(upgraded)) + assert_equal manifest["primaryRegions"], upgrade_manifest(manifest)["primaryRegions"] end - def test_v4_preserves_independent_tables_and_links_flat_policy_revisions - root_id = "11111111-1111-1111-1111-111111111111" - on_id = "22222222-2222-2222-2222-222222222222" - off_id = "33333333-3333-3333-3333-333333333333" - descendant_id = "44444444-4444-4444-4444-444444444444" - profile = { - "id" => DEVICE_ID, - "systemName" => "iPad", - "kind" => "tablet", - "registeredAt" => 100.0, - } - metadata = [ - { - "id" => "aaaaaaaa-1111-1111-1111-111111111111", - "deviceID" => DEVICE_ID, - "field" => "nickname", - "revision" => 0, - "changedAt" => 110.0, - "changedByDeviceID" => DEVICE_ID, - "nickname" => "Kitchen iPad", - }, - ] - check_ins = [ - { - "deviceID" => DEVICE_ID, - "revision" => 7, - "lastSeenAt" => 140.0, - "appliedAt" => 140.0, - "lastAppliedPolicyChangeID" => off_id, - "status" => "off", - }, - ] - policies = [ - policy(root_id, revision: 0, state: "off", effective_at: 100.0), - policy(on_id, revision: 1, state: "on", effective_at: 120.0), - policy(off_id, revision: 1, state: "off", effective_at: 121.0), - policy(descendant_id, revision: 2, state: "on", effective_at: 130.0), - ] - manifest = base_manifest(4).merge( - "recordingDeviceProfiles" => [profile], - "recordingDeviceMetadataChanges" => metadata, - "recordingDeviceCheckIns" => check_ins, - "recordingPolicyChanges" => policies, - ) - - upgraded = upgrade_manifest(deep_copy(manifest)) - upgraded_policies = upgraded.fetch("recordingPolicyChanges") - - assert_equal CURRENT_FORMAT_VERSION, upgraded.fetch("formatVersion") - assert_equal [profile.merge( - "registrationEpochID" => { "rawValue" => INITIAL_DATA_EPOCH_ID }, - )], upgraded.fetch("recordingDeviceProfiles") - assert_equal metadata, upgraded.fetch("recordingDeviceMetadataChanges") - assert_equal check_ins, upgraded.fetch("recordingDeviceCheckIns") - assert_equal policies, upgraded_policies.map { |entry| entry.reject { |key| key == "parentIDs" } } - assert_equal [], upgraded_policies[0].fetch("parentIDs") - assert_equal [root_id], upgraded_policies[1].fetch("parentIDs") - assert_equal [root_id], upgraded_policies[2].fetch("parentIDs") - # The old flat resolver preferred Off at the concurrent revision, so the - # formerly ambiguous revision-2 event is attached to that deterministic winner. - assert_equal [off_id], upgraded_policies[3].fetch("parentIDs") - refute upgraded_policies.any? { |entry| entry.key?("parentID") } - end - - def test_v6_converts_nullable_scalar_policy_parents_to_parent_sets - root_id = "11111111-1111-1111-1111-111111111111" - child_id = "22222222-2222-2222-2222-222222222222" - policies = [ - policy(root_id, revision: 0, state: "off", effective_at: 100.0).merge( - "parentID" => nil, - ), - policy(child_id, revision: 1, state: "on", effective_at: 120.0).merge( - "parentID" => root_id, - ), - ] - manifest = base_manifest(6).merge("recordingPolicyChanges" => policies) - - upgraded = upgrade_manifest(deep_copy(manifest)) - upgraded_policies = upgraded.fetch("recordingPolicyChanges") - - assert_equal CURRENT_FORMAT_VERSION, upgraded.fetch("formatVersion") - assert_equal [[], [root_id]], upgraded_policies.map { |entry| entry.fetch("parentIDs") } - refute upgraded_policies.any? { |entry| entry.key?("parentID") } - assert_equal upgraded, upgrade_manifest(deep_copy(upgraded)) + def test_v3_is_idempotent + once = upgrade_manifest(base_manifest(3)) + assert_equal once, upgrade_manifest(Marshal.load(Marshal.dump(once))) end - def test_v7_preserves_a_sorted_multi_parent_set - parent_ids = [ - "11111111-1111-1111-1111-111111111111", - "22222222-2222-2222-2222-222222222222", - ] - join = policy( - "33333333-3333-3333-3333-333333333333", - revision: 2, - state: "off", - effective_at: 130.0, - ).merge("parentIDs" => parent_ids) - manifest = base_manifest(7).merge("recordingPolicyChanges" => [join]) - - upgraded = upgrade_manifest(deep_copy(manifest)) - - assert_equal CURRENT_FORMAT_VERSION, upgraded.fetch("formatVersion") - assert_equal parent_ids, upgraded.fetch("recordingPolicyChanges").fetch(0).fetch("parentIDs") - refute upgraded.fetch("recordingPolicyChanges").fetch(0).key?("parentID") + def test_rejects_branch_only_or_future_formats + error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(4)) } + assert_equal 1, error.status end private @@ -247,30 +54,12 @@ def base_manifest(version) { "formatVersion" => version, "exportedAt" => 0.0, - "samples" => [], + "samples" => [{ "id" => "sample" }], "evidence" => [], "manualDays" => [], "dismissedIssues" => [], "trackedRegions" => [], - "primaryRegions" => [], "assets" => [], } end - - def policy(id, revision:, state:, effective_at:) - { - "id" => id, - "deviceID" => DEVICE_ID, - "revision" => revision, - "issuedAt" => effective_at, - "issuedByDeviceID" => DEVICE_ID, - "effectiveAt" => effective_at, - "state" => state, - "reason" => revision.zero? ? "initialRegistration" : "userCommand", - } - end - - def deep_copy(value) - JSON.parse(JSON.generate(value)) - end end diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index a136f544..b079f88c 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -1,64 +1,20 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Upgrades an exported Where backup `.zip` to the current manifest shape so it -# can be re-imported after the Codable / legacy-field cleanup. -# -# The app no longer migrates old data on read (see -# `Where/WhereCore/AGENTS.md`), so an export produced by an older build must be -# reshaped once, out of band, before import. This script does exactly that, -# rewriting `manifest.json` inside the archive and leaving `assets/` untouched: -# -# - Region ids: rekeys the former enum-case ids to catalog ids -# (`california` -> `us-CA`, `newYork` -> `us-NY`, -# `europeanUnion` -> `european-union`; `canada` / `other` unchanged), -# across evidence, manual days, and tracked regions. Warns on any id it -# can't map to a real catalog region. -# - Manual days: converts a legacy absolute `date` instant to a -# timezone-independent `day` (`{year,month,day}`), recovering the calendar -# day the writer meant (UTC, +12h nudge — matching the app's former -# recovery), and ensures `isAuthoritative` is present (default `false`). -# - Dismissals: converts `{ "key": "borderDrift:2026-04-01", ... }` to -# `{ "id": "store://issues/borderDrift?day=2026-04-01", ... }`, parsing the -# old joined key and recovering any legacy epoch value to a calendar day. -# - Recording devices: splits the v3 aggregate rows into immutable profiles, -# append-only nickname metadata, target-owned check-ins, and causally ordered -# complete-authority policy events. Interim archive metadata is folded into -# that policy stream, boolean policy values become explicit state/reason, -# and pre-v6 flat revisions are parent-linked into a deterministic causal -# branch while retaining concurrent losing events for audit/cleanup; v6's -# scalar `parentID` becomes v7's sorted `parentIDs` set so current commands -# can causally join every observed concurrent head. -# v1-v4 profiles are stamped as registrations from the initial logical data -# epoch; v4's nickname/check-in rows remain untouched and its flat policy -# revisions gain parent links without being reordered or renumbered. -# - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes -# `primaryRegions` from the tracked ids (null appearance, listed order) when -# absent, adds empty device/policy tables, stamps legacy samples with null -# device provenance, and sets `formatVersion` to 7 (the current version). -# -# Idempotent: re-running on an already-upgraded archive is a no-op; every legacy -# date, id, and recording-authority transform converges on one stable value. -# -# Usage (from the repo root): -# ruby Where/Tools/upgrade-backup.rb INPUT.zip [OUTPUT.zip] -# -# OUTPUT defaults to `INPUT-upgraded.zip`. Requires the `zip` / `unzip` CLIs. +# Reshapes a legacy Where backup into the current v3 manifest. The automatic-recording feature +# was not shipped in v1 or v2, so upgrading adds the four new recording tables empty; it never +# invents an installation or recording consent. require "json" require "tmpdir" require "fileutils" require "time" require "set" -require "digest" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 8 -INITIAL_DATA_EPOCH_ID = "00000000-0000-0000-0000-0000000000E0" +CURRENT_FORMAT_VERSION = 3 SUPPORTED_SOURCE_FORMAT_VERSIONS = (1..CURRENT_FORMAT_VERSION).freeze -# Former enum-case region ids -> current catalog ids. `canada` / `other` are -# unchanged but listed so an already-current id passes through untouched. REGION_MAP = { "california" => "us-CA", "newYork" => "us-NY", @@ -67,8 +23,6 @@ "other" => "other", }.freeze -# The dismissal issue types and the query-item name(s) each carries, matching -# `DataIssueID.storeURL` (`store://issues/?`). ISSUE_PARAM_NAMES = { "missingDays" => %w[start], "borderDrift" => %w[day], @@ -89,35 +43,26 @@ def print_usage USAGE end -# The set of valid region ids: the bundled catalog plus the `other` sentinel. def catalog_region_ids - manifest = File.expand_path("../RegionKit/Sources/Resources/regions.json", __dir__) - die "regions.json not found at #{manifest}" unless File.exist?(manifest) - ids = JSON.parse(File.read(manifest)).map { |entry| entry.fetch("id") } - (ids + ["other"]).to_set + path = File.expand_path("../RegionKit/Sources/Resources/regions.json", __dir__) + die "regions.json not found at #{path}" unless File.exist?(path) + (JSON.parse(File.read(path)).map { |entry| entry.fetch("id") } + ["other"]).to_set end VALID_REGION_IDS = catalog_region_ids -# The `%04d-%02d-%02d` calendar day an instant was meant to name, robust to the -# writer's time zone: legacy day keys were midnight in the writer's zone, so we -# nudge ~12h toward noon before reading UTC components (matches -# `CalendarDay.init(recoveringLegacyStartOfDay:in:)`). def recovered_day_iso(instant_seconds) - t = Time.at(instant_seconds + (12 * 60 * 60)).utc - format("%04d-%02d-%02d", t.year, t.month, t.day) + time = Time.at(instant_seconds + (12 * 60 * 60)).utc + format("%04d-%02d-%02d", time.year, time.month, time.day) end -# Normalize one identifying value from a legacy dismissal key or manual-day -# `date` into an ISO `YYYY-MM-DD` string. def value_to_day_iso(value) case value - when /\A\d{4}-\d{2}-\d{2}\z/ # already an ISO calendar day + when /\A\d{4}-\d{2}-\d{2}\z/ value - when /\A\d+(?:\.\d+)?\z/ # legacy epoch seconds + when /\A\d+(?:\.\d+)?\z/ recovered_day_iso(value.to_f) else - # A full ISO-8601 instant (legacy manual-day `date`). recovered_day_iso(Time.iso8601(value).to_f) end rescue ArgumentError @@ -128,7 +73,7 @@ def rekey_region(id, warnings) return id if id.nil? mapped = REGION_MAP.fetch(id, id) - warnings << "unknown region id #{id.inspect} (kept as-is)" unless VALID_REGION_IDS.include?(mapped) + warnings << "unknown region id #{mapped.inspect}; the app may reject this archive" unless VALID_REGION_IDS.include?(mapped) mapped end @@ -140,296 +85,38 @@ def upgrade_evidence!(manifest, warnings) def upgrade_manual_days!(manifest, warnings) Array(manifest["manualDays"]).each do |day| - if day.key?("regions") - day["regions"] = day["regions"].map { |id| rekey_region(id, warnings) } - end - # Legacy absolute `date` instant -> timezone-independent `day`. - if !day.key?("day") && day.key?("date") + unless day.key?("day") iso = value_to_day_iso(day.delete("date")) - year, month, dom = iso.split("-").map(&:to_i) - day["day"] = { "year" => year, "month" => month, "day" => dom } + year, month, day_number = iso.split("-").map(&:to_i) + day["day"] = { "year" => year, "month" => month, "day" => day_number } end + day["regions"] = Array(day["regions"]).map { |id| rekey_region(id, warnings) } day["isAuthoritative"] = false unless day.key?("isAuthoritative") end end -# Build a `store://issues/?` URL string with sorted query items, -# matching `StoreURL.url` / `DataIssueID.storeURL`. def issue_store_url(type, values) - names = ISSUE_PARAM_NAMES.fetch(type) do - die "unknown dismissal issue type: #{type.inspect}" - end - unless names.length == values.length - die "dismissal key for #{type.inspect} has #{values.length} value(s), expected #{names.length}" - end - query = names.zip(values).sort_by(&:first).map { |name, value| "#{name}=#{value}" }.join("&") + names = ISSUE_PARAM_NAMES[type] + die "unknown dismissed issue type #{type.inspect}" unless names + die "dismissed issue #{type.inspect} expected #{names.length} value(s)" unless values.length == names.length + + query = names.zip(values).map { |name, value| "#{name}=#{value_to_day_iso(value)}" }.join("&") "store://issues/#{type}?#{query}" end def upgrade_dismissals!(manifest) Array(manifest["dismissedIssues"]).each do |dismissal| - next unless dismissal.key?("key") # already `id`-shaped -> leave it + next if dismissal.key?("id") key = dismissal.delete("key") - type, *raw_values = key.split(":") - values = raw_values.map { |value| value_to_day_iso(value) } + type, *values = key.to_s.split(":") dismissal["id"] = issue_store_url(type, values) end end -def deterministic_uuid(seed) - hex = Digest::SHA256.hexdigest(seed)[0, 32] - hex[12] = "5" - hex[16] = ((hex[16].to_i(16) & 0x3) | 0x8).to_s(16) - [hex[0, 8], hex[8, 4], hex[12, 4], hex[16, 4], hex[20, 12]].join("-") -end - -def instant_seconds(value) - value.is_a?(Numeric) ? value.to_f : Time.iso8601(value).to_f -rescue ArgumentError - 0.0 -end - -def policy_sort_key(change) - [instant_seconds(change.fetch("effectiveAt")), change.fetch("id")] -end - -def normalize_policy!(change) - change["issuedAt"] ||= change.fetch("effectiveAt") - # The v3 wire format did not retain the author. The target installation is - # the only deterministic attribution available during an out-of-band upgrade. - change["issuedByDeviceID"] ||= change.fetch("deviceID") - - unless change.key?("state") - enabled = change.delete("isEnabled") do - die "recording policy #{change.fetch('id').inspect} has neither state nor isEnabled" - end - change["state"] = enabled ? "on" : "off" - end - change.delete("isEnabled") - change["reason"] ||= if change.fetch("state") == "archived" - "archive" - elsif change.fetch("revision", 0).zero? && change.fetch("issuedByDeviceID") == change.fetch("deviceID") - "initialRegistration" - else - "userCommand" - end -end - -def policy_conflict_key(change) - reason_priority = case change.fetch("reason") - when "backupReplace" then 1 - when "accountReset" then 2 - else 0 - end - state_priority = case change.fetch("state") - when "on" then 0 - when "off" then 1 - when "archived" then 2 - else die "unknown recording policy state #{change.fetch('state').inspect}" - end - [reason_priority, state_priority, change.fetch("id")] -end - -# Pre-v6 archives had only flat revisions, so the actual parent of a command written after a -# concurrent fork is unknowable. Link every event at the next revision to the same deterministic -# winner the old reader treated as current. This preserves the old archive's resolved authority; -# future writes will carry their exact observed parent and cannot cross branches. -def upgrade_policy_parents!(manifest, source_version) - return if source_version >= 6 - - Array(manifest["recordingPolicyChanges"]) - .group_by { |change| change.fetch("deviceID") } - .each_value do |changes| - by_revision = changes.group_by { |change| change.fetch("revision") } - revisions = by_revision.keys.sort - unless revisions.first == 0 && revisions.each_cons(2).all? { |before, after| after == before + 1 } - die "recording policy revisions must begin at zero without gaps" - end - - parent = nil - revisions.each do |revision| - siblings = by_revision.fetch(revision) - siblings.each do |change| - if revision.zero? - change.delete("parentID") - else - change["parentID"] = parent.fetch("id") - end - end - parent = siblings.max_by { |change| policy_conflict_key(change) } - end - end -end - -# v6 could name only one causal parent. The v7 wire shape always carries the full sorted parent -# frontier; legacy events can only contribute an empty root set or one already-canonical parent. -def upgrade_policy_parent_sets!(manifest, source_version) - return if source_version >= 7 - - Array(manifest["recordingPolicyChanges"]).each do |change| - parent_id = change.delete("parentID") - change["parentIDs"] = parent_id.nil? ? [] : [parent_id] - end -end - -def archive_policy_change(metadata) - metadata_id = metadata.fetch("id") - { - "id" => deterministic_uuid( - ["recording-policy-from-archive-metadata", metadata_id].join(":"), - ), - "deviceID" => metadata.fetch("deviceID"), - "issuedAt" => metadata.fetch("changedAt"), - "issuedByDeviceID" => metadata.fetch("changedByDeviceID"), - "effectiveAt" => metadata.fetch("changedAt"), - "archiveValue" => metadata.fetch("isArchived"), - } -end - -def upgrade_pre_v4_recording_devices!(manifest) - legacy_devices = Array(manifest.delete("recordingDevices")) - - manifest["recordingDeviceProfiles"] ||= legacy_devices.map do |device| - { - "id" => device.fetch("id"), - "systemName" => device.fetch("systemName"), - "kind" => device.fetch("kind"), - "registeredAt" => device.fetch("registeredAt"), - } - end - - manifest["recordingDeviceMetadataChanges"] ||= legacy_devices.filter_map do |device| - device_id = device.fetch("id") - next if device["nickname"].nil? - - changed_at = device["lastSeenAt"] || device.fetch("registeredAt") - nickname = device.fetch("nickname") - seed = ["recording-device-metadata", device_id, "nickname", nickname, changed_at] - .map(&:to_json).join(":") - { - "id" => deterministic_uuid(seed), - "deviceID" => device_id, - "field" => "nickname", - "revision" => 0, - "changedAt" => changed_at, - "changedByDeviceID" => device_id, - "nickname" => nickname, - } - end - - manifest["recordingDeviceCheckIns"] ||= legacy_devices.filter_map do |device| - policy_id = device["lastAppliedPolicyChangeID"] - status = device.fetch("status") - next if policy_id.nil? || status == "unknown" - - last_seen_at = device["lastSeenAt"] || device.fetch("registeredAt") - { - "deviceID" => device.fetch("id"), - "revision" => 0, - "lastSeenAt" => last_seen_at, - "appliedAt" => last_seen_at, - "lastAppliedPolicyChangeID" => policy_id, - "status" => status, - } - end - - policies = Array(manifest["recordingPolicyChanges"]) - policies.group_by { |change| change.fetch("deviceID") }.each_value do |changes| - changes.sort_by { |change| policy_sort_key(change) }.each_with_index do |change, revision| - change["revision"] ||= revision - normalize_policy!(change) - end - end - - metadata = Array(manifest["recordingDeviceMetadataChanges"]) - archive_metadata, nickname_metadata = metadata.partition { |change| change["field"] == "archive" } - manifest["recordingDeviceMetadataChanges"] = nickname_metadata - - legacy_archive_metadata = legacy_devices.filter_map do |device| - next if device["archivedAt"].nil? - - changed_at = device.fetch("archivedAt") - seed = ["recording-device-metadata", device.fetch("id"), "archive", true, changed_at] - .map(&:to_json).join(":") - { - "id" => deterministic_uuid(seed), - "deviceID" => device.fetch("id"), - "changedAt" => changed_at, - "changedByDeviceID" => device.fetch("id"), - "isArchived" => true, - } - end - - archive_commands = archive_metadata.map { |change| archive_policy_change(change) } - # `recordingDevices.archivedAt` is the v3 aggregate's final state, not merely another event - # ordered by wall clock. The paired Off cutoff can legitimately be later than `archivedAt` - # because the old writer forced effective timestamps to increase monotonically. Preserve the - # aggregate's final archived state by ordering this synthesized authority after every ordinary - # policy event for the device. - final_archive_commands = legacy_archive_metadata.map do |change| - archive_policy_change(change) - end - - # The old archive-event and desired-policy streams had no shared causal revision. Merge those - # events by effective instant, placing archive commands after ordinary policy commands at the - # same instant. The aggregate's final `archivedAt` marker is the exception handled above: it - # must remain final regardless of its paired Off cutoff. An unarchive resumes the most recent - # non-archive desired state instead of silently enabling recording. - merged = (policies.map { |change| [change, false, false] } + - archive_commands.map { |change| [change, true, false] } + - final_archive_commands.map { |change| [change, true, true] }) - .group_by { |change, _archive, _final_archive| change.fetch("deviceID") } - .flat_map do |_device_id, entries| - last_non_archive_state = "off" - entries.sort_by do |change, archive, final_archive| - [ - final_archive ? 1 : 0, - instant_seconds(change.fetch("effectiveAt")), - archive ? 1 : 0, - change.fetch("id"), - ] - end.each_with_index.map do |(change, archive, _final_archive), revision| - if archive - archived = change.delete("archiveValue") - change["state"] = archived ? "archived" : last_non_archive_state - change["reason"] = archived ? "archive" : "userCommand" - elsif change.fetch("state") != "archived" - last_non_archive_state = change.fetch("state") - end - change["revision"] = revision - change - end - end - manifest["recordingPolicyChanges"] = merged -end - -def upgrade_recording_devices!(manifest, source_version) - if source_version < 4 - upgrade_pre_v4_recording_devices!(manifest) - else - # v4 already has independent causal tables. In particular, do not sort or - # renumber its policy events by wall clock: offline writers can legitimately - # produce multiple events at one revision, and their revision is authority. - manifest.delete("recordingDevices") - manifest["recordingDeviceProfiles"] ||= [] - manifest["recordingDeviceMetadataChanges"] ||= [] - manifest["recordingDeviceCheckIns"] ||= [] - manifest["recordingPolicyChanges"] ||= [] - end - - Array(manifest["recordingDeviceProfiles"]).each do |profile| - profile["registrationEpochID"] ||= { "rawValue" => INITIAL_DATA_EPOCH_ID } - end - upgrade_policy_parents!(manifest, source_version) - upgrade_policy_parent_sets!(manifest, source_version) -end - def source_format_version(manifest) version = manifest["formatVersion"] - unless version.is_a?(Integer) - die "manifest formatVersion must be an integer" - end + die "manifest formatVersion must be an integer" unless version.is_a?(Integer) unless SUPPORTED_SOURCE_FORMAT_VERSIONS.cover?(version) die "unsupported manifest formatVersion #{version}; expected 1-#{CURRENT_FORMAT_VERSION}" end @@ -437,29 +124,30 @@ def source_format_version(manifest) end def upgrade_manifest(manifest) - source_version = source_format_version(manifest) + source_format_version(manifest) warnings = [] upgrade_evidence!(manifest, warnings) upgrade_manual_days!(manifest, warnings) upgrade_dismissals!(manifest) - if manifest.key?("trackedRegions") - manifest["trackedRegions"] = manifest["trackedRegions"].map { |id| rekey_region(id, warnings) } - end manifest["dismissedIssues"] ||= [] manifest["trackedRegions"] ||= [] - # v2 adds `primaryRegions` (each tracked region's picked look + order). - # A pre-v2 archive has no picked looks, so synthesize entries from the - # tracked ids with a null appearance, in their listed order. + manifest["trackedRegions"] = manifest["trackedRegions"].map do |id| + rekey_region(id, warnings) + end manifest["primaryRegions"] ||= manifest["trackedRegions"].each_with_index.map do |id, index| { "region" => id, "appearance" => nil, "order" => index } end Array(manifest["samples"]).each do |sample| sample["recordingDeviceID"] = nil unless sample.key?("recordingDeviceID") end - manifest["recordingPolicyChanges"] ||= [] + + manifest["recordingDeviceProfiles"] ||= [] + manifest["recordingDeviceMetadataChanges"] ||= [] manifest["recordingAssignmentChanges"] ||= [] manifest["recordingDeviceArchives"] ||= [] - upgrade_recording_devices!(manifest, source_version) + manifest.delete("recordingDevices") + manifest.delete("recordingDeviceCheckIns") + manifest.delete("recordingPolicyChanges") manifest["formatVersion"] = CURRENT_FORMAT_VERSION warnings.uniq.each { |message| warn "warning: #{message}" } manifest @@ -471,6 +159,17 @@ def run_or_die(*command) die "command failed: #{command.join(' ')}" end +def sort_deep(value) + case value + when Hash + value.keys.sort.to_h { |key| [key, sort_deep(value[key])] } + when Array + value.map { |item| sort_deep(item) } + else + value + end +end + def main(argv) if argv.empty? || argv.include?("--help") || argv.include?("-h") print_usage @@ -479,39 +178,19 @@ def main(argv) input = argv[0] die "input not found: #{input}" unless File.exist?(input) - output = argv[1] || input.sub(/(\.zip)?\z/i, "-upgraded.zip") - output = File.expand_path(output) + output = File.expand_path(argv[1] || input.sub(/(\.zip)?\z/i, "-upgraded.zip")) Dir.mktmpdir("where-backup-upgrade") do |work| run_or_die("unzip", "-q", File.expand_path(input), "-d", work) - manifest_path = File.join(work, MANIFEST_NAME) die "#{MANIFEST_NAME} not found in archive (is this a Where backup?)" unless File.exist?(manifest_path) manifest = JSON.parse(File.read(manifest_path)) - upgraded = upgrade_manifest(manifest) - # Pretty-printed + sorted keys to match the app's exporter. - File.write(manifest_path, "#{JSON.pretty_generate(sort_deep(upgraded))}\n") - + File.write(manifest_path, JSON.pretty_generate(sort_deep(upgrade_manifest(manifest))) + "\n") FileUtils.rm_f(output) - entries = Dir.children(work) - Dir.chdir(work) { run_or_die("zip", "-q", "-r", "-X", output, *entries) } - end - - puts "Wrote #{output}" -end - -# Recursively sort hash keys so the manifest is byte-stable like the app's -# `.sortedKeys` encoder output. -def sort_deep(value) - case value - when Hash - value.keys.sort.each_with_object({}) { |key, out| out[key] = sort_deep(value[key]) } - when Array - value.map { |element| sort_deep(element) } - else - value + Dir.chdir(work) { run_or_die("zip", "-q", "-r", output, ".") } end + puts output end main(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 3f0bd8fd..eede6365 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -140,14 +140,11 @@ one it belongs to rather than to a god-object: - **`WidgetSnapshotPublisher`** — republishes the App Group snapshot the widgets read, with a freshness policy. - **`BackupCoordinator`** — ZIP export/import via `ZIPFoundation`. Export pins - tables and evidence blobs to one epoch-consistent snapshot. Merge preserves - queued locations and reasserts each device's pre-import recording authority - after the imported timeline (including an existing profile's implicit Archived - state after a destructive epoch; only a newly seen policy defaults Off). Replace writes - the archive into a new child epoch, retains the global device-profile ledger, - and appends an Off/archive barrier to every imported policy timeline—even when - its profile has not synced yet—and gives a profile-only import an Off root before - pending fixes are discarded. A prepared marker in the backup-excluded installation + tables and evidence blobs to one epoch-consistent snapshot. Merge preserves queued locations + and reasserts the pre-import account-wide recording assignment after joining the imported + timeline. Replace writes the archive into a new child epoch, retains the global device-profile + ledger, and appends a newer Off assignment before pending fixes are discarded. A prepared + marker in the backup-excluded installation sidecar pairs with a receipt committed in the same store transaction as the archive; recreated services can therefore distinguish rollback from commit and gate further imports until cleanup succeeds. Onboarding acknowledgement records an independent terminal @@ -160,7 +157,7 @@ one it belongs to rather than to a god-object: a selectable look-back `RecentActivityWindow`. - **`InstallationRecordingContext`** — the device-local installation identity, explicitly confirmed initial choice, and stable IDs/timestamps for recreating - its immutable first device profile and recording policy idempotently. + its immutable first device profile and recording assignment idempotently. `InstallationRecordingContextStoring` keeps the persistence adapter outside the domain value. - **`WherePreferences`** — persisted user intent (onboarding and reminder / @@ -251,12 +248,12 @@ rotates to a Reset child epoch, and discards the retry queue only after commit. constructing `WhereServices`. - **Always-location.** Background day tracking needs Always; `requestPermission()` throws `LocationPermissionDeniedError` on denial / restriction. -- **Strong remote cutoff.** Turning a device off does not depend on that device - being online before reports become correct: once the policy event syncs, - samples at or after its effective timestamp are excluded. Its row remains - "waiting" until the target installation physically stops and acknowledges it. +- **Strong remote cutoff.** Transferring or turning off automatic recording does not depend on + the former recorder being online before reports become correct: once the assignment event + syncs, samples at or after its effective timestamp are excluded. The assigned row remains + "waiting" until the target installation starts or stops and acknowledges it. The sample gate stays closed until that check-in and any destructive-backlog - cleanup are durable; an incomplete multi-parent policy DAG also fails closed. + cleanup are durable; an incomplete multi-parent assignment DAG also fails closed. The cutoff currently uses the issuing device's wall clock; substantial cross-device clock skew can shift the historical boundary even though the causal DAG still converges the current desired state correctly. diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 2d1d3586..5fc71e04 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -8,28 +8,23 @@ import RegionKit /// /// The arrays represent the persisted collections (`SDLocationSample` / /// `SDEvidence` / `SDManualDay` / `SDDismissedIssue` / `SDTrackedRegion`) via -/// their value-type representations, plus the split recording profile / nickname / -/// policy rows. The check-in collection remains in the versioned shape for compatibility, but -/// exports leave it empty and imports ignore it: a backup cannot restore a target installation's -/// live proof that policy was applied and its local outbox was cleared. +/// their value-type representations, plus installation profiles, nickname events, the +/// account-wide recording assignment, and archive tombstones. Target-owned check-ins are +/// intentionally excluded because a backup cannot restore proof of local physical state. public struct BackupArchive: Codable, Sendable, Hashable { /// Bumped whenever the archive's on-disk shape changes in a way older /// readers can't understand, so an importer can refuse a file it doesn't /// know how to read instead of silently dropping data (see /// `BackupService.readArchive`, which rejects any other version). /// - /// v3 added sample device provenance plus the first recording-device shape. - /// v4 split that shape into immutable profiles, append-only nickname metadata, - /// target-owned check-ins, and append-only complete-authority policy events. v5 adds the - /// logical data epoch in which each immutable profile registered. v6 adds causal parent - /// metadata and state-preserving Merge barriers. v7 expands that metadata to a sorted parent - /// set so one semantic command can causally join every observed concurrent head. v8 adds the - /// account-wide recording assignment and irreversible device archive tombstones. There's no + /// v3 adds sample provenance, immutable installation profiles, nickname changes, archive + /// tombstones, and the account-wide recording assignment. Intermediate branch-only formats + /// were never shipped. There's no /// in-app decode /// fallback for an older archive — it is reshaped out of band by /// `Tools/upgrade-backup.rb`, matching the module's no-migration-on-read rule (see /// `AGENTS.md`). - public static let currentFormatVersion = 8 + public static let currentFormatVersion = 3 public let formatVersion: Int public let exportedAt: Date @@ -52,11 +47,6 @@ public struct BackupArchive: Codable, Sendable, Hashable { public let recordingDeviceProfiles: [RecordingDeviceProfile] /// Full append-only nickname history. public let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] - /// Compatibility field for target-owned acknowledgements. New exports leave it empty and - /// imports never apply it as live authority. - public let recordingDeviceCheckIns: [RecordingDeviceCheckIn] - /// The full append-only policy timeline for every device. - public let recordingPolicyChanges: [RecordingPolicyChange] /// Account-wide automatic-recording assignment history. public let recordingAssignmentChanges: [RecordingAssignmentChange] /// Irreversible installation archive tombstones. @@ -76,10 +66,8 @@ public struct BackupArchive: Codable, Sendable, Hashable { primaryRegions: [PrimaryRegion], recordingDeviceProfiles: [RecordingDeviceProfile], recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], - recordingDeviceCheckIns: [RecordingDeviceCheckIn], - recordingPolicyChanges: [RecordingPolicyChange], - recordingAssignmentChanges: [RecordingAssignmentChange] = [], - recordingDeviceArchives: [RecordingDeviceArchive] = [], + recordingAssignmentChanges: [RecordingAssignmentChange], + recordingDeviceArchives: [RecordingDeviceArchive], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -92,8 +80,6 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.primaryRegions = primaryRegions self.recordingDeviceProfiles = recordingDeviceProfiles self.recordingDeviceMetadataChanges = recordingDeviceMetadataChanges - self.recordingDeviceCheckIns = recordingDeviceCheckIns - self.recordingPolicyChanges = recordingPolicyChanges self.recordingAssignmentChanges = recordingAssignmentChanges self.recordingDeviceArchives = recordingDeviceArchives self.assets = assets diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 2315eac4..61a23e9b 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -15,11 +15,11 @@ public actor BackupCoordinator { /// Upsert the imported rows into the existing data (by `id` for /// samples/evidence, by day key for manual days), leaving anything not /// present in the file untouched. Recording authority is snapshotted before the write - /// and reasserted after every imported policy timeline; a newly seen device defaults Off. + /// and reasserted after the imported assignment timeline. case merge /// Replace synced user history and settings with the file. Recording-device identities - /// remain append-only, and every imported policy receives a newer destructive barrier so - /// restoring an archive can never silently start GPS on a device whose profile syncs later. + /// remain append-only, and a newer Off assignment ensures restoring an archive can never + /// silently start GPS. case replace } @@ -31,7 +31,7 @@ public actor BackupCoordinator { public let dismissedIssueCount: Int public let trackedRegionCount: Int public let recordingDeviceCount: Int - public let recordingPolicyChangeCount: Int + public let recordingAssignmentChangeCount: Int public init( sampleCount: Int, @@ -40,7 +40,7 @@ public actor BackupCoordinator { dismissedIssueCount: Int, trackedRegionCount: Int, recordingDeviceCount: Int = 0, - recordingPolicyChangeCount: Int = 0, + recordingAssignmentChangeCount: Int = 0, ) { self.sampleCount = sampleCount self.evidenceCount = evidenceCount @@ -48,7 +48,7 @@ public actor BackupCoordinator { self.dismissedIssueCount = dismissedIssueCount self.trackedRegionCount = trackedRegionCount self.recordingDeviceCount = recordingDeviceCount - self.recordingPolicyChangeCount = recordingPolicyChangeCount + self.recordingAssignmentChangeCount = recordingAssignmentChangeCount } } @@ -167,8 +167,6 @@ public actor BackupCoordinator { primaryRegions: store.primaryRegions(), recordingDeviceProfiles: store.recordingDeviceProfiles(), recordingDeviceMetadataChanges: store.recordingDeviceMetadataChanges(), - recordingDeviceCheckIns: [], - recordingPolicyChanges: store.recordingPolicyChanges(), recordingAssignmentChanges: store.recordingAssignmentChanges(), recordingDeviceArchives: store.recordingDeviceArchives(), ) @@ -204,8 +202,6 @@ public actor BackupCoordinator { primaryRegions: tables.primaryRegions, recordingDeviceProfiles: tables.recordingDeviceProfiles, recordingDeviceMetadataChanges: tables.recordingDeviceMetadataChanges, - recordingDeviceCheckIns: tables.recordingDeviceCheckIns, - recordingPolicyChanges: tables.recordingPolicyChanges, recordingAssignmentChanges: tables.recordingAssignmentChanges, recordingDeviceArchives: tables.recordingDeviceArchives, blobs: snapshot.blobs, @@ -227,8 +223,6 @@ public actor BackupCoordinator { let primaryRegions: [PrimaryRegion] let recordingDeviceProfiles: [RecordingDeviceProfile] let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] - let recordingDeviceCheckIns: [RecordingDeviceCheckIn] - let recordingPolicyChanges: [RecordingPolicyChange] let recordingAssignmentChanges: [RecordingAssignmentChange] let recordingDeviceArchives: [RecordingDeviceArchive] } @@ -409,8 +403,6 @@ public actor BackupCoordinator { }.value let archive = result.archive let blobs = result.blobs - let importedDeviceIDs = Set(archive.recordingPolicyChanges.map(\.deviceID)) - .union(archive.recordingDeviceProfiles.map(\.id)) let summary = ImportSummary( sampleCount: archive.samples.count, evidenceCount: archive.evidence.count, @@ -418,7 +410,7 @@ public actor BackupCoordinator { dismissedIssueCount: archive.dismissedIssues.count, trackedRegionCount: archive.primaryRegions.count, recordingDeviceCount: archive.recordingDeviceProfiles.count, - recordingPolicyChangeCount: archive.recordingPolicyChanges.count, + recordingAssignmentChangeCount: archive.recordingAssignmentChanges.count, ) let recoveryDetails = ImportRecoveryDetails( transactionID: transactionID, @@ -430,14 +422,12 @@ public actor BackupCoordinator { + archive.manualDays.count + archive.dismissedIssues.count + archive.recordingDeviceProfiles.count + archive.recordingDeviceMetadataChanges.count - + archive.recordingDeviceCheckIns.count - + archive.recordingPolicyChanges.count + archive.recordingAssignmentChanges.count + archive.recordingDeviceArchives.count // Decode and validate before touching live authority. Once the archive is known-good, // close ingestion before either merge or replace: both can change this installation's - // policy while a streamed sample is otherwise able to cross the transaction boundary. + // assignment while a streamed sample is otherwise able to cross the transaction boundary. let preparedRecovery = DurableImportRecovery.prepared(recoveryDetails) try await importRecoveryPersistence.save(preparedRecovery) do { @@ -458,26 +448,9 @@ public actor BackupCoordinator { do { try await Self.logger.measure(.importWrite) { try await store.perform(expectedDataEpochID: expectedEpochID) { - let mergeAuthority = if strategy == .merge { - try await Self.snapshotAuthority( - for: importedDeviceIDs, - in: store, - ) - } else { - [RecordingDeviceID: RecordingPolicyState]() - } let existingAssignments = try await store.recordingAssignmentChanges() - let usesGlobalAssignment = existingAssignments.count > 1 - || archive.recordingAssignmentChanges.isEmpty == false let preservedAssignment: RecordingAssignment = if strategy == .merge { - if existingAssignments.count <= 1, - let legacyState = mergeAuthority[currentDeviceID] - { - legacyState == .on ? .device(currentDeviceID) : .off - } else { - RecordingAssignmentChange.resolve(existingAssignments).assignment - ?? .off - } + RecordingAssignmentChange.resolve(existingAssignments).assignment ?? .off } else { .off } @@ -527,16 +500,6 @@ public actor BackupCoordinator { try await store.addRecordingDeviceMetadataChange(metadataChange) report() } - // A check-in is a target installation's proof that it applied policy and - // cleared its own raw outbox. A backup cannot make that proof on its behalf; - // consume progress for legacy archives but never restore it as live authority. - for _ in archive.recordingDeviceCheckIns { - report() - } - for change in archive.recordingPolicyChanges { - try await store.addRecordingPolicyChange(change) - report() - } for change in archive.recordingAssignmentChanges { try await store.addRecordingAssignmentChange(change) report() @@ -545,34 +508,16 @@ public actor BackupCoordinator { try await store.addRecordingDeviceArchive(archive) report() } - if usesGlobalAssignment { - let joinedAssignments = try await store.recordingAssignmentChanges() - let assignmentBarrier = try RecordingAssignmentChange.appendingCommand( - to: joinedAssignments, - assignment: preservedAssignment, - issuedAt: importDate, - issuedByDeviceID: currentDeviceID, - effectiveAt: importDate, - reason: strategy == .merge ? .backupMerge : .backupReplace, - ) - try await store.addRecordingAssignmentChange(assignmentBarrier) - } - if let replacementEpoch { - try await Self.appendReplacementSafetyBarriers( - to: store, - epoch: replacementEpoch, - importedDeviceIDs: importedDeviceIDs, - issuedBy: currentDeviceID, - ) - } else { - try await Self.appendMergeSafetyBarriers( - to: store, - preserving: mergeAuthority, - importedDeviceIDs: importedDeviceIDs, - issuedBy: currentDeviceID, - issuedAt: importDate, - ) - } + let joinedAssignments = try await store.recordingAssignmentChanges() + let assignmentBarrier = try RecordingAssignmentChange.appendingCommand( + to: joinedAssignments, + assignment: preservedAssignment, + issuedAt: importDate, + issuedByDeviceID: currentDeviceID, + effectiveAt: importDate, + reason: replacementEpoch == nil ? .backupMerge : .backupReplace, + ) + try await store.addRecordingAssignmentChange(assignmentBarrier) // Primary regions (with their picked looks) round-trip like any // other data. On `.replace` the store was cleared above, so write // the archive's set exactly; on `.merge` union it into the current @@ -867,107 +812,6 @@ public actor BackupCoordinator { } } - /// Snapshot the authority that a merge must preserve. This runs inside the import transaction, - /// so epoch, profiles, and policies describe one indivisible pre-import state. After a - /// destructive generation, an existing profile with no current policy has the controller's - /// implicit archived authority; only a genuinely new policy-only id defaults Off. - private static func snapshotAuthority( - for deviceIDs: Set, - in store: any WhereStore, - ) async throws -> [RecordingDeviceID: RecordingPolicyState] { - let epoch = try await store.dataEpoch() - let profiles = try await store.recordingDeviceProfiles() - let policies = try await store.recordingPolicyChanges() - let existingProfileIDs = Set(profiles.map(\.id)) - var states: [RecordingDeviceID: RecordingPolicyState] = [:] - for deviceID in deviceIDs { - let history = policies.filter { $0.deviceID == deviceID } - guard history.isEmpty || RecordingPolicyChange.formValidPersistedTimelines(history) - else { - throw RecordingPersistenceError.incompletePolicyHistory(deviceID) - } - if let state = RecordingPolicyChange.canonicalHead(in: history)?.state { - states[deviceID] = state - } else if epoch.isDestructive, existingProfileIDs.contains(deviceID) { - states[deviceID] = .archived - } else { - states[deviceID] = .off - } - } - return states - } - - /// Merge restores historical policy without letting the archive change live authority. Each - /// imported timeline receives one command joining every observed head and reasserting the - /// pre-import state; a profile without policy receives a safe root. - private static func appendMergeSafetyBarriers( - to store: any WhereStore, - preserving states: [RecordingDeviceID: RecordingPolicyState], - importedDeviceIDs: Set, - issuedBy deviceID: RecordingDeviceID, - issuedAt: Date, - ) async throws { - let policies = try await store.recordingPolicyChanges() - for importedDeviceID in importedDeviceIDs.sorted(by: deviceIDIsOrderedBefore) { - let history = policies.filter { $0.deviceID == importedDeviceID } - guard history.isEmpty || RecordingPolicyChange.formValidPersistedTimelines(history) - else { - throw RecordingPersistenceError.incompletePolicyHistory(importedDeviceID) - } - try await store.addRecordingPolicyChange(RecordingPolicyChange.appendingCommand( - to: history, - deviceID: importedDeviceID, - issuedAt: issuedAt, - issuedByDeviceID: deviceID, - effectiveAt: issuedAt, - state: states[importedDeviceID] ?? .off, - reason: .backupMerge, - )) - } - } - - /// Replace restores history but never restores recording consent. Every imported policy - /// timeline receives one destructive command joining every observed head; a profile-only - /// device receives an Off root. Active devices become Off and archived devices stay archived. - private static func appendReplacementSafetyBarriers( - to store: any WhereStore, - epoch: WhereDataEpoch, - importedDeviceIDs: Set, - issuedBy deviceID: RecordingDeviceID, - ) async throws { - let policies = try await store.recordingPolicyChanges() - for importedDeviceID in importedDeviceIDs.sorted(by: deviceIDIsOrderedBefore) { - let history = policies.filter { $0.deviceID == importedDeviceID } - guard history.isEmpty || RecordingPolicyChange.formValidPersistedTimelines(history) - else { - throw RecordingPersistenceError.incompletePolicyHistory(importedDeviceID) - } - let replacementState: RecordingPolicyState = if RecordingPolicyChange.canonicalHead( - in: history, - )?.state == .archived { - .archived - } else { - .off - } - try await store.addRecordingPolicyChange(RecordingPolicyChange.appendingCommand( - to: history, - deviceID: importedDeviceID, - issuedAt: epoch.changedAt, - issuedByDeviceID: deviceID, - effectiveAt: epoch.changedAt, - state: replacementState, - reason: .backupReplace, - )) - } - } - - private static func deviceIDIsOrderedBefore( - _ lhs: RecordingDeviceID, - _ rhs: RecordingDeviceID, - ) -> Bool { - lhs.storeURL.absoluteString < rhs.storeURL.absoluteString - } - /// Union `archive` primary regions into `current` for a `.merge` import: /// current regions keep their order and come first, archive-only regions are /// appended, and the archive's picked appearance wins on overlap (a `nil` diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 514524ef..c7176e80 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -44,7 +44,7 @@ public struct BackupService: Sendable { /// must match `BackupArchive.currentFormatVersion` exactly). case unsupportedFormatVersion(Int) /// Recording rows decoded structurally but violate persisted invariants (for example a - /// negative causal revision or an `.unknown` check-in). + /// a negative causal revision or incomplete assignment history). case invalidRecordingData public var errorDescription: String? { @@ -118,18 +118,14 @@ public struct BackupService: Sendable { primaryRegions: [PrimaryRegion] = [], recordingDeviceProfiles: [RecordingDeviceProfile], recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], - recordingDeviceCheckIns: [RecordingDeviceCheckIn], - recordingPolicyChanges: [RecordingPolicyChange], - recordingAssignmentChanges: [RecordingAssignmentChange] = [], - recordingDeviceArchives: [RecordingDeviceArchive] = [], + recordingAssignmentChanges: [RecordingAssignmentChange], + recordingDeviceArchives: [RecordingDeviceArchive], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, ) throws -> URL { try Self.validateRecordingData( metadataChanges: recordingDeviceMetadataChanges, - checkIns: recordingDeviceCheckIns, - policyChanges: recordingPolicyChanges, assignmentChanges: recordingAssignmentChanges, ) let fileManager = FileManager.default @@ -164,8 +160,6 @@ public struct BackupService: Sendable { primaryRegions: primaryRegions, recordingDeviceProfiles: recordingDeviceProfiles, recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, - recordingDeviceCheckIns: recordingDeviceCheckIns, - recordingPolicyChanges: recordingPolicyChanges, recordingAssignmentChanges: recordingAssignmentChanges, recordingDeviceArchives: recordingDeviceArchives, assets: assetEntries, @@ -279,26 +273,16 @@ public struct BackupService: Sendable { static func validateRecordingData(_ archive: BackupArchive) throws { try validateRecordingData( metadataChanges: archive.recordingDeviceMetadataChanges, - checkIns: archive.recordingDeviceCheckIns, - policyChanges: archive.recordingPolicyChanges, assignmentChanges: archive.recordingAssignmentChanges, ) } private static func validateRecordingData( metadataChanges: [RecordingDeviceMetadataChange], - checkIns: [RecordingDeviceCheckIn], - policyChanges: [RecordingPolicyChange], assignmentChanges: [RecordingAssignmentChange], ) throws { guard metadataChanges.allSatisfy({ $0.revision >= 0 }), - checkIns.allSatisfy({ - $0.revision >= 0 && $0.status != .unknown - }), - RecordingAssignmentChange.formValidPersistedTimeline(assignmentChanges), - RecordingPolicyChange.formValidPersistedTimelines( - policyChanges, - ) + RecordingAssignmentChange.formValidPersistedTimeline(assignmentChanges) else { throw BackupError.invalidRecordingData } diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index d6e7ac99..be01d6d4 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -1,17 +1,18 @@ import Foundation -/// Owns recording-device registration, desired-policy commands, and this installation's +/// Owns recording-device registration, account-wide assignment commands, and this installation's /// physical GPS reconciliation. /// /// The controller deliberately persists three independently owned device records: an immutable /// profile created by the installation, append-only nickname events authored from any device, -/// and a target-owned check-in. Desired authority (On, Off, or archived) is one append-only event -/// stream. +/// and a target-owned check-in. Desired authority is one account-wide append-only assignment; +/// irreversible archive tombstones are separate. /// Keeping those writers apart prevents CloudKit's last-writer-wins merge from rolling unrelated /// fields backward. /// /// Registration is one explicit lifecycle operation. Reads and later commands never accept or -/// infer an initial preference, so synced policy is the only authority after registration. A +/// infer an initial preference, so the synced assignment is the only authority after registration. +/// A /// focused store observer compares the current installation's effective authority and check-in. /// Unrelated sample or region writes do not repeatedly reconcile GPS except when a heartbeat /// is due. @@ -39,11 +40,11 @@ public actor DeviceRecordingController { /// Prevents two reset/import lifecycles from interleaving across their actor awaits. private var isRewritePaused = false - private var policyObservationTask: Task? - /// Exact policy event most recently applied and acknowledged on this installation. - private var lastAppliedCurrentPolicyID: UUID? + private var assignmentObservationTask: Task? + /// Exact assignment frontier most recently applied and acknowledged on this installation. + private var lastAppliedCurrentAssignmentID: UUID? /// Retained after fail-closed reconciliation so any later store ping retries it. - private var needsPolicyReconciliation = false + private var needsAssignmentReconciliation = false private var nextRuntimeSequence: UInt64 = 0 private var latestRuntimeUpdate: RecordingDeviceRuntimeUpdate? @@ -53,13 +54,12 @@ public actor DeviceRecordingController { /// Epoch-pinned recording tables used to make one authority decision. A reset/Replace that /// lands while these tables are loading makes the snapshot throw instead of combining old - /// policy with new-epoch check-ins. + /// assignment with new-epoch check-ins. private struct StoreSnapshot { let epoch: WhereDataEpoch let profiles: [RecordingDeviceProfile] let metadataChanges: [RecordingDeviceMetadataChange] let checkIns: [RecordingDeviceCheckIn] - let policyChanges: [RecordingPolicyChange] let assignmentChanges: [RecordingAssignmentChange] let archives: [RecordingDeviceArchive] } @@ -84,7 +84,7 @@ public actor DeviceRecordingController { } deinit { - policyObservationTask?.cancel() + assignmentObservationTask?.cancel() configurationBroadcaster.finishAll() } @@ -101,22 +101,23 @@ public actor DeviceRecordingController { latestRuntimeUpdate } - /// Start the focused policy observer. Safe to call repeatedly from lifecycle setup. - public func startMonitoringPolicyChanges() { + /// Start the focused assignment observer. Safe to call repeatedly from lifecycle setup. + public func startMonitoringAssignmentChanges() { recordingLifecycleStarted = true - guard policyObservationTask == nil else { return } + guard assignmentObservationTask == nil else { return } let updates = store.changes() - policyObservationTask = Task { [weak self] in + assignmentObservationTask = Task { [weak self] in for await _ in updates { guard let self else { break } - await applyObservedPolicyChange() + await applyObservedAssignmentChange() } } } /// Register this installation and its confirmed initial choice exactly once, then apply it. - /// `initialPolicyChangeID` comes from the non-backed-up installation context, making a retry - /// idempotent even if profile and policy records are observed at different times. + /// `initialAssignmentChangeID` comes from the non-backed-up installation context, making a + /// retry + /// idempotent even if profile and assignment records are observed at different times. @discardableResult public func register( authorization: LocationAuthorizationStatus, @@ -127,15 +128,15 @@ public actor DeviceRecordingController { recordingLifecycleStarted = true do { try await registerLocked( - initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, initialEnabled: initialRecordingChoice.isEnabled, ) let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID - needsPolicyReconciliation = false + lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID + needsAssignmentReconciliation = false return reconciliation } catch { - needsPolicyReconciliation = true + needsAssignmentReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) throw error @@ -157,63 +158,34 @@ public actor DeviceRecordingController { recordingLifecycleStarted = true do { try await registerLocked( - initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, initialEnabled: initialRecordingChoice.isEnabled, ) let snapshot = try await storeSnapshot() - let timeline = Self.policyTimeline( - for: currentDevice.id, - in: snapshot.policyChanges, - ) - guard Self.hasCompleteRevisionHistory(timeline), - let latestPolicy = RecordingPolicyChange.canonicalHead(in: timeline) - else { - throw RecordingPersistenceError.currentDevicePolicyUnknown(currentDevice.id) - } let commandDate = now() - let desiredState: RecordingPolicyState = desiredEnabled == true ? .on : .off let desiredAssignment: RecordingAssignment? = desiredEnabled.map { $0 ? .device(currentDevice.id) : .off } - let assignmentChange: RecordingAssignmentChange? = if desiredAssignment == nil - || RecordingAssignmentChange.resolve(snapshot.assignmentChanges).assignment - == desiredAssignment + let assignmentChange: RecordingAssignmentChange? = if let desiredAssignment, + RecordingAssignmentChange + .resolve(snapshot + .assignmentChanges).assignment + != desiredAssignment { - nil - } else { try RecordingAssignmentChange.appendingCommand( to: snapshot.assignmentChanges, - assignment: desiredAssignment!, + assignment: desiredAssignment, issuedAt: commandDate, issuedByDeviceID: currentDevice.id, effectiveAt: max(commandDate, snapshot.epoch.changedAt), reason: .userCommand, ) - } - let policyChange: RecordingPolicyChange? = if latestPolicy.state == desiredState { - nil } else { - try RecordingPolicyChange.appendingCommand( - to: timeline, - deviceID: currentDevice.id, - issuedAt: commandDate, - issuedByDeviceID: currentDevice.id, - effectiveAt: Self.nextEffectiveDate( - proposed: max(commandDate, snapshot.epoch.changedAt), - after: latestPolicy, - ), - state: desiredState, - reason: .userCommand, - ) + nil } - if policyChange != nil || assignmentChange != nil { + if let assignmentChange { try await store.perform(expectedDataEpochID: snapshot.epoch.id) { - if let policyChange { - try await self.store.addRecordingPolicyChange(policyChange) - } - if let assignmentChange { - try await self.store.addRecordingAssignmentChange(assignmentChange) - } + try await self.store.addRecordingAssignmentChange(assignmentChange) } // Historical visibility changed as soon as the authority event committed. Do // not make derived reconciliation depend on a later physical/check-in success. @@ -221,18 +193,18 @@ public actor DeviceRecordingController { } let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID - needsPolicyReconciliation = false + lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID + needsAssignmentReconciliation = false return reconciliation } catch { - needsPolicyReconciliation = true + needsAssignmentReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) throw error } } - /// Apply the latest synced policy to this installation. Failure is fail-closed: GPS is + /// Apply the latest synced assignment to this installation. Failure is fail-closed: GPS is /// stopped before the error is surfaced, so stale local preference can never authorize a fix. @discardableResult public func reconcile( @@ -244,19 +216,18 @@ public actor DeviceRecordingController { recordingLifecycleStarted = true do { let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID - needsPolicyReconciliation = false + lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID + needsAssignmentReconciliation = false return reconciliation } catch { - needsPolicyReconciliation = true + needsAssignmentReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) throw error } } - /// Pure read of active device configurations. A profile whose policy has not arrived yet is - /// returned with `.unknown` policy rather than fabricated as enabled. + /// Pure read of active device configurations paired with the global assignment. public func devices() async throws -> [RecordingDeviceConfiguration] { await beginExclusive() defer { endExclusive() } @@ -314,16 +285,6 @@ public actor DeviceRecordingController { throw RecordingPersistenceError.deviceNotFound(deviceID) } let epoch = snapshot.epoch - let timeline = Self.policyTimeline(for: deviceID, in: snapshot.policyChanges) - guard Self.hasCompleteRevisionHistory(timeline), - let latestPolicy = Self.effectivePolicy( - for: deviceID, - epoch: epoch, - timeline: timeline, - ) - else { - throw RecordingPersistenceError.devicePolicyUnknown(deviceID) - } let issuedAt = now() let desiredAssignment: RecordingAssignment = enabled ? .device(deviceID) : .off let assignmentChange: RecordingAssignmentChange? = if RecordingAssignmentChange @@ -340,42 +301,13 @@ public actor DeviceRecordingController { reason: .userCommand, ) } - let desiredState: RecordingPolicyState = enabled ? .on : .off - let causalHead = RecordingPolicyChange.canonicalHead(in: timeline) - let policyChange: RecordingPolicyChange? = if latestPolicy.state == desiredState { - nil - } else { - try RecordingPolicyChange.appendingCommand( - to: timeline, - deviceID: deviceID, - issuedAt: issuedAt, - issuedByDeviceID: currentDevice.id, - effectiveAt: Self.nextEffectiveDate( - proposed: max(issuedAt, epoch.changedAt), - after: causalHead, - ), - state: desiredState, - reason: .userCommand, - ) - } - - guard policyChange != nil || assignmentChange != nil else { - if deviceID == currentDevice.id { - if !enabled { - await ingestor.revokeRecordingAuthorization() - } - try await reconcileCurrentAfterCommandLocked() - } + guard let assignmentChange else { + try await reconcileCurrentAfterCommandLocked() return try await configurationsLocked(includeArchived: false) } try await store.perform(expectedDataEpochID: epoch.id) { - if let policyChange { - try await self.store.addRecordingPolicyChange(policyChange) - } - if let assignmentChange { - try await self.store.addRecordingAssignmentChange(assignmentChange) - } + try await self.store.addRecordingAssignmentChange(assignmentChange) } // Close local physical authority before any potentially slow derived-data rebuild. The // durable cutoff already hides history, but raw fixes must not continue entering the @@ -383,15 +315,13 @@ public actor DeviceRecordingController { if deviceID == currentDevice.id, !enabled { await ingestor.revokeRecordingAuthorization() } - if let assignmentChange, assignmentChange.assignedDeviceID != currentDevice.id { + if assignmentChange.assignedDeviceID != currentDevice.id { await ingestor.revokeRecordingAuthorization() } // The cutoff is already durable even if physical acknowledgement below fails. await onPolicyChanged() - if deviceID == currentDevice.id || assignmentChange != nil { - try await reconcileCurrentAfterCommandLocked() - } + try await reconcileCurrentAfterCommandLocked() return try await configurationsLocked(includeArchived: false) } @@ -428,7 +358,8 @@ public actor DeviceRecordingController { return try await configurationsLocked(includeArchived: false) } - /// Hide a non-current device and append an Off policy atomically. History and raw samples + /// Hide a non-current device and turn global recording Off if it was assigned. History and raw + /// samples /// remain in the event log and backups. public func archive( _ deviceID: RecordingDeviceID, @@ -444,17 +375,6 @@ public actor DeviceRecordingController { let date = now() let epoch = snapshot.epoch - let timeline = Self.policyTimeline(for: deviceID, in: snapshot.policyChanges) - guard Self.hasCompleteRevisionHistory(timeline), - let latestPolicy = Self.effectivePolicy( - for: deviceID, - epoch: epoch, - timeline: timeline, - ) - else { - throw RecordingPersistenceError.devicePolicyUnknown(deviceID) - } - let causalHead = RecordingPolicyChange.canonicalHead(in: timeline) let archive = snapshot.archives.contains(where: { $0.deviceID == deviceID }) ? nil : RecordingDeviceArchive( id: UUID(), @@ -476,29 +396,10 @@ public actor DeviceRecordingController { } else { nil } - let policyChange: RecordingPolicyChange? = if latestPolicy.state != .archived { - try RecordingPolicyChange.appendingCommand( - to: timeline, - deviceID: deviceID, - issuedAt: date, - issuedByDeviceID: currentDevice.id, - effectiveAt: Self.nextEffectiveDate( - proposed: max(date, epoch.changedAt), - after: causalHead, - ), - state: .archived, - reason: .archive, - ) - } else { - nil - } - guard policyChange != nil || archive != nil || assignmentChange != nil else { + guard archive != nil || assignmentChange != nil else { return try await configurationsLocked(includeArchived: false) } try await store.perform(expectedDataEpochID: epoch.id) { - if let policyChange { - try await self.store.addRecordingPolicyChange(policyChange) - } if let archive { try await self.store.addRecordingDeviceArchive(archive) } @@ -523,8 +424,8 @@ public actor DeviceRecordingController { || recordingLifecycleStarted recordingLifecycleStarted = false acceptsOperations = false - policyObservationTask?.cancel() - policyObservationTask = nil + assignmentObservationTask?.cancel() + assignmentObservationTask = nil await ingestor.pause() } @@ -548,18 +449,18 @@ public actor DeviceRecordingController { let shouldResumeAuthority = shouldResumeAuthorityAfterPause shouldResumeAuthorityAfterPause = false guard shouldResumeAuthority else { return } - startMonitoringPolicyChanges() + startMonitoringAssignmentChanges() do { try await registerLocked( - initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, initialEnabled: initialRecordingChoice.isEnabled, ) let authorization = await ingestor.authorizationStatus() let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID - needsPolicyReconciliation = false + lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID + needsAssignmentReconciliation = false } catch { - needsPolicyReconciliation = true + needsAssignmentReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) Self.logger(attachments: [.error(error, name: "rollback-recovery-error")]) { @@ -584,7 +485,7 @@ public actor DeviceRecordingController { // recording stack paused so a retry can remove the same sidecar safely. isRewritePaused = false acceptsOperations = false - needsPolicyReconciliation = true + needsAssignmentReconciliation = true publishRuntimeState(.unavailable) endExclusive() throw error @@ -596,19 +497,19 @@ public actor DeviceRecordingController { endExclusive() return } - startMonitoringPolicyChanges() + startMonitoringAssignmentChanges() do { try await registerLocked( - initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, initialEnabled: initialRecordingChoice.isEnabled, ) let authorization = await ingestor.authorizationStatus() let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID - needsPolicyReconciliation = false + lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID + needsAssignmentReconciliation = false endExclusive() } catch { - needsPolicyReconciliation = true + needsAssignmentReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) endExclusive() @@ -627,7 +528,7 @@ public actor DeviceRecordingController { try await ingestor.discardRetryBacklog() } catch { isRewritePaused = false - needsPolicyReconciliation = true + needsAssignmentReconciliation = true publishRuntimeState(.unavailable) endExclusive() throw error @@ -638,7 +539,7 @@ public actor DeviceRecordingController { } private func registerLocked( - initialPolicyChangeID: UUID, + initialAssignmentChangeID: UUID, initialEnabled: Bool, ) async throws { let snapshot = try await storeSnapshot() @@ -647,33 +548,16 @@ public actor DeviceRecordingController { let profile = expectedProfile( registrationEpochID: existingProfile?.registrationEpochID ?? epoch.id, ) - let ownsInitialPolicyInThisEpoch = existingProfile == nil - || existingProfile?.registrationEpochID == epoch.id - let initialPolicy = expectedInitialPolicy( - id: initialPolicyChangeID, - isEnabled: initialEnabled, - in: epoch, - ) - let existingInitialPolicy = snapshot.policyChanges - .first(where: { $0.id == initialPolicyChangeID }) let needsInitialAssignment = snapshot.assignmentChanges.isEmpty let needsProfileWrite = existingProfile != profile - let needsInitialPolicyWrite = ownsInitialPolicyInThisEpoch - && existingInitialPolicy != initialPolicy - guard needsProfileWrite || needsInitialPolicyWrite || needsInitialAssignment else { return } + guard needsProfileWrite || needsInitialAssignment else { return } // The add APIs validate identical immutable retries and reject conflicting payloads. - // An installation first seen in this epoch also retries its immutable initial policy. - // An existing profile entering a newer destructive epoch must not replay that old first - // choice: the epoch's fail-closed default remains authoritative until a new command. try await store.perform(expectedDataEpochID: epoch.id) { try await self.store.addRecordingDeviceProfile(profile) - if ownsInitialPolicyInThisEpoch { - try await self.store.addRecordingPolicyChange(initialPolicy) - } if needsInitialAssignment { try await self.store.addRecordingAssignmentChange(RecordingAssignmentChange( - id: initialPolicyChangeID, + id: initialAssignmentChangeID, parentIDs: [], revision: 0, issuedAt: self.initialRecordingChoice.confirmedAt, @@ -690,10 +574,10 @@ public actor DeviceRecordingController { do { let authorization = await ingestor.authorizationStatus() let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID - needsPolicyReconciliation = false + lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID + needsAssignmentReconciliation = false } catch { - needsPolicyReconciliation = true + needsAssignmentReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) throw error @@ -708,53 +592,21 @@ public actor DeviceRecordingController { throw RecordingPersistenceError.currentDeviceNotRegistered(currentDevice.id) } let epoch = snapshot.epoch - let policies = snapshot.policyChanges - let timeline = Self.policyTimeline(for: currentDevice.id, in: policies) - guard Self.hasCompleteRevisionHistory(timeline) else { - throw RecordingPersistenceError.incompletePolicyHistory(currentDevice.id) - } - guard let legacyPolicy = Self.effectivePolicy( - for: currentDevice.id, - epoch: epoch, - timeline: timeline, - ) else { - throw RecordingPersistenceError.currentDevicePolicyUnknown(currentDevice.id) - } - let latest: RecordingPolicyChange - let usesGlobalAssignment = snapshot.assignmentChanges.count > 1 - || snapshot.assignmentChanges.first?.id != initialRecordingChoice.policyChangeID - if usesGlobalAssignment == false { - latest = legacyPolicy - } else { - let resolution = RecordingAssignmentChange.resolve(snapshot.assignmentChanges) - guard let assignment = resolution.assignment, - let frontierID = RecordingAssignmentChange.frontierToken( - in: snapshot.assignmentChanges, - ), - let heads = RecordingAssignmentChange.maximalHeads( - in: snapshot.assignmentChanges, - ) - else { - throw RecordingPersistenceError.incompleteAssignmentHistory - } - let assignedDeviceIsArchived = assignment.deviceID.map { assignedID in - snapshot.archives.contains(where: { $0.deviceID == assignedID }) - } ?? false - guard assignedDeviceIsArchived == false else { - throw RecordingPersistenceError.incompleteAssignmentHistory - } - latest = RecordingPolicyChange( - id: frontierID, - deviceID: currentDevice.id, - parentIDs: [], - revision: 0, - issuedAt: heads.map(\.issuedAt).max() ?? epoch.changedAt, - issuedByDeviceID: heads.last?.issuedByDeviceID ?? currentDevice.id, - effectiveAt: heads.map(\.effectiveAt).max() ?? epoch.changedAt, - state: assignment.deviceID == currentDevice.id ? .on : .off, - reason: .userCommand, - ) - } + let resolution = RecordingAssignmentChange.resolve(snapshot.assignmentChanges) + guard let assignment = resolution.assignment, + let frontierID = RecordingAssignmentChange.frontierToken( + in: snapshot.assignmentChanges, + ), + let heads = RecordingAssignmentChange.maximalHeads(in: snapshot.assignmentChanges) + else { throw RecordingPersistenceError.incompleteAssignmentHistory } + let assignedDeviceIsArchived = assignment.deviceID.map { assignedID in + snapshot.archives.contains(where: { $0.deviceID == assignedID }) + } ?? false + guard assignedDeviceIsArchived == false else { + throw RecordingPersistenceError.incompleteAssignmentHistory + } + let isEnabled = assignment.deviceID == currentDevice.id + let effectiveAt = heads.map(\.effectiveAt).max() ?? epoch.changedAt let nickname = Self.latestMetadata( for: currentDevice.id, field: .nickname, @@ -763,37 +615,34 @@ public actor DeviceRecordingController { let existing = snapshot.checkIns .first(where: { $0.deviceID == currentDevice.id }) - let requiredCleanupToken: RecordingPolicyCleanupToken? = if epoch.isDestructive { - RecordingPolicyCleanupToken(rawValue: epoch.id.rawValue) + let requiredCleanupToken: RecordingAssignmentCleanupToken? = if epoch.isDestructive { + RecordingAssignmentCleanupToken(rawValue: epoch.id.rawValue) } else { - Self.destructiveCleanupToken( - for: currentDevice.id, - in: policies, - ) + nil } // Close the sample gate before computing or acknowledging authority. In particular, // `authorizeRecording` restores and drains the durable outbox, so it cannot run until - // the check-in proving this policy was applied has committed. + // the check-in proving this assignment was applied has committed. await ingestor.revokeRecordingAuthorization() - if existing?.lastDiscardedPolicyFrontierToken != requiredCleanupToken, + if existing?.lastDiscardedAssignmentFrontierToken != requiredCleanupToken, requiredCleanupToken != nil { try await ingestor.discardRetryBacklog() } - if latest.isEnabled { + if isEnabled { try await ingestor.prepareRetryBacklog() } - let status: RecordingDeviceStatus = if latest.isEnabled { + let status: RecordingDeviceStatus = if isEnabled { authorization.allowsBackgroundTracking ? .recording : .permissionRequired } else { .off } let checkInDate = now() - let needsAcknowledgement = existing?.lastAppliedPolicyChangeID != latest.id - || existing?.lastDiscardedPolicyFrontierToken != requiredCleanupToken + let needsAcknowledgement = existing?.lastAppliedAssignmentChangeID != frontierID + || existing?.lastDiscardedAssignmentFrontierToken != requiredCleanupToken || existing?.status != status let needsPeriodicCheckIn = existing.map { checkInDate.timeIntervalSince($0.lastSeenAt) >= Self.checkInInterval @@ -809,8 +658,8 @@ public actor DeviceRecordingController { lastSeenAt: checkInDate, appliedAt: needsAcknowledgement ? checkInDate : (existing?.appliedAt ?? checkInDate), - lastAppliedPolicyChangeID: latest.id, - lastDiscardedPolicyFrontierToken: requiredCleanupToken, + lastAppliedAssignmentChangeID: frontierID, + lastDiscardedAssignmentFrontierToken: requiredCleanupToken, status: status, ) try await store.perform(expectedDataEpochID: epoch.id) { @@ -824,17 +673,17 @@ public actor DeviceRecordingController { // Only a durable acknowledgement opens physical authority. This ordering also prevents // an outbox drain from committing samples when the check-in write fails. - if latest.isEnabled { + if isEnabled { if authorization.allowsBackgroundTracking { try await ingestor.start( - effectiveAt: latest.effectiveAt, + effectiveAt: effectiveAt, dataEpochID: epoch.id, ) } else { // Keep foreground fill-in fixes authorized for When-In-Use while pausing // background monitoring. try await ingestor.authorizeRecording( - effectiveAt: latest.effectiveAt, + effectiveAt: effectiveAt, dataEpochID: epoch.id, ) await ingestor.stop() @@ -846,10 +695,13 @@ public actor DeviceRecordingController { profile: profile, nicknameChange: nickname, checkIn: checkIn, - policyChange: latest, + archive: snapshot.archives.first(where: { $0.deviceID == currentDevice.id }), ), - policyChange: latest, - requiredCleanupToken: requiredCleanupToken, + assignmentResolution: resolution, + assignmentFrontierID: frontierID, + isAssignmentAcknowledged: checkIn.lastAppliedAssignmentChangeID == frontierID + && checkIn.lastDiscardedAssignmentFrontierToken == requiredCleanupToken, + isArchived: false, ) publishRuntimeState(.applied(configuration)) return configuration @@ -860,76 +712,27 @@ public actor DeviceRecordingController { ) async throws -> [RecordingDeviceConfiguration] { try await store.readSnapshot { async let devices = store.recordingDevices() - async let policies = store.recordingPolicyChanges() async let assignments = store.recordingAssignmentChanges() async let archives = store.recordingDeviceArchives() - async let epoch = store.dataEpoch() - let ( - resolvedDevices, - resolvedPolicies, - resolvedAssignments, - resolvedArchives, - resolvedEpoch - ) = try await ( + let (resolvedDevices, resolvedAssignments, resolvedArchives) = try await ( devices, - policies, assignments, archives, - epoch, ) - let assignment = RecordingAssignmentChange.resolve(resolvedAssignments).assignment + let resolution = RecordingAssignmentChange.resolve(resolvedAssignments) let assignmentFrontierID = RecordingAssignmentChange.frontierToken( in: resolvedAssignments, ) - let assignmentHeads = RecordingAssignmentChange.maximalHeads(in: resolvedAssignments) let archivedIDs = Set(resolvedArchives.map(\.deviceID)) return resolvedDevices .map { device in - if device.id == currentDevice.id, - resolvedAssignments.count > 1, - let assignment, - let assignmentFrontierID, - let assignmentHeads - { - let change = RecordingPolicyChange( - id: assignmentFrontierID, - deviceID: device.id, - parentIDs: [], - revision: 0, - issuedAt: assignmentHeads.map(\.issuedAt).max() ?? resolvedEpoch - .changedAt, - issuedByDeviceID: assignmentHeads.last? - .issuedByDeviceID ?? currentDevice.id, - effectiveAt: assignmentHeads.map(\.effectiveAt).max() ?? resolvedEpoch - .changedAt, - state: assignment.deviceID == device.id ? .on : .off, - reason: .userCommand, - ) - return RecordingDeviceConfiguration( - device: device, - policyChange: change, - requiredCleanupToken: nil, - ) - } - let timeline = Self.policyTimeline(for: device.id, in: resolvedPolicies) - guard Self.hasCompleteRevisionHistory(timeline), - let latest = Self.effectivePolicy( - for: device.id, - epoch: resolvedEpoch, - timeline: timeline, - ) - else { - return RecordingDeviceConfiguration(device: device, policy: .unknown) - } - return RecordingDeviceConfiguration( + RecordingDeviceConfiguration( device: device, - policyChange: latest, - requiredCleanupToken: resolvedEpoch.isDestructive - ? RecordingPolicyCleanupToken(rawValue: resolvedEpoch.id.rawValue) - : Self.destructiveCleanupToken( - for: device.id, - in: resolvedPolicies, - ), + assignmentResolution: resolution, + assignmentFrontierID: assignmentFrontierID, + isAssignmentAcknowledged: device.lastAppliedAssignmentChangeID + == assignmentFrontierID, + isArchived: archivedIDs.contains(device.id), ) } .filter { @@ -948,7 +751,7 @@ public actor DeviceRecordingController { } } - private func applyObservedPolicyChange() async { + private func applyObservedAssignmentChange() async { await beginExclusive() guard acceptsOperations else { endExclusive() @@ -957,60 +760,47 @@ public actor DeviceRecordingController { do { let snapshot = try await storeSnapshot() let epoch = snapshot.epoch - let policies = snapshot.policyChanges let checkIns = snapshot.checkIns let existingProfile = snapshot.profiles.first { $0.id == currentDevice.id } let hasExpectedProfile = existingProfile.map { $0 == expectedProfile(registrationEpochID: $0.registrationEpochID) } ?? false - let requiresInitialPolicy = existingProfile?.registrationEpochID == epoch.id - let hasExpectedInitialPolicy = !requiresInitialPolicy || policies - .contains(expectedInitialPolicy( - id: initialRecordingChoice.policyChangeID, - isEnabled: initialRecordingChoice.isEnabled, - in: epoch, - )) - let timeline = Self.policyTimeline(for: currentDevice.id, in: policies) - let latestCurrentPolicyID = Self.effectivePolicy( - for: currentDevice.id, - epoch: epoch, - timeline: timeline, - )?.id - let requiredCleanupToken: RecordingPolicyCleanupToken? = epoch.isDestructive - ? RecordingPolicyCleanupToken(rawValue: epoch.id.rawValue) - : Self.destructiveCleanupToken( - for: currentDevice.id, - in: policies, - ) - let acknowledgedCurrentPolicyID = checkIns.first(where: { + let hasAssignment = snapshot.assignmentChanges.isEmpty == false + let latestCurrentAssignmentID = RecordingAssignmentChange.frontierToken( + in: snapshot.assignmentChanges, + ) + let requiredCleanupToken: RecordingAssignmentCleanupToken? = epoch.isDestructive + ? RecordingAssignmentCleanupToken(rawValue: epoch.id.rawValue) + : nil + let acknowledgedCurrentAssignmentID = checkIns.first(where: { $0.deviceID == currentDevice.id - })?.lastAppliedPolicyChangeID + })?.lastAppliedAssignmentChangeID let currentCheckIn = checkIns.first { $0.deviceID == currentDevice.id } let acknowledgedCleanupToken = currentCheckIn? - .lastDiscardedPolicyFrontierToken + .lastDiscardedAssignmentFrontierToken let heartbeatDue = currentCheckIn.map { now().timeIntervalSince($0.lastSeenAt) >= Self.checkInInterval } ?? true - let shouldReconcile = needsPolicyReconciliation + let shouldReconcile = needsAssignmentReconciliation || !hasExpectedProfile - || !hasExpectedInitialPolicy - || latestCurrentPolicyID != lastAppliedCurrentPolicyID - || acknowledgedCurrentPolicyID != latestCurrentPolicyID + || !hasAssignment + || latestCurrentAssignmentID != lastAppliedCurrentAssignmentID + || acknowledgedCurrentAssignmentID != latestCurrentAssignmentID || acknowledgedCleanupToken != requiredCleanupToken || heartbeatDue if shouldReconcile { try await registerLocked( - initialPolicyChangeID: initialRecordingChoice.policyChangeID, + initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, initialEnabled: initialRecordingChoice.isEnabled, ) let authorization = await ingestor.authorizationStatus() let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentPolicyID = reconciliation.latestPolicyChangeID - needsPolicyReconciliation = false + lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID + needsAssignmentReconciliation = false } endExclusive() } catch { - needsPolicyReconciliation = true + needsAssignmentReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) endExclusive() @@ -1026,7 +816,6 @@ public actor DeviceRecordingController { async let profiles = store.recordingDeviceProfiles() async let metadataChanges = store.recordingDeviceMetadataChanges() async let checkIns = store.recordingDeviceCheckIns() - async let policyChanges = store.recordingPolicyChanges() async let assignmentChanges = store.recordingAssignmentChanges() async let archives = store.recordingDeviceArchives() let values = try await ( @@ -1034,7 +823,6 @@ public actor DeviceRecordingController { profiles, metadataChanges, checkIns, - policyChanges, assignmentChanges, archives, ) @@ -1043,68 +831,12 @@ public actor DeviceRecordingController { profiles: values.1, metadataChanges: values.2, checkIns: values.3, - policyChanges: values.4, - assignmentChanges: values.5, - archives: values.6, + assignmentChanges: values.4, + archives: values.5, ) } } - private static func policyTimeline( - for deviceID: RecordingDeviceID, - in changes: [RecordingPolicyChange], - ) -> [RecordingPolicyChange] { - changes - .filter { $0.deviceID == deviceID } - .sorted(by: RecordingPolicyChange.isOrderedBefore) - } - - /// A destructive epoch is a universal fail-closed authority event. Devices absent from the - /// issuer's CloudKit snapshot therefore still resolve archived when their old profile arrives - /// later; a new per-device command at revision zero can explicitly reopen them. - private static func effectivePolicy( - for deviceID: RecordingDeviceID, - epoch: WhereDataEpoch, - timeline: [RecordingPolicyChange], - ) -> RecordingPolicyChange? { - if let latest = RecordingPolicyChange.canonicalHead(in: timeline) { return latest } - guard epoch.isDestructive, let issuer = epoch.changedByDeviceID else { return nil } - let reason: RecordingPolicyReason = switch epoch.reason { - case .initial: preconditionFailure("The initial epoch is not destructive.") - case .accountReset: .accountReset - case .backupReplace: .backupReplace - } - return RecordingPolicyChange( - id: epoch.id.rawValue, - deviceID: deviceID, - parentIDs: [], - revision: 0, - issuedAt: epoch.changedAt, - issuedByDeviceID: issuer, - effectiveAt: epoch.changedAt, - state: .archived, - reason: reason, - ) - } - - private static func destructiveCleanupToken( - for deviceID: RecordingDeviceID, - in changes: [RecordingPolicyChange], - ) -> RecordingPolicyCleanupToken? { - RecordingPolicyChange.destructiveCleanupToken( - in: changes.filter { $0.deviceID == deviceID }, - ) - } - - /// A higher revision arriving before one of its predecessors, or a malformed reason/state - /// pair, is not authority. Waiting for a valid complete timeline prevents a later On from - /// opening and draining the outbox before an intervening destructive barrier arrives. - private static func hasCompleteRevisionHistory( - _ timeline: [RecordingPolicyChange], - ) -> Bool { - RecordingPolicyChange.formValidPersistedTimelines(timeline) - } - private func expectedProfile( registrationEpochID: WhereDataEpochID, ) -> RecordingDeviceProfile { @@ -1117,24 +849,6 @@ public actor DeviceRecordingController { ) } - private func expectedInitialPolicy( - id: UUID, - isEnabled: Bool, - in epoch: WhereDataEpoch, - ) -> RecordingPolicyChange { - RecordingPolicyChange( - id: id, - deviceID: currentDevice.id, - parentIDs: [], - revision: 0, - issuedAt: initialRecordingChoice.confirmedAt, - issuedByDeviceID: currentDevice.id, - effectiveAt: max(initialRecordingChoice.confirmedAt, epoch.changedAt), - state: isEnabled ? .on : .off, - reason: .initialRegistration, - ) - } - private static func latestMetadata( for deviceID: RecordingDeviceID, field: RecordingDeviceMetadataField, @@ -1166,16 +880,6 @@ public actor DeviceRecordingController { configurationBroadcaster.send(update) } - /// Keep historical cutoffs monotonic for a writer whose wall clock moves backward. Causal - /// ordering is carried separately by `revision`, so equal cutoffs need no timestamp mutation. - private static func nextEffectiveDate( - proposed: Date, - after latest: RecordingPolicyChange?, - ) -> Date { - guard let latest else { return proposed } - return max(proposed, latest.effectiveAt) - } - private func requireActive() throws { guard acceptsOperations else { throw CancellationError() } } diff --git a/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift index e651cf7c..3c007896 100644 --- a/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift +++ b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift @@ -6,22 +6,22 @@ import Foundation /// therefore gets a new identity and must confirm its own initial recording /// choice, while repeated launches of the same installation reuse both the /// identity and the complete immutable payload inputs for its first synced -/// device profile and policy event. +/// device profile and assignment event. public struct InstallationRecordingContext: Sendable, Hashable { /// The explicitly confirmed first policy for this installation, including /// the timestamp reused whenever its immutable event must be recreated. public struct InitialRecordingChoice: Sendable, Hashable { public let isEnabled: Bool - public let policyChangeID: UUID + public let assignmentChangeID: UUID public let confirmedAt: Date public init( isEnabled: Bool, - policyChangeID: UUID, + assignmentChangeID: UUID, confirmedAt: Date, ) { self.isEnabled = isEnabled - self.policyChangeID = policyChangeID + self.assignmentChangeID = assignmentChangeID self.confirmedAt = confirmedAt } } @@ -47,10 +47,10 @@ public struct InstallationRecordingContext: Sendable, Hashable { } /// Return the confirmed form of a newly proposed context, freezing every - /// value needed to recreate the first policy event byte-for-byte. + /// value needed to recreate the first assignment event byte-for-byte. public func confirmingInitialRecording( isEnabled: Bool, - policyChangeID: UUID, + assignmentChangeID: UUID, confirmedAt: Date, ) -> InstallationRecordingContext { precondition( @@ -62,7 +62,7 @@ public struct InstallationRecordingContext: Sendable, Hashable { registeredAt: registeredAt, initialRecordingChoice: InitialRecordingChoice( isEnabled: isEnabled, - policyChangeID: policyChangeID, + assignmentChangeID: assignmentChangeID, confirmedAt: confirmedAt, ), ) @@ -81,7 +81,7 @@ public struct InstallationRecordingContext: Sendable, Hashable { registeredAt: Date(timeIntervalSinceReferenceDate: 0), initialRecordingChoice: InitialRecordingChoice( isEnabled: true, - policyChangeID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!, + assignmentChangeID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!, confirmedAt: Date(timeIntervalSinceReferenceDate: 1), ), ) @@ -100,7 +100,7 @@ public struct InstallationRecordingContext: Sendable, Hashable { registeredAt: Date(timeIntervalSinceReferenceDate: 0), initialRecordingChoice: InitialRecordingChoice( isEnabled: true, - policyChangeID: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + assignmentChangeID: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, confirmedAt: Date(timeIntervalSinceReferenceDate: 1), ), ) diff --git a/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift b/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift index f735eff8..d53de8f6 100644 --- a/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift +++ b/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift @@ -14,7 +14,7 @@ public protocol InstallationRecordingContextStoring: AnyObject { /// Persist the first explicit choice and its immutable event time beside /// the installation identity and immutable profile time. Later calls return - /// that frozen choice; subsequent intent changes belong in the synced policy stream. + /// that frozen choice; subsequent intent changes belong in the synced assignment stream. func confirmInitialRecording(isEnabled: Bool) throws -> InstallationRecordingContext /// Durable two-phase state for an import started by this installation. Kept beside the diff --git a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift index bfb93d6e..9422af3a 100644 --- a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift +++ b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift @@ -13,22 +13,14 @@ public struct LocationHistoryReader: Sendable { public func samples(in interval: DateInterval) async throws -> [LocationSample] { try await store.readSnapshot { async let samples = store.samples(in: interval) - async let policyChanges = store.recordingPolicyChanges() async let assignmentChanges = store.recordingAssignmentChanges() - let (resolvedSamples, resolvedPolicyChanges, resolvedAssignmentChanges) = try await ( + let (resolvedSamples, resolvedAssignmentChanges) = try await ( samples, - policyChanges, assignmentChanges, ) - if resolvedAssignmentChanges.isEmpty == false { - return RecordingPolicyFilter.visibleSamples( - resolvedSamples, - assignmentChanges: resolvedAssignmentChanges, - ) - } - return RecordingPolicyFilter.visibleSamples( + return RecordingAssignmentFilter.visibleSamples( resolvedSamples, - policyChanges: resolvedPolicyChanges, + assignmentChanges: resolvedAssignmentChanges, ) } } diff --git a/Where/WhereCore/Sources/Devices/RecordingAssignmentFilter.swift b/Where/WhereCore/Sources/Devices/RecordingAssignmentFilter.swift new file mode 100644 index 00000000..e8a36904 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingAssignmentFilter.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Applies the account-wide recording assignment to raw location samples. +/// +/// Assignment changes are append-only and evaluated at each sample timestamp. +/// Legacy samples without a device ID remain visible because no installation +/// can be attributed to them safely. +public enum RecordingAssignmentFilter { + public static func visibleSamples( + _ samples: [LocationSample], + assignmentChanges: [RecordingAssignmentChange], + ) -> [LocationSample] { + samples.filter { sample in + guard sample.source.isGPS, let deviceID = sample.recordingDeviceID else { + return true + } + return RecordingAssignmentChange.resolve( + assignmentChanges, + at: sample.timestamp, + ).permitsRecording(on: deviceID) + } + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDevice.swift b/Where/WhereCore/Sources/Devices/RecordingDevice.swift index cc5a7764..d9ca4499 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDevice.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDevice.swift @@ -29,8 +29,8 @@ public enum RecordingDeviceStatus: String, Codable, Sendable, Hashable { /// Read model for one device assembled from independently synced records. /// -/// The immutable profile, append-only nickname timeline, desired-authority timeline, and -/// target-owned check-in have +/// The immutable profile, append-only nickname timeline, archive tombstone, and target-owned +/// check-in have /// deliberately separate persistence rows. This aggregate is never written back wholesale: /// doing so would let CloudKit's last writer overwrite fields owned by another device. public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { @@ -41,12 +41,12 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { public let registeredAt: Date public let lastSeenAt: Date public let archivedAt: Date? - public let lastAppliedPolicyChangeID: UUID? - /// Stable storage field; see `RecordingDeviceCheckIn.lastDiscardedPolicyChangeID`. - public let lastDiscardedPolicyChangeID: UUID? + public let lastAppliedAssignmentChangeID: UUID? + /// Stable storage field; see `RecordingDeviceCheckIn.lastDiscardedAssignmentChangeID`. + public let lastDiscardedAssignmentChangeID: UUID? - var lastDiscardedPolicyFrontierToken: RecordingPolicyCleanupToken? { - lastDiscardedPolicyChangeID.map(RecordingPolicyCleanupToken.init(rawValue:)) + var lastDiscardedAssignmentFrontierToken: RecordingAssignmentCleanupToken? { + lastDiscardedAssignmentChangeID.map(RecordingAssignmentCleanupToken.init(rawValue:)) } public let status: RecordingDeviceStatus @@ -59,7 +59,7 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { registeredAt: Date, lastSeenAt: Date, archivedAt: Date?, - lastAppliedPolicyChangeID: UUID?, + lastAppliedAssignmentChangeID: UUID?, status: RecordingDeviceStatus, ) { self.id = id @@ -69,8 +69,8 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { self.registeredAt = registeredAt self.lastSeenAt = lastSeenAt self.archivedAt = archivedAt - self.lastAppliedPolicyChangeID = lastAppliedPolicyChangeID - lastDiscardedPolicyChangeID = nil + self.lastAppliedAssignmentChangeID = lastAppliedAssignmentChangeID + lastDiscardedAssignmentChangeID = nil self.status = status } @@ -83,7 +83,7 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { profile: RecordingDeviceProfile, nicknameChange: RecordingDeviceMetadataChange?, checkIn: RecordingDeviceCheckIn?, - policyChange: RecordingPolicyChange?, + archive: RecordingDeviceArchive?, ) { id = profile.id systemName = profile.systemName @@ -91,9 +91,9 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { kind = profile.kind registeredAt = profile.registeredAt lastSeenAt = checkIn?.lastSeenAt ?? profile.registeredAt - archivedAt = policyChange?.isArchived == true ? policyChange?.effectiveAt : nil - lastAppliedPolicyChangeID = checkIn?.lastAppliedPolicyChangeID - lastDiscardedPolicyChangeID = checkIn?.lastDiscardedPolicyChangeID + archivedAt = archive?.archivedAt + lastAppliedAssignmentChangeID = checkIn?.lastAppliedAssignmentChangeID + lastDiscardedAssignmentChangeID = checkIn?.lastDiscardedAssignmentChangeID status = checkIn?.status ?? .unknown } } diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift index ab48d968..42eb60aa 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift @@ -4,7 +4,7 @@ import Foundation /// /// A single barrier uses its event id; concurrent barriers use a deterministic digest of every /// frontier event id, so this is deliberately not modeled as one policy-change identity. -struct RecordingPolicyCleanupToken: RawRepresentable, Hashable { +struct RecordingAssignmentCleanupToken: RawRepresentable, Hashable { let rawValue: UUID } @@ -23,14 +23,14 @@ public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable public let revision: Int64 public let lastSeenAt: Date public let appliedAt: Date - public let lastAppliedPolicyChangeID: UUID + public let lastAppliedAssignmentChangeID: UUID /// Persisted UUID backing the destructive-frontier cleanup proof. It is an event id for a /// singleton frontier and a deterministic digest for concurrent barriers. The legacy storage - /// name remains stable; domain code uses ``lastDiscardedPolicyFrontierToken``. - public let lastDiscardedPolicyChangeID: UUID? + /// name remains stable; domain code uses ``lastDiscardedAssignmentFrontierToken``. + public let lastDiscardedAssignmentChangeID: UUID? - var lastDiscardedPolicyFrontierToken: RecordingPolicyCleanupToken? { - lastDiscardedPolicyChangeID.map(RecordingPolicyCleanupToken.init(rawValue:)) + var lastDiscardedAssignmentFrontierToken: RecordingAssignmentCleanupToken? { + lastDiscardedAssignmentChangeID.map(RecordingAssignmentCleanupToken.init(rawValue:)) } public let status: RecordingDeviceStatus @@ -40,7 +40,7 @@ public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable revision: Int64, lastSeenAt: Date, appliedAt: Date, - lastAppliedPolicyChangeID: UUID, + lastAppliedAssignmentChangeID: UUID, status: RecordingDeviceStatus, ) { precondition(revision >= 0, "A recording-device check-in revision cannot be negative.") @@ -49,8 +49,8 @@ public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable self.revision = revision self.lastSeenAt = lastSeenAt self.appliedAt = appliedAt - self.lastAppliedPolicyChangeID = lastAppliedPolicyChangeID - lastDiscardedPolicyChangeID = nil + self.lastAppliedAssignmentChangeID = lastAppliedAssignmentChangeID + lastDiscardedAssignmentChangeID = nil self.status = status } @@ -59,8 +59,8 @@ public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable revision: Int64, lastSeenAt: Date, appliedAt: Date, - lastAppliedPolicyChangeID: UUID, - lastDiscardedPolicyFrontierToken: RecordingPolicyCleanupToken?, + lastAppliedAssignmentChangeID: UUID, + lastDiscardedAssignmentFrontierToken: RecordingAssignmentCleanupToken?, status: RecordingDeviceStatus, ) { precondition(revision >= 0, "A recording-device check-in revision cannot be negative.") @@ -69,8 +69,8 @@ public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable self.revision = revision self.lastSeenAt = lastSeenAt self.appliedAt = appliedAt - self.lastAppliedPolicyChangeID = lastAppliedPolicyChangeID - lastDiscardedPolicyChangeID = lastDiscardedPolicyFrontierToken?.rawValue + self.lastAppliedAssignmentChangeID = lastAppliedAssignmentChangeID + lastDiscardedAssignmentChangeID = lastDiscardedAssignmentFrontierToken?.rawValue self.status = status } @@ -78,13 +78,13 @@ public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable if lhs.revision != rhs.revision { return lhs.revision < rhs.revision } - if lhs.lastAppliedPolicyChangeID != rhs.lastAppliedPolicyChangeID { - return lhs.lastAppliedPolicyChangeID.uuidString - < rhs.lastAppliedPolicyChangeID.uuidString + if lhs.lastAppliedAssignmentChangeID != rhs.lastAppliedAssignmentChangeID { + return lhs.lastAppliedAssignmentChangeID.uuidString + < rhs.lastAppliedAssignmentChangeID.uuidString } - if lhs.lastDiscardedPolicyChangeID != rhs.lastDiscardedPolicyChangeID { - return (lhs.lastDiscardedPolicyChangeID?.uuidString ?? "") - < (rhs.lastDiscardedPolicyChangeID?.uuidString ?? "") + if lhs.lastDiscardedAssignmentChangeID != rhs.lastDiscardedAssignmentChangeID { + return (lhs.lastDiscardedAssignmentChangeID?.uuidString ?? "") + < (rhs.lastDiscardedAssignmentChangeID?.uuidString ?? "") } if lhs.appliedAt != rhs.appliedAt { return lhs.appliedAt < rhs.appliedAt diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift index 4e610a95..fde607a4 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift @@ -1,75 +1,43 @@ import Foundation -/// One row shown by device-management UI: the assembled profile plus an honest -/// policy resolution that can represent staggered CloudKit delivery. +/// One installation row paired with the account-wide assignment resolution. public struct RecordingDeviceConfiguration: Identifiable, Sendable, Hashable { public let device: RecordingDevice - public let policy: RecordingPolicyResolution + public let assignmentResolution: RecordingAssignmentResolution + public let assignmentFrontierID: UUID? + public let isAssignmentAcknowledged: Bool + public let isArchived: Bool public var id: RecordingDeviceID { device.id } + /// Whether this row is the one assigned recorder. Nil means authority is not safely resolved. public var isEnabled: Bool? { - guard case let .resolved(policy) = policy else { return nil } - return policy.isEnabled + guard let assignment = assignmentResolution.assignment else { return nil } + return assignment.deviceID == id } - public var latestPolicyChangeID: UUID? { - guard case let .resolved(policy) = policy else { return nil } - return policy.changeID - } - - public var isArchived: Bool { - guard case let .resolved(policy) = policy else { return false } - return policy.isArchived + public var latestAssignmentChangeID: UUID? { + assignmentFrontierID } public var isPending: Bool { - switch policy { - case .unknown: true - case let .resolved(policy): policy.isAcknowledged == false - } + guard assignmentResolution.assignment != nil else { return true } + return isEnabled == true && isAssignmentAcknowledged == false } public init( device: RecordingDevice, - policy: RecordingPolicyResolution, + assignmentResolution: RecordingAssignmentResolution, + assignmentFrontierID: UUID?, + isAssignmentAcknowledged: Bool, + isArchived: Bool, ) { self.device = device - self.policy = policy - } - - /// Convenience for callers assembling a configuration from a known policy. - public init(device: RecordingDevice, policyChange: RecordingPolicyChange) { - self.init( - device: device, - policyChange: policyChange, - requiredCleanupToken: nil, - ) - } - - init( - device: RecordingDevice, - policyChange: RecordingPolicyChange, - requiredCleanupToken: RecordingPolicyCleanupToken?, - ) { - let isEffectivelyEnabled = policyChange.isEnabled - let acknowledgedStatus = if isEffectivelyEnabled { - device.status == .recording || device.status == .permissionRequired - } else { - device.status == .off - } - self.init( - device: device, - policy: .resolved(ResolvedRecordingPolicy( - isEnabled: isEffectivelyEnabled, - isArchived: policyChange.isArchived, - changeID: policyChange.id, - isAcknowledged: device.lastAppliedPolicyChangeID == policyChange.id - && device.lastDiscardedPolicyFrontierToken == requiredCleanupToken - && acknowledgedStatus, - )), - ) + self.assignmentResolution = assignmentResolution + self.assignmentFrontierID = assignmentFrontierID + self.isAssignmentAcknowledged = isAssignmentAcknowledged + self.isArchived = isArchived } } diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift index 6760ffa2..9d9869e1 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift @@ -7,8 +7,8 @@ public enum RecordingDeviceMetadataField: String, Codable, Sendable, Hashable { /// Append-only nickname edit for one recording installation. /// -/// Recording authority, including archive, deliberately does not live here: On, Off, and -/// archived are mutually exclusive states in the single ``RecordingPolicyChange`` stream. +/// Recording authority deliberately does not live here; the account-wide assignment and +/// irreversible archive tombstones own it. public struct RecordingDeviceMetadataChange: Identifiable, Codable, Sendable, Hashable { public let id: UUID public let deviceID: RecordingDeviceID diff --git a/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift b/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift index 932fbbbd..d957fde1 100644 --- a/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift +++ b/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift @@ -6,11 +6,8 @@ public enum RecordingPersistenceError: Error, LocalizedError, Sendable, Hashable case assignmentRevisionExhausted case conflictingImmutableRecord(id: UUID) case deviceNotFound(RecordingDeviceID) - case devicePolicyUnknown(RecordingDeviceID) case currentDeviceNotRegistered(RecordingDeviceID) - case currentDevicePolicyUnknown(RecordingDeviceID) - case incompletePolicyHistory(RecordingDeviceID) - case corruptRecordingPolicyHistory + case currentDeviceAssignmentUnknown(RecordingDeviceID) case revisionExhausted(RecordingDeviceID) case incompleteDataEpochHistory case dataEpochRevisionExhausted @@ -27,16 +24,10 @@ public enum RecordingPersistenceError: Error, LocalizedError, Sendable, Hashable String(localized: .recordingErrorConflictingImmutableRecord) case .deviceNotFound: String(localized: .recordingErrorDeviceNotFound) - case .devicePolicyUnknown: - String(localized: .recordingErrorDevicePolicyUnknown) case .currentDeviceNotRegistered: String(localized: .recordingErrorCurrentDeviceNotRegistered) - case .currentDevicePolicyUnknown: + case .currentDeviceAssignmentUnknown: String(localized: .recordingErrorCurrentDevicePolicyUnknown) - case .incompletePolicyHistory: - String(localized: .recordingErrorIncompletePolicyHistory) - case .corruptRecordingPolicyHistory: - String(localized: .recordingErrorCorruptPolicyHistory) case .revisionExhausted: String(localized: .recordingErrorRevisionExhausted) case .incompleteDataEpochHistory: diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift deleted file mode 100644 index 201f8d54..00000000 --- a/Where/WhereCore/Sources/Devices/RecordingPolicyChange.swift +++ /dev/null @@ -1,445 +0,0 @@ -import CryptoKit -import Foundation - -/// Complete desired authority for one recording installation. -/// -/// Archive belongs here rather than in the editable profile metadata stream: turning a device -/// Off and hiding it is one causal command, so CloudKit can never deliver independent halves -/// that later undo a newer re-enable. -public enum RecordingPolicyState: String, Codable, Sendable, Hashable { - case on - case off - case archived - - public var isEnabled: Bool { - self == .on - } - - public var isArchived: Bool { - self == .archived - } -} - -/// Why a recording-authority event was appended. -public enum RecordingPolicyReason: String, Codable, Sendable, Hashable { - case initialRegistration - case userCommand - case archive - case backupMerge - case accountReset - case backupReplace - - /// Destructive account operations must discard a target's unsynced retry backlog so an - /// offline device cannot repopulate data the user explicitly erased or replaced. - var discardsPendingSamples: Bool { - switch self { - case .accountReset, .backupReplace: true - case .initialRegistration, .userCommand, .archive, .backupMerge: false - } - } -} - -/// Append-only change to automatic recording policy for one device. -/// -/// `parentIDs` and `revision` form a causal DAG without comparing clocks from different devices. -/// One command names every maximal event its writer observed, so it is a semantic join rather -/// than one physical child per branch. `effectiveAt` remains the historical cutoff applied to -/// samples, while `issuedAt` and `issuedByDeviceID` retain an auditable account of the command. -public struct RecordingPolicyChange: Identifiable, Codable, Sendable, Hashable { - public let id: UUID - public let deviceID: RecordingDeviceID - public let parentIDs: [UUID] - public let revision: Int64 - public let issuedAt: Date - public let issuedByDeviceID: RecordingDeviceID - public let effectiveAt: Date - public let state: RecordingPolicyState - public let reason: RecordingPolicyReason - - public var isEnabled: Bool { - state.isEnabled - } - - public var isArchived: Bool { - state.isArchived - } - - public init( - id: UUID, - deviceID: RecordingDeviceID, - parentIDs: [UUID], - revision: Int64, - issuedAt: Date, - issuedByDeviceID: RecordingDeviceID, - effectiveAt: Date, - state: RecordingPolicyState, - reason: RecordingPolicyReason, - ) { - precondition(revision >= 0, "A recording-policy revision cannot be negative.") - precondition( - (revision == 0) == parentIDs.isEmpty, - "Only a recording-policy root may omit its parents.", - ) - let canonicalParentIDs = parentIDs.sorted { $0.uuidString < $1.uuidString } - precondition( - Set(canonicalParentIDs).count == canonicalParentIDs.count, - "A recording-policy command cannot name the same parent twice.", - ) - precondition( - canonicalParentIDs.contains(id) == false, - "A recording-policy command cannot parent itself.", - ) - self.id = id - self.deviceID = deviceID - self.parentIDs = canonicalParentIDs - self.revision = revision - self.issuedAt = issuedAt - self.issuedByDeviceID = issuedByDeviceID - self.effectiveAt = effectiveAt - self.state = state - self.reason = reason - } -} - -extension RecordingPolicyChange { - private struct CausalGraph { - let byID: [UUID: RecordingPolicyChange] - let heads: [RecordingPolicyChange] - } - - /// Whether the persisted reason and complete-authority state describe a command the domain - /// can issue. Kept on the value so every wire/storage boundary can reject the same malformed - /// combinations without duplicating the matrix. - var hasValidReasonAndState: Bool { - switch (reason, state) { - case (.initialRegistration, .on), - (.initialRegistration, .off), - (.userCommand, .on), - (.userCommand, .off), - (.archive, .archived), - (.backupMerge, .on), - (.backupMerge, .off), - (.backupMerge, .archived), - (.accountReset, .off), - (.accountReset, .archived), - (.backupReplace, .off), - (.backupReplace, .archived): - true - case (.initialRegistration, .archived), - (.userCommand, .archived), - (.archive, .on), - (.archive, .off), - (.accountReset, .on), - (.backupReplace, .on): - false - } - } - - /// A complete persisted policy snapshot has at least one revision-zero root for every - /// device, and every later event names a unique, present parent set, advances one revision - /// beyond that set's maximum, and never moves its historical cutoff before any parent. - /// Multiple roots or unjoined heads remain valid: concurrent CloudKit writers can legitimately - /// produce them, and resolution compares every maximal causal head. - static func formValidPersistedTimelines(_ changes: [RecordingPolicyChange]) -> Bool { - guard changes.allSatisfy({ $0.revision >= 0 && $0.hasValidReasonAndState }) else { - return false - } - return Dictionary(grouping: changes, by: \.deviceID).values.allSatisfy { timeline in - canonicalTimeline(in: timeline) != nil - } - } - - /// Resolve the authoritative maximal head's ancestor DAG for one installation. - /// - /// Descendants supersede ancestors. Concurrent maximal heads remain eligible even when they - /// descend from a sibling that previously lost resolution; destructive/restrictive state - /// wins between those heads, followed by immutable identity. A local command names every - /// observed head so one post-convergence action can causally supersede all of them. The - /// returned order is deterministic and causal (revision first), with the selected - /// head last; historical evaluation resolves the eligible induced DAG directly rather than - /// treating this array as a single branch. - static func canonicalTimeline( - in changes: [RecordingPolicyChange], - ) -> [RecordingPolicyChange]? { - guard changes.isEmpty == false else { return [] } - guard let graph = causalGraph(in: changes), - let head = graph.heads.max(by: isPreferredBefore) - else { return nil } - - var ancestorIDs: Set = [head.id] - var pending = head.parentIDs - while let parentID = pending.popLast() { - guard let parent = graph.byID[parentID] else { return nil } - if ancestorIDs.insert(parent.id).inserted { - pending.append(contentsOf: parent.parentIDs) - } - } - return ancestorIDs - .compactMap { graph.byID[$0] } - .sorted(by: isOrderedBefore) - } - - static func canonicalHead( - in changes: [RecordingPolicyChange], - ) -> RecordingPolicyChange? { - canonicalTimeline(in: changes)?.last - } - - /// Resolve authority at one historical instant from the induced causal DAG. Effective times - /// are monotonic across every parent edge, so removing future commands leaves an - /// ancestor-complete graph whose concurrent heads use the same safety-first join as current - /// authority. - static func effectiveHead( - in changes: [RecordingPolicyChange], - at date: Date, - ) -> RecordingPolicyChange? { - guard causalGraph(in: changes) != nil else { return nil } - let eligible = changes.filter { $0.effectiveAt <= date } - guard eligible.isEmpty == false else { return nil } - return canonicalHead(in: eligible) - } - - /// Every currently maximal causal head, ordered by the same deterministic safety lattice as - /// resolution. An empty valid history has an empty frontier; malformed history returns nil. - static func maximalHeads( - in changes: [RecordingPolicyChange], - ) -> [RecordingPolicyChange]? { - guard changes.isEmpty == false else { return [] } - return causalGraph(in: changes)?.heads.sorted(by: isPreferredBefore) - } - - /// Create one semantic command naming every observed maximal head. After this node syncs, - /// every observed branch has been causally superseded while an unseen concurrent branch - /// remains eligible. - static func appendingCommand( - to changes: [RecordingPolicyChange], - deviceID: RecordingDeviceID, - issuedAt: Date, - issuedByDeviceID: RecordingDeviceID, - effectiveAt: Date, - state: RecordingPolicyState, - reason: RecordingPolicyReason, - ) throws -> RecordingPolicyChange { - guard let heads = maximalHeads(in: changes), - changes.allSatisfy({ $0.deviceID == deviceID }) - else { - throw RecordingPersistenceError.incompletePolicyHistory(deviceID) - } - let commandEffectiveAt = heads.reduce(effectiveAt) { partialResult, head in - max(partialResult, head.effectiveAt) - } - let maximumRevision = heads.map(\.revision).max() - let revision: Int64 - if let maximumRevision { - let (next, overflow) = maximumRevision.addingReportingOverflow(1) - guard overflow == false else { - throw RecordingPersistenceError.revisionExhausted(deviceID) - } - revision = next - } else { - revision = 0 - } - return RecordingPolicyChange( - id: UUID(), - deviceID: deviceID, - parentIDs: heads.map(\.id), - revision: revision, - issuedAt: issuedAt, - issuedByDeviceID: issuedByDeviceID, - effectiveAt: commandEffectiveAt, - state: state, - reason: reason, - ) - } - - /// Stable acknowledgement token for the causally maximal destructive frontier. A singleton - /// uses its event id; multiple concurrent barriers hash the entire sorted id set so delivery - /// of any newly relevant barrier changes the token and forces the target to clear its outbox - /// again before acknowledging authority. - static func destructiveCleanupToken( - in changes: [RecordingPolicyChange], - ) -> RecordingPolicyCleanupToken? { - guard let frontier = destructiveFrontier(in: changes), frontier.isEmpty == false else { - return nil - } - guard frontier.count > 1 else { - return RecordingPolicyCleanupToken(rawValue: frontier[0].id) - } - - var hasher = SHA256() - hasher.update(data: Data("com.stuff.where.recording-cleanup-frontier.v1".utf8)) - for change in frontier.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { - hasher.update(data: Data("\n\(change.id.uuidString)".utf8)) - } - let digest = Array(hasher.finalize().prefix(16)) - return RecordingPolicyCleanupToken(rawValue: UUID(uuid: ( - digest[0], - digest[1], - digest[2], - digest[3], - digest[4], - digest[5], - digest[6], - digest[7], - digest[8], - digest[9], - digest[10], - digest[11], - digest[12], - digest[13], - digest[14], - digest[15], - ))) - } - - /// Conservative historical erase floor contributed by active account-reset barriers. A - /// later non-destructive On does not clear it; only a causally later destructive boundary - /// removes its ancestor from the destructive frontier. Concurrent reset floors join by the - /// latest effective cutoff. - static func activeAccountResetFloor( - in changes: [RecordingPolicyChange], - ) -> Date? { - destructiveFrontier(in: changes)? - .filter { $0.reason == .accountReset } - .map(\.effectiveAt) - .max() - } - - /// Deterministic causal ordering. At an equal revision, destructive cleanup wins first, - /// followed by the more restrictive state, so a concurrent On cannot defeat an - /// Off/archive/reset command; UUID text breaks the remaining tie. - static func isOrderedBefore( - _ lhs: RecordingPolicyChange, - _ rhs: RecordingPolicyChange, - ) -> Bool { - if lhs.revision != rhs.revision { - return lhs.revision < rhs.revision - } - return isPreferredBefore(lhs, rhs) - } - - /// Orders competing roots or children of the same parent. Destructive cleanup wins first, - /// followed by the more restrictive state; immutable identity breaks the remaining tie. - private static func isPreferredBefore( - _ lhs: RecordingPolicyChange, - _ rhs: RecordingPolicyChange, - ) -> Bool { - if lhs.reason.conflictPriority != rhs.reason.conflictPriority { - return lhs.reason.conflictPriority < rhs.reason.conflictPriority - } - if lhs.state.conflictPriority != rhs.state.conflictPriority { - return lhs.state.conflictPriority < rhs.state.conflictPriority - } - return lhs.id.uuidString < rhs.id.uuidString - } - - private static func causalGraph( - in changes: [RecordingPolicyChange], - ) -> CausalGraph? { - guard let deviceID = changes.first?.deviceID, - changes.allSatisfy({ - $0.deviceID == deviceID && $0.revision >= 0 && $0.hasValidReasonAndState - }) - else { return nil } - - let groupedByID = Dictionary(grouping: changes, by: \.id) - guard groupedByID.values.allSatisfy({ $0.count == 1 }) else { return nil } - let byID = groupedByID.compactMapValues(\.first) - var parentIDs = Set() - for change in changes { - let canonicalParentIDs = change.parentIDs.sorted { $0.uuidString < $1.uuidString } - if change.revision == 0 { - guard change.parentIDs.isEmpty, change.parentIDs == canonicalParentIDs else { - return nil - } - continue - } - guard change.parentIDs.isEmpty == false, - change.parentIDs == canonicalParentIDs, - Set(change.parentIDs).count == change.parentIDs.count, - change.parentIDs.contains(change.id) == false - else { return nil } - let parents = change.parentIDs.compactMap { byID[$0] } - guard parents.count == change.parentIDs.count, - parents.allSatisfy({ $0.deviceID == deviceID }), - let maximumRevision = parents.map(\.revision).max(), - maximumRevision < Int64.max, - change.revision == maximumRevision + 1, - parents.allSatisfy({ change.effectiveAt >= $0.effectiveAt }) - else { - return nil - } - parentIDs.formUnion(change.parentIDs) - } - let heads = changes.filter { parentIDs.contains($0.id) == false } - guard heads.isEmpty == false else { return nil } - return CausalGraph(byID: byID, heads: heads) - } - - /// Destructive boundaries remain active until another destructive event causally descends - /// from them. Non-destructive On/Off commands intentionally do not clear a reset's historical - /// erase floor. - private static func destructiveFrontier( - in changes: [RecordingPolicyChange], - ) -> [RecordingPolicyChange]? { - guard changes.isEmpty == false else { return [] } - guard let graph = causalGraph(in: changes) else { return nil } - let destructive = changes.filter(\.reason.discardsPendingSamples) - var superseded = Set() - for change in destructive { - var pending = change.parentIDs - var visited = Set() - while let id = pending.popLast(), let ancestor = graph.byID[id] { - guard visited.insert(id).inserted else { continue } - if ancestor.reason.discardsPendingSamples { - superseded.insert(ancestor.id) - } - pending.append(contentsOf: ancestor.parentIDs) - } - } - return destructive.filter { superseded.contains($0.id) == false } - } - - /// Stable winner when CloudKit supplies conflicting values for one immutable event id. - static func isCanonicalBefore( - _ lhs: RecordingPolicyChange, - _ rhs: RecordingPolicyChange, - ) -> Bool { - if lhs.deviceID != rhs.deviceID { - return lhs.deviceID.storeURL.absoluteString < rhs.deviceID.storeURL.absoluteString - } - if lhs.parentIDs != rhs.parentIDs { - return lhs.parentIDs.map(\.uuidString).joined(separator: ",") - < rhs.parentIDs.map(\.uuidString).joined(separator: ",") - } - if lhs.revision != rhs.revision { return lhs.revision < rhs.revision } - if lhs.issuedAt != rhs.issuedAt { return lhs.issuedAt < rhs.issuedAt } - if lhs.issuedByDeviceID != rhs.issuedByDeviceID { - return lhs.issuedByDeviceID.storeURL.absoluteString - < rhs.issuedByDeviceID.storeURL.absoluteString - } - if lhs.effectiveAt != rhs.effectiveAt { return lhs.effectiveAt < rhs.effectiveAt } - if lhs.state != rhs.state { return lhs.state.rawValue < rhs.state.rawValue } - return lhs.reason.rawValue < rhs.reason.rawValue - } -} - -extension RecordingPolicyState { - fileprivate var conflictPriority: Int { - switch self { - case .on: 0 - case .off: 1 - case .archived: 2 - } - } -} - -extension RecordingPolicyReason { - fileprivate var conflictPriority: Int { - switch self { - case .initialRegistration, .userCommand, .archive, .backupMerge: 0 - case .backupReplace: 1 - case .accountReset: 2 - } - } -} diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift deleted file mode 100644 index 05fcb0ff..00000000 --- a/Where/WhereCore/Sources/Devices/RecordingPolicyFilter.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation - -/// Applies device recording policy to raw location samples. -/// -/// Policy changes are append-only and evaluated at each sample timestamp. -/// Legacy samples without a device ID remain visible because no device policy -/// can be attributed to them safely. -public enum RecordingPolicyFilter { - public static func visibleSamples( - _ samples: [LocationSample], - assignmentChanges: [RecordingAssignmentChange], - ) -> [LocationSample] { - samples.filter { sample in - guard sample.source.isGPS, let deviceID = sample.recordingDeviceID else { - return true - } - return RecordingAssignmentChange.resolve( - assignmentChanges, - at: sample.timestamp, - ).permitsRecording(on: deviceID) - } - } - - public static func visibleSamples( - _ samples: [LocationSample], - policyChanges: [RecordingPolicyChange], - ) -> [LocationSample] { - let histories = Dictionary(grouping: policyChanges, by: \.deviceID) - - return samples.filter { sample in - guard sample.source.isGPS, let deviceID = sample.recordingDeviceID else { - return true - } - // Device-stamped rows fail closed while CloudKit delivery is incomplete. The sample - // and its installation's policy are separate records, so briefly receiving only the - // sample must never make an unproven location visible. - guard let history = histories[deviceID], - RecordingPolicyChange.formValidPersistedTimelines(history), - RecordingPolicyChange.canonicalTimeline(in: history) != nil - else { - return false - } - // Account reset is a historical erase boundary, not merely an Off interval. A fix - // captured before reset but uploaded by an offline device later must stay erased. - // A subsequent backup replacement intentionally restores historical rows, so only - // the latest destructive boundary carries this reset floor. - // Resolve the causally maximal destructive frontier independently from current - // On/Off authority. A non-destructive re-enable does not clear a reset floor, while a - // later Replace must name that reset as a parent to retire it. Concurrent reset floors - // join conservatively at their latest cutoff. - if let resetFloor = RecordingPolicyChange.activeAccountResetFloor(in: history), - sample.timestamp <= resetFloor - { - return false - } - // Evaluate the induced causal DAG at the sample instant. Effective times are monotonic - // across every parent edge, so future commands can be removed without orphaning an - // eligible ancestor; concurrent heads still use the safety-first state join. - guard let latest = RecordingPolicyChange.effectiveHead( - in: history, - at: sample.timestamp, - ) else { return false } - return latest.isEnabled - } - } -} diff --git a/Where/WhereCore/Sources/Devices/RecordingPolicyResolution.swift b/Where/WhereCore/Sources/Devices/RecordingPolicyResolution.swift deleted file mode 100644 index 3cf23923..00000000 --- a/Where/WhereCore/Sources/Devices/RecordingPolicyResolution.swift +++ /dev/null @@ -1,6 +0,0 @@ -/// Eventual-consistency state of a device's desired policy. -public enum RecordingPolicyResolution: Sendable, Hashable { - /// The profile has synced but no policy command has arrived yet. - case unknown - case resolved(ResolvedRecordingPolicy) -} diff --git a/Where/WhereCore/Sources/Devices/ResolvedRecordingPolicy.swift b/Where/WhereCore/Sources/Devices/ResolvedRecordingPolicy.swift deleted file mode 100644 index fabf0b5a..00000000 --- a/Where/WhereCore/Sources/Devices/ResolvedRecordingPolicy.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -/// Resolved desired policy for one device. -public struct ResolvedRecordingPolicy: Sendable, Hashable { - public let isEnabled: Bool - public let isArchived: Bool - public let changeID: UUID - public let isAcknowledged: Bool - - public init( - isEnabled: Bool, - isArchived: Bool, - changeID: UUID, - isAcknowledged: Bool, - ) { - self.isEnabled = isEnabled - self.isArchived = isArchived - self.changeID = changeID - self.isAcknowledged = isAcknowledged - } -} diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index 4fabef72..f13ce680 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -209,7 +209,7 @@ public actor LocationIngestor { } // Rows written before device provenance existed intentionally remain unstamped and // legacy-visible. Re-attributing them to this installation would make them depend on - // a policy event that did not exist when they were captured. + // an assignment event that did not exist when they were captured. retryQueue = restored + retryQueue didLoadDurableBacklog = true } diff --git a/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift index 43875f50..58115315 100644 --- a/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift +++ b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift @@ -1,6 +1,6 @@ import PeriscopeCore -/// Structured failures from background recording-policy reconciliation. +/// Structured failures from background recording-assignment reconciliation. enum DeviceRecordingControllerLog: LogEvent { case policyObservationFailed(description: String) case rollbackRecoveryFailed(description: String) @@ -15,7 +15,7 @@ enum DeviceRecordingControllerLog: LogEvent { var message: String { switch self { case let .policyObservationFailed(description): - "Failed to apply a synced recording policy; recording was stopped: \(description)" + "Failed to apply a synced recording assignment; recording was stopped: \(description)" case let .rollbackRecoveryFailed(description): "Failed to restore recording after an operation rolled back: \(description)" case let .importRecoveryFailed(description): diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index fa1dbb4f..c915deed 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -257,7 +257,6 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { SDRecordingDeviceProfile.self, SDRecordingDeviceMetadataChange.self, SDRecordingDeviceCheckIn.self, - SDRecordingPolicyChange.self, SDRecordingAssignmentChange.self, SDRecordingDeviceArchive.self, ] @@ -459,7 +458,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { profiles: [RecordingDeviceProfile], metadataChanges: [RecordingDeviceMetadataChange], checkIns: [RecordingDeviceCheckIn], - policyChanges: [RecordingPolicyChange], + assignmentChanges: [RecordingAssignmentChange], + archives: [RecordingDeviceArchive], ) async throws { try await perform(sendsChange: false, expectedDataEpochID: nil) { for profile in profiles { @@ -471,8 +471,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { for checkIn in checkIns { try await self.setRecordingDeviceCheckIn(checkIn) } - for policyChange in policyChanges { - try await self.addRecordingPolicyChange(policyChange) + for assignmentChange in assignmentChanges { + try await self.addRecordingAssignmentChange(assignmentChange) + } + for archive in archives { + try await self.addRecordingDeviceArchive(archive) } } } @@ -841,11 +844,6 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { { context.delete(record) } - for record in try context.fetch(FetchDescriptor()) - where belongs(record.epochID, to: epochID) - { - context.delete(record) - } for record in try context.fetch(FetchDescriptor()) where belongs(record.epochID, to: epochID) { @@ -924,12 +922,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { async let profiles = recordingDeviceProfiles() async let metadataChanges = recordingDeviceMetadataChanges() async let checkIns = recordingDeviceCheckIns() - async let policies = recordingPolicyChanges() - let (resolvedProfiles, resolvedMetadata, resolvedCheckIns, resolvedPolicies) = try await ( + async let archives = recordingDeviceArchives() + let (resolvedProfiles, resolvedMetadata, resolvedCheckIns, resolvedArchives) = try await ( profiles, metadataChanges, checkIns, - policies, + archives, ) let latestNicknames = Dictionary( grouping: resolvedMetadata.filter { $0.field == .nickname }, @@ -939,21 +937,15 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let checkInsByDevice = Dictionary(uniqueKeysWithValues: resolvedCheckIns.map { ($0.deviceID, $0) }) - let policyTimelines = Dictionary(grouping: resolvedPolicies, by: \.deviceID) - for (deviceID, timeline) in policyTimelines { - guard RecordingPolicyChange.formValidPersistedTimelines(timeline) else { - throw RecordingPersistenceError.incompletePolicyHistory(deviceID) - } - } - let policiesByDevice = policyTimelines - .compactMapValues { RecordingPolicyChange.canonicalHead(in: $0) } + let archivesByDevice = Dictionary(grouping: resolvedArchives, by: \.deviceID) + .compactMapValues { $0.min(by: { $0.archivedAt < $1.archivedAt }) } return resolvedProfiles .map { RecordingDevice( profile: $0, nicknameChange: latestNicknames[$0.id], checkIn: checkInsByDevice[$0.id], - policyChange: policiesByDevice[$0.id], + archive: archivesByDevice[$0.id], ) } .sorted { @@ -1118,60 +1110,6 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } - public func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { - let context = readContext() - let epochID = try readEpochID(in: context) - var descriptor = FetchDescriptor( - sortBy: [ - SortDescriptor(\.revision), - SortDescriptor(\.id), - ], - ) - descriptor.includePendingChanges = true - let records = try context.fetch(descriptor) - var values: [RecordingPolicyChange] = [] - for record in records where Self.belongs(record.epochID, to: epochID) { - guard let value = record.toValue() else { - Self.logFault(forCorrupt: record) - throw RecordingPersistenceError.corruptRecordingPolicyHistory - } - values.append(value) - } - // Keep one immutable value per event id if CloudKit delivers duplicate rows. - return Dictionary(grouping: values, by: \.id) - .compactMap { id, duplicates in - if Set(duplicates).count > 1 { - Self.logImmutableConflict( - type: String(describing: RecordingPolicyChange.self), - id: id.uuidString, - count: duplicates.count, - ) - } - return duplicates.min(by: RecordingPolicyChange.isCanonicalBefore) - } - .sorted(by: RecordingPolicyChange.isOrderedBefore) - } - - public func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { - let context = mutationContext() - let epochID = mutationEpochID() - let id = change.id - let existing = try context.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == id }), - ) - let active = existing.filter { Self.belongs($0.epochID, to: epochID) } - guard !active.isEmpty else { - context.insert(SDRecordingPolicyChange(value: change, epochID: epochID)) - return - } - guard active.allSatisfy({ $0.toValue() == change }) else { - throw RecordingPersistenceError.conflictingImmutableRecord(id: id) - } - for duplicate in active.dropFirst() { - context.delete(duplicate) - } - } - public func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { let context = readContext() let epochID = try readEpochID(in: context) @@ -2186,10 +2124,10 @@ final class SDRecordingDeviceCheckIn { var revision: Int64? var lastSeenAt: Date? var appliedAt: Date? - var lastAppliedPolicyChangeID: UUID? + var lastAppliedAssignmentChangeID: UUID? /// Singleton destructive event id or deterministic multi-head frontier digest. The field name /// predates multi-parent policy and remains stable for CloudKit compatibility. - var lastDiscardedPolicyChangeID: UUID? + var lastDiscardedAssignmentChangeID: UUID? var statusRaw: String? init() {} @@ -2205,8 +2143,8 @@ final class SDRecordingDeviceCheckIn { revision = value.revision lastSeenAt = value.lastSeenAt appliedAt = value.appliedAt - lastAppliedPolicyChangeID = value.lastAppliedPolicyChangeID - lastDiscardedPolicyChangeID = value.lastDiscardedPolicyChangeID + lastAppliedAssignmentChangeID = value.lastAppliedAssignmentChangeID + lastDiscardedAssignmentChangeID = value.lastDiscardedAssignmentChangeID statusRaw = value.status.rawValue } @@ -2216,7 +2154,7 @@ final class SDRecordingDeviceCheckIn { revision >= 0, let lastSeenAt, let appliedAt, - let lastAppliedPolicyChangeID, + let lastAppliedAssignmentChangeID, let statusRaw, let status = RecordingDeviceStatus(rawValue: statusRaw), status != .unknown @@ -2226,96 +2164,15 @@ final class SDRecordingDeviceCheckIn { revision: revision, lastSeenAt: lastSeenAt, appliedAt: appliedAt, - lastAppliedPolicyChangeID: lastAppliedPolicyChangeID, - lastDiscardedPolicyFrontierToken: lastDiscardedPolicyChangeID.map { - RecordingPolicyCleanupToken(rawValue: $0) + lastAppliedAssignmentChangeID: lastAppliedAssignmentChangeID, + lastDiscardedAssignmentFrontierToken: lastDiscardedAssignmentChangeID.map { + RecordingAssignmentCleanupToken(rawValue: $0) }, status: status, ) } } -/// Append-only desired recording state. Optional columns keep the CloudKit -/// schema additive and tolerant of partially synced rows. -@Model -final class SDRecordingPolicyChange { - var epochID: UUID? - var id: UUID? - var deviceID: UUID? - /// Legacy scalar parent. New multi-parent rows leave it nil; a non-root row therefore remains - /// unavailable until its complete parent-id array arrives. - var parentID: UUID? - var parentIDs: [UUID]? - var revision: Int64? - var issuedAt: Date? - var issuedByDeviceID: UUID? - var effectiveAt: Date? - var stateRaw: String? - var reasonRaw: String? - - init() {} - - convenience init(value: RecordingPolicyChange, epochID: WhereDataEpochID) { - self.init() - update(from: value, epochID: epochID) - } - - func update(from value: RecordingPolicyChange, epochID: WhereDataEpochID) { - self.epochID = epochID.rawValue - id = value.id - deviceID = value.deviceID.rawValue - parentID = nil - parentIDs = value.parentIDs - revision = value.revision - issuedAt = value.issuedAt - issuedByDeviceID = value.issuedByDeviceID.rawValue - effectiveAt = value.effectiveAt - stateRaw = value.state.rawValue - reasonRaw = value.reason.rawValue - } - - func toValue() -> RecordingPolicyChange? { - guard let id, - let deviceID, - let revision, - revision >= 0, - let issuedAt, - let issuedByDeviceID, - let effectiveAt, - let stateRaw, - let state = RecordingPolicyState(rawValue: stateRaw), - let reasonRaw, - let reason = RecordingPolicyReason(rawValue: reasonRaw) - else { return nil } - let resolvedParentIDs: [UUID] - if let parentIDs { - resolvedParentIDs = parentIDs - } else if let parentID { - resolvedParentIDs = [parentID] - } else if revision == 0 { - resolvedParentIDs = [] - } else { - return nil - } - guard (revision == 0) == resolvedParentIDs.isEmpty, - Set(resolvedParentIDs).count == resolvedParentIDs.count, - resolvedParentIDs.contains(id) == false - else { return nil } - let value = RecordingPolicyChange( - id: id, - deviceID: RecordingDeviceID(rawValue: deviceID), - parentIDs: resolvedParentIDs, - revision: revision, - issuedAt: issuedAt, - issuedByDeviceID: RecordingDeviceID(rawValue: issuedByDeviceID), - effectiveAt: effectiveAt, - state: state, - reason: reason, - ) - return value.hasValidReasonAndState ? value : nil - } -} - /// Account-wide automatic-recording assignment command. @Model final class SDRecordingAssignmentChange { diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index f5688526..07fc2928 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -39,7 +39,8 @@ public protocol WhereStore: Sendable { /// Pin every read in `block` to one logical data epoch and verify that epoch and the durable /// store generation are still current before returning. This is the multi-table read boundary - /// for policy decisions and backup export; a remote commit crossing its reads invalidates the + /// for authority decisions and backup export; a remote commit crossing its reads invalidates + /// the /// result through persistent history even if its notification has not arrived yet. @discardableResult func readSnapshot( @@ -71,7 +72,7 @@ public protocol WhereStore: Sendable { /// Atomically erase the active epoch's synced rows and append a fresh destructive epoch. /// Every subsequent write in the same transaction is stamped into the returned epoch. /// Immutable device profiles remain global so a late/offline installation can still be - /// identified, but its old policy and user-data rows cannot affect the new generation. + /// identified, but its old assignment and user-data rows cannot affect the new generation. func rotateDataEpoch( reason: WhereDataEpochReason, changedBy deviceID: RecordingDeviceID, @@ -113,8 +114,7 @@ public protocol WhereStore: Sendable { /// an existing installation id. Must run inside `perform { ... }`. func addRecordingDeviceProfile(_ profile: RecordingDeviceProfile) async throws - /// Full append-only nickname timeline. Effective archive authority lives in - /// ``recordingPolicyChanges()``. + /// Full append-only nickname timeline. func recordingDeviceMetadataChanges() async throws -> [RecordingDeviceMetadataChange] /// Insert an immutable metadata event. Must run inside `perform { ... }`. @@ -127,14 +127,6 @@ public protocol WhereStore: Sendable { /// Must run inside `perform { ... }`. func setRecordingDeviceCheckIn(_ checkIn: RecordingDeviceCheckIn) async throws - /// Every append-only recording-policy event, oldest first. - func recordingPolicyChanges() async throws -> [RecordingPolicyChange] - - /// Insert one immutable policy event naming every causal head its command observed. An - /// identical retry is idempotent; a different value with the same id throws. Must run inside - /// `perform { ... }`. - func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws - /// Complete account-wide automatic-recording assignment history for the active epoch. func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 3db59f87..39630478 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -155,7 +155,8 @@ public struct WhereServices: Sendable { ) let liveAttribution = attributor as? RegionAttribution let reconcileAllDerivedData: @Sendable () async -> Void = { - // Remote, backup, and recording-policy writes can change the tracked set at the same + // Remote, backup, and recording-assignment writes can change the tracked set at the + // same // time as the data being rebuilt. Await the shared live attributor first so every // downstream projection starts from current attribution instead of racing its // independent store-change observer. diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 9c160717..4ca872f9 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -92,7 +92,7 @@ struct BackupCoordinatorTests { private static let recordingDeviceID = RecordingDeviceID( rawValue: UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")!, ) - private static let recordingPolicyID = + private static let recordingAssignmentID = UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")! /// Seed every persisted domain directly into a store so backup tests don't @@ -127,19 +127,18 @@ struct BackupCoordinatorTests { revision: 0, lastSeenAt: dismissal.dismissedAt, appliedAt: dismissal.dismissedAt, - lastAppliedPolicyChangeID: recordingPolicyID, + lastAppliedAssignmentChangeID: recordingAssignmentID, status: .recording, )) - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: recordingPolicyID, - deviceID: recordingDeviceID, + try await store.addRecordingAssignmentChange(RecordingAssignmentChange( + id: recordingAssignmentID, parentIDs: [], revision: 0, issuedAt: dismissal.dismissedAt, issuedByDeviceID: recordingDeviceID, effectiveAt: dismissal.dismissedAt, - state: .on, - reason: .initialRegistration, + assignedDeviceID: recordingDeviceID, + reason: .onboarding, )) } } @@ -159,7 +158,7 @@ struct BackupCoordinatorTests { #expect(summary.manualDayCount == 1) #expect(summary.dismissedIssueCount == 1) #expect(summary.recordingDeviceCount == 1) - #expect(summary.recordingPolicyChangeCount == 1) + #expect(summary.recordingAssignmentChangeCount == 1) #expect(try await destination.store.allSamples() == source.store.allSamples()) #expect(try await destination.store.allEvidence() == source.store.allEvidence()) @@ -175,283 +174,17 @@ struct BackupCoordinatorTests { // Check-ins prove that a particular installation applied policy and cleared its own // outbox. A backup cannot safely reproduce that proof on another installation. #expect(try await destination.store.recordingDeviceCheckIns().isEmpty) - let policies = try await destination.store.recordingPolicyChanges() - let sourcePolicies = try await source.store.recordingPolicyChanges() - #expect(Array(policies.dropLast()) == sourcePolicies) - #expect(policies.last?.parentIDs == [Self.recordingPolicyID]) - #expect(policies.last?.state == .off) - #expect(policies.last?.reason == .backupMerge) + let assignments = try await destination.store.recordingAssignmentChanges() + let sourceAssignments = try await source.store.recordingAssignmentChanges() + #expect(Array(assignments.dropLast()) == sourceAssignments) + #expect(assignments.last?.parentIDs == [Self.recordingAssignmentID]) + #expect(assignments.last?.assignedDeviceID == nil) + #expect(assignments.last?.reason == .backupMerge) #expect(try await destination.store.evidenceBlob(for: Self.evidence.id) == Self.blob) // An import that lands new data runs the post-commit hook once. #expect(await destination.didCommit.count == 1) } - @Test func exportOmitsTargetOwnedRecordingCheckIns() async throws { - let source = try Self.makeHarness() - try await Self.seed(source.store) - - let url = try await source.coordinator.exportBackup() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - - let archive = try BackupService().readArchive(at: url).archive - #expect(archive.recordingDeviceCheckIns.isEmpty) - } - - @Test func importTreatsLegacyRecordingCheckInsAsInertHistory() async throws { - let profile = RecordingDeviceProfile( - id: Self.recordingDeviceID, - systemName: "iPad", - kind: .tablet, - registeredAt: Self.dismissal.dismissedAt, - registrationEpochID: .initial, - ) - let policy = RecordingPolicyChange( - id: Self.recordingPolicyID, - deviceID: Self.recordingDeviceID, - parentIDs: [], - revision: 0, - issuedAt: Self.dismissal.dismissedAt, - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: Self.dismissal.dismissedAt, - state: .off, - reason: .userCommand, - ) - let checkIn = RecordingDeviceCheckIn( - deviceID: Self.recordingDeviceID, - revision: 0, - lastSeenAt: Self.dismissal.dismissedAt, - appliedAt: Self.dismissal.dismissedAt, - lastAppliedPolicyChangeID: Self.recordingPolicyID, - status: .off, - ) - let url = try BackupService().makeArchiveFile( - samples: [], - evidence: [], - manualDays: [], - recordingDeviceProfiles: [profile], - recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [checkIn], - recordingPolicyChanges: [policy], - blobs: [:], - ) - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - - let destination = try Self.makeHarness() - _ = try await destination.coordinator.importBackup(from: url, strategy: .merge) - - #expect(try await destination.store.recordingDeviceProfiles() == [profile]) - let policies = try await destination.store.recordingPolicyChanges() - #expect(policies.first == policy) - #expect(policies.last?.parentIDs == [policy.id]) - #expect(policies.last?.state == .off) - #expect(policies.last?.reason == .backupMerge) - #expect(try await destination.store.recordingDeviceCheckIns().isEmpty) - } - - @Test func mergeGivesANewProfileWithoutPolicyAnOffRoot() async throws { - let profile = RecordingDeviceProfile( - id: Self.recordingDeviceID, - systemName: "Travel iPad", - kind: .tablet, - registeredAt: Self.dismissal.dismissedAt, - registrationEpochID: .initial, - ) - let url = try BackupService().makeArchiveFile( - samples: [], - evidence: [], - manualDays: [], - recordingDeviceProfiles: [profile], - recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [], - blobs: [:], - ) - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - - let destination = try Self.makeHarness() - _ = try await destination.coordinator.importBackup(from: url, strategy: .merge) - - #expect(try await destination.store.recordingDeviceProfiles() == [profile]) - let policy = try #require(try await destination.store.recordingPolicyChanges().first) - #expect(policy.deviceID == profile.id) - #expect(policy.revision == 0) - #expect(policy.parentIDs.isEmpty) - #expect(policy.state == .off) - #expect(policy.reason == .backupMerge) - } - - @Test func mergeImportReassertsTheAuthorityThatExistedBeforeImportedPolicy() async throws { - let root = try RecordingPolicyChange( - id: #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")), - deviceID: Self.recordingDeviceID, - parentIDs: [], - revision: 0, - issuedAt: Date(timeIntervalSinceReferenceDate: 100), - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: Date(timeIntervalSinceReferenceDate: 100), - state: .on, - reason: .initialRegistration, - ) - let off = try RecordingPolicyChange( - id: #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")), - deviceID: Self.recordingDeviceID, - parentIDs: [root.id], - revision: 1, - issuedAt: Date(timeIntervalSinceReferenceDate: 200), - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: Date(timeIntervalSinceReferenceDate: 200), - state: .off, - reason: .userCommand, - ) - let importedOn = try RecordingPolicyChange( - id: #require(UUID(uuidString: "30000000-0000-0000-0000-000000000000")), - deviceID: Self.recordingDeviceID, - parentIDs: [off.id], - revision: 2, - issuedAt: Date(timeIntervalSinceReferenceDate: 300), - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: Date(timeIntervalSinceReferenceDate: 300), - state: .on, - reason: .userCommand, - ) - let url = try BackupService().makeArchiveFile( - samples: [], - evidence: [], - manualDays: [], - recordingDeviceProfiles: [], - recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [root, off, importedOn], - blobs: [:], - ) - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - - let destination = try Self.makeHarness() - try await destination.store.perform { - try await destination.store.addRecordingPolicyChange(root) - try await destination.store.addRecordingPolicyChange(off) - } - - _ = try await destination.coordinator.importBackup(from: url, strategy: .merge) - - let timeline = try #require(try await RecordingPolicyChange.canonicalTimeline( - in: destination.store.recordingPolicyChanges(), - )) - #expect(timeline.map(\.state) == [.on, .off, .on, .off]) - #expect(timeline.last?.parentIDs == [importedOn.id]) - #expect(timeline.last?.reason == .backupMerge) - } - - @Test func mergePreservesImplicitArchiveAuthorityAfterReplace() async throws { - let importedOn = try RecordingPolicyChange( - id: #require(UUID(uuidString: "40000000-0000-0000-0000-000000000000")), - deviceID: Self.recordingDeviceID, - parentIDs: [], - revision: 0, - issuedAt: Date(timeIntervalSinceReferenceDate: 100), - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: Date(timeIntervalSinceReferenceDate: 100), - state: .on, - reason: .initialRegistration, - ) - let emptyURL = try BackupService().makeArchiveFile( - samples: [], - evidence: [], - manualDays: [], - recordingDeviceProfiles: [], - recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [], - blobs: [:], - ) - defer { try? FileManager.default.removeItem(at: emptyURL.deletingLastPathComponent()) } - let mergeURL = try BackupService().makeArchiveFile( - samples: [], - evidence: [], - manualDays: [], - recordingDeviceProfiles: [], - recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [importedOn], - blobs: [:], - ) - defer { try? FileManager.default.removeItem(at: mergeURL.deletingLastPathComponent()) } - - let destination = try Self.makeHarness() - try await destination.store.perform { - try await destination.store.addRecordingDeviceProfile(RecordingDeviceProfile( - id: Self.recordingDeviceID, - systemName: "iPad", - kind: .tablet, - registeredAt: Date(timeIntervalSinceReferenceDate: 50), - registrationEpochID: .initial, - )) - } - _ = try await destination.coordinator.importBackup(from: emptyURL, strategy: .replace) - #expect(try await destination.store.dataEpoch().isDestructive) - #expect(try await destination.store.recordingPolicyChanges().isEmpty) - - _ = try await destination.coordinator.importBackup(from: mergeURL, strategy: .merge) - - let timeline = try #require(try await RecordingPolicyChange.canonicalTimeline( - in: destination.store.recordingPolicyChanges(), - )) - #expect(timeline.map(\.state) == [.on, .archived]) - #expect(timeline.last?.parentIDs == [importedOn.id]) - #expect(timeline.last?.reason == .backupMerge) - } - - @Test func replaceAddsABarrierForAPolicyOnlyDevice() async throws { - let policyOnlyDeviceID = try RecordingDeviceID( - rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), - ) - let importedOn = try RecordingPolicyChange( - id: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), - deviceID: policyOnlyDeviceID, - parentIDs: [], - revision: 0, - issuedAt: Date(timeIntervalSinceReferenceDate: 100), - issuedByDeviceID: policyOnlyDeviceID, - effectiveAt: Date(timeIntervalSinceReferenceDate: 100), - state: .on, - reason: .initialRegistration, - ) - let url = try BackupService().makeArchiveFile( - samples: [], - evidence: [], - manualDays: [], - recordingDeviceProfiles: [], - recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [importedOn], - blobs: [:], - ) - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - - let destination = try Self.makeHarness() - _ = try await destination.coordinator.importBackup(from: url, strategy: .replace) - - var policies = try await destination.store.recordingPolicyChanges() - #expect(policies.map(\.state) == [.on, .off]) - #expect(policies.last?.parentIDs == [importedOn.id]) - #expect(policies.last?.reason == .backupReplace) - - try await destination.store.perform { - try await destination.store.addRecordingDeviceProfile(RecordingDeviceProfile( - id: policyOnlyDeviceID, - systemName: "iPad", - kind: .tablet, - registeredAt: Date(timeIntervalSinceReferenceDate: 100), - registrationEpochID: .initial, - )) - } - policies = try await destination.store.recordingPolicyChanges() - let device = try #require(try await destination.store.recordingDevices().first) - #expect(device.id == policyOnlyDeviceID) - #expect(policies.last?.deviceID == device.id) - #expect(policies.last?.state == .off) - } - @Test func mergeImportKeepsPreexistingRows() async throws { let source = try Self.makeHarness() try await Self.seed(source.store) @@ -738,8 +471,8 @@ struct BackupCoordinatorTests { manualDays: [], recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [], + recordingAssignmentChanges: [], + recordingDeviceArchives: [], blobs: [:], ) defer { try? FileManager.default.removeItem(at: secondURL.deletingLastPathComponent()) } diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index 8544038f..fd5fd7d1 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -13,7 +13,7 @@ struct BackupServiceTests { private static let recordingDeviceID = RecordingDeviceID( rawValue: UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!, ) - private static let recordingPolicyID = + private static let recordingAssignmentID = UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")! private static func sampleFixtures() -> [LocationSample] { @@ -71,30 +71,29 @@ struct BackupServiceTests { revision: 0, lastSeenAt: exportDate, appliedAt: exportDate, - lastAppliedPolicyChangeID: recordingPolicyID, + lastAppliedAssignmentChangeID: recordingAssignmentID, status: .recording, ), ] } - private static func recordingPolicyFixtures() -> [RecordingPolicyChange] { + private static func recordingAssignmentFixtures() -> [RecordingAssignmentChange] { [ - RecordingPolicyChange( - id: recordingPolicyID, - deviceID: recordingDeviceID, + RecordingAssignmentChange( + id: recordingAssignmentID, parentIDs: [], revision: 0, issuedAt: exportDate, issuedByDeviceID: recordingDeviceID, effectiveAt: exportDate, - state: .on, - reason: .initialRegistration, + assignedDeviceID: recordingDeviceID, + reason: .onboarding, ), ] } private static func archive( - recordingPolicyChanges: [RecordingPolicyChange], + recordingAssignmentChanges: [RecordingAssignmentChange], ) -> BackupArchive { BackupArchive( exportedAt: exportDate, @@ -106,8 +105,8 @@ struct BackupServiceTests { primaryRegions: [], recordingDeviceProfiles: recordingDeviceProfileFixtures(), recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: recordingPolicyChanges, + recordingAssignmentChanges: recordingAssignmentChanges, + recordingDeviceArchives: [], assets: [], ) } @@ -166,18 +165,7 @@ struct BackupServiceTests { let dismissedIssues = Self.dismissedIssueFixtures() let recordingDeviceProfiles = Self.recordingDeviceProfileFixtures() let recordingDeviceMetadataChanges = Self.recordingDeviceMetadataFixtures() - let recordingDeviceCheckIns = Self.recordingDeviceCheckInFixtures() - let recordingPolicies = Self.recordingPolicyFixtures() - let assignment = RecordingAssignmentChange( - id: UUID(), - parentIDs: [], - revision: 0, - issuedAt: Self.exportDate, - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: Self.exportDate, - assignedDeviceID: Self.recordingDeviceID, - reason: .userCommand, - ) + let recordingAssignments = Self.recordingAssignmentFixtures() let deviceArchive = RecordingDeviceArchive( id: UUID(), deviceID: Self.recordingDeviceID, @@ -192,9 +180,7 @@ struct BackupServiceTests { dismissedIssues: dismissedIssues, recordingDeviceProfiles: recordingDeviceProfiles, recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, - recordingDeviceCheckIns: recordingDeviceCheckIns, - recordingPolicyChanges: recordingPolicies, - recordingAssignmentChanges: [assignment], + recordingAssignmentChanges: recordingAssignments, recordingDeviceArchives: [deviceArchive], blobs: blobs, exportedAt: Self.exportDate, @@ -215,16 +201,13 @@ struct BackupServiceTests { #expect(result.archive.dismissedIssues == dismissedIssues) #expect(result.archive.recordingDeviceProfiles == recordingDeviceProfiles) #expect(result.archive.recordingDeviceMetadataChanges == recordingDeviceMetadataChanges) - #expect(result.archive.recordingDeviceCheckIns == recordingDeviceCheckIns) - #expect(result.archive.recordingPolicyChanges == recordingPolicies) - #expect(result.archive.recordingAssignmentChanges == [assignment]) + #expect(result.archive.recordingAssignmentChanges == recordingAssignments) #expect(result.archive.recordingDeviceArchives == [deviceArchive]) let encodedManifest = try #require(String( data: BackupService.makeEncoder().encode(result.archive), encoding: .utf8, )) - #expect(encodedManifest.contains("\"state\" : \"on\"")) - #expect(encodedManifest.contains("\"reason\" : \"initialRegistration\"")) + #expect(encodedManifest.contains("\"reason\" : \"onboarding\"")) #expect(encodedManifest.contains("\"isEnabled\"") == false) #expect(encodedManifest.contains("\"registrationEpochID\"")) #expect(encodedManifest.contains("00000000-0000-0000-0000-0000000000E0")) @@ -233,24 +216,22 @@ struct BackupServiceTests { #expect(result.blobs == blobs) } - @Test func rapidPolicyChangesPreserveTheirSubsecondOrder() throws { + @Test func rapidAssignmentChangesPreserveTheirSubsecondOrder() throws { let service = BackupService() let firstDate = Date(timeIntervalSince1970: 1_700_000_000.123_456) - let policies = try [ - RecordingPolicyChange( + let assignments = try [ + RecordingAssignmentChange( id: #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")), - deviceID: Self.recordingDeviceID, parentIDs: [], revision: 0, issuedAt: firstDate, issuedByDeviceID: Self.recordingDeviceID, effectiveAt: firstDate, - state: .on, - reason: .initialRegistration, + assignedDeviceID: Self.recordingDeviceID, + reason: .onboarding, ), - RecordingPolicyChange( + RecordingAssignmentChange( id: #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")), - deviceID: Self.recordingDeviceID, parentIDs: [#require(UUID( uuidString: "11111111-1111-1111-1111-111111111111", ))], @@ -258,7 +239,7 @@ struct BackupServiceTests { issuedAt: firstDate.addingTimeInterval(0.000_001), issuedByDeviceID: Self.recordingDeviceID, effectiveAt: firstDate.addingTimeInterval(0.000_001), - state: .off, + assignedDeviceID: nil, reason: .userCommand, ), ] @@ -268,16 +249,16 @@ struct BackupServiceTests { manualDays: [], recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: policies, + recordingAssignmentChanges: assignments, + recordingDeviceArchives: [], blobs: [:], exportedAt: Self.exportDate, ) defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let restored = try service.readArchive(at: url).archive.recordingPolicyChanges + let restored = try service.readArchive(at: url).archive.recordingAssignmentChanges - #expect(restored == policies) + #expect(restored == assignments) let first = try #require(restored.first) let last = try #require(restored.last) #expect(first.effectiveAt < last.effectiveAt) @@ -294,8 +275,8 @@ struct BackupServiceTests { primaryRegions: [], recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [], + recordingAssignmentChanges: [], + recordingDeviceArchives: [], assets: [], ) let legacyEncoder = JSONEncoder() @@ -322,7 +303,7 @@ struct BackupServiceTests { @Test func currentFormatDoesNotSilentlyBackfillAMissingProfileEpoch() throws { let data = try BackupService.makeEncoder().encode( - Self.archive(recordingPolicyChanges: Self.recordingPolicyFixtures()), + Self.archive(recordingAssignmentChanges: Self.recordingAssignmentFixtures()), ) var manifest = try #require( JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -348,7 +329,7 @@ struct BackupServiceTests { } } - @Test func validationRejectsANegativePolicyRevisionDecodedFromABackup() throws { + @Test func validationRejectsANegativeAssignmentRevisionDecodedFromABackup() throws { let archive = try BackupArchive( exportedAt: Self.exportDate, samples: [], @@ -359,8 +340,8 @@ struct BackupServiceTests { primaryRegions: [], recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [#require(Self.recordingPolicyFixtures().first)], + recordingAssignmentChanges: [#require(Self.recordingAssignmentFixtures().first)], + recordingDeviceArchives: [], assets: [], ) var json = try #require(String( @@ -376,7 +357,7 @@ struct BackupServiceTests { do { try BackupService.validateRecordingData(decoded) - Issue.record("Expected the negative policy revision to be rejected.") + Issue.record("Expected the negative assignment revision to be rejected.") } catch BackupService.BackupError.invalidRecordingData { // Expected. } catch { @@ -384,48 +365,23 @@ struct BackupServiceTests { } } - @Test func validationRejectsAGapInADevicePolicyTimeline() throws { - let initial = try #require(Self.recordingPolicyFixtures().first) - let skippedRevision = try RecordingPolicyChange( + @Test func validationRejectsAGapInADeviceAssignmentTimeline() throws { + let initial = try #require(Self.recordingAssignmentFixtures().first) + let skippedRevision = try RecordingAssignmentChange( id: #require(UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")), - deviceID: Self.recordingDeviceID, parentIDs: [initial.id], revision: 2, issuedAt: Self.exportDate.addingTimeInterval(1), issuedByDeviceID: Self.recordingDeviceID, effectiveAt: Self.exportDate.addingTimeInterval(1), - state: .off, + assignedDeviceID: nil, reason: .userCommand, ) - let archive = Self.archive(recordingPolicyChanges: [initial, skippedRevision]) - - do { - try BackupService.validateRecordingData(archive) - Issue.record("Expected the incomplete policy timeline to be rejected.") - } catch BackupService.BackupError.invalidRecordingData { - // Expected. - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func validationRejectsADestructivePolicyThatTurnsRecordingOn() throws { - let invalid = RecordingPolicyChange( - id: Self.recordingPolicyID, - deviceID: Self.recordingDeviceID, - parentIDs: [], - revision: 0, - issuedAt: Self.exportDate, - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: Self.exportDate, - state: .on, - reason: .accountReset, - ) - let archive = Self.archive(recordingPolicyChanges: [invalid]) + let archive = Self.archive(recordingAssignmentChanges: [initial, skippedRevision]) do { try BackupService.validateRecordingData(archive) - Issue.record("Expected the invalid reason/state pair to be rejected.") + Issue.record("Expected the incomplete assignment timeline to be rejected.") } catch BackupService.BackupError.invalidRecordingData { // Expected. } catch { @@ -441,8 +397,8 @@ struct BackupServiceTests { manualDays: [], recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [], + recordingAssignmentChanges: [], + recordingDeviceArchives: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -473,8 +429,8 @@ struct BackupServiceTests { manualDays: manualDays, recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [], + recordingAssignmentChanges: [], + recordingDeviceArchives: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -495,8 +451,8 @@ struct BackupServiceTests { trackedRegions: [.california, texas], recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [], + recordingAssignmentChanges: [], + recordingDeviceArchives: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -529,8 +485,8 @@ struct BackupServiceTests { primaryRegions: primary, recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [], + recordingAssignmentChanges: [], + recordingDeviceArchives: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -567,8 +523,8 @@ struct BackupServiceTests { manualDays: manualDays, recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [], + recordingAssignmentChanges: [], + recordingDeviceArchives: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -601,8 +557,8 @@ struct BackupServiceTests { ], recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), - recordingDeviceCheckIns: Self.recordingDeviceCheckInFixtures(), - recordingPolicyChanges: Self.recordingPolicyFixtures(), + recordingAssignmentChanges: Self.recordingAssignmentFixtures(), + recordingDeviceArchives: [], assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, filename: "assets/\(Self.evidenceWithBlobId.uuidString)", diff --git a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift index 18524867..f59c1b95 100644 --- a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift +++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift @@ -1,25 +1,20 @@ import Foundation -import RegionKit import Testing @_spi(Testing) @testable import WhereCore struct DeviceRecordingControllerTests { - private static let now = WhereCoreTestSupport.iso("2026-07-30T12:00:00-07:00") - private static let initialPolicyID = UUID( + private static let now = Date(timeIntervalSinceReferenceDate: 1000) + private static let initialAssignmentID = UUID( uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", )! private static let remoteDeviceID = RecordingDeviceID( rawValue: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, ) - private static let remotePolicyID = UUID( - uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", - )! + private static func makeServices( - authorization: LocationAuthorizationStatus, + authorization: LocationAuthorizationStatus = .always, initialEnabled: Bool = true, - now: @escaping @Sendable () -> Date = { Self.now }, remoteChanges: ScriptedStoreRemoteChangeSource? = nil, - outbox: any LocationOutbox = NoOpLocationOutbox(), ) throws -> (WhereServices, SwiftDataStore) { let store = try if let remoteChanges { SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) @@ -34,86 +29,31 @@ struct DeviceRecordingControllerTests { registeredAt: Self.now, initialRecordingChoice: .init( isEnabled: initialEnabled, - policyChangeID: initialPolicyID, + assignmentChangeID: Self.initialAssignmentID, confirmedAt: Self.now, ), ), - locationOutbox: outbox, - now: now, + now: { Self.now }, ) return (services, store) } - private static func register( - _ services: WhereServices, - authorization: LocationAuthorizationStatus = .always, - ) async throws -> RecordingDeviceConfiguration { - try await services.recording.register(authorization: authorization) - } - - private static func pendingSample( - at timestamp: Date = Self.now.addingTimeInterval(-30), - ) -> LocationSample { - LocationSample( - timestamp: timestamp, - coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), - horizontalAccuracy: 5, - source: .gpsVisit, - recordingDeviceID: CurrentRecordingDevice.preview.id, - ) - } - - private static func seedRemoteDevice( - in store: SwiftDataStore, - nickname: String? = "Travel iPad", - status: RecordingDeviceStatus = .recording, - ) async throws { - let profile = RecordingDeviceProfile( - id: remoteDeviceID, - systemName: "iPad", - kind: .tablet, - registeredAt: now, - registrationEpochID: .initial, - ) - let policy = RecordingPolicyChange( - id: remotePolicyID, - deviceID: remoteDeviceID, - parentIDs: [], - revision: 0, - issuedAt: now.addingTimeInterval(-60), - issuedByDeviceID: remoteDeviceID, - effectiveAt: now.addingTimeInterval(-60), - state: status == .recording ? .on : .off, - reason: .initialRegistration, - ) - let metadata = RecordingDeviceMetadataChange( - id: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!, - deviceID: remoteDeviceID, - revision: 0, - changedAt: now, - changedByDeviceID: remoteDeviceID, - nickname: nickname, - ) - let checkIn = RecordingDeviceCheckIn( - deviceID: remoteDeviceID, - revision: 0, - lastSeenAt: now, - appliedAt: now, - lastAppliedPolicyChangeID: remotePolicyID, - status: status, - ) + private static func addRemoteProfile(to store: SwiftDataStore) async throws { try await store.perform { - try await store.addRecordingDeviceProfile(profile) - try await store.addRecordingDeviceMetadataChange(metadata) - try await store.addRecordingPolicyChange(policy) - try await store.setRecordingDeviceCheckIn(checkIn) + try await store.addRecordingDeviceProfile(RecordingDeviceProfile( + id: remoteDeviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: now, + registrationEpochID: .initial, + )) } } - @Test func explicitRegistrationPersistsIdentityPolicyAndAcknowledgement() async throws { - let (services, store) = try Self.makeServices(authorization: .always) + @Test func registrationPersistsOneGlobalAssignmentAndAcknowledgesIt() async throws { + let (services, store) = try Self.makeServices() - let configuration = try await Self.register(services) + let configuration = try await services.recording.register(authorization: .always) #expect(configuration.id == CurrentRecordingDevice.preview.id) #expect(configuration.isEnabled == true) @@ -121,70 +61,17 @@ struct DeviceRecordingControllerTests { #expect(configuration.device.status == .recording) #expect(await services.ingestor.isActive) #expect(try await store.recordingDeviceProfiles().count == 1) - #expect(try await store.recordingDeviceCheckIns().count == 1) - #expect(try await store.recordingPolicyChanges().map(\.id) == [Self.initialPolicyID]) - - _ = try await Self.register(services) - #expect(try await store.recordingDeviceProfiles().count == 1) - #expect(try await store.recordingPolicyChanges().count == 1) - } - - @Test func registrationInDestructiveEpochRejectsABufferedPreEraseFix() async throws { - let store = try SwiftDataStore.inMemory() - let source = ScriptedLocationSource(authorizationStatus: .always) - let erasedAt = Self.now.addingTimeInterval(60) - let epoch = try await store.perform { - try await store.rotateDataEpoch( - reason: .accountReset, - changedBy: Self.remoteDeviceID, - at: erasedAt, - ) - } - let services = WhereServices( - store: store, - locationSource: source, - installationContext: InstallationRecordingContext( - currentDevice: .preview, - registeredAt: Self.now, - initialRecordingChoice: .init( - isEnabled: true, - policyChangeID: Self.initialPolicyID, - confirmedAt: Self.now, - ), - ), - now: { erasedAt }, - ) - let bufferedBeforeErase = Self.pendingSample( - at: erasedAt.addingTimeInterval(-1), - ) - let afterErase = Self.pendingSample( - at: erasedAt.addingTimeInterval(1), - ) - - source.emit(bufferedBeforeErase) - let configuration = try await Self.register(services) - - #expect(configuration.isEnabled == true) - #expect(await services.ingestor.isActive) - let initialPolicy = try #require(try await store.recordingPolicyChanges().first) - #expect(initialPolicy.effectiveAt == epoch.changedAt) - try await waitUntil { - await services.ingestor.testingHasConsumedSample(id: bufferedBeforeErase.id) - } - #expect(try await store.allSamples().isEmpty) + #expect(try await store.recordingAssignmentChanges() + .map(\.id) == [Self.initialAssignmentID]) - source.emit(afterErase) - try await waitUntil { await (try? store.allSamples().count) == 1 } - #expect(try await store.allSamples().map(\.id) == [afterErase.id]) + _ = try await services.recording.register(authorization: .always) + #expect(try await store.recordingAssignmentChanges().count == 1) } - @Test func enabledWithoutAlwaysPermissionIsAcknowledgedAsPermissionRequired() async throws { + @Test func assignmentWithoutAlwaysPermissionIsAcknowledgedAsPermissionRequired() async throws { let (services, _) = try Self.makeServices(authorization: .whenInUse) - let configuration = try await Self.register( - services, - authorization: .whenInUse, - ) + let configuration = try await services.recording.register(authorization: .whenInUse) #expect(configuration.isEnabled == true) #expect(configuration.isPending == false) @@ -192,754 +79,96 @@ struct DeviceRecordingControllerTests { #expect(await services.ingestor.isActive == false) } - @Test func rapidChangesWithTheSameClockValueKeepInvocationOrder() async throws { - let (services, store) = try Self.makeServices( - authorization: .always, - initialEnabled: false, - ) - _ = try await Self.register(services) + @Test func transferMovesTheOnlyAssignmentAndStopsThisDevice() async throws { + let (services, store) = try Self.makeServices() + _ = try await services.recording.register(authorization: .always) + try await Self.addRemoteProfile(to: store) - _ = try await services.recording.setEnabled( + let configurations = try await services.recording.setEnabled( true, - for: CurrentRecordingDevice.preview.id, - ) - let devices = try await services.recording.setEnabled( - false, - for: CurrentRecordingDevice.preview.id, - ) - let current = try #require( - devices.first(where: { $0.id == CurrentRecordingDevice.preview.id }), + for: Self.remoteDeviceID, ) + let current = try #require(configurations.first { + $0.id == CurrentRecordingDevice.preview.id + }) + let remote = try #require(configurations.first { $0.id == Self.remoteDeviceID }) #expect(current.isEnabled == false) - #expect(current.device.status == .off) - #expect(current.isPending == false) - #expect(await services.ingestor.isActive == false) - let policies = try await store.recordingPolicyChanges() - .filter { $0.deviceID == CurrentRecordingDevice.preview.id } - #expect(policies.map(\.revision) == [0, 1, 2]) - #expect(Set(policies.map(\.effectiveAt)) == Set([Self.now])) - } - - @Test func localOffClosesIngestionBeforeDerivedDataFanoutFinishes() async throws { - let store = try SwiftDataStore.inMemory() - let source = ScriptedLocationSource(authorizationStatus: .always) - let ingestor = LocationIngestor( - store: store, - locationSource: source, - recordingDeviceID: CurrentRecordingDevice.preview.id, - calendar: WhereCoreTestSupport.calendar(), - onPersisted: { _ in }, - ) - let (fanoutStarted, fanoutStartedContinuation) = AsyncStream.makeStream(of: Void.self) - let (releaseFanout, releaseFanoutContinuation) = AsyncStream.makeStream(of: Void.self) - let controller = DeviceRecordingController( - store: store, - ingestor: ingestor, - installationContext: InstallationRecordingContext( - currentDevice: .preview, - registeredAt: Self.now, - initialRecordingChoice: .init( - isEnabled: true, - policyChangeID: Self.initialPolicyID, - confirmedAt: Self.now, - ), - ), - now: { Self.now }, - onPolicyChanged: { - fanoutStartedContinuation.yield() - fanoutStartedContinuation.finish() - for await _ in releaseFanout { - break - } - }, - ) - _ = try await controller.register(authorization: .always) - #expect(await ingestor.isActive) - - let command = Task { - try await controller.setEnabled( - false, - for: CurrentRecordingDevice.preview.id, - ) - } - var fanoutIterator = fanoutStarted.makeAsyncIterator() - _ = await fanoutIterator.next() - - #expect(await ingestor.isActive == false) - releaseFanoutContinuation.yield() - releaseFanoutContinuation.finish() - _ = try await command.value - } - - @Test func unreadableRetryBacklogLeavesOnPolicyUnacknowledgedAndClosed() async throws { - let outbox = ScriptedLocationOutbox(failsToLoad: true) - let (services, store) = try Self.makeServices( - authorization: .always, - outbox: outbox, - ) - - await #expect(throws: ScriptedLocationOutbox.Failure.self) { - try await Self.register(services) - } - - #expect(await services.ingestor.isActive == false) - #expect(try await store.recordingDeviceCheckIns().isEmpty) - - await outbox.setFailsToLoad(false) - let configuration = try await Self.register(services) - #expect(configuration.device.status == .recording) - #expect(configuration.isPending == false) - #expect(await services.ingestor.isActive) - } - - @Test func onboardingRetryPreservesInitialEventAndAppliesTheCurrentSelection() async throws { - let (services, store) = try Self.makeServices( - authorization: .always, - initialEnabled: true, - ) - - let configuration = try await services.recording.registerForOnboarding( - desiredEnabled: false, - authorization: .always, - ) - - #expect(configuration.isEnabled == false) - #expect(configuration.device.status == .off) + #expect(remote.isEnabled == true) + #expect(remote.isPending) #expect(await services.ingestor.isActive == false) - let policies = try await store.recordingPolicyChanges() - #expect(policies.map(\.id).contains(Self.initialPolicyID)) - #expect(policies.map(\.isEnabled) == [true, false]) - #expect(policies.map(\.revision) == [0, 1]) - } - - @Test func profileWithoutPolicyRendersUnknownInsteadOfInventingEnabled() async throws { - let (services, store) = try Self.makeServices(authorization: .always) - try await store.perform { - try await store.addRecordingDeviceProfile(RecordingDeviceProfile( - id: Self.remoteDeviceID, - systemName: "iPad", - kind: .tablet, - registeredAt: Self.now, - registrationEpochID: .initial, - )) - } - - let remote = try #require( - try await services.recording.devices().first(where: { - $0.id == Self.remoteDeviceID - }), + #expect( + try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) + == .resolved(.device(Self.remoteDeviceID)), ) - - #expect(remote.policy == .unknown) - #expect(remote.isEnabled == nil) - #expect(remote.isPending) - #expect(remote.device.status == .unknown) - } - - @Test func remoteDisableIsPendingUntilThatDeviceAcknowledges() async throws { - let (services, store) = try Self.makeServices(authorization: .always) - _ = try await Self.register(services) - try await Self.seedRemoteDevice(in: store) - - let devices = try await services.recording.setEnabled(false, for: Self.remoteDeviceID) - let remote = try #require(devices.first(where: { $0.id == Self.remoteDeviceID })) - - #expect(remote.isEnabled == false) - #expect(remote.isPending) - #expect(remote.device.status == .recording) - #expect(remote.device.nickname == "Travel iPad") } - @Test func targetDeviceCheckInAcknowledgesRemoteDisableAndClearsPending() async throws { - let (services, store) = try Self.makeServices(authorization: .always) - _ = try await Self.register(services) - try await Self.seedRemoteDevice(in: store) + @Test func offClosesRecording() async throws { + let (services, store) = try Self.makeServices() + _ = try await services.recording.register(authorization: .always) - let pendingDevices = try await services.recording.setEnabled( + _ = try await services.recording.setEnabled( false, - for: Self.remoteDeviceID, - ) - let pending = try #require( - pendingDevices.first(where: { $0.id == Self.remoteDeviceID }), - ) - let disableID = try #require(pending.latestPolicyChangeID) - #expect(pending.isPending) - #expect(pending.device.status == .recording) - - try await store.simulateRemoteRecordingImport( - profiles: [], - metadataChanges: [], - checkIns: [RecordingDeviceCheckIn( - deviceID: Self.remoteDeviceID, - revision: 1, - lastSeenAt: Self.now.addingTimeInterval(60), - appliedAt: Self.now.addingTimeInterval(60), - lastAppliedPolicyChangeID: disableID, - status: .off, - )], - policyChanges: [], - ) - - let acknowledged = try #require( - try await services.recording.devices().first(where: { - $0.id == Self.remoteDeviceID - }), - ) - #expect(acknowledged.isEnabled == false) - #expect(acknowledged.isPending == false) - #expect(acknowledged.device.status == .off) - #expect(acknowledged.latestPolicyChangeID == disableID) - } - - @Test func archivingTurnsRemoteDeviceOffAndHidesItAtomically() async throws { - let (services, store) = try Self.makeServices(authorization: .always) - _ = try await Self.register(services) - try await Self.seedRemoteDevice(in: store, nickname: nil, status: .off) - - let visible = try await services.recording.archive(Self.remoteDeviceID) - - #expect(visible.contains(where: { $0.id == Self.remoteDeviceID }) == false) - let archived = try #require( - try await store.recordingDevices().first(where: { $0.id == Self.remoteDeviceID }), - ) - #expect(archived.archivedAt == Self.now) - let latest = try #require( - try await store.recordingPolicyChanges() - .filter { $0.deviceID == Self.remoteDeviceID } - .max(by: RecordingPolicyChange.isOrderedBefore), - ) - #expect(latest.isEnabled == false) - } - - @Test func remotelyArchivedCurrentDeviceCanSeeItselfAndReenable() async throws { - let (services, store) = try Self.makeServices( - authorization: .always, - initialEnabled: false, - ) - _ = try await Self.register(services) - try await store.perform { - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")!, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [Self.initialPolicyID], - revision: 1, - issuedAt: Self.now, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now, - state: .archived, - reason: .archive, - )) - } - - let before = try await services.recording.devices() - #expect(before.map(\.id) == [CurrentRecordingDevice.preview.id]) - - let after = try await services.recording.setEnabled( - true, for: CurrentRecordingDevice.preview.id, ) - let current = try #require(after.first) - #expect(current.isEnabled == true) - #expect(current.device.archivedAt == nil) - #expect(current.device.status == .recording) - } - - @Test func remotePolicyNotificationStopsTheTargetAndAcknowledgesIt() async throws { - let remoteChanges = ScriptedStoreRemoteChangeSource() - let (services, store) = try Self.makeServices( - authorization: .always, - remoteChanges: remoteChanges, - ) - _ = try await Self.register(services) - await services.recording.startMonitoringPolicyChanges() - let disableID = try #require(UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")) - - try await store.simulateRemoteRecordingImport( - profiles: [], - metadataChanges: [], - checkIns: [], - policyChanges: [RecordingPolicyChange( - id: disableID, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [Self.initialPolicyID], - revision: 1, - issuedAt: Self.now.addingTimeInterval(60), - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now.addingTimeInterval(60), - state: .off, - reason: .userCommand, - )], - ) - remoteChanges.yield() - - try await waitUntil { - let checkIn = try? await store.recordingDeviceCheckIns().first - return checkIn?.lastAppliedPolicyChangeID == disableID - } - #expect(await services.ingestor.isActive == false) - let configuration = try #require( - try await services.recording.devices().first(where: { - $0.id == CurrentRecordingDevice.preview.id - }), - ) - #expect(configuration.isEnabled == false) - #expect(configuration.isPending == false) - #expect(configuration.device.status == .off) - } - - @Test func remoteArchivedAuthorityStopsTheTargetAndAcknowledgesIt() async throws { - let remoteChanges = ScriptedStoreRemoteChangeSource() - let (services, store) = try Self.makeServices( - authorization: .always, - remoteChanges: remoteChanges, - ) - _ = try await Self.register(services) - await services.recording.startMonitoringPolicyChanges() - let archiveID = try #require( - UUID(uuidString: "ABABABAB-ABAB-ABAB-ABAB-ABABABABABAB"), - ) - - try await store.simulateRemoteRecordingImport( - profiles: [], - metadataChanges: [], - checkIns: [], - policyChanges: [RecordingPolicyChange( - id: archiveID, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [Self.initialPolicyID], - revision: 1, - issuedAt: Self.now.addingTimeInterval(60), - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now.addingTimeInterval(60), - state: .archived, - reason: .archive, - )], - ) - remoteChanges.yield() - try await waitUntil { - let checkIn = try? await store.recordingDeviceCheckIns().first - return checkIn?.lastAppliedPolicyChangeID == archiveID && checkIn?.status == .off - } #expect(await services.ingestor.isActive == false) - let current = try #require(try await services.recording.devices().first) - #expect(current.id == CurrentRecordingDevice.preview.id) - #expect(current.isArchived) - #expect(current.device.status == .off) - } - - @Test func destructivePolicyClearsTheBacklogBeforeAcknowledgement() async throws { - let pending = Self.pendingSample() - let outbox = ScriptedLocationOutbox([pending]) - let (services, store) = try Self.makeServices( - authorization: .always, - initialEnabled: false, - outbox: outbox, - ) - _ = try await Self.register(services) - let barrierID = UUID() - try await store.perform { - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: barrierID, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [Self.initialPolicyID], - revision: 1, - issuedAt: Self.now, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now, - state: .off, - reason: .backupReplace, - )) - } - - _ = try await services.recording.reconcile(authorization: .always) - - #expect(await outbox.persistedSamples.isEmpty) - let checkIn = try #require(try await store.recordingDeviceCheckIns().first) - #expect(checkIn.lastAppliedPolicyChangeID == barrierID) - #expect(checkIn.lastDiscardedPolicyChangeID == barrierID) - #expect(checkIn.revision == 1) - } - - @Test func destructiveCleanupFailureLeavesTheBarrierUnacknowledgedAndOff() async throws { - let pending = Self.pendingSample() - let outbox = ScriptedLocationOutbox([pending], failsToClear: true) - let (services, store) = try Self.makeServices( - authorization: .always, - initialEnabled: false, - outbox: outbox, + #expect( + try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) + == .resolved(.off), + ) + } + + @Test func concurrentDifferentAssignmentsFailClosed() async throws { + let (services, store) = try Self.makeServices(initialEnabled: false) + _ = try await services.recording.register(authorization: .always) + try await Self.addRemoteProfile(to: store) + let local = RecordingAssignmentChange( + id: UUID(), + parentIDs: [Self.initialAssignmentID], + revision: 1, + issuedAt: Self.now, + issuedByDeviceID: CurrentRecordingDevice.preview.id, + effectiveAt: Self.now, + assignedDeviceID: CurrentRecordingDevice.preview.id, + reason: .userCommand, + ) + let remote = RecordingAssignmentChange( + id: UUID(), + parentIDs: [Self.initialAssignmentID], + revision: 1, + issuedAt: Self.now, + issuedByDeviceID: Self.remoteDeviceID, + effectiveAt: Self.now, + assignedDeviceID: Self.remoteDeviceID, + reason: .userCommand, ) - _ = try await Self.register(services) - let initialCheckIn = try #require(try await store.recordingDeviceCheckIns().first) try await store.perform { - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: UUID(), - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [Self.initialPolicyID], - revision: 1, - issuedAt: Self.now, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now, - state: .off, - reason: .backupReplace, - )) + try await store.addRecordingAssignmentChange(local) + try await store.addRecordingAssignmentChange(remote) } - await #expect(throws: ScriptedLocationOutbox.Failure.self) { + await #expect(throws: RecordingPersistenceError.incompleteAssignmentHistory) { try await services.recording.reconcile(authorization: .always) } - - #expect(await outbox.persistedSamples == [pending]) - #expect(try await store.recordingDeviceCheckIns().first == initialCheckIn) #expect(await services.ingestor.isActive == false) + #expect(try await services.recording.authoritySnapshot().resolution + == .conflict([CurrentRecordingDevice.preview.id, Self.remoteDeviceID])) } - @Test func laterOnStillClearsAnInterveningDestructiveBarrier() async throws { - let pending = Self.pendingSample() - let outbox = ScriptedLocationOutbox([pending]) - let (services, store) = try Self.makeServices( - authorization: .always, - initialEnabled: false, - outbox: outbox, - ) - _ = try await Self.register(services) - let barrierID = UUID() - let onID = UUID() - try await store.perform { - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: barrierID, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [Self.initialPolicyID], - revision: 1, - issuedAt: Self.now, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now, - state: .off, - reason: .backupReplace, - )) - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: onID, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [barrierID], - revision: 2, - issuedAt: Self.now.addingTimeInterval(1), - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now.addingTimeInterval(1), - state: .on, - reason: .userCommand, - )) - } - - let configuration = try await services.recording.reconcile(authorization: .always) - - #expect(configuration.isEnabled == true) - #expect(await services.ingestor.isActive) - #expect(await outbox.persistedSamples.isEmpty) - let checkIn = try #require(try await store.recordingDeviceCheckIns().first) - #expect(checkIn.lastAppliedPolicyChangeID == onID) - #expect(checkIn.lastDiscardedPolicyChangeID == barrierID) - } - - @Test func destructiveEventOnLosingBranchStillClearsTheBacklog() async throws { - let pending = Self.pendingSample() - let outbox = ScriptedLocationOutbox([pending]) - let (services, store) = try Self.makeServices( - authorization: .always, - initialEnabled: false, - outbox: outbox, - ) - _ = try await Self.register(services) - let losingOffID = try #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")) - let archiveID = try #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")) - let barrierID = try #require(UUID(uuidString: "30000000-0000-0000-0000-000000000000")) - try await store.perform { - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: losingOffID, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [Self.initialPolicyID], - revision: 1, - issuedAt: Self.now, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now, - state: .off, - reason: .userCommand, - )) - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: archiveID, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [Self.initialPolicyID], - revision: 1, - issuedAt: Self.now, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now, - state: .archived, - reason: .archive, - )) - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: barrierID, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [losingOffID], - revision: 2, - issuedAt: Self.now, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now, - state: .off, - reason: .backupReplace, - )) - } - - let configuration = try await services.recording.reconcile(authorization: .always) - - // The destructive descendant remains a maximal head even though its parent lost the - // prior tie. Destructive safety outranks the concurrent archive state. - #expect(configuration.latestPolicyChangeID == barrierID) - #expect(configuration.isEnabled == false) - #expect(configuration.isArchived == false) - #expect(await outbox.persistedSamples.isEmpty) - let checkIn = try #require(try await store.recordingDeviceCheckIns().first) - #expect(checkIn.lastAppliedPolicyChangeID == barrierID) - #expect(checkIn.lastDiscardedPolicyChangeID == barrierID) - } - - @Test func policyRevisionGapFailsClosedUntilTheMissingEventArrives() async throws { - let pending = Self.pendingSample() - let outbox = ScriptedLocationOutbox([pending]) - let (services, store) = try Self.makeServices( - authorization: .always, - initialEnabled: false, - outbox: outbox, - ) - _ = try await Self.register(services) - try await store.perform { - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: UUID(), - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [Self.initialPolicyID], - revision: 2, - issuedAt: Self.now, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now, - state: .on, - reason: .userCommand, - )) - } - - await #expect(throws: RecordingPersistenceError.self) { - try await services.recording.reconcile(authorization: .always) - } + @Test func archivingTheRecorderAppendsATombstoneAndTurnsRecordingOff() async throws { + let (services, store) = try Self.makeServices() + _ = try await services.recording.register(authorization: .always) + try await Self.addRemoteProfile(to: store) + _ = try await services.recording.setEnabled(true, for: Self.remoteDeviceID) - #expect(await services.ingestor.isActive == false) - #expect(await outbox.persistedSamples == [pending]) - } + let remaining = try await services.recording.archive(Self.remoteDeviceID) - @Test func oldInstallationDoesNotReplayInitialConsentInADestructiveEpoch() async throws { - let (services, store) = try Self.makeServices( - authorization: .always, - initialEnabled: true, + #expect(remaining.contains { $0.id == Self.remoteDeviceID } == false) + #expect(try await store.recordingDeviceArchives().map(\.deviceID) == [Self.remoteDeviceID]) + #expect( + try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) + == .resolved(.off), ) - _ = try await Self.register(services) - let epoch = try await store.perform { - try await store.rotateDataEpoch( - reason: .accountReset, - changedBy: Self.remoteDeviceID, - at: Self.now.addingTimeInterval(60), - ) - } - - let configuration = try await Self.register(services) - - #expect(configuration.isArchived) - #expect(configuration.latestPolicyChangeID == epoch.id.rawValue) - #expect(configuration.device.status == .off) - #expect(await services.ingestor.isActive == false) - #expect(try await store.recordingPolicyChanges().isEmpty) - } - - @Test func profileArrivingAfterDestructiveEpochStartsArchivedUntilExplicitlyReenabled( - ) async throws { - let (services, store) = try Self.makeServices(authorization: .always) - _ = try await Self.register(services) - let epoch = try await store.perform { - try await store.rotateDataEpoch( - reason: .accountReset, - changedBy: CurrentRecordingDevice.preview.id, - at: Self.now.addingTimeInterval(60), - ) - } - try await store.perform { - try await store.addRecordingDeviceProfile(RecordingDeviceProfile( - id: Self.remoteDeviceID, - systemName: "iPad", - kind: .tablet, - registeredAt: Self.now.addingTimeInterval(-3600), - registrationEpochID: .initial, - )) - } - - let before = try await services.recording.devices() - #expect(before.map(\.id) == [CurrentRecordingDevice.preview.id]) - #expect(before.first?.latestPolicyChangeID == epoch.id.rawValue) - - let after = try await services.recording.setEnabled(true, for: Self.remoteDeviceID) - let remote = try #require(after.first(where: { $0.id == Self.remoteDeviceID })) - #expect(remote.isEnabled == true) - #expect(remote.isArchived == false) - #expect(remote.isPending) - let policy = try #require( - try await store.recordingPolicyChanges().first(where: { - $0.deviceID == Self.remoteDeviceID - }), - ) - #expect(policy.revision == 0) - #expect(policy.reason == .userCommand) - } - - @Test func storeChangeRefreshesARecordingHeartbeatAfterTheInterval() async throws { - let clock = MutableRecordingTestClock(Self.now) - let (services, store) = try Self.makeServices( - authorization: .always, - now: { clock.value }, - ) - _ = try await Self.register(services) - await services.recording.startMonitoringPolicyChanges() - clock.value = Self.now.addingTimeInterval(60 * 60) - - try await store.perform { - try await store.setManualDay(DayPresence( - date: Self.now, - in: WhereCoreTestSupport.calendar(), - regions: [.california], - )) - } - try await waitUntil { - let checkIn = try? await store.recordingDeviceCheckIns().first - return checkIn?.lastSeenAt == clock.value - } - - let checkIn = try #require(try await store.recordingDeviceCheckIns().first) - #expect(checkIn.lastSeenAt == clock.value) - } - - @Test func storeChangeAppliesDestructiveEpochWithoutReplayingInitialConsent() async throws { - let (services, store) = try Self.makeServices(authorization: .always) - _ = try await Self.register(services) - await services.recording.startMonitoringPolicyChanges() - let epoch = try await store.perform { - try await store.rotateDataEpoch( - reason: .accountReset, - changedBy: Self.remoteDeviceID, - at: Self.now.addingTimeInterval(60), - ) - } - - try await waitUntil { - let checkIn = try? await store.recordingDeviceCheckIns().first - return checkIn?.lastAppliedPolicyChangeID == epoch.id.rawValue - } - #expect(try await store.recordingPolicyChanges().isEmpty) - #expect(await services.ingestor.isActive == false) - } - - @Test func reconcileWithoutRegistrationFailsClosed() async throws { - let (services, _) = try Self.makeServices(authorization: .always) - - await #expect(throws: RecordingPersistenceError.self) { - try await services.recording.reconcile(authorization: .always) - } - - #expect(await services.ingestor.isActive == false) - } - - @Test func replaceRecoveryPreservesImportedPolicyWithoutReplayingLocalConsent() async throws { - let clock = MutableRecordingTestClock(Self.now) - let (services, store) = try Self.makeServices( - authorization: .always, - now: { clock.value }, - ) - _ = try await Self.register(services) - - try await services.recording.pause() - let importedPolicyID = try #require( - UUID(uuidString: "99999999-9999-9999-9999-999999999999"), - ) - let importedAt = Self.now.addingTimeInterval(60) - try await store.perform { - _ = try await store.rotateDataEpoch( - reason: .backupReplace, - changedBy: Self.remoteDeviceID, - at: importedAt, - ) - try await store.addRecordingPolicyChange(RecordingPolicyChange( - id: importedPolicyID, - deviceID: CurrentRecordingDevice.preview.id, - parentIDs: [], - revision: 0, - issuedAt: importedAt, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: importedAt, - state: .off, - reason: .initialRegistration, - )) - } - clock.value = Self.now.addingTimeInterval(3600) - - try await services.recording.resumeAfterImport(discardPendingSamples: true) - - let profile = try #require(try await store.recordingDeviceProfiles().first) - #expect(profile.registeredAt == Self.now) - let policies = try await store.recordingPolicyChanges() - #expect(policies.map(\.id) == [importedPolicyID]) - let checkIn = try #require(try await store.recordingDeviceCheckIns().first) - #expect(checkIn.lastAppliedPolicyChangeID == importedPolicyID) - #expect(checkIn.status == .off) - #expect(await services.ingestor.isActive == false) - } - - @Test func pausedControllerCannotMutateTheNewEpochAfterReset() async throws { - let (services, store) = try Self.makeServices(authorization: .always) - _ = try await Self.register(services) - - try await services.recording.pause() - try await store.perform { - _ = try await store.rotateDataEpoch( - reason: .accountReset, - changedBy: CurrentRecordingDevice.preview.id, - at: Self.now.addingTimeInterval(60), - ) - } - - await #expect(throws: CancellationError.self) { - _ = try await services.recording.devices() - } - #expect(try await store.recordingDevices().count == 1) - #expect(try await store.recordingPolicyChanges().isEmpty) - } - - private func waitUntil( - timeout: Duration = .seconds(2), - condition: @escaping @Sendable () async -> Bool, - ) async throws { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - while clock.now < deadline { - if await condition() { return } - await Task.yield() - } - Issue.record("waitUntil timed out") - } -} - -private final class MutableRecordingTestClock: @unchecked Sendable { - private let lock = NSLock() - private var storedValue: Date - - init(_ value: Date) { - storedValue = value - } - - var value: Date { - get { lock.withLock { storedValue } } - set { lock.withLock { storedValue = newValue } } } } diff --git a/Where/WhereCore/Tests/InstallationRecordingContextTests.swift b/Where/WhereCore/Tests/InstallationRecordingContextTests.swift index 65e76b57..ee876c6d 100644 --- a/Where/WhereCore/Tests/InstallationRecordingContextTests.swift +++ b/Where/WhereCore/Tests/InstallationRecordingContextTests.swift @@ -13,20 +13,20 @@ struct InstallationRecordingContextTests { @Test func confirmationPreservesIdentityAndCarriesAStablePolicyToken() throws { let proposed = context(kind: .tablet) - let policyChangeID = try #require( + let assignmentChangeID = try #require( UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"), ) let confirmed = proposed.confirmingInitialRecording( isEnabled: false, - policyChangeID: policyChangeID, + assignmentChangeID: assignmentChangeID, confirmedAt: Self.confirmedAt, ) #expect(confirmed.currentDevice == proposed.currentDevice) #expect(confirmed.registeredAt == proposed.registeredAt) #expect(confirmed.initialRecordingChoice?.isEnabled == false) - #expect(confirmed.initialRecordingChoice?.policyChangeID == policyChangeID) + #expect(confirmed.initialRecordingChoice?.assignmentChangeID == assignmentChangeID) #expect(confirmed.initialRecordingChoice?.confirmedAt == Self.confirmedAt) } diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index c4422f59..91c8a8eb 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -1001,14 +1001,6 @@ private actor ToggleFailingStore: WhereStore { try await backing.setRecordingDeviceCheckIn(checkIn) } - func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { - try await backing.recordingPolicyChanges() - } - - func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { - try await backing.addRecordingPolicyChange(change) - } - func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { try await backing.recordingAssignmentChanges() } diff --git a/Where/WhereCore/Tests/RecordingAssignmentFilterTests.swift b/Where/WhereCore/Tests/RecordingAssignmentFilterTests.swift new file mode 100644 index 00000000..ccc08b12 --- /dev/null +++ b/Where/WhereCore/Tests/RecordingAssignmentFilterTests.swift @@ -0,0 +1,108 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct RecordingAssignmentFilterTests { + private static let phone = device("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") + private static let tablet = device("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB") + private static let start = Date(timeIntervalSinceReferenceDate: 1000) + + @Test func transferMovesVisibilityAtItsEffectiveDate() throws { + let initial = Self.change(id: 1, assignedDeviceID: Self.phone) + let transfer = try RecordingAssignmentChange.appendingCommand( + to: [initial], + assignment: .device(Self.tablet), + issuedAt: Self.start.addingTimeInterval(10), + issuedByDeviceID: Self.phone, + effectiveAt: Self.start.addingTimeInterval(10), + reason: .userCommand, + ) + let samples = [ + Self.sample(deviceID: Self.phone, offset: 5), + Self.sample(deviceID: Self.phone, offset: 15), + Self.sample(deviceID: Self.tablet, offset: 5), + Self.sample(deviceID: Self.tablet, offset: 15), + ] + + let visible = RecordingAssignmentFilter.visibleSamples( + samples, + assignmentChanges: [initial, transfer], + ) + + #expect(visible.map(\.timestamp) == [samples[0].timestamp, samples[3].timestamp]) + } + + @Test func offAndConflictsFailClosed() { + let phoneClaim = Self.change(id: 1, assignedDeviceID: Self.phone) + let tabletClaim = Self.change(id: 2, assignedDeviceID: Self.tablet) + let gps = Self.sample(deviceID: Self.phone, offset: 5) + + #expect(RecordingAssignmentFilter.visibleSamples( + [gps], + assignmentChanges: [Self.change(id: 3, assignedDeviceID: nil)], + ).isEmpty) + #expect(RecordingAssignmentFilter.visibleSamples( + [gps], + assignmentChanges: [phoneClaim, tabletClaim], + ).isEmpty) + } + + @Test func unattributedAndManualSamplesRemainVisible() { + let unattributed = LocationSample( + timestamp: Self.start, + coordinate: Coordinate(latitude: 1, longitude: 2), + horizontalAccuracy: 3, + source: .gpsVisit, + ) + let manual = LocationSample( + timestamp: Self.start, + coordinate: Coordinate(latitude: 1, longitude: 2), + horizontalAccuracy: 3, + source: .manual, + recordingDeviceID: Self.phone, + ) + + #expect(RecordingAssignmentFilter.visibleSamples( + [unattributed, manual], + assignmentChanges: [], + ).count == 2) + } + + private static func change( + id: Int, + assignedDeviceID: RecordingDeviceID?, + ) -> RecordingAssignmentChange { + RecordingAssignmentChange( + id: uuid(id), + parentIDs: [], + revision: 0, + issuedAt: start, + issuedByDeviceID: phone, + effectiveAt: start, + assignedDeviceID: assignedDeviceID, + reason: .userCommand, + ) + } + + private static func sample( + deviceID: RecordingDeviceID, + offset: TimeInterval, + ) -> LocationSample { + LocationSample( + timestamp: start.addingTimeInterval(offset), + coordinate: Coordinate(latitude: 1, longitude: 2), + horizontalAccuracy: 3, + source: .gpsVisit, + recordingDeviceID: deviceID, + ) + } + + private static func device(_ value: String) -> RecordingDeviceID { + RecordingDeviceID(rawValue: UUID(uuidString: value)!) + } + + private static func uuid(_ value: Int) -> UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", value))! + } +} diff --git a/Where/WhereCore/Tests/RecordingPolicyChangeTests.swift b/Where/WhereCore/Tests/RecordingPolicyChangeTests.swift deleted file mode 100644 index 06208738..00000000 --- a/Where/WhereCore/Tests/RecordingPolicyChangeTests.swift +++ /dev/null @@ -1,371 +0,0 @@ -import Foundation -import Testing -@testable import WhereCore - -struct RecordingPolicyChangeTests { - private static let deviceID = RecordingDeviceID( - rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, - ) - private static let writerID = RecordingDeviceID( - rawValue: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, - ) - private static let date = Date(timeIntervalSinceReferenceDate: 1000) - - @Test func laterOffFromThePreviouslyLosingSiblingRemainsAuthoritative() throws { - let history = Self.historyWithRestrictiveDescendant( - descendantID: "00000000-0000-0000-0000-000000000004", - state: .off, - reason: .userCommand, - ) - - #expect(RecordingPolicyChange.formValidPersistedTimelines(history)) - let canonical = try #require(RecordingPolicyChange.canonicalTimeline(in: history)) - #expect(canonical.map(\.id) == [history[0].id, history[2].id, history[3].id]) - #expect(canonical.last?.state == .off) - } - - @Test func laterArchiveFromThePreviouslyLosingSiblingRemainsAuthoritative() throws { - let history = Self.historyWithRestrictiveDescendant( - descendantID: "00000000-0000-0000-0000-000000000005", - state: .archived, - reason: .archive, - ) - - #expect(RecordingPolicyChange.formValidPersistedTimelines(history)) - let canonical = try #require(RecordingPolicyChange.canonicalTimeline(in: history)) - #expect(canonical.map(\.id) == [history[0].id, history[2].id, history[3].id]) - #expect(canonical.last?.state == .archived) - } - - @Test func oneAppendingCommandJoinsAndClearsEveryObservedHead() throws { - let root = Self.change( - id: "10000000-0000-0000-0000-000000000000", - parentIDs: [], - revision: 0, - effectiveAt: Self.date, - state: .on, - reason: .initialRegistration, - ) - let first = Self.change( - id: "20000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: Self.date.addingTimeInterval(1), - state: .on, - reason: .userCommand, - ) - let second = Self.change( - id: "30000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: Self.date.addingTimeInterval(2), - state: .off, - reason: .userCommand, - ) - let observed = [second, root, first] - - let command = try RecordingPolicyChange.appendingCommand( - to: observed, - deviceID: Self.deviceID, - issuedAt: Self.date.addingTimeInterval(3), - issuedByDeviceID: Self.writerID, - effectiveAt: Self.date.addingTimeInterval(1), - state: .on, - reason: .userCommand, - ) - let joined = observed + [command] - - #expect(command.parentIDs == [first.id, second.id]) - #expect(command.revision == 2) - #expect(command.effectiveAt == second.effectiveAt) - #expect(joined.count(where: { $0.revision == 2 }) == 1) - #expect(RecordingPolicyChange.maximalHeads(in: joined) == [command]) - #expect(RecordingPolicyChange.canonicalHead(in: joined) == command) - } - - @Test func incompleteMultiParentCommandFailsClosed() { - let root = Self.change( - id: "10000000-0000-0000-0000-000000000000", - parentIDs: [], - revision: 0, - state: .on, - reason: .initialRegistration, - ) - let orphanedJoin = Self.change( - id: "30000000-0000-0000-0000-000000000000", - parentIDs: [ - root.id, - Self.id("20000000-0000-0000-0000-000000000000"), - ], - revision: 1, - state: .off, - reason: .userCommand, - ) - - #expect( - RecordingPolicyChange.formValidPersistedTimelines([root, orphanedJoin]) == false, - ) - #expect(RecordingPolicyChange.canonicalTimeline(in: [root, orphanedJoin]) == nil) - #expect(RecordingPolicyChange.maximalHeads(in: [root, orphanedJoin]) == nil) - } - - @Test func historicalAuthorityResolvesTheEligibleInducedDAG() { - let root = Self.change( - id: "10000000-0000-0000-0000-000000000000", - parentIDs: [], - revision: 0, - effectiveAt: Self.date, - state: .on, - reason: .initialRegistration, - ) - let off = Self.change( - id: "20000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: Self.date.addingTimeInterval(10), - state: .off, - reason: .userCommand, - ) - let concurrentOn = Self.change( - id: "30000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: Self.date.addingTimeInterval(20), - state: .on, - reason: .userCommand, - ) - let joinedOn = Self.change( - id: "40000000-0000-0000-0000-000000000000", - parentIDs: [off.id, concurrentOn.id], - revision: 2, - effectiveAt: Self.date.addingTimeInterval(30), - state: .on, - reason: .userCommand, - ) - let history = [joinedOn, concurrentOn, root, off] - - #expect(RecordingPolicyChange.effectiveHead( - in: history, - at: Self.date.addingTimeInterval(5), - ) == root) - #expect(RecordingPolicyChange.effectiveHead( - in: history, - at: Self.date.addingTimeInterval(15), - ) == off) - #expect(RecordingPolicyChange.effectiveHead( - in: history, - at: Self.date.addingTimeInterval(25), - ) == off) - #expect(RecordingPolicyChange.effectiveHead( - in: history, - at: Self.date.addingTimeInterval(35), - ) == joinedOn) - } - - @Test func concurrentDestructiveBarrierChangesTheCleanupTokenEvenWhenItsUUIDLoses() throws { - let root = Self.change( - id: "10000000-0000-0000-0000-000000000000", - parentIDs: [], - revision: 0, - state: .on, - reason: .initialRegistration, - ) - let previousUUIDWinner = Self.change( - id: "F0000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: Self.date.addingTimeInterval(1), - state: .off, - reason: .accountReset, - ) - let newLowerUUIDBarrier = Self.change( - id: "20000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: Self.date.addingTimeInterval(1), - state: .off, - reason: .accountReset, - ) - let prior = try #require(RecordingPolicyChange.destructiveCleanupToken( - in: [root, previousUUIDWinner], - )) - let joined = try #require(RecordingPolicyChange.destructiveCleanupToken( - in: [newLowerUUIDBarrier, root, previousUUIDWinner], - )) - - #expect(prior.rawValue == previousUUIDWinner.id) - #expect(joined != prior) - #expect(RecordingPolicyChange.destructiveCleanupToken( - in: [previousUUIDWinner, newLowerUUIDBarrier, root], - ) == joined) - } - - @Test func concurrentResetFloorJoinsLatestCutoffUntilADescendantReplace() { - let root = Self.change( - id: "10000000-0000-0000-0000-000000000000", - parentIDs: [], - revision: 0, - state: .on, - reason: .initialRegistration, - ) - let earlierReset = Self.change( - id: "20000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: Self.date.addingTimeInterval(10), - state: .off, - reason: .accountReset, - ) - let laterReset = Self.change( - id: "30000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: Self.date.addingTimeInterval(20), - state: .off, - reason: .accountReset, - ) - let reenabled = Self.change( - id: "40000000-0000-0000-0000-000000000000", - parentIDs: [earlierReset.id, laterReset.id], - revision: 2, - effectiveAt: Self.date.addingTimeInterval(30), - state: .on, - reason: .userCommand, - ) - let replacement = Self.change( - id: "50000000-0000-0000-0000-000000000000", - parentIDs: [reenabled.id], - revision: 3, - effectiveAt: Self.date.addingTimeInterval(40), - state: .off, - reason: .backupReplace, - ) - let throughReenable = [laterReset, root, reenabled, earlierReset] - - #expect( - RecordingPolicyChange.activeAccountResetFloor(in: throughReenable) - == laterReset.effectiveAt, - ) - #expect(RecordingPolicyChange.activeAccountResetFloor( - in: throughReenable + [replacement], - ) == nil) - } - - @Test func childCannotMoveItsHistoricalCutoffBeforeItsParent() { - let root = Self.change( - id: "10000000-0000-0000-0000-000000000000", - parentIDs: [], - revision: 0, - effectiveAt: Self.date, - state: .on, - reason: .initialRegistration, - ) - let off = Self.change( - id: "20000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: Self.date.addingTimeInterval(2), - state: .off, - reason: .userCommand, - ) - let backdatedEnable = Self.change( - id: "30000000-0000-0000-0000-000000000000", - parentIDs: [off.id], - revision: 2, - effectiveAt: Self.date.addingTimeInterval(1), - state: .on, - reason: .userCommand, - ) - let history = [root, off, backdatedEnable] - - #expect(RecordingPolicyChange.formValidPersistedTimelines(history) == false) - #expect(RecordingPolicyChange.canonicalTimeline(in: history) == nil) - } - - @Test func backupMergeCanPreserveEveryCompleteAuthorityState() { - let ids = [ - "10000000-0000-0000-0000-000000000000", - "20000000-0000-0000-0000-000000000000", - "30000000-0000-0000-0000-000000000000", - ] - for (id, state) in zip(ids, [ - RecordingPolicyState.on, - .off, - .archived, - ]) { - let barrier = Self.change( - id: id, - parentIDs: [], - revision: 0, - state: state, - reason: .backupMerge, - ) - #expect(barrier.hasValidReasonAndState) - #expect(barrier.reason.discardsPendingSamples == false) - } - } - - private static func historyWithRestrictiveDescendant( - descendantID: String, - state: RecordingPolicyState, - reason: RecordingPolicyReason, - ) -> [RecordingPolicyChange] { - let root = change( - id: "10000000-0000-0000-0000-000000000000", - parentIDs: [], - revision: 0, - state: .on, - reason: .initialRegistration, - ) - let previousWinner = change( - id: "F0000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: date.addingTimeInterval(1), - state: .on, - reason: .userCommand, - ) - let previousLoser = change( - id: "20000000-0000-0000-0000-000000000000", - parentIDs: [root.id], - revision: 1, - effectiveAt: date.addingTimeInterval(1), - state: .on, - reason: .userCommand, - ) - let restrictiveDescendant = change( - id: descendantID, - parentIDs: [previousLoser.id], - revision: 2, - effectiveAt: date.addingTimeInterval(2), - state: state, - reason: reason, - ) - return [root, previousWinner, previousLoser, restrictiveDescendant] - } - - private static func change( - id: String, - parentIDs: [UUID], - revision: Int64, - effectiveAt: Date = date, - state: RecordingPolicyState, - reason: RecordingPolicyReason, - ) -> RecordingPolicyChange { - RecordingPolicyChange( - id: self.id(id), - deviceID: deviceID, - parentIDs: parentIDs, - revision: revision, - issuedAt: date, - issuedByDeviceID: writerID, - effectiveAt: effectiveAt, - state: state, - reason: reason, - ) - } - - private static func id(_ value: String) -> UUID { - UUID(uuidString: value)! - } -} diff --git a/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift b/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift deleted file mode 100644 index b490b81d..00000000 --- a/Where/WhereCore/Tests/RecordingPolicyFilterTests.swift +++ /dev/null @@ -1,328 +0,0 @@ -import Foundation -import RegionKit -import Testing -@testable import WhereCore - -struct RecordingPolicyFilterTests { - private static let deviceID = RecordingDeviceID( - rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, - ) - - private static func sample( - _ timestamp: String, - source: SampleSource = .gpsVisit, - deviceID: RecordingDeviceID? = Self.deviceID, - ) -> LocationSample { - LocationSample( - timestamp: WhereCoreTestSupport.iso(timestamp), - coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), - horizontalAccuracy: 5, - source: source, - recordingDeviceID: deviceID, - ) - } - - private static func policy( - _ timestamp: String, - enabled: Bool, - id: String, - parentIDs: [String] = [], - revision: Int64 = 0, - ) -> RecordingPolicyChange { - RecordingPolicyChange( - id: UUID(uuidString: id)!, - deviceID: deviceID, - parentIDs: parentIDs.compactMap(UUID.init(uuidString:)), - revision: revision, - issuedAt: WhereCoreTestSupport.iso(timestamp), - issuedByDeviceID: deviceID, - effectiveAt: WhereCoreTestSupport.iso(timestamp), - state: enabled ? .on : .off, - reason: .userCommand, - ) - } - - @Test func disabledIntervalIsExcludedAndReenabledIntervalReturns() { - let before = Self.sample("2026-03-01T08:00:00-08:00") - let during = Self.sample("2026-03-02T08:00:00-08:00") - let after = Self.sample("2026-03-03T08:00:00-08:00") - let policies = [ - Self.policy( - "2026-03-01T00:00:00-08:00", - enabled: true, - id: "05000000-0000-0000-0000-000000000000", - ), - Self.policy( - "2026-03-02T00:00:00-08:00", - enabled: false, - id: "10000000-0000-0000-0000-000000000000", - parentIDs: ["05000000-0000-0000-0000-000000000000"], - revision: 1, - ), - Self.policy( - "2026-03-03T00:00:00-08:00", - enabled: true, - id: "20000000-0000-0000-0000-000000000000", - parentIDs: ["10000000-0000-0000-0000-000000000000"], - revision: 2, - ), - ] - - let visible = RecordingPolicyFilter.visibleSamples( - [before, during, after], - policyChanges: policies, - ) - - #expect(visible.map(\.id) == [before.id, after.id]) - } - - @Test func cutoffTimestampIsInclusive() { - let cutoff = Self.policy( - "2026-03-02T08:00:00-08:00", - enabled: false, - id: "10000000-0000-0000-0000-000000000000", - ) - let sample = Self.sample("2026-03-02T08:00:00-08:00") - - #expect(RecordingPolicyFilter.visibleSamples( - [sample], - policyChanges: [cutoff], - ).isEmpty) - } - - @Test func legacyAndUserAssertedSamplesRemainVisible() { - let legacy = Self.sample("2026-03-02T08:00:00-08:00", deviceID: nil) - let manual = Self.sample( - "2026-03-02T09:00:00-08:00", - source: .manual, - deviceID: Self.deviceID, - ) - let cutoff = Self.policy( - "2026-03-01T00:00:00-08:00", - enabled: false, - id: "10000000-0000-0000-0000-000000000000", - ) - - let visible = RecordingPolicyFilter.visibleSamples( - [legacy, manual], - policyChanges: [cutoff], - ) - - #expect(visible.map(\.id) == [legacy.id, manual.id]) - } - - @Test func equalRevisionPoliciesPreferTheMoreRestrictiveState() { - let disabled = Self.policy( - "2026-03-02T00:00:00-08:00", - enabled: false, - id: "10000000-0000-0000-0000-000000000000", - ) - let enabled = Self.policy( - "2026-03-02T00:00:00-08:00", - enabled: true, - id: "20000000-0000-0000-0000-000000000000", - ) - let sample = Self.sample("2026-03-02T08:00:00-08:00") - - #expect(RecordingPolicyFilter.visibleSamples( - [sample], - policyChanges: [enabled, disabled], - ).isEmpty) - } - - @Test func causalWinnerIsNotReversedByAnotherDevicesLaterClock() { - let initial = Self.policy( - "2026-02-28T00:00:00-08:00", - enabled: true, - id: "10000000-0000-0000-0000-000000000000", - ) - let causallyLaterDisable = Self.policy( - "2026-03-02T00:00:00-08:00", - enabled: false, - id: "20000000-0000-0000-0000-000000000000", - parentIDs: ["30000000-0000-0000-0000-000000000000"], - revision: 2, - ) - let clockSkewedOlderEnable = Self.policy( - "2026-03-03T00:00:00-08:00", - enabled: true, - id: "30000000-0000-0000-0000-000000000000", - parentIDs: ["10000000-0000-0000-0000-000000000000"], - revision: 1, - ) - let sample = Self.sample("2026-03-04T08:00:00-08:00") - - #expect(RecordingPolicyFilter.visibleSamples( - [sample], - policyChanges: [initial, clockSkewedOlderEnable, causallyLaterDisable], - ).isEmpty) - } - - @Test func deviceStampedSampleFailsClosedUntilItsPolicyArrives() { - let stamped = Self.sample("2026-03-02T08:00:00-08:00") - let legacy = Self.sample("2026-03-02T09:00:00-08:00", deviceID: nil) - - let visible = RecordingPolicyFilter.visibleSamples( - [stamped, legacy], - policyChanges: [], - ) - - #expect(visible == [legacy]) - } - - @Test func deviceStampedSampleFailsClosedWhilePolicyRevisionsHaveAGap() { - let initial = Self.policy( - "2026-03-01T00:00:00-08:00", - enabled: true, - id: "10000000-0000-0000-0000-000000000000", - ) - let laterEnable = Self.policy( - "2026-03-03T00:00:00-08:00", - enabled: true, - id: "30000000-0000-0000-0000-000000000000", - parentIDs: ["10000000-0000-0000-0000-000000000000"], - revision: 2, - ) - let stamped = Self.sample("2026-03-04T08:00:00-08:00") - let legacy = Self.sample("2026-03-04T09:00:00-08:00", deviceID: nil) - - let visible = RecordingPolicyFilter.visibleSamples( - [stamped, legacy], - policyChanges: [initial, laterEnable], - ) - - #expect(visible == [legacy]) - } - - @Test func backdatedChildCannotReExposeSamplesBeforeItsOffParent() { - let initial = Self.policy( - "2026-03-01T00:00:00-08:00", - enabled: true, - id: "10000000-0000-0000-0000-000000000000", - ) - let off = Self.policy( - "2026-03-03T00:00:00-08:00", - enabled: false, - id: "20000000-0000-0000-0000-000000000000", - parentIDs: ["10000000-0000-0000-0000-000000000000"], - revision: 1, - ) - let backdatedEnable = Self.policy( - "2026-03-02T00:00:00-08:00", - enabled: true, - id: "30000000-0000-0000-0000-000000000000", - parentIDs: ["20000000-0000-0000-0000-000000000000"], - revision: 2, - ) - let sample = Self.sample("2026-03-02T08:00:00-08:00") - - #expect(RecordingPolicyFilter.visibleSamples( - [sample], - policyChanges: [initial, off, backdatedEnable], - ).isEmpty) - } - - @Test func archivedAuthorityExcludesLaterSamples() throws { - let sample = Self.sample("2026-03-03T08:00:00-08:00") - let archiveID = try #require( - UUID(uuidString: "20000000-0000-0000-0000-000000000000"), - ) - let archived = try RecordingPolicyChange( - id: archiveID, - deviceID: Self.deviceID, - parentIDs: [#require(UUID(uuidString: "10000000-0000-0000-0000-000000000000"))], - revision: 1, - issuedAt: WhereCoreTestSupport.iso("2026-03-02T00:00:00-08:00"), - issuedByDeviceID: Self.deviceID, - effectiveAt: WhereCoreTestSupport.iso("2026-03-02T00:00:00-08:00"), - state: .archived, - reason: .archive, - ) - - #expect(RecordingPolicyFilter.visibleSamples( - [sample], - policyChanges: [ - Self.policy( - "2026-03-01T00:00:00-08:00", - enabled: true, - id: "10000000-0000-0000-0000-000000000000", - ), - archived, - ], - ).isEmpty) - } - - @Test func accountResetKeepsLatePreResetSamplesErasedAfterReenable() throws { - let initial = Self.policy( - "2026-03-01T00:00:00-08:00", - enabled: true, - id: "10000000-0000-0000-0000-000000000000", - ) - let resetAt = WhereCoreTestSupport.iso("2026-03-03T00:00:00-08:00") - let reset = try RecordingPolicyChange( - id: #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")), - deviceID: Self.deviceID, - parentIDs: [initial.id], - revision: 1, - issuedAt: resetAt, - issuedByDeviceID: Self.deviceID, - effectiveAt: resetAt, - state: .off, - reason: .accountReset, - ) - let reenabled = Self.policy( - "2026-03-04T00:00:00-08:00", - enabled: true, - id: "30000000-0000-0000-0000-000000000000", - parentIDs: ["20000000-0000-0000-0000-000000000000"], - revision: 2, - ) - let latePreReset = Self.sample("2026-03-02T08:00:00-08:00") - let afterReenable = Self.sample("2026-03-05T08:00:00-08:00") - - let visible = RecordingPolicyFilter.visibleSamples( - [latePreReset, afterReenable], - policyChanges: [initial, reset, reenabled], - ) - - #expect(visible.map(\.id) == [afterReenable.id]) - } - - @Test func concurrentAccountResetOutranksBackupReplacement() throws { - let initial = Self.policy( - "2026-03-01T00:00:00-08:00", - enabled: true, - id: "05000000-0000-0000-0000-000000000000", - ) - let commandDate = WhereCoreTestSupport.iso("2026-03-03T00:00:00-08:00") - let reset = try RecordingPolicyChange( - id: #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")), - deviceID: Self.deviceID, - parentIDs: [initial.id], - revision: 1, - issuedAt: commandDate, - issuedByDeviceID: Self.deviceID, - effectiveAt: commandDate, - state: .off, - reason: .accountReset, - ) - // Its lexically later id would win the old UUID tie-break, dropping the reset floor. - let replacement = try RecordingPolicyChange( - id: #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")), - deviceID: Self.deviceID, - parentIDs: [initial.id], - revision: 1, - issuedAt: commandDate, - issuedByDeviceID: Self.deviceID, - effectiveAt: commandDate, - state: .off, - reason: .backupReplace, - ) - let latePreReset = Self.sample("2026-03-02T08:00:00-08:00") - - #expect(RecordingPolicyFilter.visibleSamples( - [latePreReset], - policyChanges: [replacement, initial, reset], - ).isEmpty) - } -} diff --git a/Where/WhereCore/Tests/ReportReaderTests.swift b/Where/WhereCore/Tests/ReportReaderTests.swift index 6bf36b87..dc9078c1 100644 --- a/Where/WhereCore/Tests/ReportReaderTests.swift +++ b/Where/WhereCore/Tests/ReportReaderTests.swift @@ -47,16 +47,15 @@ struct ReportReaderTests { rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) try await store.perform { - try await store.addRecordingPolicyChange(RecordingPolicyChange( + try await store.addRecordingAssignmentChange(RecordingAssignmentChange( id: UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!, - deviceID: deviceID, parentIDs: [], revision: 0, issuedAt: WhereCoreTestSupport.iso("2026-01-01T00:00:00-08:00"), issuedByDeviceID: deviceID, effectiveAt: WhereCoreTestSupport.iso("2026-01-01T00:00:00-08:00"), - state: .on, - reason: .initialRegistration, + assignedDeviceID: deviceID, + reason: .onboarding, )) try await store.add(sample: LocationSample( timestamp: WhereCoreTestSupport.iso("2026-01-10T12:00:00-08:00"), @@ -72,15 +71,14 @@ struct ReportReaderTests { source: .gpsVisit, recordingDeviceID: deviceID, )) - try await store.addRecordingPolicyChange(RecordingPolicyChange( + try await store.addRecordingAssignmentChange(RecordingAssignmentChange( id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, - deviceID: deviceID, parentIDs: [UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!], revision: 1, issuedAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"), issuedByDeviceID: deviceID, effectiveAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"), - state: .off, + assignedDeviceID: nil, reason: .userCommand, )) } diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 11c037c8..a956c9c5 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -149,12 +149,12 @@ struct SwiftDataStoreTests { #expect(await !firstPing(stream, within: .milliseconds(200))) } - @Test func recordingDeviceAndPolicyRowsRoundTripWithoutDuplicateLogicalRows() async throws { + @Test func recordingDeviceAndAssignmentRowsRoundTripWithoutDuplicateLogicalRows() async throws { let store = try SwiftDataStore.inMemory() let deviceID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) - let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) + let assignmentID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) let date = Date(timeIntervalSinceReferenceDate: 100) let profile = RecordingDeviceProfile( id: deviceID, @@ -176,19 +176,18 @@ struct SwiftDataStoreTests { revision: 0, lastSeenAt: date, appliedAt: date, - lastAppliedPolicyChangeID: policyID, + lastAppliedAssignmentChangeID: assignmentID, status: .off, ) - let policy = RecordingPolicyChange( - id: policyID, - deviceID: deviceID, + let assignment = RecordingAssignmentChange( + id: assignmentID, parentIDs: [], revision: 0, issuedAt: date, issuedByDeviceID: deviceID, effectiveAt: date, - state: .off, - reason: .initialRegistration, + assignedDeviceID: nil, + reason: .onboarding, ) try await store.perform { @@ -198,8 +197,8 @@ struct SwiftDataStoreTests { try await store.addRecordingDeviceMetadataChange(nicknameMetadata) try await store.setRecordingDeviceCheckIn(checkIn) try await store.setRecordingDeviceCheckIn(checkIn) - try await store.addRecordingPolicyChange(policy) - try await store.addRecordingPolicyChange(policy) + try await store.addRecordingAssignmentChange(assignment) + try await store.addRecordingAssignmentChange(assignment) } #expect(try await store.recordingDeviceProfiles() == [profile]) @@ -213,10 +212,10 @@ struct SwiftDataStoreTests { registeredAt: date, lastSeenAt: date, archivedAt: nil, - lastAppliedPolicyChangeID: policyID, + lastAppliedAssignmentChangeID: assignmentID, status: .off, )]) - #expect(try await store.recordingPolicyChanges() == [policy]) + #expect(try await store.recordingAssignmentChanges() == [assignment]) } /// A remote import (simulated via a scripted source) re-pings the same @@ -243,7 +242,7 @@ struct SwiftDataStoreTests { let deviceID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) - let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) + let assignmentID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) let date = Date(timeIntervalSinceReferenceDate: 100) let profile = RecordingDeviceProfile( id: deviceID, @@ -265,26 +264,26 @@ struct SwiftDataStoreTests { revision: 0, lastSeenAt: date, appliedAt: date, - lastAppliedPolicyChangeID: policyID, + lastAppliedAssignmentChangeID: assignmentID, status: .recording, ) - let policy = RecordingPolicyChange( - id: policyID, - deviceID: deviceID, + let assignment = RecordingAssignmentChange( + id: assignmentID, parentIDs: [], revision: 0, issuedAt: date, issuedByDeviceID: deviceID, effectiveAt: date, - state: .on, - reason: .initialRegistration, + assignedDeviceID: deviceID, + reason: .onboarding, ) try await store.simulateRemoteRecordingImport( profiles: [profile], metadataChanges: [metadata], checkIns: [checkIn], - policyChanges: [policy], + assignmentChanges: [assignment], + archives: [], ) // The seam suppresses the ordinary local-commit ping: observers must @@ -297,11 +296,11 @@ struct SwiftDataStoreTests { #expect(try await store.recordingDeviceProfiles() == [profile]) #expect(try await store.recordingDeviceMetadataChanges() == [metadata]) #expect(try await store.recordingDeviceCheckIns() == [checkIn]) - #expect(try await store.recordingPolicyChanges() == [policy]) + #expect(try await store.recordingAssignmentChanges() == [assignment]) let device = try #require(try await store.recordingDevices().first) #expect(device.nickname == "Travel iPad") #expect(device.status == .recording) - #expect(device.lastAppliedPolicyChangeID == policyID) + #expect(device.lastAppliedAssignmentChangeID == assignmentID) } @Test func newerCheckInRevisionWinsEvenWhenItsWallClockMovedBackward() async throws { @@ -316,7 +315,7 @@ struct SwiftDataStoreTests { revision: 0, lastSeenAt: Date(timeIntervalSinceReferenceDate: 200), appliedAt: Date(timeIntervalSinceReferenceDate: 200), - lastAppliedPolicyChangeID: firstPolicyID, + lastAppliedAssignmentChangeID: firstPolicyID, status: .recording, ) let causallyLater = RecordingDeviceCheckIn( @@ -324,7 +323,7 @@ struct SwiftDataStoreTests { revision: 1, lastSeenAt: Date(timeIntervalSinceReferenceDate: 100), appliedAt: Date(timeIntervalSinceReferenceDate: 100), - lastAppliedPolicyChangeID: secondPolicyID, + lastAppliedAssignmentChangeID: secondPolicyID, status: .off, ) @@ -363,34 +362,20 @@ struct SwiftDataStoreTests { checkIn.revision = -1 checkIn.lastSeenAt = date checkIn.appliedAt = date - checkIn.lastAppliedPolicyChangeID = eventID + checkIn.lastAppliedAssignmentChangeID = eventID checkIn.statusRaw = RecordingDeviceStatus.off.rawValue - let policy = SDRecordingPolicyChange() - policy.id = eventID - policy.deviceID = deviceID - policy.revision = -1 - policy.issuedAt = date - policy.issuedByDeviceID = deviceID - policy.effectiveAt = date - policy.stateRaw = RecordingPolicyState.off.rawValue - policy.reasonRaw = RecordingPolicyReason.userCommand.rawValue - context.insert(negativeMetadata) context.insert(combinedMetadata) context.insert(checkIn) - context.insert(policy) try context.save() let store = SwiftDataStore(modelContainer: container) #expect(try await store.recordingDeviceMetadataChanges().isEmpty) #expect(try await store.recordingDeviceCheckIns().isEmpty) - await #expect(throws: RecordingPersistenceError.corruptRecordingPolicyHistory) { - try await store.recordingPolicyChanges() - } } - @Test func newMultiParentRowsFailClosedWhileTheirParentArrayIsUnavailable() throws { + @Test func newMultiParentRowsFailClosedWhileTheirParentArrayIsUnavailable() { let firstEpochParent = Self.epochID("10000000-0000-0000-0000-000000000000") let secondEpochParent = Self.epochID("20000000-0000-0000-0000-000000000000") let epoch = Self.epoch( @@ -408,28 +393,7 @@ struct SwiftDataStoreTests { ]) epochRow.parentIDs = nil - let policyParentIDs = try [ - #require(UUID(uuidString: "40000000-0000-0000-0000-000000000000")), - #require(UUID(uuidString: "50000000-0000-0000-0000-000000000000")), - ] - let policy = try RecordingPolicyChange( - id: #require(UUID(uuidString: "60000000-0000-0000-0000-000000000000")), - deviceID: Self.epochWriterID, - parentIDs: policyParentIDs, - revision: 2, - issuedAt: Date(timeIntervalSinceReferenceDate: 300), - issuedByDeviceID: Self.epochWriterID, - effectiveAt: Date(timeIntervalSinceReferenceDate: 300), - state: .off, - reason: .userCommand, - ) - let policyRow = SDRecordingPolicyChange(value: policy, epochID: .initial) - #expect(policyRow.parentID == nil) - #expect(policyRow.parentIDs == policyParentIDs) - policyRow.parentIDs = nil - #expect(epochRow.toValue() == nil) - #expect(policyRow.toValue() == nil) } @Test func legacyScalarParentsStillDecodeAsSingleParentArrays() throws { @@ -443,23 +407,7 @@ struct SwiftDataStoreTests { epochRow.changedByDeviceID = Self.epochWriterID.rawValue epochRow.reasonRaw = WhereDataEpochReason.accountReset.rawValue - let policyID = try #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")) - let policyParentID = try #require(UUID(uuidString: "30000000-0000-0000-0000-000000000000")) - let policyRow = SDRecordingPolicyChange() - policyRow.epochID = WhereDataEpochID.initial.rawValue - policyRow.id = policyID - policyRow.deviceID = Self.epochWriterID.rawValue - policyRow.parentID = policyParentID - policyRow.parentIDs = nil - policyRow.revision = 1 - policyRow.issuedAt = Date(timeIntervalSinceReferenceDate: 100) - policyRow.issuedByDeviceID = Self.epochWriterID.rawValue - policyRow.effectiveAt = Date(timeIntervalSinceReferenceDate: 100) - policyRow.stateRaw = RecordingPolicyState.off.rawValue - policyRow.reasonRaw = RecordingPolicyReason.userCommand.rawValue - #expect(try #require(epochRow.toValue()).parentIDs == [.initial]) - #expect(try #require(policyRow.toValue()).parentIDs == [policyParentID]) } @Test func lateRowsFromASupersededEpochCannotRepopulateAnySyncedUserData() async throws { @@ -514,19 +462,18 @@ struct SwiftDataStoreTests { revision: 0, lastSeenAt: date, appliedAt: date, - lastAppliedPolicyChangeID: policyID, + lastAppliedAssignmentChangeID: policyID, status: .recording, ) - let policy = RecordingPolicyChange( + let assignment = RecordingAssignmentChange( id: policyID, - deviceID: deviceID, parentIDs: [], revision: 0, issuedAt: date, issuedByDeviceID: deviceID, effectiveAt: date, - state: .on, - reason: .initialRegistration, + assignedDeviceID: deviceID, + reason: .onboarding, ) let epoch = try await store.perform { @@ -553,7 +500,7 @@ struct SwiftDataStoreTests { remoteContext.insert(SDTrackedRegion(regionID: "us-TX", epochID: .initial)) remoteContext.insert(SDRecordingDeviceMetadataChange(value: metadata, epochID: .initial)) remoteContext.insert(SDRecordingDeviceCheckIn(value: checkIn, epochID: .initial)) - remoteContext.insert(SDRecordingPolicyChange(value: policy, epochID: .initial)) + remoteContext.insert(SDRecordingAssignmentChange(value: assignment, epochID: .initial)) try remoteContext.save() let reader = SwiftDataStore(modelContainer: container) @@ -567,7 +514,7 @@ struct SwiftDataStoreTests { #expect(try await reader.recordingDeviceProfiles() == [profile]) #expect(try await reader.recordingDeviceMetadataChanges().isEmpty) #expect(try await reader.recordingDeviceCheckIns().isEmpty) - #expect(try await reader.recordingPolicyChanges().isEmpty) + #expect(try await reader.recordingAssignmentChanges().isEmpty) } @Test func expectedEpochTransactionRejectsStaleAuthorityWithoutWriting() async throws { @@ -981,16 +928,15 @@ struct SwiftDataStoreTests { changedByDeviceID: deviceID, nickname: "Home iPad", ) - let policy = RecordingPolicyChange( + let assignment = RecordingAssignmentChange( id: policyID, - deviceID: deviceID, parentIDs: [], revision: 0, issuedAt: date, issuedByDeviceID: deviceID, effectiveAt: date, - state: .off, - reason: .initialRegistration, + assignedDeviceID: nil, + reason: .onboarding, ) let currentEpoch = try await store.perform { try await store.rotateDataEpoch( @@ -1003,13 +949,13 @@ struct SwiftDataStoreTests { let remoteContext = ModelContext(container) remoteContext.insert(SDLocationSample(value: sample, epochID: .initial)) remoteContext.insert(SDRecordingDeviceMetadataChange(value: metadata, epochID: .initial)) - remoteContext.insert(SDRecordingPolicyChange(value: policy, epochID: .initial)) + remoteContext.insert(SDRecordingAssignmentChange(value: assignment, epochID: .initial)) try remoteContext.save() try await store.perform(expectedDataEpochID: currentEpoch.id) { try await store.add(sample: sample) try await store.addRecordingDeviceMetadataChange(metadata) - try await store.addRecordingPolicyChange(policy) + try await store.addRecordingAssignmentChange(assignment) } let inspectionContext = ModelContext(container) @@ -1021,8 +967,10 @@ struct SwiftDataStoreTests { $0.id == metadataID }), ) - let policyRows = try inspectionContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == policyID }), + let assignmentRows = try inspectionContext.fetch( + FetchDescriptor(predicate: #Predicate { + $0.id == policyID + }), ) let expectedEpochIDs = Set([ WhereDataEpochID.initial.rawValue, @@ -1033,11 +981,11 @@ struct SwiftDataStoreTests { #expect(Set(sampleRows.compactMap(\.epochID)) == expectedEpochIDs) #expect(metadataRows.count == 2) #expect(Set(metadataRows.compactMap(\.epochID)) == expectedEpochIDs) - #expect(policyRows.count == 2) - #expect(Set(policyRows.compactMap(\.epochID)) == expectedEpochIDs) + #expect(assignmentRows.count == 2) + #expect(Set(assignmentRows.compactMap(\.epochID)) == expectedEpochIDs) #expect(try await store.allSamples() == [sample]) #expect(try await store.recordingDeviceMetadataChanges() == [metadata]) - #expect(try await store.recordingPolicyChanges() == [policy]) + #expect(try await store.recordingAssignmentChanges() == [assignment]) } @Test func duplicateProfilesResolveDeterministicallyByRegistrationEpoch() async throws { diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 5e2abb2e..9633f69d 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -741,37 +741,34 @@ struct WhereServicesTests { let context = InstallationRecordingContext.testing let currentDeviceID = context.currentDevice.id let initialChoice = try #require(context.initialRecordingChoice) - let initial = RecordingPolicyChange( - id: initialChoice.policyChangeID, - deviceID: currentDeviceID, + let initial = RecordingAssignmentChange( + id: initialChoice.assignmentChangeID, parentIDs: [], revision: 0, issuedAt: initialChoice.confirmedAt, issuedByDeviceID: currentDeviceID, effectiveAt: initialChoice.confirmedAt, - state: .on, - reason: .initialRegistration, + assignedDeviceID: currentDeviceID, + reason: .onboarding, ) - let off = try RecordingPolicyChange( + let off = try RecordingAssignmentChange( id: #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")), - deviceID: currentDeviceID, parentIDs: [initial.id], revision: 1, issuedAt: Date(timeIntervalSinceReferenceDate: 2), issuedByDeviceID: currentDeviceID, effectiveAt: Date(timeIntervalSinceReferenceDate: 2), - state: .off, + assignedDeviceID: nil, reason: .userCommand, ) - let importedOn = try RecordingPolicyChange( + let importedOn = try RecordingAssignmentChange( id: #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")), - deviceID: currentDeviceID, parentIDs: [off.id], revision: 2, issuedAt: Date(timeIntervalSinceReferenceDate: 3), issuedByDeviceID: currentDeviceID, effectiveAt: Date(timeIntervalSinceReferenceDate: 3), - state: .on, + assignedDeviceID: currentDeviceID, reason: .userCommand, ) let profile = RecordingDeviceProfile( @@ -787,8 +784,8 @@ struct WhereServicesTests { manualDays: [], recordingDeviceProfiles: [profile], recordingDeviceMetadataChanges: [], - recordingDeviceCheckIns: [], - recordingPolicyChanges: [initial, off, importedOn], + recordingAssignmentChanges: [initial, off, importedOn], + recordingDeviceArchives: [], blobs: [:], ) defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } @@ -796,8 +793,8 @@ struct WhereServicesTests { let store = try SwiftDataStore.inMemory() try await store.perform { try await store.addRecordingDeviceProfile(profile) - try await store.addRecordingPolicyChange(initial) - try await store.addRecordingPolicyChange(off) + try await store.addRecordingAssignmentChange(initial) + try await store.addRecordingAssignmentChange(off) } let destination = WhereServices( store: store, @@ -810,12 +807,13 @@ struct WhereServicesTests { _ = try await destination.backup.importBackup(from: url, strategy: .merge) - let policies = try await store.recordingPolicyChanges() - let head = try #require(RecordingPolicyChange.canonicalHead(in: policies)) + let assignments = try await store.recordingAssignmentChanges() + let head = try #require(RecordingAssignmentChange.maximalHeads(in: assignments)?.first) #expect(head.parentIDs == [importedOn.id]) - #expect(head.state == .off) + #expect(head.assignedDeviceID == nil) #expect(head.reason == .backupMerge) - #expect(try await store.recordingDeviceCheckIns().first?.lastAppliedPolicyChangeID == head + #expect(try await store.recordingDeviceCheckIns().first? + .lastAppliedAssignmentChangeID == head .id) #expect(await destination.ingestor.isActive == false) } @@ -890,9 +888,9 @@ struct WhereServicesTests { #expect(await destination.ingestor.isActive) _ = try await destination.backup.importBackup(from: url, strategy: .replace) - let policies = try await store.recordingPolicyChanges() - #expect(policies.map(\.state) == [.on, .off]) - #expect(policies.last?.reason == .backupReplace) + let assignments = try await store.recordingAssignmentChanges() + #expect(assignments.map(\.assignedDeviceID) == [CurrentRecordingDevice.preview.id, nil]) + #expect(assignments.last?.reason == .backupReplace) #expect(try await store.recordingDeviceCheckIns().first?.status == .off) #expect(await destination.ingestor.isActive == false) } @@ -918,7 +916,10 @@ struct WhereServicesTests { #expect(await outbox.persistedSamples == [pending]) #expect(await destination.ingestor.isActive == false) #expect(try await store.dataEpoch().reason == .backupReplace) - #expect(try await store.recordingPolicyChanges().isEmpty) + #expect( + try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) + == .resolved(.off), + ) } @Test func resetCleanupFailureKeepsTheOldInstallationForSafeRetry() async throws { @@ -943,7 +944,7 @@ struct WhereServicesTests { #expect(await services.ingestor.isActive == false) #expect(try await store.recordingDeviceProfiles().count == 1) #expect(try await store.recordingDeviceCheckIns().isEmpty) - #expect(try await store.recordingPolicyChanges().isEmpty) + #expect(try await store.recordingAssignmentChanges().isEmpty) #expect(try await store.dataEpoch().reason == .accountReset) } @@ -963,7 +964,7 @@ struct WhereServicesTests { #expect(await outbox.persistedSamples.isEmpty) #expect(try await store.recordingDeviceProfiles().count == 1) - #expect(try await store.recordingPolicyChanges().isEmpty) + #expect(try await store.recordingAssignmentChanges().isEmpty) #expect(try await store.dataEpoch().reason == .accountReset) #expect(await services.ingestor.isActive == false) } @@ -991,7 +992,10 @@ struct WhereServicesTests { #expect(configuration.isEnabled == false) #expect(configuration.device.status == .off) #expect(await destination.ingestor.isActive == false) - #expect(try await store.recordingPolicyChanges().map(\.isEnabled) == [true, false]) + #expect( + try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) + == .resolved(.off), + ) } @Test func failedBackupTransactionRestoresThePreviousRecordingAuthority() async throws { @@ -1161,19 +1165,18 @@ struct WhereServicesTests { revision: 0, lastSeenAt: seedSample.timestamp, appliedAt: seedSample.timestamp, - lastAppliedPolicyChangeID: policyID, + lastAppliedAssignmentChangeID: policyID, status: .recording, )) - try await store.addRecordingPolicyChange(RecordingPolicyChange( + try await store.addRecordingAssignmentChange(RecordingAssignmentChange( id: policyID, - deviceID: deviceID, parentIDs: [], revision: 0, issuedAt: seedSample.timestamp, issuedByDeviceID: deviceID, effectiveAt: seedSample.timestamp, - state: .on, - reason: .initialRegistration, + assignedDeviceID: deviceID, + reason: .onboarding, )) } @@ -1189,7 +1192,7 @@ struct WhereServicesTests { #expect(try await store.allEvidence().isEmpty) #expect(try await store.allManualDays().isEmpty) #expect(try await store.recordingDevices().count == 1) - #expect(try await store.recordingPolicyChanges().isEmpty) + #expect(try await store.recordingAssignmentChanges().isEmpty) } // MARK: - Logging reminders @@ -1879,14 +1882,6 @@ private actor ToggleFailingStore: WhereStore { try await backing.setRecordingDeviceCheckIn(checkIn) } - func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { - try await backing.recordingPolicyChanges() - } - - func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { - try await backing.addRecordingPolicyChange(change) - } - func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { try await backing.recordingAssignmentChanges() } diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png index d4ed16d7..ddd1792c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5e5d24c1b225a48e576b0f92e800af16d7c25d9bfa0b75d6c1df8eb45a9cf09 -size 242321 +oid sha256:768f6f387b758c9f32d4ee60e47c8c2376ca2c68d0be009215cf2f6f24adc93d +size 237419 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png index d88f3026..0e93792f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:485419f9b4d31fa8eecfe6bc62879f5608d864b53315f0d2b821bb8bf4264bfb -size 501089 +oid sha256:c1556ea09d3109a522204f760e3eef5ce3ba2853cd93a217bd0b228a50500518 +size 491463 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png index 31dbbb17..65223f18 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0932bdf9bae3ae9b0e6e3b185cdc6bdc2fc5f0e0d267a690017c9517eaadf265 -size 246330 +oid sha256:89fa9191fcfb3087e4ee3556da2f6148e338431fd7dc59de10d71743d5b5ef5c +size 240537 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png index 6a34281e..8dda6820 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dbc1d8a954947f8ddef3f0ac59a0d5511b7156ad3378a730e07a3807d58a66b4 -size 247658 +oid sha256:95e84671ccacda4f1808b5cd3f331789e62e8740c5fd4b91a5d93f0427f8dfe3 +size 242084 diff --git a/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift index aa72890f..ab0e5c74 100644 --- a/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift +++ b/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift @@ -44,7 +44,7 @@ public final class InMemoryInstallationRecordingContextStore: if onboardingContext.initialRecordingChoice != nil { return onboardingContext } onboardingContext = onboardingContext.confirmingInitialRecording( isEnabled: isEnabled, - policyChangeID: makeUUID(), + assignmentChangeID: makeUUID(), confirmedAt: now(), ) return onboardingContext diff --git a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift index ceb44388..a3864f1c 100644 --- a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift +++ b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift @@ -9,7 +9,7 @@ import WhereCore /// context stays in memory until onboarding confirms its first choice, which /// keeps merely viewing onboarding or entering demo mode free of durable writes. /// The sidecar also freezes the timestamps used by the first immutable device -/// profile and policy event, and retains active import recovery plus terminal +/// profile and assignment event, and retains active import recovery plus terminal /// onboarding-import authority, so retries and cold-launch repair are deterministic. @MainActor public final class FileInstallationRecordingContextStore: @@ -46,12 +46,12 @@ public final class FileInstallationRecordingContextStore: private struct StoredContext: Codable { struct InitialRecordingChoice: Codable { let isEnabled: Bool - let policyChangeID: UUID + let assignmentChangeID: UUID let confirmedAt: Date enum CodingKeys: String, CodingKey { case isEnabled - case policyChangeID + case assignmentChangeID case confirmedAt } } @@ -74,7 +74,7 @@ public final class FileInstallationRecordingContextStore: let dismissedIssueCount: Int let trackedRegionCount: Int let recordingDeviceCount: Int - let recordingPolicyChangeCount: Int + let recordingAssignmentChangeCount: Int init(_ summary: BackupCoordinator.ImportSummary) { sampleCount = summary.sampleCount @@ -83,7 +83,7 @@ public final class FileInstallationRecordingContextStore: dismissedIssueCount = summary.dismissedIssueCount trackedRegionCount = summary.trackedRegionCount recordingDeviceCount = summary.recordingDeviceCount - recordingPolicyChangeCount = summary.recordingPolicyChangeCount + recordingAssignmentChangeCount = summary.recordingAssignmentChangeCount } var value: BackupCoordinator.ImportSummary { @@ -94,7 +94,7 @@ public final class FileInstallationRecordingContextStore: dismissedIssueCount: dismissedIssueCount, trackedRegionCount: trackedRegionCount, recordingDeviceCount: recordingDeviceCount, - recordingPolicyChangeCount: recordingPolicyChangeCount, + recordingAssignmentChangeCount: recordingAssignmentChangeCount, ) } } @@ -192,7 +192,7 @@ public final class FileInstallationRecordingContextStore: initialRecordingChoice = context.initialRecordingChoice.map { InitialRecordingChoice( isEnabled: $0.isEnabled, - policyChangeID: $0.policyChangeID, + assignmentChangeID: $0.assignmentChangeID, confirmedAt: $0.confirmedAt, ) } @@ -211,7 +211,7 @@ public final class FileInstallationRecordingContextStore: initialRecordingChoice: initialRecordingChoice.map { InstallationRecordingContext.InitialRecordingChoice( isEnabled: $0.isEnabled, - policyChangeID: $0.policyChangeID, + assignmentChangeID: $0.assignmentChangeID, confirmedAt: $0.confirmedAt, ) }, @@ -368,13 +368,13 @@ public final class FileInstallationRecordingContextStore: isEnabled: Bool, ) throws -> InstallationRecordingContext { let context = try resolution.get() - // Confirmation freezes one immutable policy event. A later UI retry cannot rewrite - // that event under the same id; subsequent changes belong in the synced policy stream. + // Confirmation freezes one immutable assignment event. A later UI retry cannot rewrite + // that event under the same id; subsequent changes belong in the synced assignment stream. if context.initialRecordingChoice != nil { return context } let confirmed = context.confirmingInitialRecording( isEnabled: isEnabled, - policyChangeID: makeUUID(), + assignmentChangeID: makeUUID(), confirmedAt: now(), ) try persist( diff --git a/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift b/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift index 8fa84f2d..2982cb74 100644 --- a/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift +++ b/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift @@ -131,7 +131,7 @@ struct WhereLifecycleFailureView: View { dismissedIssueCount: 2, trackedRegionCount: 5, recordingDeviceCount: 2, - recordingPolicyChangeCount: 4, + recordingAssignmentChangeCount: 4, ) } diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index ef405668..a79ef58a 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -341,7 +341,7 @@ public final class WhereSession { observeRecordingConfigurationChanges() let wasTracking = isTracking do { - await services.recording.startMonitoringPolicyChanges() + await services.recording.startMonitoringAssignmentChanges() if didRegisterRecordingDevice { _ = try await services.recording.reconcile( authorization: authorizationStatus, @@ -503,7 +503,7 @@ public final class WhereSession { guard let current = devices.first(where: { $0.id == deviceID }) else { return } guard let resolvedEnabled = current.isEnabled else { - throw RecordingPersistenceError.currentDevicePolicyUnknown(deviceID) + throw RecordingPersistenceError.currentDeviceAssignmentUnknown(deviceID) } await synchronizeRecordingRuntimeState() permissionDenied = resolvedEnabled && permissionRequestFailed diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 91ceaae5..7cc499ece 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -122,9 +122,6 @@ let currentPolicyID = UUID( uuidString: "10000000-0000-0000-0000-000000000001", )! - let remoteAppliedPolicyID = UUID( - uuidString: "20000000-0000-0000-0000-000000000001", - )! let remoteLatestPolicyID = UUID( uuidString: "20000000-0000-0000-0000-000000000002", )! @@ -141,15 +138,15 @@ registeredAt: referenceNow.addingTimeInterval(-90 * 24 * 60 * 60), lastSeenAt: referenceNow, archivedAt: nil, - lastAppliedPolicyChangeID: currentPolicyID, + lastAppliedAssignmentChangeID: currentPolicyID, status: .recording, ), - policy: .resolved(ResolvedRecordingPolicy( - isEnabled: true, - isArchived: false, - changeID: currentPolicyID, - isAcknowledged: true, + assignmentResolution: .resolved(.device( + InstallationRecordingContext.testing.currentDevice.id, )), + assignmentFrontierID: currentPolicyID, + isAssignmentAcknowledged: true, + isArchived: false, ), RecordingDeviceConfiguration( device: RecordingDevice( @@ -160,15 +157,15 @@ registeredAt: referenceNow.addingTimeInterval(-60 * 24 * 60 * 60), lastSeenAt: referenceNow.addingTimeInterval(-2 * 24 * 60 * 60), archivedAt: nil, - lastAppliedPolicyChangeID: remoteAppliedPolicyID, - status: .recording, + lastAppliedAssignmentChangeID: nil, + status: .off, ), - policy: .resolved(ResolvedRecordingPolicy( - isEnabled: false, - isArchived: false, - changeID: remoteLatestPolicyID, - isAcknowledged: false, + assignmentResolution: .resolved(.device( + InstallationRecordingContext.testing.currentDevice.id, )), + assignmentFrontierID: remoteLatestPolicyID, + isAssignmentAcknowledged: true, + isArchived: false, ), ] } diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift index 28793cc9..873717d4 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift @@ -9,8 +9,8 @@ final class DeviceSettingsRowModel: Identifiable { /// Why the desired recording setting is not yet settled. A missing policy /// is still arriving through CloudKit; a resolved policy can instead be /// waiting for its target installation to acknowledge it. - enum PolicyPresentationState: Equatable { - case syncingPolicy + enum AssignmentPresentationState: Equatable { + case syncingAssignment case resolved(isAcknowledged: Bool) } @@ -53,7 +53,7 @@ final class DeviceSettingsRowModel: Identifiable { private(set) var operationState: OperationState = .idle private(set) var status: RecordingDeviceStatus private(set) var lastSeenAt: Date - private(set) var policyPresentationState: PolicyPresentationState + private(set) var assignmentPresentationState: AssignmentPresentationState init(configuration: RecordingDeviceConfiguration, isCurrent: Bool) { id = configuration.id @@ -69,7 +69,7 @@ final class DeviceSettingsRowModel: Identifiable { draftValues = editableValues status = configuration.device.status lastSeenAt = configuration.device.lastSeenAt - policyPresentationState = Self.policyPresentationState(for: configuration.policy) + assignmentPresentationState = Self.assignmentPresentationState(for: configuration) } var nickname: String { @@ -94,17 +94,17 @@ final class DeviceSettingsRowModel: Identifiable { } } - var hasResolvedRecordingPolicy: Bool { + var hasResolvedRecordingAssignment: Bool { draftValues.isEnabled != nil } - var isSyncingRecordingPolicy: Bool { - if case .syncingPolicy = policyPresentationState { true } else { false } + var isSyncingRecordingAssignment: Bool { + if case .syncingAssignment = assignmentPresentationState { true } else { false } } var isPending: Bool { - switch policyPresentationState { - case .syncingPolicy: true + switch assignmentPresentationState { + case .syncingAssignment: true case let .resolved(isAcknowledged): !isAcknowledged } } @@ -133,7 +133,7 @@ final class DeviceSettingsRowModel: Identifiable { } var disablesDestructiveActions: Bool { - guard hasResolvedRecordingPolicy else { return true } + guard hasResolvedRecordingAssignment else { return true } return if case .saving = operationState { true } else { false } } @@ -261,7 +261,7 @@ final class DeviceSettingsRowModel: Identifiable { } status = configuration.device.status lastSeenAt = configuration.device.lastSeenAt - policyPresentationState = Self.policyPresentationState(for: configuration.policy) + assignmentPresentationState = Self.assignmentPresentationState(for: configuration) } private var normalizedNickname: String { @@ -283,15 +283,13 @@ final class DeviceSettingsRowModel: Identifiable { } } - private static func policyPresentationState( - for resolution: RecordingPolicyResolution, - ) -> PolicyPresentationState { - switch resolution { - case .unknown: - .syncingPolicy - case let .resolved(policy): - .resolved(isAcknowledged: policy.isAcknowledged) + private static func assignmentPresentationState( + for configuration: RecordingDeviceConfiguration, + ) -> AssignmentPresentationState { + guard configuration.assignmentResolution.assignment != nil else { + return .syncingAssignment } + return .resolved(isAcknowledged: !configuration.isPending) } } diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift index 5db2fc74..9832be87 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift @@ -137,7 +137,7 @@ struct DeviceSettingsSection: View { if row.isCurrent, case .unavailable = session.recordingRuntimeState { return String(localized: .settingsDevicesStatusUnavailable) } - if row.isSyncingRecordingPolicy { + if row.isSyncingRecordingAssignment { return String(localized: .settingsDevicesStatusSyncing) } if row.isPending || row.isApplyingRecordingChange { @@ -156,7 +156,7 @@ struct DeviceSettingsSection: View { if row.isCurrent, case .unavailable = session.recordingRuntimeState { return "exclamationmark.triangle" } - if row.isSyncingRecordingPolicy { + if row.isSyncingRecordingAssignment { return "icloud.and.arrow.down" } if row.isPending || row.isApplyingRecordingChange { diff --git a/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift index 3c270c55..3b00709b 100644 --- a/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift +++ b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift @@ -46,9 +46,9 @@ struct DeviceSettingsRowModelTests { #expect(row.id == Self.id) #expect(row.displayName == "Desk") #expect(row.status == .off) - #expect(row.isPending) - #expect(row.isSyncingRecordingPolicy == false) - #expect(row.policyPresentationState == .resolved(isAcknowledged: false)) + #expect(row.isPending == false) + #expect(row.isSyncingRecordingAssignment == false) + #expect(row.assignmentPresentationState == .resolved(isAcknowledged: true)) #expect(row.isEnabled == false) } @@ -82,15 +82,18 @@ struct DeviceSettingsRowModelTests { let row = DeviceSettingsRowModel( configuration: RecordingDeviceConfiguration( device: device, - policy: .unknown, + assignmentResolution: .unconfigured, + assignmentFrontierID: nil, + isAssignmentAcknowledged: false, + isArchived: false, ), isCurrent: false, ) - #expect(row.hasResolvedRecordingPolicy == false) + #expect(row.hasResolvedRecordingAssignment == false) #expect(row.isPending) - #expect(row.isSyncingRecordingPolicy) - #expect(row.policyPresentationState == .syncingPolicy) + #expect(row.isSyncingRecordingAssignment) + #expect(row.assignmentPresentationState == .syncingAssignment) #expect(row.disablesDestructiveActions) row.update(from: configuration( @@ -99,11 +102,11 @@ struct DeviceSettingsRowModelTests { appliedPolicyID: Self.policyID, )) - #expect(row.hasResolvedRecordingPolicy) + #expect(row.hasResolvedRecordingAssignment) #expect(row.isEnabled == false) #expect(row.isPending == false) - #expect(row.isSyncingRecordingPolicy == false) - #expect(row.policyPresentationState == .resolved(isAcknowledged: true)) + #expect(row.isSyncingRecordingAssignment == false) + #expect(row.assignmentPresentationState == .resolved(isAcknowledged: true)) #expect(row.disablesDestructiveActions == false) } @@ -121,15 +124,15 @@ struct DeviceSettingsRowModelTests { registeredAt: Self.date, lastSeenAt: Self.date, archivedAt: nil, - lastAppliedPolicyChangeID: appliedPolicyID, + lastAppliedAssignmentChangeID: appliedPolicyID, status: status, ), - policy: .resolved(ResolvedRecordingPolicy( - isEnabled: status != .off, - isArchived: false, - changeID: Self.policyID, - isAcknowledged: appliedPolicyID == Self.policyID, - )), + assignmentResolution: .resolved( + status == .off ? .off : .device(Self.id), + ), + assignmentFrontierID: Self.policyID, + isAssignmentAcknowledged: appliedPolicyID == Self.policyID, + isArchived: false, ) } } diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift index 53a3e9ea..3bc39066 100644 --- a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -49,7 +49,7 @@ struct DevicesSettingsModelTests { registeredAt: InstallationRecordingContext.testing.registeredAt, initialRecordingChoice: .init( isEnabled: false, - policyChangeID: Self.disabledInitialPolicyID, + assignmentChangeID: Self.disabledInitialPolicyID, confirmedAt: Date(timeIntervalSinceReferenceDate: 1), ), ) @@ -217,12 +217,12 @@ struct DevicesSettingsModelTests { let row = try #require(subject.model.rows.first) #expect(row.isEnabled == false) - await store.gateNextRecordingPolicyWrite() + await store.gateNextRecordingAssignmentWrite() row.isEnabled = true let firstWrite = Task { await subject.model.recordingPreferenceChanged(for: row) } - await store.awaitRecordingPolicyWriteGate() + await store.awaitRecordingAssignmentWriteGate() #expect(row.isApplyingRecordingChange) row.isEnabled = false @@ -230,14 +230,15 @@ struct DevicesSettingsModelTests { await subject.model.recordingPreferenceChanged(for: row) } await newestIntent.value - await store.releaseRecordingPolicyWriteGate() + await store.releaseRecordingAssignmentWriteGate() await firstWrite.value #expect(row.isEnabled == false) #expect(row.operationState == .idle) #expect(row.isApplyingRecordingChange == false) - let policyChanges = try await store.recordingPolicyChanges() - #expect(policyChanges.suffix(2).map(\.isEnabled) == [true, false]) + let assignmentChanges = try await store.recordingAssignmentChanges() + #expect(assignmentChanges.suffix(2).map(\.assignedDeviceID) + == [subject.session.currentRecordingDeviceID, nil]) } @Test func remoteRefreshWinsWhileANicknameCommandIsSuspended() async throws { @@ -267,7 +268,7 @@ struct DevicesSettingsModelTests { await subject.model.retry() let row = try #require(subject.model.rows.first) - await store.failNextRecordingPolicyWrite() + await store.failNextRecordingAssignmentWrite() row.isEnabled = false await subject.model.recordingPreferenceChanged(for: row) @@ -351,23 +352,23 @@ struct DevicesSettingsModelTests { revision: 0, lastSeenAt: Self.now, appliedAt: Self.now, - lastAppliedPolicyChangeID: policyID, + lastAppliedAssignmentChangeID: policyID, status: .off, ), ], - policyChanges: [ - RecordingPolicyChange( + assignmentChanges: [ + RecordingAssignmentChange( id: policyID, - deviceID: remoteID, parentIDs: [], revision: 0, issuedAt: Self.now, issuedByDeviceID: subject.session.currentRecordingDeviceID, effectiveAt: Self.now, - state: .off, + assignedDeviceID: nil, reason: .userCommand, ), ], + archives: [], ) // The imported rows alone are intentionally silent: this assertion @@ -421,17 +422,10 @@ struct DevicesSettingsModelTests { } let remote = try #require(subject.model.rows.first(where: { $0.id == remoteID })) - remote.isEnabled = false - await subject.model.recordingPreferenceChanged(for: remote) - - #expect(remote.isEnabled == false) + #expect(remote.isEnabled) #expect(remote.isPending) #expect(remote.status == .recording) - let disableID = try #require( - try await store.recordingPolicyChanges().first(where: { - $0.deviceID == remoteID && $0.state == .off - })?.id, - ) + let assignmentID = try #require(try await store.recordingAssignmentChanges().last?.id) try await store.simulateRemoteRecordingImport( profiles: [], @@ -441,21 +435,22 @@ struct DevicesSettingsModelTests { revision: 1, lastSeenAt: Self.now.addingTimeInterval(60), appliedAt: Self.now.addingTimeInterval(60), - lastAppliedPolicyChangeID: disableID, - status: .off, + lastAppliedAssignmentChangeID: assignmentID, + status: .recording, )], - policyChanges: [], + assignmentChanges: [], + archives: [], ) #expect(remote.isPending) remoteChanges.yield() await waitUntil { - remote.isPending == false && remote.status == .off + remote.isPending == false && remote.status == .recording } - #expect(remote.isEnabled == false) + #expect(remote.isEnabled) #expect(remote.isPending == false) - #expect(remote.status == .off) + #expect(remote.status == .recording) runTask.cancel() await runTask.value } @@ -521,23 +516,20 @@ struct DevicesSettingsModelTests { nickname: $0, ) } - let policy = RecordingPolicyChange( - id: policyID, - deviceID: id, - parentIDs: [], - revision: 0, + let assignment = try await RecordingAssignmentChange.appendingCommand( + to: store.recordingAssignmentChanges(), + assignment: enabled ? .device(id) : .off, issuedAt: Self.now, issuedByDeviceID: writerID, effectiveAt: Self.now, - state: enabled ? .on : .off, - reason: .initialRegistration, + reason: .userCommand, ) let checkIn = RecordingDeviceCheckIn( deviceID: id, revision: 0, lastSeenAt: Self.now, appliedAt: Self.now, - lastAppliedPolicyChangeID: policyID, + lastAppliedAssignmentChangeID: policyID, status: status, ) try await store.perform { @@ -545,7 +537,7 @@ struct DevicesSettingsModelTests { if let metadata { try await store.addRecordingDeviceMetadataChange(metadata) } - try await store.addRecordingPolicyChange(policy) + try await store.addRecordingAssignmentChange(assignment) try await store.setRecordingDeviceCheckIn(checkIn) } } @@ -620,15 +612,13 @@ private final class SuspendedDevicesSettingsSession: DevicesSettingsSession { registeredAt: Date(timeIntervalSinceReferenceDate: 1000), lastSeenAt: Date(timeIntervalSinceReferenceDate: 1000), archivedAt: nil, - lastAppliedPolicyChangeID: policyID, + lastAppliedAssignmentChangeID: policyID, status: .recording, ), - policy: .resolved(ResolvedRecordingPolicy( - isEnabled: true, - isArchived: false, - changeID: policyID, - isAcknowledged: true, - )), + assignmentResolution: .resolved(.device(currentRecordingDeviceID)), + assignmentFrontierID: policyID, + isAssignmentAcknowledged: true, + isArchived: false, ) } } @@ -692,15 +682,15 @@ private final class ScriptedDevicesSettingsSession: DevicesSettingsSession { registeredAt: Date(timeIntervalSinceReferenceDate: 1000), lastSeenAt: Date(timeIntervalSinceReferenceDate: 1000), archivedAt: nil, - lastAppliedPolicyChangeID: policyID, + lastAppliedAssignmentChangeID: policyID, status: isEnabled ? .recording : .off, ), - policy: .resolved(ResolvedRecordingPolicy( - isEnabled: isEnabled, - isArchived: false, - changeID: policyID, - isAcknowledged: true, - )), + assignmentResolution: .resolved( + isEnabled ? .device(currentRecordingDeviceID) : .off, + ), + assignmentFrontierID: policyID, + isAssignmentAcknowledged: true, + isArchived: false, ) } } diff --git a/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift index 4790faae..9bd3546a 100644 --- a/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift +++ b/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift @@ -17,13 +17,13 @@ struct InMemoryInstallationRecordingContextStoreTests { ) let store = InMemoryInstallationRecordingContextStore( context: context, - makeUUID: { Self.policyChangeID }, + makeUUID: { Self.assignmentChangeID }, now: { Self.confirmedAt }, ) let confirmed = try store.confirmInitialRecording(isEnabled: false) - #expect(confirmed.initialRecordingChoice?.policyChangeID == Self.policyChangeID) + #expect(confirmed.initialRecordingChoice?.assignmentChangeID == Self.assignmentChangeID) #expect(confirmed.registeredAt == Self.registeredAt) #expect(confirmed.initialRecordingChoice?.confirmedAt == Self.confirmedAt) #expect(try store.resolve() == confirmed) @@ -57,7 +57,7 @@ struct InMemoryInstallationRecordingContextStoreTests { } private static let deviceID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! - private static let policyChangeID = UUID( + private static let assignmentChangeID = UUID( uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", )! private static let resetDeviceID = UUID( diff --git a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift index b1f405af..d918bb34 100644 --- a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift +++ b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift @@ -37,7 +37,7 @@ struct InstallationRecordingContextStoreTests { #expect(restored.currentDevice.id.rawValue == Self.deviceID) #expect(restored.registeredAt == Self.registeredAt) #expect(restored.initialRecordingChoice?.isEnabled == false) - #expect(restored.initialRecordingChoice?.policyChangeID == Self.policyChangeID) + #expect(restored.initialRecordingChoice?.assignmentChangeID == Self.assignmentChangeID) #expect(restored.initialRecordingChoice?.confirmedAt == Self.confirmedAt) #expect( try fixture.fileURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) @@ -197,7 +197,7 @@ struct InstallationRecordingContextStoreTests { @Test func completePendingReplacementWinsOverAnOlderAuthoritativeContext() throws { let oldFixture = try makeFixture() let newFixture = try makeFixture( - ids: [Self.resetDeviceID, Self.resetPolicyChangeID], + ids: [Self.resetDeviceID, Self.resetAssignmentChangeID], dates: [Self.resetRegisteredAt, Self.resetConfirmedAt], ) defer { @@ -229,7 +229,7 @@ struct InstallationRecordingContextStoreTests { @Test func resetRemovesTheSidecarAndRotatesTheInstallationIdentity() throws { let fixture = try makeFixture(ids: [ Self.deviceID, - Self.policyChangeID, + Self.assignmentChangeID, Self.resetDeviceID, ], dates: [ Self.registeredAt, @@ -253,7 +253,7 @@ struct InstallationRecordingContextStoreTests { @Test func committedResetCleanupRetriesWithoutRestoringTheOldContextOrRotatingAgain() throws { let fixture = try makeFixture(ids: [ Self.deviceID, - Self.policyChangeID, + Self.assignmentChangeID, Self.resetDeviceID, ], dates: [ Self.registeredAt, @@ -287,13 +287,13 @@ struct InstallationRecordingContextStoreTests { private nonisolated static let deviceID = UUID( uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", )! - private nonisolated static let policyChangeID = UUID( + private nonisolated static let assignmentChangeID = UUID( uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", )! private nonisolated static let resetDeviceID = UUID( uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", )! - private nonisolated static let resetPolicyChangeID = UUID( + private nonisolated static let resetAssignmentChangeID = UUID( uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD", )! private nonisolated static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) @@ -302,7 +302,7 @@ struct InstallationRecordingContextStoreTests { private nonisolated static let resetConfirmedAt = Date(timeIntervalSinceReferenceDate: 400) private func makeFixture( - ids: [UUID] = [Self.deviceID, Self.policyChangeID], + ids: [UUID] = [Self.deviceID, Self.assignmentChangeID], dates: [Date] = [Self.registeredAt, Self.confirmedAt], ) throws -> Fixture { let directory = FileManager.default.temporaryDirectory diff --git a/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift index 604d591b..acf8a61f 100644 --- a/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift +++ b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift @@ -55,6 +55,6 @@ struct OnboardingRestoreSelectionTests { dismissedIssueCount: 4, trackedRegionCount: 5, recordingDeviceCount: 2, - recordingPolicyChangeCount: 3, + recordingAssignmentChangeCount: 3, ) } diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index 3ad074a2..57e731ab 100644 --- a/Where/WhereUI/Tests/OnboardingTests.swift +++ b/Where/WhereUI/Tests/OnboardingTests.swift @@ -27,7 +27,7 @@ struct OnboardingModelTests { #expect(model.hasOnboarded) #expect(model.hasConfirmedRecordingChoice) #expect(confirmed.initialRecordingChoice?.isEnabled == false) - #expect(confirmed.initialRecordingChoice?.policyChangeID != nil) + #expect(confirmed.initialRecordingChoice?.assignmentChangeID != nil) let relaunched = makeModel(preferences: preferences, contextStore: contextStore) #expect(relaunched.hasOnboarded) diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 6b4d94c2..710a6fd2 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -10,7 +10,7 @@ struct SampleReadFailure: Error, Equatable {} /// Thrown by the Devices settings save-failure hooks below. struct RecordingDeviceSaveFailure: Error, Equatable {} -struct RecordingPolicySaveFailure: Error, Equatable {} +struct RecordingAssignmentSaveFailure: Error, Equatable {} /// Test `WhereStore` that forwards to an in-memory `SwiftDataStore` but adds /// hooks the view-model tests need: @@ -20,9 +20,9 @@ struct RecordingPolicySaveFailure: Error, Equatable {} /// of order (the stale-year race). /// - `gateRecordingDevices(afterCalls:)` suspends a selected device read after /// capturing its result, so a committed change can race an initial load. -/// - `gateNextRecordingPolicyWrite()` suspends one recording-policy write, so +/// - `gateNextRecordingAssignmentWrite()` suspends one recording-assignment write, so /// the Devices model can accept a newer toggle while the first is in flight. -/// - `failNextRecordingDeviceWrite()` / `failNextRecordingPolicyWrite()` make +/// - `failNextRecordingDeviceWrite()` / `failNextRecordingAssignmentWrite()` make /// one Devices save fail without contaminating later retry assertions. /// - `failManualDays()` makes `setManualDay` throw, so manual-entry error /// handling is exercisable without a real persistence fault. @@ -41,15 +41,15 @@ actor TestStore: WhereStore { private var recordingDevicesGate: CheckedContinuation? private var recordingDevicesArrival: CheckedContinuation? - private var shouldGateNextRecordingPolicyWrite = false - private var recordingPolicyWriteGateReached = false - private var recordingPolicyWriteGate: CheckedContinuation? - private var recordingPolicyWriteArrival: CheckedContinuation? + private var shouldGateNextRecordingAssignmentWrite = false + private var recordingAssignmentWriteGateReached = false + private var recordingAssignmentWriteGate: CheckedContinuation? + private var recordingAssignmentWriteArrival: CheckedContinuation? private var shouldFailManualDay = false private var shouldFailSamples = false private var shouldFailNextRecordingDeviceWrite = false - private var shouldFailNextRecordingPolicyWrite = false + private var shouldFailNextRecordingAssignmentWrite = false init() throws { backing = try SwiftDataStore.inMemory() @@ -89,27 +89,27 @@ actor TestStore: WhereStore { recordingDevicesGate = nil } - func gateNextRecordingPolicyWrite() { - shouldGateNextRecordingPolicyWrite = true - recordingPolicyWriteGateReached = false + func gateNextRecordingAssignmentWrite() { + shouldGateNextRecordingAssignmentWrite = true + recordingAssignmentWriteGateReached = false } - func awaitRecordingPolicyWriteGate() async { - guard !recordingPolicyWriteGateReached else { return } - await withCheckedContinuation { recordingPolicyWriteArrival = $0 } + func awaitRecordingAssignmentWriteGate() async { + guard !recordingAssignmentWriteGateReached else { return } + await withCheckedContinuation { recordingAssignmentWriteArrival = $0 } } - func releaseRecordingPolicyWriteGate() { - recordingPolicyWriteGate?.resume() - recordingPolicyWriteGate = nil + func releaseRecordingAssignmentWriteGate() { + recordingAssignmentWriteGate?.resume() + recordingAssignmentWriteGate = nil } func failNextRecordingDeviceWrite() { shouldFailNextRecordingDeviceWrite = true } - func failNextRecordingPolicyWrite() { - shouldFailNextRecordingPolicyWrite = true + func failNextRecordingAssignmentWrite() { + shouldFailNextRecordingAssignmentWrite = true } func failManualDays() { @@ -230,30 +230,22 @@ actor TestStore: WhereStore { try await backing.setRecordingDeviceCheckIn(checkIn) } - func recordingPolicyChanges() async throws -> [RecordingPolicyChange] { - try await backing.recordingPolicyChanges() - } - - func addRecordingPolicyChange(_ change: RecordingPolicyChange) async throws { - if shouldFailNextRecordingPolicyWrite { - shouldFailNextRecordingPolicyWrite = false - throw RecordingPolicySaveFailure() - } - if shouldGateNextRecordingPolicyWrite { - shouldGateNextRecordingPolicyWrite = false - recordingPolicyWriteGateReached = true - recordingPolicyWriteArrival?.resume() - recordingPolicyWriteArrival = nil - await withCheckedContinuation { recordingPolicyWriteGate = $0 } - } - try await backing.addRecordingPolicyChange(change) - } - func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { try await backing.recordingAssignmentChanges() } func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { + if shouldFailNextRecordingAssignmentWrite { + shouldFailNextRecordingAssignmentWrite = false + throw RecordingAssignmentSaveFailure() + } + if shouldGateNextRecordingAssignmentWrite { + shouldGateNextRecordingAssignmentWrite = false + recordingAssignmentWriteGateReached = true + recordingAssignmentWriteArrival?.resume() + recordingAssignmentWriteArrival = nil + await withCheckedContinuation { recordingAssignmentWriteGate = $0 } + } try await backing.addRecordingAssignmentChange(change) } diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index c995e83f..cf08342d 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -99,7 +99,7 @@ struct WhereFormatTests { dismissedIssueCount: 4, trackedRegionCount: 6, recordingDeviceCount: 2, - recordingPolicyChangeCount: 7, + recordingAssignmentChangeCount: 7, ) let message = WhereFormat.backupImportCleanupMessage(summary) diff --git a/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift b/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift index c739177c..5d1620df 100644 --- a/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift +++ b/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift @@ -12,7 +12,7 @@ struct WhereLifecycleFailureViewTests { dismissedIssueCount: 4, trackedRegionCount: 6, recordingDeviceCount: 2, - recordingPolicyChangeCount: 7, + recordingAssignmentChangeCount: 7, ) @Test func committedImportCleanupPreservesSummaryInADedicatedPresentation() throws { diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index a7d1ae9a..97bad033 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -11,7 +11,7 @@ import WhereUI /// last tap. @MainActor struct WhereSessionTrackingTests { - private static let disabledInitialPolicyChangeID = UUID( + private static let disabledInitialAssignmentChangeID = UUID( uuidString: "00000000-0000-0000-0000-000000000003", )! @@ -53,7 +53,7 @@ struct WhereSessionTrackingTests { registeredAt: InstallationRecordingContext.testing.registeredAt, initialRecordingChoice: .init( isEnabled: false, - policyChangeID: Self.disabledInitialPolicyChangeID, + assignmentChangeID: Self.disabledInitialAssignmentChangeID, confirmedAt: Date(timeIntervalSinceReferenceDate: 1), ), ) @@ -186,18 +186,17 @@ struct WhereSessionTrackingTests { #expect(session.isTracking) #expect(source.isMonitoring) - let policyID = try #require( + let assignmentID = try #require( UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"), ) - let parentID = try #require(try await store.recordingPolicyChanges().first?.id) + let parentID = try #require(try await store.recordingAssignmentChanges().first?.id) try await store.simulateRemoteRecordingImport( profiles: [], metadataChanges: [], checkIns: [], - policyChanges: [ - RecordingPolicyChange( - id: policyID, - deviceID: session.currentRecordingDeviceID, + assignmentChanges: [ + RecordingAssignmentChange( + id: assignmentID, parentIDs: [parentID], revision: 1, issuedAt: now.addingTimeInterval(1), @@ -207,10 +206,11 @@ struct WhereSessionTrackingTests { )), ), effectiveAt: now.addingTimeInterval(1), - state: .off, + assignedDeviceID: nil, reason: .userCommand, ), ], + archives: [], ) // Saving the imported row is not enough; the session must be responding @@ -230,7 +230,7 @@ struct WhereSessionTrackingTests { #expect(source.startCount == 1) #expect(source.stopCount == 1) #expect(current.status == .off) - #expect(current.lastAppliedPolicyChangeID == policyID) + #expect(current.lastAppliedAssignmentChangeID == assignmentID) } @Test func foregroundLogsTodayWhenWantedAndAuthorized() async throws { From 5c40f152686c17b3fb2c17552c9ef24a31147995 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 11:57:29 -0700 Subject: [PATCH 16/31] Keep recording off after account reset --- .../Sources/Journal/DayJournal.swift | 15 ++++++++++++-- .../WhereCore/Tests/WhereServicesTests.swift | 20 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Where/WhereCore/Sources/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index 9a5e9584..53560f27 100644 --- a/Where/WhereCore/Sources/Journal/DayJournal.swift +++ b/Where/WhereCore/Sources/Journal/DayJournal.swift @@ -244,13 +244,24 @@ public actor DayJournal { /// `clearYear`'s reconciliation so the badge/reminders reflect the now-empty /// store immediately rather than relying on a later launch step. public func eraseAllData() async throws { + let resetAt = now() try await Self.logger.measure(.eraseAllData, budget: .seconds(10)) { try await store.perform { - _ = try await store.rotateDataEpoch( + let epoch = try await store.rotateDataEpoch( reason: .accountReset, changedBy: currentDeviceID, - at: now(), + at: resetAt, ) + try await store.addRecordingAssignmentChange(RecordingAssignmentChange( + id: UUID(), + parentIDs: [], + revision: 0, + issuedAt: resetAt, + issuedByDeviceID: currentDeviceID, + effectiveAt: epoch.changedAt, + assignedDeviceID: nil, + reason: .accountReset, + )) } } await reconcileAfterDayChange() diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 9633f69d..2cb0f448 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -944,8 +944,21 @@ struct WhereServicesTests { #expect(await services.ingestor.isActive == false) #expect(try await store.recordingDeviceProfiles().count == 1) #expect(try await store.recordingDeviceCheckIns().isEmpty) - #expect(try await store.recordingAssignmentChanges().isEmpty) + #expect( + try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) + == .resolved(.off), + ) #expect(try await store.dataEpoch().reason == .accountReset) + + // A retained installation context must not mistake the reset-empty generation for first + // run and restore its original On choice after process restart. + let relaunched = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + ) + let configuration = try await relaunched.recording.register(authorization: .always) + #expect(configuration.isEnabled == false) + #expect(await relaunched.ingestor.isActive == false) } @Test func committedResetDiscardsPendingLocationsAndPreservesTheGlobalProfile() async throws { @@ -964,7 +977,10 @@ struct WhereServicesTests { #expect(await outbox.persistedSamples.isEmpty) #expect(try await store.recordingDeviceProfiles().count == 1) - #expect(try await store.recordingAssignmentChanges().isEmpty) + #expect( + try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) + == .resolved(.off), + ) #expect(try await store.dataEpoch().reason == .accountReset) #expect(await services.ingestor.isActive == false) } From 719b3f4315087c16e2a9d1a033c85d9f640a4368 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 11:59:12 -0700 Subject: [PATCH 17/31] Fail closed on unreadable recording assignments --- .../Sources/Persistence/SwiftDataStore.swift | 14 ++++---- .../WhereCore/Tests/SwiftDataStoreTests.swift | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index c915deed..2b46e660 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -1117,15 +1117,15 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { sortBy: [SortDescriptor(\.revision), SortDescriptor(\.id)], ) descriptor.includePendingChanges = true - let values = try context.fetch(descriptor) + let records = try context.fetch(descriptor) .filter { Self.belongs($0.epochID, to: epochID) } - .compactMap { record -> RecordingAssignmentChange? in - guard let value = record.toValue() else { - Self.logFault(forCorrupt: record) - return nil - } - return value + let values = try records.map { record in + guard let value = record.toValue() else { + Self.logFault(forCorrupt: record) + throw RecordingPersistenceError.incompleteAssignmentHistory } + return value + } guard RecordingAssignmentChange.formValidPersistedTimeline(values) else { throw RecordingPersistenceError.incompleteAssignmentHistory } diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index a956c9c5..5c08342c 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -235,6 +235,42 @@ struct SwiftDataStoreTests { #expect(await firstPing(stream, within: .seconds(2))) } + @Test func unreadableAssignmentHeadInvalidatesTheWholeTimeline() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + let deviceID = RecordingDeviceID(rawValue: UUID()) + let date = Date(timeIntervalSinceReferenceDate: 100) + let initial = RecordingAssignmentChange( + id: UUID(), + parentIDs: [], + revision: 0, + issuedAt: date, + issuedByDeviceID: deviceID, + effectiveAt: date, + assignedDeviceID: deviceID, + reason: .onboarding, + ) + context.insert(SDRecordingAssignmentChange(value: initial, epochID: .initial)) + let unreadableOff = SDRecordingAssignmentChange() + unreadableOff.epochID = WhereDataEpochID.initial.rawValue + unreadableOff.id = UUID() + unreadableOff.parentIDs = [initial.id] + unreadableOff.revision = 1 + unreadableOff.issuedAt = date + unreadableOff.issuedByDeviceID = deviceID.rawValue + unreadableOff.effectiveAt = date + unreadableOff.assignedDeviceID = nil + unreadableOff.reasonRaw = nil + context.insert(unreadableOff) + try context.save() + + let store = SwiftDataStore(modelContainer: container) + + await #expect(throws: RecordingPersistenceError.incompleteAssignmentHistory) { + try await store.recordingAssignmentChanges() + } + } + @Test func simulatedRemoteRecordingImportIsReadableAfterRemoteChange() async throws { let source = ScriptedStoreRemoteChangeSource() let store = try SwiftDataStore.inMemory(remoteChangeSource: source) From 97e331e9e6bbdf1cb656d7e4002a7aec0a2f5274 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 12:00:50 -0700 Subject: [PATCH 18/31] Canonicalize duplicate recording assignments --- .../Sources/Persistence/SwiftDataStore.swift | 30 ++++++--- .../WhereCore/Tests/SwiftDataStoreTests.swift | 61 +++++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 2b46e660..213bebdb 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -1126,18 +1126,28 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } return value } - guard RecordingAssignmentChange.formValidPersistedTimeline(values) else { - throw RecordingPersistenceError.incompleteAssignmentHistory - } - return Dictionary(grouping: values, by: \.id) - .compactMap { _, duplicates in - duplicates.min(by: RecordingAssignmentChange.isCanonicalBefore) + let canonicalValues = try Dictionary(grouping: values, by: \.id).map { id, duplicates in + guard let canonical = duplicates.first else { + preconditionFailure("A grouped assignment event must contain at least one value.") } - .sorted { - $0.revision == $1.revision - ? $0.id.uuidString < $1.id.uuidString - : $0.revision < $1.revision + guard duplicates.allSatisfy({ $0 == canonical }) else { + Self.logImmutableConflict( + type: String(describing: RecordingAssignmentChange.self), + id: id.uuidString, + count: duplicates.count, + ) + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) } + return canonical + } + guard RecordingAssignmentChange.formValidPersistedTimeline(canonicalValues) else { + throw RecordingPersistenceError.incompleteAssignmentHistory + } + return canonicalValues.sorted { + $0.revision == $1.revision + ? $0.id.uuidString < $1.id.uuidString + : $0.revision < $1.revision + } } public func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 5c08342c..7c3bc9e2 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -271,6 +271,67 @@ struct SwiftDataStoreTests { } } + @Test func identicalPhysicalAssignmentRowsCollapseBeforeTimelineValidation() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + let deviceID = RecordingDeviceID(rawValue: UUID()) + let date = Date(timeIntervalSinceReferenceDate: 100) + let assignment = RecordingAssignmentChange( + id: UUID(), + parentIDs: [], + revision: 0, + issuedAt: date, + issuedByDeviceID: deviceID, + effectiveAt: date, + assignedDeviceID: deviceID, + reason: .onboarding, + ) + context.insert(SDRecordingAssignmentChange(value: assignment, epochID: .initial)) + context.insert(SDRecordingAssignmentChange(value: assignment, epochID: .initial)) + try context.save() + + let store = SwiftDataStore(modelContainer: container) + + #expect(try await store.recordingAssignmentChanges() == [assignment]) + } + + @Test func conflictingPhysicalAssignmentRowsFailClosed() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + let deviceID = RecordingDeviceID(rawValue: UUID()) + let id = UUID() + let date = Date(timeIntervalSinceReferenceDate: 100) + let first = RecordingAssignmentChange( + id: id, + parentIDs: [], + revision: 0, + issuedAt: date, + issuedByDeviceID: deviceID, + effectiveAt: date, + assignedDeviceID: deviceID, + reason: .onboarding, + ) + let conflicting = RecordingAssignmentChange( + id: id, + parentIDs: [], + revision: 0, + issuedAt: date, + issuedByDeviceID: deviceID, + effectiveAt: date, + assignedDeviceID: nil, + reason: .onboarding, + ) + context.insert(SDRecordingAssignmentChange(value: first, epochID: .initial)) + context.insert(SDRecordingAssignmentChange(value: conflicting, epochID: .initial)) + try context.save() + + let store = SwiftDataStore(modelContainer: container) + + await #expect(throws: RecordingPersistenceError.conflictingImmutableRecord(id: id)) { + try await store.recordingAssignmentChanges() + } + } + @Test func simulatedRemoteRecordingImportIsReadableAfterRemoteChange() async throws { let source = ScriptedStoreRemoteChangeSource() let store = try SwiftDataStore.inMemory(remoteChangeSource: source) From d7b4812424e5c74fb158dd6ced8fb2225970e68e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 12:02:58 -0700 Subject: [PATCH 19/31] Validate persisted recording assignments safely --- .../Devices/RecordingAssignmentChange.swift | 28 +++++++++ .../Sources/Persistence/SwiftDataStore.swift | 5 +- .../WhereCore/Tests/SwiftDataStoreTests.swift | 58 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift b/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift index e78fecd0..f64ae881 100644 --- a/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift +++ b/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift @@ -108,6 +108,34 @@ extension RecordingAssignmentChange { let heads: [RecordingAssignmentChange] } + static func persisted( + id: UUID, + parentIDs: [UUID], + revision: Int64, + issuedAt: Date, + issuedByDeviceID: RecordingDeviceID, + effectiveAt: Date, + assignedDeviceID: RecordingDeviceID?, + reason: RecordingAssignmentReason, + ) -> RecordingAssignmentChange? { + guard revision >= 0, + (revision == 0) == parentIDs.isEmpty, + Set(parentIDs).count == parentIDs.count, + parentIDs.contains(id) == false, + isValid(reason: reason, assignedDeviceID: assignedDeviceID) + else { return nil } + return RecordingAssignmentChange( + id: id, + parentIDs: parentIDs, + revision: revision, + issuedAt: issuedAt, + issuedByDeviceID: issuedByDeviceID, + effectiveAt: effectiveAt, + assignedDeviceID: assignedDeviceID, + reason: reason, + ) + } + public static func resolve( _ changes: [RecordingAssignmentChange], ) -> RecordingAssignmentResolution { diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 213bebdb..c6d2721b 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -2219,10 +2219,9 @@ final class SDRecordingAssignmentChange { let issuedByDeviceID, let effectiveAt, let reasonRaw, - let reason = RecordingAssignmentReason(rawValue: reasonRaw), - (revision == 0) == parentIDs.isEmpty + let reason = RecordingAssignmentReason(rawValue: reasonRaw) else { return nil } - return RecordingAssignmentChange( + return RecordingAssignmentChange.persisted( id: id, parentIDs: parentIDs, revision: revision, diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 7c3bc9e2..b4ca6dcd 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -9,6 +9,13 @@ import Testing /// covered by `StoreChangeBroadcasterTests`; here we assert the *store* fires it /// on a committed `perform` and stays silent on a rolled-back one. struct SwiftDataStoreTests { + enum AssignmentMalformation: CaseIterable { + case negativeRevision + case duplicateParent + case selfParent + case resetTargetsDevice + } + @Test func inspectorStoreURLUsesTheResolvedAppGroupRoot() { let groupURL = FileManager.default.temporaryDirectory.appending( path: "where-group-\(UUID().uuidString)", @@ -332,6 +339,57 @@ struct SwiftDataStoreTests { } } + @Test(arguments: AssignmentMalformation.allCases) + func malformedPersistedAssignmentsFailClosed( + _ malformation: AssignmentMalformation, + ) async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + let deviceID = RecordingDeviceID(rawValue: UUID()) + let date = Date(timeIntervalSinceReferenceDate: 100) + let initial = RecordingAssignmentChange( + id: UUID(), + parentIDs: [], + revision: 0, + issuedAt: date, + issuedByDeviceID: deviceID, + effectiveAt: date, + assignedDeviceID: deviceID, + reason: .onboarding, + ) + context.insert(SDRecordingAssignmentChange(value: initial, epochID: .initial)) + let malformed = SDRecordingAssignmentChange() + let malformedID = UUID() + malformed.epochID = WhereDataEpochID.initial.rawValue + malformed.id = malformedID + malformed.parentIDs = [initial.id] + malformed.revision = 1 + malformed.issuedAt = date + malformed.issuedByDeviceID = deviceID.rawValue + malformed.effectiveAt = date + malformed.assignedDeviceID = nil + malformed.reasonRaw = RecordingAssignmentReason.userCommand.rawValue + switch malformation { + case .negativeRevision: + malformed.revision = -1 + case .duplicateParent: + malformed.parentIDs = [initial.id, initial.id] + case .selfParent: + malformed.parentIDs = [malformedID] + case .resetTargetsDevice: + malformed.assignedDeviceID = deviceID.rawValue + malformed.reasonRaw = RecordingAssignmentReason.accountReset.rawValue + } + context.insert(malformed) + try context.save() + + let store = SwiftDataStore(modelContainer: container) + + await #expect(throws: RecordingPersistenceError.incompleteAssignmentHistory) { + try await store.recordingAssignmentChanges() + } + } + @Test func simulatedRemoteRecordingImportIsReadableAfterRemoteChange() async throws { let source = ScriptedStoreRemoteChangeSource() let store = try SwiftDataStore.inMemory(remoteChangeSource: source) From 8c1c09f4cc4df3bdcfe2625dad0850002629fe85 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 12:05:33 -0700 Subject: [PATCH 20/31] Separate recorder refreshes from user choices --- .../Settings/DevicesSettingsModel.swift | 43 +++++++++++++------ .../Settings/RecordingAuthoritySection.swift | 13 ++++-- .../Tests/DevicesSettingsModelTests.swift | 41 ++++++++++++++++++ 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift index 0f2b69be..643e8cad 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift @@ -57,6 +57,12 @@ extension DevicesSettingsSession { @MainActor @Observable final class DevicesSettingsModel { + enum RecordingSelection: Hashable { + case unresolved + case off + case device(RecordingDeviceID) + } + struct Failure: Identifiable, Equatable { enum Context: Equatable { case initialLoad @@ -93,7 +99,7 @@ final class DevicesSettingsModel { private(set) var state: LoadState = .idle private(set) var rows: [DeviceSettingsRowModel] = [] private(set) var authorityResolution: RecordingAssignmentResolution = .unconfigured - var selectedRecordingDeviceID: RecordingDeviceID? + var recordingSelection = RecordingSelection.unresolved private(set) var presentedFailure: Failure? @ObservationIgnored private var refreshTask: Task? @ObservationIgnored private var requestedRefreshGeneration: UInt64 = 0 @@ -128,7 +134,7 @@ final class DevicesSettingsModel { apply(configurations) let authority = Self.compatibilityAuthority(from: configurations) authorityResolution = authority.resolution - selectedRecordingDeviceID = authority.resolution.assignment?.deviceID + recordingSelection = Self.recordingSelection(for: authority.resolution) state = configurations.isEmpty ? .empty : .loaded } #endif @@ -176,11 +182,17 @@ final class DevicesSettingsModel { } func recordingAssignmentChanged() async { + guard recordingSelection != Self.recordingSelection(for: authorityResolution) else { + return + } do { - if let selectedRecordingDeviceID { - try await session.assignAutomaticRecording(to: selectedRecordingDeviceID) - } else { - try await session.turnOffAutomaticRecording() + switch recordingSelection { + case .unresolved: + return + case .off: + try await session.turnOffAutomaticRecording() + case let .device(deviceID): + try await session.assignAutomaticRecording(to: deviceID) } await load(showLoading: false) } catch { @@ -281,17 +293,13 @@ final class DevicesSettingsModel { let generation = requestedRefreshGeneration do { let configurations = try await session.recordingDevices() - let authority = if let liveSession = session as? WhereSession { - try await liveSession.recordingAuthoritySnapshot() - } else { - Self.compatibilityAuthority(from: configurations) - } + let authority = try await session.recordingAuthoritySnapshot() completedRefreshGeneration = generation guard generation == requestedRefreshGeneration else { continue } lastRefreshFailure = nil apply(configurations) authorityResolution = authority.resolution - selectedRecordingDeviceID = authority.resolution.assignment?.deviceID + recordingSelection = Self.recordingSelection(for: authority.resolution) } catch { completedRefreshGeneration = generation guard generation == requestedRefreshGeneration else { continue } @@ -317,6 +325,17 @@ final class DevicesSettingsModel { ) } + private static func recordingSelection( + for resolution: RecordingAssignmentResolution, + ) -> RecordingSelection { + switch resolution { + case .unconfigured, .conflict, .invalid: + .unresolved + case let .resolved(assignment): + assignment.deviceID.map(RecordingSelection.device) ?? .off + } + } + private func apply(_ configurations: [RecordingDeviceConfiguration]) { let existing = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) rows = configurations.map { configuration in diff --git a/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift b/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift index c07eead5..23c77ef7 100644 --- a/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift +++ b/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift @@ -8,14 +8,19 @@ struct RecordingAuthoritySection: View { var body: some View { Section { - Picker("Automatic recording", selection: $model.selectedRecordingDeviceID) { - Text("Off").tag(RecordingDeviceID?.none) + Picker("Automatic recording", selection: $model.recordingSelection) { + if model.recordingSelection == .unresolved { + Text("Choose a recorder") + .tag(DevicesSettingsModel.RecordingSelection.unresolved) + .disabled(true) + } + Text("Off").tag(DevicesSettingsModel.RecordingSelection.off) ForEach(model.rows) { row in Label(row.displayName, systemImage: row.systemImage) - .tag(Optional(row.id)) + .tag(DevicesSettingsModel.RecordingSelection.device(row.id)) } } - .onChange(of: model.selectedRecordingDeviceID) { oldValue, newValue in + .onChange(of: model.recordingSelection) { oldValue, newValue in guard oldValue != newValue else { return } Task { await model.recordingAssignmentChanged() } } diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift index 3bc39066..3da6a6b2 100644 --- a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -156,6 +156,34 @@ struct DevicesSettingsModelTests { #expect(model.presentedFailure?.context == .refresh) } + @Test func remoteConflictRefreshDoesNotSubmitAnOffCommand() async { + let session = ScriptedDevicesSettingsSession() + let model = DevicesSettingsModel(session: session) + await model.retry() + #expect(model.recordingSelection == .off) + + session.simulateRemoteAuthority(.conflict([session.currentRecordingDeviceID])) + await model.retry() + #expect(model.recordingSelection == .unresolved) + + // SwiftUI observes the refreshed picker selection after the model applies it. The same + // callback used for a user gesture must recognize that persisted truth already matches. + await model.recordingAssignmentChanged() + + #expect(session.setEnabledCalls.isEmpty) + } + + @Test func explicitPickerSelectionStillSubmitsACommand() async { + let session = ScriptedDevicesSettingsSession() + let model = DevicesSettingsModel(session: session) + await model.retry() + + model.recordingSelection = .device(session.currentRecordingDeviceID) + await model.recordingAssignmentChanged() + + #expect(session.setEnabledCalls == [true]) + } + @Test func refreshRetrySubmitsANewerToggleWithoutRepeatingTheCommittedToggle() async throws { let session = ScriptedDevicesSettingsSession(isEnabled: false) let model = DevicesSettingsModel(session: session) @@ -640,6 +668,7 @@ private final class ScriptedDevicesSettingsSession: DevicesSettingsSession { private let policyID = UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")! private var isEnabled: Bool + private var authorityOverride: RecordingAssignmentResolution? private var failingRecordingDevicesCalls: Set = [] init(hasDevice: Bool = true, isEnabled: Bool = false) { @@ -659,6 +688,14 @@ private final class ScriptedDevicesSettingsSession: DevicesSettingsSession { return hasDevice ? [configuration] : [] } + func recordingAuthoritySnapshot() async throws -> RecordingAuthoritySnapshot { + RecordingAuthoritySnapshot( + resolution: authorityOverride ?? configuration.assignmentResolution, + devices: hasDevice ? [configuration.device] : [], + archivedDeviceIDs: [], + ) + } + func setRecordingEnabled(_ enabled: Bool, for _: RecordingDeviceID) async throws { setEnabledCalls.append(enabled) isEnabled = enabled @@ -672,6 +709,10 @@ private final class ScriptedDevicesSettingsSession: DevicesSettingsSession { failingRecordingDevicesCalls.insert(call) } + func simulateRemoteAuthority(_ resolution: RecordingAssignmentResolution) { + authorityOverride = resolution + } + private var configuration: RecordingDeviceConfiguration { RecordingDeviceConfiguration( device: RecordingDevice( From dab0a1a7700de2fb0fc1bccbd4762ddc86519341 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 12:07:32 -0700 Subject: [PATCH 21/31] Require a fresh recorder choice after replace --- .../Onboarding/OnboardingRestoreSelection.swift | 6 ++++++ .../WhereUI/Sources/Onboarding/OnboardingView.swift | 13 ++++++++++--- .../Tests/OnboardingRestoreSelectionTests.swift | 12 ++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingRestoreSelection.swift b/Where/WhereUI/Sources/Onboarding/OnboardingRestoreSelection.swift index ca208d4a..6bed1b4e 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingRestoreSelection.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingRestoreSelection.swift @@ -58,6 +58,12 @@ struct OnboardingRestoreSelection { } } + /// Replace deliberately reopens recording Off in a new data epoch. Merge and the ordinary + /// onboarding path may retain authority already discovered in the account. + var permitsPreservingExistingRecorder: Bool { + strategy != .replace + } + var committedSummary: BackupCoordinator.ImportSummary? { if case let .committed(summary) = state { summary } else { nil } } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 8d70af7e..4af6f89d 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -323,7 +323,9 @@ public struct OnboardingView: View { case .idle, .loading: ProgressView("Checking your other devices…") case let .ready(.resolved(existing)): - if existing.deviceID != nil { + if existing.deviceID != nil, + restoreSelection.permitsPreservingExistingRecorder + { Toggle( "Keep the current recorder", isOn: $preserveExistingAssignment, @@ -425,6 +427,8 @@ public struct OnboardingView: View { phase = .intro } isFinishing = true + let shouldPreserveExistingAssignment = preserveExistingAssignment + && restoreSelection.permitsPreservingExistingRecorder Task { do { let context = try model.confirmInitialRecordingChoice(isEnabled: enableLocation) @@ -534,7 +538,7 @@ public struct OnboardingView: View { do { let authorization = await scope.services.ingestor.authorizationStatus() try await scope.services.recording.registerForOnboarding( - desiredEnabled: preserveExistingAssignment ? nil : enableLocation, + desiredEnabled: shouldPreserveExistingAssignment ? nil : enableLocation, authorization: authorization, ) } catch { @@ -556,7 +560,7 @@ public struct OnboardingView: View { return } - if enableLocation, preserveExistingAssignment == false { + if enableLocation, shouldPreserveExistingAssignment == false { await enableTracking(in: scope) } // Only commit when the user actually picked regions in the manual @@ -669,6 +673,9 @@ public struct OnboardingView: View { return } restoreSelection.choose(strategy) + if restoreSelection.permitsPreservingExistingRecorder == false { + preserveExistingAssignment = false + } phase = .location } diff --git a/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift index acf8a61f..22c2b2fe 100644 --- a/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift +++ b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift @@ -27,6 +27,18 @@ struct OnboardingRestoreSelectionTests { selection.choose(.replace) #expect(selection.strategy == .replace) + #expect(selection.permitsPreservingExistingRecorder == false) + } + + @Test func mergeAllowsPreservingTheDiscoveredRecorder() { + var selection = OnboardingRestoreSelection( + url: URL(fileURLWithPath: "/tmp/where-backup.zip"), + hasScopedAccess: false, + ) + + selection.choose(.merge) + + #expect(selection.permitsPreservingExistingRecorder) } @Test func committedImportRetainsItsBoundaryAndCannotReturnToSelection() throws { From 23ab717577195031ee4d1fd35b73343c34227160 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 3 Aug 2026 18:06:39 -0700 Subject: [PATCH 22/31] Simplify multi-device recording ownership Replace the synced assignment DAG with installation-local recording consent, advisory device status, and append-only removal tombstones. Add onboarding recommendations, removed-device rejoin, backup/reset preservation, read filtering, settings UX, and end-to-end coverage. --- Where/AGENTS.md | 15 +- Where/TODOs.md | 5 +- Where/Tools/Tests/upgrade_backup_test.rb | 3 +- Where/Tools/upgrade-backup.rb | 3 +- Where/WhereCore/AGENTS.md | 26 +- Where/WhereCore/README.md | 58 +- .../Sources/Backup/BackupArchive.swift | 16 +- .../Sources/Backup/BackupCoordinator.swift | 68 +- .../Sources/Backup/BackupService.swift | 15 +- .../Devices/DeviceRecordingController.swift | 769 ++++++----------- .../InstallationRecordingContext.swift | 80 +- .../InstallationRecordingContextStoring.swift | 10 +- .../Devices/LocationHistoryReader.swift | 10 +- .../Devices/RecordingAssignmentChange.swift | 315 ------- .../Devices/RecordingAssignmentFilter.swift | 23 - .../Devices/RecordingAuthoritySnapshot.swift | 16 - .../Sources/Devices/RecordingDevice.swift | 30 +- .../Devices/RecordingDeviceArchive.swift | 21 - .../Devices/RecordingDeviceCheckIn.swift | 59 +- .../RecordingDeviceConfiguration.swift | 39 +- .../RecordingDeviceMetadataChange.swift | 4 +- .../Devices/RecordingDeviceRemoval.swift | 21 + .../RecordingDeviceRemovalFilter.swift | 19 + .../Devices/RecordingDeviceRuntimeState.swift | 6 +- .../RecordingOnboardingRecommendation.swift | 32 + .../Devices/RecordingPersistenceError.swift | 11 +- .../Sources/Journal/DayJournal.swift | 22 +- .../Sources/Location/LocationIngestor.swift | 6 +- .../DeviceRecordingControllerLog.swift | 4 +- .../Sources/Persistence/SwiftDataStore.swift | 208 ++--- .../Sources/Persistence/WhereStore.swift | 16 +- Where/WhereCore/Sources/WhereServices.swift | 2 +- .../Tests/BackupCoordinatorTests.swift | 46 +- .../WhereCore/Tests/BackupServiceTests.swift | 158 +--- .../DeviceRecordingControllerTests.swift | 214 ++--- .../InstallationRecordingContextTests.swift | 26 +- .../Tests/LocationIngestorTests.swift | 18 +- .../RecordingAssignmentChangeTests.swift | 133 --- .../RecordingAssignmentFilterTests.swift | 108 --- .../Tests/RecordingDeviceArchiveTests.swift | 21 - .../RecordingDeviceRemovalFilterTests.swift | 73 ++ .../Tests/RecordingDeviceRemovalTests.swift | 21 + ...cordingOnboardingRecommendationTests.swift | 104 +++ Where/WhereCore/Tests/ReportReaderTests.swift | 22 +- .../WhereCore/Tests/SwiftDataStoreTests.swift | 260 ++---- .../WhereCore/Tests/WhereServicesTests.swift | 168 +--- .../devices.Default_iPad.png | 4 +- .../devices.Default_iPad_accessibility.png | 4 +- .../devices.Default_iPad_ax5.png | 4 +- .../devices.Default_iPad_contrast.png | 4 +- .../devices.Default_iPad_dark.png | 4 +- .../devices.Default_iPhone.png | 4 +- .../devices.Default_iPhone_accessibility.png | 4 +- .../devices.Default_iPhone_ax5.png | 4 +- .../devices.Default_iPhone_contrast.png | 4 +- .../devices.Default_iPhone_dark.png | 4 +- .../Sources/Devices/RemovedDeviceView.swift | 50 ++ ...oryInstallationRecordingContextStore.swift | 26 +- .../InstallationRecordingContextStore.swift | 93 ++- .../WhereUI/Sources/Launch/WhereLaunch.swift | 26 +- .../Sources/Launch/WhereLaunchSteps.swift | 13 +- .../Launch/WhereLifecycleFailureView.swift | 2 +- Where/WhereUI/Sources/Model/WhereModel.swift | 53 +- .../WhereUI/Sources/Model/WhereSession.swift | 114 ++- .../Sources/Onboarding/OnboardingView.swift | 96 +-- .../Sources/Preview/PreviewSupport.swift | 32 +- .../Sources/Resources/Localizable.xcstrings | 107 ++- Where/WhereUI/Sources/RootView.swift | 16 +- .../Settings/DeviceSettingsRowModel.swift | 248 ++---- .../Settings/DeviceSettingsSection.swift | 60 +- .../Settings/DevicesSettingsModel.swift | 310 +------ .../Settings/DevicesSettingsView.swift | 5 +- .../Settings/RecordingAuthoritySection.swift | 76 -- .../Tests/DeviceSettingsRowModelTests.swift | 110 +-- .../Tests/DevicesSettingsModelTests.swift | 783 +++--------------- ...stallationRecordingContextStoreTests.swift | 92 +- ...stallationRecordingContextStoreTests.swift | 53 +- .../OnboardingRestoreSelectionTests.swift | 2 +- Where/WhereUI/Tests/OnboardingTests.swift | 8 +- Where/WhereUI/Tests/Support/TestStore.swift | 59 +- Where/WhereUI/Tests/WhereFormatTests.swift | 2 +- Where/WhereUI/Tests/WhereLaunchTests.swift | 6 +- .../WhereLifecycleFailureViewTests.swift | 2 +- Where/WhereUI/Tests/WhereResetTests.swift | 21 +- .../Tests/WhereSessionTrackingTests.swift | 76 +- 85 files changed, 1768 insertions(+), 4117 deletions(-) delete mode 100644 Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift delete mode 100644 Where/WhereCore/Sources/Devices/RecordingAssignmentFilter.swift delete mode 100644 Where/WhereCore/Sources/Devices/RecordingAuthoritySnapshot.swift delete mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceArchive.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceRemoval.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingDeviceRemovalFilter.swift create mode 100644 Where/WhereCore/Sources/Devices/RecordingOnboardingRecommendation.swift delete mode 100644 Where/WhereCore/Tests/RecordingAssignmentChangeTests.swift delete mode 100644 Where/WhereCore/Tests/RecordingAssignmentFilterTests.swift delete mode 100644 Where/WhereCore/Tests/RecordingDeviceArchiveTests.swift create mode 100644 Where/WhereCore/Tests/RecordingDeviceRemovalFilterTests.swift create mode 100644 Where/WhereCore/Tests/RecordingDeviceRemovalTests.swift create mode 100644 Where/WhereCore/Tests/RecordingOnboardingRecommendationTests.swift create mode 100644 Where/WhereUI/Sources/Devices/RemovedDeviceView.swift delete mode 100644 Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift diff --git a/Where/AGENTS.md b/Where/AGENTS.md index f0a445fb..be2051db 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -67,15 +67,12 @@ Rules the code enforces and agents must preserve: `CoreLocationSource` in production, `ScriptedLocationSource` in tests/previews. The one-shot `requestCurrentLocation()` returns `nil` rather than throwing when no fix is available. -- **Automatic location authority is one account-wide append-only assignment.** Stamp every - automatic GPS sample with its `RecordingDeviceID` and route every user-facing sample read - through `LocationHistoryReader`. Resolve Off or exactly one installation; concurrent claims, - incomplete history, and an archived assignee fail closed. Show samples only when their source - held the assignment at capture time; legacy/manual history remains visible. Persist immutable - profiles, nickname events, archive tombstones, target-owned check-ins, and assignment events - separately. Keep the confirmed choice and - immutable first profile/policy IDs and timestamps beside the backup-excluded - installation identity; phone recommends On, while tablet/other recommends Off. +- **Automatic recording consent is installation-local.** Stamp automatic GPS samples with their + `RecordingDeviceID` and route user-facing reads through `LocationHistoryReader`. Sync profiles, + nickname events, advisory check-ins, and global removal tombstones, but never another device's + recording toggle. Keep consent beside the backup-excluded installation identity; phone + onboarding recommends On only when no other active device recently reported recording, while + tablet/other and explicit rejoins recommend Off. - **Manual entries carry a `ManualEntryAudit`**; `DayJournal`'s write methods take an explicit `audit:` (no default). An additive backfill can't downgrade an authoritative row's regions, but the newer audit always wins. diff --git a/Where/TODOs.md b/Where/TODOs.md index 5176eeb7..93d0f3a3 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -22,7 +22,6 @@ The item format and the placement rule live in the root - perf(WhereCore) [needs-design]: Performance pass — how often is the app booting? Can we only do it on changes of, say, 1 km or more? (human) ## P1s (Should do) -- fix(WhereCore) [needs-design]: Replace cross-device wall-clock recording cutoffs with a target-applied or server-time boundary. Causal revisions converge which assignment wins, but `RecordingAssignmentFilter` still compares sample timestamps against `effectiveAt` authored on the issuing device, so substantial clock skew can hide pre-transfer samples or expose post-transfer samples. Preserve the immediate remote-history cutoff while making its boundary independent of peer clock agreement. (`RecordingAssignmentChange.swift`, `RecordingAssignmentFilter.swift`; PR #160 review) - refactor(WhereCore) [needs-design]: Scope diagnostic emission so Flyover's unactivated sibling demo world cannot write its activity through the process-global `WhereLog` / `Periscope.shared` facade into the active real scope's durable diagnostic store. `WhereFlyoverWorld.build()` correctly gives the sibling a private `Periscope` and never starts its sink, but static `WhereLog` channels still bypass that injection; carry the scope's logging system through services/models or add a task-/environment-scoped routing context before treating Flyover's diagnostic activity as isolated. Domain data, preferences, widgets, notifications, and location remain in memory/no-op already. (`WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift`, `WhereCore/Sources/Logging/WhereLog.swift`; agent 2026-07-29) - fix(WhereUI) [quick-win]: `CalendarDay.displayDate` resolves through `Calendar.current` (`DateRangeFormatting.swift:33`), so every day label that flows through it — relabel, logged days, resolution details, the region drill-in — renders a wrong date on a non-Gregorian device: `startOfDay(in:)` interprets the day's Gregorian Y-M-D as *that* calendar's components, so a Buddhist-era device resolves 2026-07-26 to a date ~543 years off. `DateRangeFormatting.abbreviated` (`:6`, `:19`) and `PresenceTimeline.stints` (`PresenceTimeline.swift:37`) also *default* to `.current`, and `PresenceTimelineList` (`:12`) doesn't pass `report.calendar`. Take an explicit calendar (Gregorian + current time zone) in the helper and thread the report's calendar from the call sites. The `where.gregorian_calendar` Bumper rule that should catch this is blind to the implicit-member form — filed in the root [`TODOs.md`](../TODOs.md). (audit 2026-07-26) - fix(WhereCore) [needs-design]: `WhereServices.setPrimaryRegions(_:)` (`:285`) commits atomically but skips `DayJournal.reconcileAfterDayChange()` — widgets/reminders/summary don't refresh until foreground/configure. Route picker commits through the unified fan-out, or document the intentional deferral. (audit 2026-07-26) @@ -62,7 +61,6 @@ The item format and the placement rule live in the root - fix(WhereUI): broken-snapshots: `locations.Loaded_iPad.png` bakes in raw inflection markup — the Elsewhere card's subtitle renders literally as `^[3 region](inflect: true)`. This is the `locations.elsewhere.subtitle` P1 filed above, now pinned as a reference; recorded here so the image isn't mistaken for correct output, and so that reference is re-recorded when the fix lands. (pr#101 review) ## P2s (Nice to have) -- perf(WhereCore) [needs-design]: Measure and bound the append-only recording-assignment and device-metadata timelines. Compaction must preserve the current causal winner, historical cutoff semantics, backup round trips, and enough audit history to diagnose cross-device commands; do not delete events merely because a newer one exists. (`DeviceRecordingController.swift`, `RecordingAssignmentFilter.swift`; PR #160 review) - feat(Where): Consider the user-assigned device-name entitlement and matching provisioning-profile support so the Devices screen can offer a better initial label than the generic hardware family. Keep the current generic name until the entitlement is intentionally provisioned; never silently depend on an entitlement absent from developer signing. (`FileInstallationRecordingContextStore`; PR #160 review) - feat(WhereUI) [needs-design]: Give the app a branded launch screen. `UILaunchScreen` is an empty dictionary (`Project.swift`), so the pre-main frame is plain white. Measured from a fresh-install simulator recording, a first run reads as ~1.7s of white → ~0.25s of the dark `LaunchSplashView` → the light onboarding screen, so the splash registers as a quarter-second dark blip between two light screens rather than as the app opening. A launch screen matching the splash's background + icon would make that continuous. Note this is the right layer to fix it at: the splash's own `minimumSplashDuration` hold deliberately gates only the `.ready` reveal, not a gate transition like onboarding, so lengthening the hold would just delay interactive UI. (agent) - refactor(WhereUI) [needs-design]: Make the scene-scoped model wiring compiler-checked rather than an `@Environment` lookup that fails silently. `WhereSession` (the always-on coordinator) is read from the environment, so a screen mounted without a parent injecting it resolves to a runtime fallback/precondition instead of a compile error. The scoped models (`YearReportModel`, `ResolveModel`, `BackupModel`, `RemindersSettingsModel`) are already constructor-injected; explore threading the coordinator the same way (or a non-defaulting typed `EnvironmentKey`) so a broken wiring can't build. Follow-up from the `WhereSession` split. (agent) @@ -103,7 +101,8 @@ re-recording: # Completed issues -- fix(WhereUI): Serialize automatic-recording changes and separate desired from effective state. (Resolved 2026-07-30: the fire-and-forget `trackingEnabled` binding was replaced by awaited `DevicesSettingsModel` intents over the reentrancy-safe `DeviceRecordingController`; policy is per-device and append-only, current-device acknowledgement mirrors physical GPS state, and same-clock rapid changes are ordered by a focused adversarial test. The Devices UI now renders intent, pending acknowledgement, permission state, and rollback independently.) +- fix(WhereCore): Remove the cross-device assignment DAG and its clock-skew/compaction liabilities. (Resolved 2026-08-03: automatic-recording consent is now installation-local; CloudKit syncs only profiles, nickname events, advisory check-ins, and append-only removal tombstones. A removal retains the intentional remote history cutoff without a mutable authority timeline.) +- fix(WhereUI): Serialize automatic-recording changes and separate desired from effective state. (Resolved 2026-08-03: the fire-and-forget binding became an awaited, installation-local intent persisted beside the backup-excluded device identity. Refreshes cannot manufacture commands, and the Devices UI renders remote status read-only.) - fix(WhereUI) [quick-win]: `resolution.Empty_iPhone` and `..._dark` baked in the **real-world date** and drifted every day — the reference read "Jan 1 – Jul 25 / 206 days" because that is when it was recorded, and it had been silently wrong every day since, passing only because two digit glyphs are 0.046% of the image. (Resolved: `PreviewSupport.previewServices()` now passes `now: { referenceNow }`, which `WhereServices` already threads into every collaborator including the `DataIssueScanner` that computes the missing-days range. `referenceNow`'s own doc comment names "missing-day math" as a reason it exists, so this was a fixture bug against a documented intent rather than a new pin. The two references were re-recorded once and now read "Jan 1 – Jul 14 / 195 days", derived from the pinned instant. Surfaced by `./test --review`, which reported it at max channel delta 255 while the suite still reported green.) - fix(WhereUI): `resolution.Empty` never rendered the empty state, and its capture raced a live store scan — which turned `main` red (run 30402846712) the first time CI lost that race, baking the `AppIconLoadingView` placeholder over 91.7% of `Empty_iPhone`. `PreviewSupport.resolveModel(seededWithIssues: false)` skipped `setDataIssues` entirely, so the fixture came back with `hasLoaded == false` — which `ResolutionView` can't distinguish from "the first scan hasn't landed" — and the view showed the placeholder until its `.task(id:)` scan of the empty in-memory store returned the whole year as missing days. So the *reference* was that scan's output (a populated list titled "Missing days"), not the all-clear state the case names, and every capture was a race the settle loop can't see: a pixel-stable placeholder settles clean, exactly as in the `root.LoggedIn` entry below. Previously masked by the ~1s that `drainInFlightAnimations` wasted per capture; removing that waste (#151) exposed it. (Resolved: both fixture modes now seed — `setDataIssues([])` for the empty one, which is what marks it loaded *and* `isSeeded`, so the view's `load(...)` is a no-op and the first rendered frame is final. The case is now fully synchronous, independent of the store and of `now`, and the two references were re-recorded once to the "All clear" state — coverage the suite never had, since `WithIssues` already pins the populated list. `ResolveModelTests` gained two guards: the fixture is loaded up front in both modes, and `load(...)` leaves a seeded fixture alone against a store whose scan does find issues.) ## Deferred snapshot-test flakiness diff --git a/Where/Tools/Tests/upgrade_backup_test.rb b/Where/Tools/Tests/upgrade_backup_test.rb index 7e373ba0..196d369a 100644 --- a/Where/Tools/Tests/upgrade_backup_test.rb +++ b/Where/Tools/Tests/upgrade_backup_test.rb @@ -10,8 +10,7 @@ def test_v1_adds_current_tables_without_inventing_recording_consent assert_equal 3, upgraded.fetch("formatVersion") assert_equal [], upgraded.fetch("recordingDeviceProfiles") assert_equal [], upgraded.fetch("recordingDeviceMetadataChanges") - assert_equal [], upgraded.fetch("recordingAssignmentChanges") - assert_equal [], upgraded.fetch("recordingDeviceArchives") + assert_equal [], upgraded.fetch("recordingDeviceRemovals") assert_nil upgraded.fetch("samples").first.fetch("recordingDeviceID") end diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index b079f88c..ecd90abc 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -143,8 +143,7 @@ def upgrade_manifest(manifest) manifest["recordingDeviceProfiles"] ||= [] manifest["recordingDeviceMetadataChanges"] ||= [] - manifest["recordingAssignmentChanges"] ||= [] - manifest["recordingDeviceArchives"] ||= [] + manifest["recordingDeviceRemovals"] ||= [] manifest.delete("recordingDevices") manifest.delete("recordingDeviceCheckIns") manifest.delete("recordingPolicyChanges") diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 4a9ef3ba..4ee540c3 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -52,9 +52,9 @@ internal shape. lossless.** Add persisted user-data shapes end-to-end and cover both import strategies, but export no target-owned recording check-ins and ignore any in an imported archive (`BackupServiceTests` / `BackupCoordinatorTests`). -- **Backup import never adopts restored recording authority.** Merge reasserts the pre-import - global assignment; Replace rotates to a child epoch and appends global Off before discarding - the local outbox (`BackupCoordinatorTests`). +- **Backup import never adopts or changes local recording consent.** Archives omit that + device-local choice; Replace preserves it and every existing removal tombstone while rotating + the data epoch and discarding the local outbox (`BackupCoordinatorTests`). - **Gate import recovery with a two-phase sidecar plus an atomic store receipt.** Never clear a committed onboarding marker before its independent terminal completion tombstone (`BackupCoordinatorTests` / `WhereLaunchTests`). @@ -79,7 +79,7 @@ internal shape. `WhereStoreID`. Used to stamp Periscope `LogEvent.externalID`s. - **No in-app data migration or legacy recovery.** `SD….toValue()` reads only the current shape and fault-logs a row it can't place; incomplete epoch or - policy authority throws and fails closed instead of dropping into a benign + removal history throws and fails closed instead of dropping into a benign state. The one-time reshape path is backup **export → transform ([`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb)) → @@ -104,19 +104,15 @@ internal shape. `ScriptedLocationSource` in tests/previews; `requestCurrentLocation()` returns `nil`, never throws, and backs `LocationIngestor.captureTodayIfNeeded(now:)`. -- **`DeviceRecordingController` owns the account-wide recording assignment and physical GPS - state.** Keep assignment events append-only, serialize mutations across - awaits, fail closed when authority or acknowledgement is unavailable, stamp - every ingested GPS sample with the current installation id, seed the first - policy from `InstallationRecordingContext`'s explicitly confirmed choice plus - its stable profile/policy IDs and timestamps, and apply `LocationHistoryReader` - to every user-facing projection. Require the source installation to hold the effective - assignment for every device-stamped sample. Make each command name every observed maximal head, resolve concurrent heads - safety-first, and derive cleanup/reset floors from the independent destructive frontier. - Persist immutable profiles, nickname events, archive tombstones, target-owned check-ins, and assignment events separately. +- **`DeviceRecordingController` owns this installation's local recording choice and physical GPS + state.** Serialize mutations across awaits, fail closed when the current identity is removed, + stamp every ingested GPS sample with the current installation id, and apply + `LocationHistoryReader` to every user-facing projection. Persist immutable profiles, nickname + events, global removal tombstones, and target-owned advisory check-ins separately. A remote + device may rename or remove an identity, but never change another installation's local consent. Stamp every durable location-outbox entry with its authorizing data epoch and never replay it into another generation; backups alone read lossless raw - samples and policy/device timelines, excluding non-restorable check-ins. + samples and device/removal timelines, excluding non-restorable check-ins. - **Tracked regions live in the store, not preferences** — one `SDTrackedRegion` row per region so cross-device edits merge; read as a `Set` defaulting to the four. `RegionAttribution` derives the attributor diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index eede6365..ea8b843f 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -44,9 +44,9 @@ one it belongs to rather than to a god-object: which surface and persist each region's picked `RegionAppearance` — color token, emoji, SF Symbol — and pick order alongside the synced rows) — one row per region, defaulting to the four until the user chooses in the onboarding / - Settings region picker. Recording identity and authority are split into - immutable profiles, append-only nickname events and archive tombstones, one global assignment - history, and target-owned check-ins rather than one mutable device row. + Settings region picker. Recording identity and synced status are split into + immutable profiles, append-only nickname events and removal tombstones, and target-owned + advisory check-ins rather than one mutable device row. Recording consent stays local. - **`WhereDataEpoch`** — the account-wide logical generation that keeps late uploads from an offline device from repopulating data after Reset or Replace. Each destructive operation appends one immutable node naming every real @@ -105,22 +105,14 @@ one it belongs to rather than to a god-object: the current installation's `RecordingDeviceID`. Every durable retry entry also carries the data epoch that authorized it, so a pre-reset fix can be discarded but never written into the replacement generation. -- **`DeviceRecordingController`** — serializes one account-wide Off-or-one-device assignment - with the current installation's physical `LocationIngestor`. Immutable - profiles, nickname events, target-owned check-ins, and complete-authority - assignment events sync independently so one writer cannot roll another field backward. Each - assignment command names every maximal event it observed; - concurrent unjoined heads resolve to the most restrictive authority, while a - later command joins them with one identity. - A remote disable or archive affects history at its timestamp - as soon as it syncs, and the target device acknowledges only after privacy-critical - cleanup is durable. -- **`LocationHistoryReader`** — the shared policy-aware read boundary used by - reports, widgets, recent activity, and foreground capture checks. It filters - GPS samples during disabled/archived intervals while keeping raw storage, - backups, legacy samples without provenance, and user-asserted samples - lossless. A device-stamped sample remains invisible until its matching - effective assignment arrives, so partial CloudKit delivery and concurrent claims fail closed. +- **`DeviceRecordingController`** — applies this installation's local automatic-recording + preference to its physical `LocationIngestor`. Immutable profiles, nickname events, + target-owned advisory check-ins, and global removal tombstones sync independently. Another + installation can rename or remove a device identity, but cannot change its recording consent. +- **`LocationHistoryReader`** — the shared removal-aware read boundary used by reports, widgets, + recent activity, and foreground capture checks. It hides a removed identity's GPS samples at + and after its earliest tombstone while keeping earlier raw storage, backups, legacy samples + without provenance, and user-asserted samples lossless. ### Detection, notifications & the rest @@ -141,23 +133,21 @@ one it belongs to rather than to a god-object: read, with a freshness policy. - **`BackupCoordinator`** — ZIP export/import via `ZIPFoundation`. Export pins tables and evidence blobs to one epoch-consistent snapshot. Merge preserves queued locations - and reasserts the pre-import account-wide recording assignment after joining the imported - timeline. Replace writes the archive into a new child epoch, retains the global device-profile - ledger, and appends a newer Off assignment before pending fixes are discarded. A prepared + and the installation-local recording choice. Replace writes the archive into a new child epoch, + retains existing removal tombstones, and preserves the local choice before pending fixes are + discarded. A prepared marker in the backup-excluded installation sidecar pairs with a receipt committed in the same store transaction as the archive; recreated services can therefore distinguish rollback from commit and gate further imports until cleanup succeeds. Onboarding acknowledgement records an independent terminal sidecar tombstone before clearing recovery, so a cold launch can repair a preference write that did not reach disk without blocking later Settings imports. - Check-ins are deliberately neither exported nor restored: an archive cannot - prove that the target installation applied authority and cleared its local - outbox. + Check-ins are deliberately neither exported nor restored because they are live advisory status. - **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over a selectable look-back `RecentActivityWindow`. - **`InstallationRecordingContext`** — the device-local installation identity, - explicitly confirmed initial choice, and stable IDs/timestamps for recreating - its immutable first device profile and recording assignment idempotently. + explicitly confirmed local recording choice, and stable timestamp for recreating + its immutable device profile idempotently. `InstallationRecordingContextStoring` keeps the persistence adapter outside the domain value. - **`WherePreferences`** — persisted user intent (onboarding and reminder / @@ -248,15 +238,11 @@ rotates to a Reset child epoch, and discards the retry queue only after commit. constructing `WhereServices`. - **Always-location.** Background day tracking needs Always; `requestPermission()` throws `LocationPermissionDeniedError` on denial / restriction. -- **Strong remote cutoff.** Transferring or turning off automatic recording does not depend on - the former recorder being online before reports become correct: once the assignment event - syncs, samples at or after its effective timestamp are excluded. The assigned row remains - "waiting" until the target installation starts or stops and acknowledges it. - The sample gate stays closed until that check-in and any destructive-backlog - cleanup are durable; an incomplete multi-parent assignment DAG also fails closed. - The cutoff currently uses the issuing device's wall clock; substantial - cross-device clock skew can shift the historical boundary even though the causal - DAG still converges the current desired state correctly. +- **Removal is global; recording consent is local.** A synced removal tombstone immediately hides + the target identity's samples at and after its timestamp and makes that installation stop when + it next observes the change. Turning recording on or off affects only the installation where + the user made the choice. Device check-ins are advisory status, not command acknowledgements; + Apple Lost Mode or remote erase remains the security boundary for a missing device. - **Destructive operations are logical generations.** Old rows may remain in CloudKit as sync/audit history, but ordinary reads select only the resolved epoch. Concurrent unjoined resets select a synthetic empty generation; an diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 5fc71e04..5400c5a8 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -9,7 +9,7 @@ import RegionKit /// The arrays represent the persisted collections (`SDLocationSample` / /// `SDEvidence` / `SDManualDay` / `SDDismissedIssue` / `SDTrackedRegion`) via /// their value-type representations, plus installation profiles, nickname events, the -/// account-wide recording assignment, and archive tombstones. Target-owned check-ins are +/// device-removal tombstones. Target-owned check-ins and local recording consent are /// intentionally excluded because a backup cannot restore proof of local physical state. public struct BackupArchive: Codable, Sendable, Hashable { /// Bumped whenever the archive's on-disk shape changes in a way older @@ -18,7 +18,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// `BackupService.readArchive`, which rejects any other version). /// /// v3 adds sample provenance, immutable installation profiles, nickname changes, archive - /// tombstones, and the account-wide recording assignment. Intermediate branch-only formats + /// tombstones. Intermediate branch-only formats /// were never shipped. There's no /// in-app decode /// fallback for an older archive — it is reshaped out of band by @@ -47,10 +47,8 @@ public struct BackupArchive: Codable, Sendable, Hashable { public let recordingDeviceProfiles: [RecordingDeviceProfile] /// Full append-only nickname history. public let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] - /// Account-wide automatic-recording assignment history. - public let recordingAssignmentChanges: [RecordingAssignmentChange] - /// Irreversible installation archive tombstones. - public let recordingDeviceArchives: [RecordingDeviceArchive] + /// Irreversible installation-removal tombstones. + public let recordingDeviceRemovals: [RecordingDeviceRemoval] /// One entry per evidence record that has blob bytes in the archive. /// Evidence without bytes simply has no entry here. public let assets: [BackupAssetEntry] @@ -66,8 +64,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { primaryRegions: [PrimaryRegion], recordingDeviceProfiles: [RecordingDeviceProfile], recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], - recordingAssignmentChanges: [RecordingAssignmentChange], - recordingDeviceArchives: [RecordingDeviceArchive], + recordingDeviceRemovals: [RecordingDeviceRemoval], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -80,8 +77,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.primaryRegions = primaryRegions self.recordingDeviceProfiles = recordingDeviceProfiles self.recordingDeviceMetadataChanges = recordingDeviceMetadataChanges - self.recordingAssignmentChanges = recordingAssignmentChanges - self.recordingDeviceArchives = recordingDeviceArchives + self.recordingDeviceRemovals = recordingDeviceRemovals self.assets = assets } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 61a23e9b..5e5d9546 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -3,7 +3,7 @@ import PeriscopeCore import RegionKit /// Owns backup export/import over the `BackupService` and the store. Its lifecycle seam lets the -/// composition root revoke recording before the transaction, restore the old authority after a +/// composition root pause recording before the transaction, restore the local choice after a /// rollback, and reconcile all derived state after a commit. /// /// Public so its `ImportStrategy` / `ImportSummary` types stay nameable from the @@ -14,12 +14,10 @@ public actor BackupCoordinator { public enum ImportStrategy: Sendable, Hashable { /// Upsert the imported rows into the existing data (by `id` for /// samples/evidence, by day key for manual days), leaving anything not - /// present in the file untouched. Recording authority is snapshotted before the write - /// and reasserted after the imported assignment timeline. + /// present in the file untouched. Local recording consent is not stored in the archive. case merge - /// Replace synced user history and settings with the file. Recording-device identities - /// remain append-only, and a newer Off assignment ensures restoring an archive can never - /// silently start GPS. + /// Replace synced user history and settings with the file. Local recording consent is + /// untouched, and existing removals are retained so restore cannot reactivate a device. case replace } @@ -31,7 +29,7 @@ public actor BackupCoordinator { public let dismissedIssueCount: Int public let trackedRegionCount: Int public let recordingDeviceCount: Int - public let recordingAssignmentChangeCount: Int + public let recordingDeviceRemovalCount: Int public init( sampleCount: Int, @@ -40,7 +38,7 @@ public actor BackupCoordinator { dismissedIssueCount: Int, trackedRegionCount: Int, recordingDeviceCount: Int = 0, - recordingAssignmentChangeCount: Int = 0, + recordingDeviceRemovalCount: Int = 0, ) { self.sampleCount = sampleCount self.evidenceCount = evidenceCount @@ -48,7 +46,7 @@ public actor BackupCoordinator { self.dismissedIssueCount = dismissedIssueCount self.trackedRegionCount = trackedRegionCount self.recordingDeviceCount = recordingDeviceCount - self.recordingAssignmentChangeCount = recordingAssignmentChangeCount + self.recordingDeviceRemovalCount = recordingDeviceRemovalCount } } @@ -167,8 +165,7 @@ public actor BackupCoordinator { primaryRegions: store.primaryRegions(), recordingDeviceProfiles: store.recordingDeviceProfiles(), recordingDeviceMetadataChanges: store.recordingDeviceMetadataChanges(), - recordingAssignmentChanges: store.recordingAssignmentChanges(), - recordingDeviceArchives: store.recordingDeviceArchives(), + recordingDeviceRemovals: store.recordingDeviceRemovals(), ) } let evidence = tables.evidence @@ -202,8 +199,7 @@ public actor BackupCoordinator { primaryRegions: tables.primaryRegions, recordingDeviceProfiles: tables.recordingDeviceProfiles, recordingDeviceMetadataChanges: tables.recordingDeviceMetadataChanges, - recordingAssignmentChanges: tables.recordingAssignmentChanges, - recordingDeviceArchives: tables.recordingDeviceArchives, + recordingDeviceRemovals: tables.recordingDeviceRemovals, blobs: snapshot.blobs, ) }.value @@ -223,8 +219,7 @@ public actor BackupCoordinator { let primaryRegions: [PrimaryRegion] let recordingDeviceProfiles: [RecordingDeviceProfile] let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] - let recordingAssignmentChanges: [RecordingAssignmentChange] - let recordingDeviceArchives: [RecordingDeviceArchive] + let recordingDeviceRemovals: [RecordingDeviceRemoval] } private struct ExportSnapshot { @@ -410,7 +405,7 @@ public actor BackupCoordinator { dismissedIssueCount: archive.dismissedIssues.count, trackedRegionCount: archive.primaryRegions.count, recordingDeviceCount: archive.recordingDeviceProfiles.count, - recordingAssignmentChangeCount: archive.recordingAssignmentChanges.count, + recordingDeviceRemovalCount: archive.recordingDeviceRemovals.count, ) let recoveryDetails = ImportRecoveryDetails( transactionID: transactionID, @@ -422,12 +417,11 @@ public actor BackupCoordinator { + archive.manualDays.count + archive.dismissedIssues.count + archive.recordingDeviceProfiles.count + archive.recordingDeviceMetadataChanges.count - + archive.recordingAssignmentChanges.count - + archive.recordingDeviceArchives.count + + archive.recordingDeviceRemovals.count - // Decode and validate before touching live authority. Once the archive is known-good, - // close ingestion before either merge or replace: both can change this installation's - // assignment while a streamed sample is otherwise able to cross the transaction boundary. + // Decode and validate before touching live recording. Once the archive is known-good, + // close ingestion before either merge or replace so a streamed sample cannot cross the + // transaction boundary. let preparedRecovery = DurableImportRecovery.prepared(recoveryDetails) try await importRecoveryPersistence.save(preparedRecovery) do { @@ -448,20 +442,17 @@ public actor BackupCoordinator { do { try await Self.logger.measure(.importWrite) { try await store.perform(expectedDataEpochID: expectedEpochID) { - let existingAssignments = try await store.recordingAssignmentChanges() - let preservedAssignment: RecordingAssignment = if strategy == .merge { - RecordingAssignmentChange.resolve(existingAssignments).assignment ?? .off + let preservedRemovals: [RecordingDeviceRemoval] = if strategy == .replace { + try await store.recordingDeviceRemovals() } else { - .off + [] } - let replacementEpoch: WhereDataEpoch? = if strategy == .replace { - try await store.rotateDataEpoch( + if strategy == .replace { + _ = try await store.rotateDataEpoch( reason: .backupReplace, changedBy: currentDeviceID, at: importDate, ) - } else { - nil } // `completed`/`report` are local to this `@Sendable` block, so // the running count never crosses the actor boundary; only the @@ -500,24 +491,13 @@ public actor BackupCoordinator { try await store.addRecordingDeviceMetadataChange(metadataChange) report() } - for change in archive.recordingAssignmentChanges { - try await store.addRecordingAssignmentChange(change) - report() + for removal in preservedRemovals { + try await store.addRecordingDeviceRemoval(removal) } - for archive in archive.recordingDeviceArchives { - try await store.addRecordingDeviceArchive(archive) + for removal in archive.recordingDeviceRemovals { + try await store.addRecordingDeviceRemoval(removal) report() } - let joinedAssignments = try await store.recordingAssignmentChanges() - let assignmentBarrier = try RecordingAssignmentChange.appendingCommand( - to: joinedAssignments, - assignment: preservedAssignment, - issuedAt: importDate, - issuedByDeviceID: currentDeviceID, - effectiveAt: importDate, - reason: replacementEpoch == nil ? .backupMerge : .backupReplace, - ) - try await store.addRecordingAssignmentChange(assignmentBarrier) // Primary regions (with their picked looks) round-trip like any // other data. On `.replace` the store was cleared above, so write // the archive's set exactly; on `.merge` union it into the current diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index c7176e80..376dd518 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -44,7 +44,7 @@ public struct BackupService: Sendable { /// must match `BackupArchive.currentFormatVersion` exactly). case unsupportedFormatVersion(Int) /// Recording rows decoded structurally but violate persisted invariants (for example a - /// a negative causal revision or incomplete assignment history). + /// a negative causal revision or incomplete removal history). case invalidRecordingData public var errorDescription: String? { @@ -118,15 +118,13 @@ public struct BackupService: Sendable { primaryRegions: [PrimaryRegion] = [], recordingDeviceProfiles: [RecordingDeviceProfile], recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], - recordingAssignmentChanges: [RecordingAssignmentChange], - recordingDeviceArchives: [RecordingDeviceArchive], + recordingDeviceRemovals: [RecordingDeviceRemoval], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, ) throws -> URL { try Self.validateRecordingData( metadataChanges: recordingDeviceMetadataChanges, - assignmentChanges: recordingAssignmentChanges, ) let fileManager = FileManager.default let workRoot = fileManager.temporaryDirectory @@ -160,8 +158,7 @@ public struct BackupService: Sendable { primaryRegions: primaryRegions, recordingDeviceProfiles: recordingDeviceProfiles, recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, - recordingAssignmentChanges: recordingAssignmentChanges, - recordingDeviceArchives: recordingDeviceArchives, + recordingDeviceRemovals: recordingDeviceRemovals, assets: assetEntries, ) try Self.logger.measure(.encodeManifest) { @@ -273,17 +270,13 @@ public struct BackupService: Sendable { static func validateRecordingData(_ archive: BackupArchive) throws { try validateRecordingData( metadataChanges: archive.recordingDeviceMetadataChanges, - assignmentChanges: archive.recordingAssignmentChanges, ) } private static func validateRecordingData( metadataChanges: [RecordingDeviceMetadataChange], - assignmentChanges: [RecordingAssignmentChange], ) throws { - guard metadataChanges.allSatisfy({ $0.revision >= 0 }), - RecordingAssignmentChange.formValidPersistedTimeline(assignmentChanges) - else { + guard metadataChanges.allSatisfy({ $0.revision >= 0 }) else { throw BackupError.invalidRecordingData } } diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index be01d6d4..6aed709e 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -1,67 +1,47 @@ import Foundation -/// Owns recording-device registration, account-wide assignment commands, and this installation's +/// Owns this installation's local automatic-recording choice, synced device presence, and /// physical GPS reconciliation. /// -/// The controller deliberately persists three independently owned device records: an immutable -/// profile created by the installation, append-only nickname events authored from any device, -/// and a target-owned check-in. Desired authority is one account-wide append-only assignment; -/// irreversible archive tombstones are separate. -/// Keeping those writers apart prevents CloudKit's last-writer-wins merge from rolling unrelated -/// fields backward. -/// -/// Registration is one explicit lifecycle operation. Reads and later commands never accept or -/// infer an initial preference, so the synced assignment is the only authority after registration. -/// A -/// focused store observer compares the current installation's effective authority and check-in. -/// Unrelated sample or region writes do not repeatedly reconcile GPS except when a heartbeat -/// is due. +/// Recording consent never enters CloudKit: the controller receives it from the backup-excluded +/// installation sidecar. Synced check-ins are advisory status, while an append-only removal +/// tombstone permanently retires an identity. Every removal read is epoch-pinned and failures +/// stop recording rather than trusting stale state. public actor DeviceRecordingController { private let store: any WhereStore private let ingestor: LocationIngestor public nonisolated let currentDevice: CurrentRecordingDevice + private let registeredAt: Date private let now: @Sendable () -> Date private let onPolicyChanged: @Sendable () async -> Void - private let registeredAt: Date - private let initialRecordingChoice: InstallationRecordingContext.InitialRecordingChoice private let configurationBroadcaster = RecordingConfigurationBroadcaster() - /// Reentrancy-safe gate held across store and physical-ingestor awaits. Actor isolation alone - /// is insufficient because another command can enter while an actor method is suspended. + private var automaticRecordingEnabled: Bool + private var enabledAt: Date? + private var preparedEpochID: WhereDataEpochID? + + /// Actor reentrancy permits another command to enter at an `await`; this gate serializes the + /// full store/physical transition rather than only its synchronous fragments. private var isExclusive = false private var waiters: [CheckedContinuation] = [] private var acceptsOperations = true - /// Whether this stack has entered the recording lifecycle. A scope created only to restore - /// onboarding data has not: backup completion must not invent authority that did not exist - /// before the reversible import pause. private var recordingLifecycleStarted = false - /// Snapshot consumed by the matching resume path after a reversible pause. - private var shouldResumeAuthorityAfterPause = false - /// Prevents two reset/import lifecycles from interleaving across their actor awaits. + private var shouldResumeAfterPause = false private var isRewritePaused = false - - private var assignmentObservationTask: Task? - /// Exact assignment frontier most recently applied and acknowledged on this installation. - private var lastAppliedCurrentAssignmentID: UUID? - /// Retained after fail-closed reconciliation so any later store ping retries it. - private var needsAssignmentReconciliation = false + private var observationTask: Task? + private var needsReconciliation = false private var nextRuntimeSequence: UInt64 = 0 private var latestRuntimeUpdate: RecordingDeviceRuntimeUpdate? private static let checkInInterval: TimeInterval = 15 * 60 - private static let logger = WhereLog.root(DeviceRecordingControllerLog.self) - /// Epoch-pinned recording tables used to make one authority decision. A reset/Replace that - /// lands while these tables are loading makes the snapshot throw instead of combining old - /// assignment with new-epoch check-ins. private struct StoreSnapshot { let epoch: WhereDataEpoch let profiles: [RecordingDeviceProfile] let metadataChanges: [RecordingDeviceMetadataChange] let checkIns: [RecordingDeviceCheckIn] - let assignmentChanges: [RecordingAssignmentChange] - let archives: [RecordingDeviceArchive] + let removals: [RecordingDeviceRemoval] } init( @@ -71,53 +51,45 @@ public actor DeviceRecordingController { now: @escaping @Sendable () -> Date, onPolicyChanged: @escaping @Sendable () async -> Void, ) { - self.store = store - self.ingestor = ingestor - guard let initialRecordingChoice = installationContext.initialRecordingChoice else { + guard let automaticRecordingEnabled = installationContext.automaticRecordingEnabled else { preconditionFailure("Recording services require a confirmed installation context.") } + self.store = store + self.ingestor = ingestor currentDevice = installationContext.currentDevice registeredAt = installationContext.registeredAt - self.initialRecordingChoice = initialRecordingChoice + self.automaticRecordingEnabled = automaticRecordingEnabled + enabledAt = automaticRecordingEnabled ? installationContext.registeredAt : nil self.now = now self.onPolicyChanged = onPolicyChanged } deinit { - assignmentObservationTask?.cancel() + observationTask?.cancel() configurationBroadcaster.finishAll() } - /// Applied current-installation states, emitted only after acknowledgement is durable. - public nonisolated func runtimeUpdates() - -> AsyncStream - { + public nonisolated func runtimeUpdates() -> AsyncStream { configurationBroadcaster.subscribe() } - /// Latest controller-ordered runtime state, for a caller that needs to synchronize after an - /// awaited command without racing a newer emission already queued on the async stream. public func currentRuntimeUpdate() -> RecordingDeviceRuntimeUpdate? { latestRuntimeUpdate } - /// Start the focused assignment observer. Safe to call repeatedly from lifecycle setup. - public func startMonitoringAssignmentChanges() { + /// Observe local commits and CloudKit imports for removal, status, and heartbeat changes. + public func startMonitoringChanges() { recordingLifecycleStarted = true - guard assignmentObservationTask == nil else { return } + guard observationTask == nil else { return } let updates = store.changes() - assignmentObservationTask = Task { [weak self] in + observationTask = Task { [weak self] in for await _ in updates { guard let self else { break } - await applyObservedAssignmentChange() + await applyObservedChange() } } } - /// Register this installation and its confirmed initial choice exactly once, then apply it. - /// `initialAssignmentChangeID` comes from the non-backed-up installation context, making a - /// retry - /// idempotent even if profile and assignment records are observed at different times. @discardableResult public func register( authorization: LocationAuthorizationStatus, @@ -126,27 +98,9 @@ public actor DeviceRecordingController { defer { endExclusive() } try requireActive() recordingLifecycleStarted = true - do { - try await registerLocked( - initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, - initialEnabled: initialRecordingChoice.isEnabled, - ) - let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID - needsAssignmentReconciliation = false - return reconciliation - } catch { - needsAssignmentReconciliation = true - await ingestor.revokeRecordingAuthorization() - publishRuntimeState(.unavailable) - throw error - } + return try await registerAndReconcileLocked(authorization: authorization) } - /// Register the immutable first choice, then apply the user's current onboarding selection - /// before opening physical recording authority. This differs only on a retry after the first - /// choice was already persisted: the immutable event stays intact and the new selection is a - /// causal follow-up command, instead of silently snapping the UI back to the earlier choice. @discardableResult public func registerForOnboarding( desiredEnabled: Bool?, @@ -156,56 +110,13 @@ public actor DeviceRecordingController { defer { endExclusive() } try requireActive() recordingLifecycleStarted = true - do { - try await registerLocked( - initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, - initialEnabled: initialRecordingChoice.isEnabled, - ) - let snapshot = try await storeSnapshot() - let commandDate = now() - let desiredAssignment: RecordingAssignment? = desiredEnabled.map { - $0 ? .device(currentDevice.id) : .off - } - let assignmentChange: RecordingAssignmentChange? = if let desiredAssignment, - RecordingAssignmentChange - .resolve(snapshot - .assignmentChanges).assignment - != desiredAssignment - { - try RecordingAssignmentChange.appendingCommand( - to: snapshot.assignmentChanges, - assignment: desiredAssignment, - issuedAt: commandDate, - issuedByDeviceID: currentDevice.id, - effectiveAt: max(commandDate, snapshot.epoch.changedAt), - reason: .userCommand, - ) - } else { - nil - } - if let assignmentChange { - try await store.perform(expectedDataEpochID: snapshot.epoch.id) { - try await self.store.addRecordingAssignmentChange(assignmentChange) - } - // Historical visibility changed as soon as the authority event committed. Do - // not make derived reconciliation depend on a later physical/check-in success. - await onPolicyChanged() - } - - let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID - needsAssignmentReconciliation = false - return reconciliation - } catch { - needsAssignmentReconciliation = true - await ingestor.revokeRecordingAuthorization() - publishRuntimeState(.unavailable) - throw error + if let desiredEnabled, desiredEnabled != automaticRecordingEnabled { + automaticRecordingEnabled = desiredEnabled + enabledAt = desiredEnabled ? now() : nil } + return try await registerAndReconcileLocked(authorization: authorization) } - /// Apply the latest synced assignment to this installation. Failure is fail-closed: GPS is - /// stopped before the error is surfaced, so stale local preference can never authorize a fix. @discardableResult public func reconcile( authorization: LocationAuthorizationStatus, @@ -214,115 +125,34 @@ public actor DeviceRecordingController { defer { endExclusive() } try requireActive() recordingLifecycleStarted = true - do { - let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID - needsAssignmentReconciliation = false - return reconciliation - } catch { - needsAssignmentReconciliation = true - await ingestor.revokeRecordingAuthorization() - publishRuntimeState(.unavailable) - throw error - } - } - - /// Pure read of active device configurations paired with the global assignment. - public func devices() async throws -> [RecordingDeviceConfiguration] { - await beginExclusive() - defer { endExclusive() } - try requireActive() - return try await configurationsLocked(includeArchived: false) - } - - /// Read the one account-wide assignment and every installation eligible to receive it. - public func authoritySnapshot() async throws -> RecordingAuthoritySnapshot { - await beginExclusive() - defer { endExclusive() } - try requireActive() - return try await store.readSnapshot { - async let devices = store.recordingDevices() - async let changes = store.recordingAssignmentChanges() - async let archives = store.recordingDeviceArchives() - let values = try await (devices, changes, archives) - return RecordingAuthoritySnapshot( - resolution: RecordingAssignmentChange.resolve(values.1), - devices: values.0, - archivedDeviceIDs: Set(values.2.map(\.deviceID)), - ) - } - } - - /// Transfer automatic recording immediately to one installation. - @discardableResult - public func assignAutomaticRecording( - to deviceID: RecordingDeviceID, - ) async throws -> RecordingAuthoritySnapshot { - _ = try await setEnabled(true, for: deviceID) - return try await authoritySnapshot() - } - - /// Turn account-wide automatic recording Off. - @discardableResult - public func turnOffAutomaticRecording() async throws -> RecordingAuthoritySnapshot { - _ = try await setEnabled(false, for: currentDevice.id) - return try await authoritySnapshot() + return try await reconcileOrFailClosed(authorization: authorization) } - /// Append a desired-state command. A command for this installation is physically reconciled - /// and acknowledged before returning; a remote command remains pending until its target syncs. + /// Apply a choice already persisted by the installation sidecar. @discardableResult - public func setEnabled( + public func setAutomaticRecordingEnabled( _ enabled: Bool, - for deviceID: RecordingDeviceID, - ) async throws -> [RecordingDeviceConfiguration] { + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { await beginExclusive() defer { endExclusive() } try requireActive() - - let snapshot = try await storeSnapshot() - guard snapshot.profiles.contains(where: { $0.id == deviceID }) else { - throw RecordingPersistenceError.deviceNotFound(deviceID) - } - let epoch = snapshot.epoch - let issuedAt = now() - let desiredAssignment: RecordingAssignment = enabled ? .device(deviceID) : .off - let assignmentChange: RecordingAssignmentChange? = if RecordingAssignmentChange - .resolve(snapshot.assignmentChanges).assignment == desiredAssignment - { - nil - } else { - try RecordingAssignmentChange.appendingCommand( - to: snapshot.assignmentChanges, - assignment: desiredAssignment, - issuedAt: issuedAt, - issuedByDeviceID: currentDevice.id, - effectiveAt: max(issuedAt, epoch.changedAt), - reason: .userCommand, - ) - } - guard let assignmentChange else { - try await reconcileCurrentAfterCommandLocked() - return try await configurationsLocked(includeArchived: false) - } - - try await store.perform(expectedDataEpochID: epoch.id) { - try await self.store.addRecordingAssignmentChange(assignmentChange) - } - // Close local physical authority before any potentially slow derived-data rebuild. The - // durable cutoff already hides history, but raw fixes must not continue entering the - // store/outbox after the user turns this installation Off. - if deviceID == currentDevice.id, !enabled { - await ingestor.revokeRecordingAuthorization() + if automaticRecordingEnabled != enabled { + automaticRecordingEnabled = enabled + enabledAt = enabled ? now() : nil } - if assignmentChange.assignedDeviceID != currentDevice.id { + if !enabled { await ingestor.revokeRecordingAuthorization() + try await ingestor.discardRetryBacklog() } - // The cutoff is already durable even if physical acknowledgement below fails. - await onPolicyChanged() + return try await reconcileOrFailClosed(authorization: authorization) + } - try await reconcileCurrentAfterCommandLocked() - return try await configurationsLocked(includeArchived: false) + public func devices() async throws -> [RecordingDeviceConfiguration] { + await beginExclusive() + defer { endExclusive() } + try requireActive() + return try await configurationsLocked(includeRemoved: false) } /// Append a user-editable nickname change. Empty or whitespace-only input clears it. @@ -337,12 +167,15 @@ public actor DeviceRecordingController { guard snapshot.profiles.contains(where: { $0.id == deviceID }) else { throw RecordingPersistenceError.deviceNotFound(deviceID) } - let changes = snapshot.metadataChanges - let latest = Self.latestMetadata(for: deviceID, field: .nickname, in: changes) + let latest = Self.latestMetadata( + for: deviceID, + field: .nickname, + in: snapshot.metadataChanges, + ) let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedNickname = trimmed.isEmpty ? nil : trimmed guard latest?.nickname != resolvedNickname else { - return try await configurationsLocked(includeArchived: false) + return try await configurationsLocked(includeRemoved: false) } let change = try RecordingDeviceMetadataChange( id: UUID(), @@ -355,16 +188,15 @@ public actor DeviceRecordingController { try await store.perform(expectedDataEpochID: snapshot.epoch.id) { try await self.store.addRecordingDeviceMetadataChange(change) } - return try await configurationsLocked(includeArchived: false) + return try await configurationsLocked(includeRemoved: false) } - /// Hide a non-current device and turn global recording Off if it was assigned. History and raw - /// samples - /// remain in the event log and backups. - public func archive( + /// Permanently retire a remote installation identity. The target stops when it receives the + /// tombstone; retained samples before the cutoff remain part of history. + public func remove( _ deviceID: RecordingDeviceID, ) async throws -> [RecordingDeviceConfiguration] { - precondition(deviceID != currentDevice.id, "The current device cannot archive itself.") + precondition(deviceID != currentDevice.id, "The current device cannot remove itself.") await beginExclusive() defer { endExclusive() } try requireActive() @@ -372,47 +204,22 @@ public actor DeviceRecordingController { guard snapshot.profiles.contains(where: { $0.id == deviceID }) else { throw RecordingPersistenceError.deviceNotFound(deviceID) } - - let date = now() - let epoch = snapshot.epoch - let archive = snapshot.archives.contains(where: { $0.deviceID == deviceID }) ? nil : - RecordingDeviceArchive( - id: UUID(), - deviceID: deviceID, - archivedAt: date, - archivedByDeviceID: currentDevice.id, - ) - let assignmentChange: RecordingAssignmentChange? = if RecordingAssignmentChange - .resolve(snapshot.assignmentChanges).assignment?.deviceID == deviceID - { - try RecordingAssignmentChange.appendingCommand( - to: snapshot.assignmentChanges, - assignment: .off, - issuedAt: date, - issuedByDeviceID: currentDevice.id, - effectiveAt: max(date, epoch.changedAt), - reason: .userCommand, - ) - } else { - nil - } - guard archive != nil || assignmentChange != nil else { - return try await configurationsLocked(includeArchived: false) + guard snapshot.removals.contains(where: { $0.deviceID == deviceID }) == false else { + return try await configurationsLocked(includeRemoved: false) } - try await store.perform(expectedDataEpochID: epoch.id) { - if let archive { - try await self.store.addRecordingDeviceArchive(archive) - } - if let assignmentChange { - try await self.store.addRecordingAssignmentChange(assignmentChange) - } + let removal = RecordingDeviceRemoval( + id: UUID(), + deviceID: deviceID, + removedAt: now(), + removedByDeviceID: currentDevice.id, + ) + try await store.perform(expectedDataEpochID: snapshot.epoch.id) { + try await self.store.addRecordingDeviceRemoval(removal) } await onPolicyChanged() - return try await configurationsLocked(includeArchived: false) + return try await configurationsLocked(includeRemoved: false) } - /// Reversibly close this stack around a backup import or reset transaction. Pending samples - /// remain owned by the installation until the destructive operation actually commits. func pause() async throws { await beginExclusive() defer { endExclusive() } @@ -420,164 +227,158 @@ public actor DeviceRecordingController { throw RecordingPersistenceError.recordingRewriteInProgress } isRewritePaused = true - shouldResumeAuthorityAfterPause = shouldResumeAuthorityAfterPause - || recordingLifecycleStarted + shouldResumeAfterPause = shouldResumeAfterPause || recordingLifecycleStarted recordingLifecycleStarted = false acceptsOperations = false - assignmentObservationTask?.cancel() - assignmentObservationTask = nil + observationTask?.cancel() + observationTask = nil await ingestor.pause() } - /// Reopen a stack retained after a failed reset. func resumeAfterFailedReset() async { await beginExclusive() defer { endExclusive() } await resumeLocked() } - /// Reopen the old authority after a backup-import transaction rolls back. func resumeAfterImportRollback() async { await beginExclusive() defer { endExclusive() } await resumeLocked() } - private func resumeLocked() async { - acceptsOperations = true - isRewritePaused = false - let shouldResumeAuthority = shouldResumeAuthorityAfterPause - shouldResumeAuthorityAfterPause = false - guard shouldResumeAuthority else { return } - startMonitoringAssignmentChanges() - do { - try await registerLocked( - initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, - initialEnabled: initialRecordingChoice.isEnabled, - ) - let authorization = await ingestor.authorizationStatus() - let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID - needsAssignmentReconciliation = false - } catch { - needsAssignmentReconciliation = true - await ingestor.revokeRecordingAuthorization() - publishRuntimeState(.unavailable) - Self.logger(attachments: [.error(error, name: "rollback-recovery-error")]) { - .rollbackRecoveryFailed(description: error.localizedDescription) - } - } - } - - /// Reactivate after a committed backup import, restore this installation's fixed - /// registration, and apply imported authority. Import data is already committed at this - /// point. Privacy-critical sidecar cleanup throws so the coordinator can report committed - /// partial success; later physical recovery remains fail-closed and logged for retry. func resumeAfterImport(discardPendingSamples: Bool) async throws { await beginExclusive() acceptsOperations = true - let shouldResumeAuthority = shouldResumeAuthorityAfterPause + let shouldResume = shouldResumeAfterPause if discardPendingSamples { do { try await ingestor.discardRetryBacklog() } catch { - // The import is already committed. Keep the old installation context and - // recording stack paused so a retry can remove the same sidecar safely. isRewritePaused = false acceptsOperations = false - needsAssignmentReconciliation = true + needsReconciliation = true publishRuntimeState(.unavailable) endExclusive() throw error } } - shouldResumeAuthorityAfterPause = false + shouldResumeAfterPause = false isRewritePaused = false - guard shouldResumeAuthority else { + guard shouldResume else { endExclusive() return } - startMonitoringAssignmentChanges() + startMonitoringChanges() do { - try await registerLocked( - initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, - initialEnabled: initialRecordingChoice.isEnabled, - ) let authorization = await ingestor.authorizationStatus() - let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID - needsAssignmentReconciliation = false + _ = try await registerAndReconcileLocked(authorization: authorization) + } catch RecordingPersistenceError.currentDeviceRemoved { endExclusive() + return } catch { - needsAssignmentReconciliation = true + needsReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) - endExclusive() Self.logger(attachments: [.error(error, name: "import-recovery-error")]) { .importRecoveryFailed(description: error.localizedDescription) } } + endExclusive() } - /// Finish a committed reset without reopening this installation's authority. A failed - /// sidecar cleanup leaves the old installation fail-closed; the destructive data epoch makes - /// a later retry clear the same backlog before acknowledgement. func finishReset() async throws { await beginExclusive() do { try await ingestor.discardRetryBacklog() } catch { isRewritePaused = false - needsAssignmentReconciliation = true + needsReconciliation = true publishRuntimeState(.unavailable) endExclusive() throw error } - shouldResumeAuthorityAfterPause = false + shouldResumeAfterPause = false isRewritePaused = false endExclusive() } - private func registerLocked( - initialAssignmentChangeID: UUID, - initialEnabled: Bool, - ) async throws { - let snapshot = try await storeSnapshot() - let epoch = snapshot.epoch - let existingProfile = snapshot.profiles.first(where: { $0.id == currentDevice.id }) - let profile = expectedProfile( - registrationEpochID: existingProfile?.registrationEpochID ?? epoch.id, - ) - let needsInitialAssignment = snapshot.assignmentChanges.isEmpty - let needsProfileWrite = existingProfile != profile - guard needsProfileWrite || needsInitialAssignment else { return } - - // The add APIs validate identical immutable retries and reject conflicting payloads. - try await store.perform(expectedDataEpochID: epoch.id) { - try await self.store.addRecordingDeviceProfile(profile) - if needsInitialAssignment { - try await self.store.addRecordingAssignmentChange(RecordingAssignmentChange( - id: initialAssignmentChangeID, - parentIDs: [], - revision: 0, - issuedAt: self.initialRecordingChoice.confirmedAt, - issuedByDeviceID: self.currentDevice.id, - effectiveAt: max(self.initialRecordingChoice.confirmedAt, epoch.changedAt), - assignedDeviceID: initialEnabled ? self.currentDevice.id : nil, - reason: .onboarding, - )) - } + /// Permanently close the removed scope before the app rotates its local identity. + public func retireForRejoin() async throws { + await beginExclusive() + acceptsOperations = false + recordingLifecycleStarted = false + observationTask?.cancel() + observationTask = nil + await ingestor.pause() + do { + try await ingestor.discardRetryBacklog() + publishRuntimeState(.removed) + endExclusive() + } catch { + publishRuntimeState(.unavailable) + endExclusive() + throw error } } - private func reconcileCurrentAfterCommandLocked() async throws { + private func resumeLocked() async { + acceptsOperations = true + isRewritePaused = false + let shouldResume = shouldResumeAfterPause + shouldResumeAfterPause = false + guard shouldResume else { return } + startMonitoringChanges() do { let authorization = await ingestor.authorizationStatus() - let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID - needsAssignmentReconciliation = false + _ = try await registerAndReconcileLocked(authorization: authorization) + } catch RecordingPersistenceError.currentDeviceRemoved { + return + } catch { + needsReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + Self.logger(attachments: [.error(error, name: "rollback-recovery-error")]) { + .rollbackRecoveryFailed(description: error.localizedDescription) + } + } + } + + private func registerAndReconcileLocked( + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + do { + let snapshot = try await storeSnapshot() + let epoch = snapshot.epoch + let existing = snapshot.profiles.first(where: { $0.id == currentDevice.id }) + let expected = expectedProfile( + registrationEpochID: existing?.registrationEpochID ?? epoch.id, + ) + if existing != expected { + try await store.perform(expectedDataEpochID: epoch.id) { + try await self.store.addRecordingDeviceProfile(expected) + } + } + return try await reconcileLocked(authorization: authorization) + } catch RecordingPersistenceError.currentDeviceRemoved { + throw RecordingPersistenceError.currentDeviceRemoved(currentDevice.id) + } catch { + needsReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + throw error + } + } + + private func reconcileOrFailClosed( + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + do { + return try await reconcileLocked(authorization: authorization) + } catch RecordingPersistenceError.currentDeviceRemoved { + throw RecordingPersistenceError.currentDeviceRemoved(currentDevice.id) } catch { - needsAssignmentReconciliation = true + needsReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) throw error @@ -591,75 +392,58 @@ public actor DeviceRecordingController { guard let profile = snapshot.profiles.first(where: { $0.id == currentDevice.id }) else { throw RecordingPersistenceError.currentDeviceNotRegistered(currentDevice.id) } - let epoch = snapshot.epoch - let resolution = RecordingAssignmentChange.resolve(snapshot.assignmentChanges) - guard let assignment = resolution.assignment, - let frontierID = RecordingAssignmentChange.frontierToken( - in: snapshot.assignmentChanges, - ), - let heads = RecordingAssignmentChange.maximalHeads(in: snapshot.assignmentChanges) - else { throw RecordingPersistenceError.incompleteAssignmentHistory } - let assignedDeviceIsArchived = assignment.deviceID.map { assignedID in - snapshot.archives.contains(where: { $0.deviceID == assignedID }) - } ?? false - guard assignedDeviceIsArchived == false else { - throw RecordingPersistenceError.incompleteAssignmentHistory - } - let isEnabled = assignment.deviceID == currentDevice.id - let effectiveAt = heads.map(\.effectiveAt).max() ?? epoch.changedAt - let nickname = Self.latestMetadata( - for: currentDevice.id, - field: .nickname, - in: snapshot.metadataChanges, - ) - - let existing = snapshot.checkIns - .first(where: { $0.deviceID == currentDevice.id }) - let requiredCleanupToken: RecordingAssignmentCleanupToken? = if epoch.isDestructive { - RecordingAssignmentCleanupToken(rawValue: epoch.id.rawValue) - } else { - nil - } + let removal = snapshot.removals + .filter { $0.deviceID == currentDevice.id } + .min { $0.removedAt < $1.removedAt } - // Close the sample gate before computing or acknowledging authority. In particular, - // `authorizeRecording` restores and drains the durable outbox, so it cannot run until - // the check-in proving this assignment was applied has committed. await ingestor.revokeRecordingAuthorization() - if existing?.lastDiscardedAssignmentFrontierToken != requiredCleanupToken, - requiredCleanupToken != nil - { + if removal != nil { try await ingestor.discardRetryBacklog() + needsReconciliation = false + publishRuntimeState(.removed) + throw RecordingPersistenceError.currentDeviceRemoved(currentDevice.id) } - if isEnabled { - try await ingestor.prepareRetryBacklog() + + let epoch = snapshot.epoch + if preparedEpochID != epoch.id { + try await ingestor.discardRetryBacklog() + preparedEpochID = epoch.id } - let status: RecordingDeviceStatus = if isEnabled { + let status: RecordingDeviceStatus = if automaticRecordingEnabled { authorization.allowsBackgroundTracking ? .recording : .permissionRequired } else { .off } + if automaticRecordingEnabled { + try await ingestor.prepareRetryBacklog() + let effectiveAt = max(enabledAt ?? registeredAt, epoch.changedAt) + if authorization.allowsBackgroundTracking { + try await ingestor.start(effectiveAt: effectiveAt, dataEpochID: epoch.id) + } else { + try await ingestor.authorizeRecording( + effectiveAt: effectiveAt, + dataEpochID: epoch.id, + ) + await ingestor.stop() + } + } else { + try await ingestor.discardRetryBacklog() + } + // Publish advisory status only after the physical transition succeeds. If the write + // fails, the caller revokes recording again rather than advertising an uncommitted state. + let existing = snapshot.checkIns.first { $0.deviceID == currentDevice.id } let checkInDate = now() - let needsAcknowledgement = existing?.lastAppliedAssignmentChangeID != frontierID - || existing?.lastDiscardedAssignmentFrontierToken != requiredCleanupToken - || existing?.status != status - let needsPeriodicCheckIn = existing.map { + let checkInDue = existing.map { checkInDate.timeIntervalSince($0.lastSeenAt) >= Self.checkInInterval } ?? true let checkIn: RecordingDeviceCheckIn - if needsAcknowledgement || needsPeriodicCheckIn { + if existing?.status != status || checkInDue { checkIn = try RecordingDeviceCheckIn( deviceID: currentDevice.id, - revision: Self.nextRevision( - after: existing?.revision, - for: currentDevice.id, - ), + revision: Self.nextRevision(after: existing?.revision, for: currentDevice.id), lastSeenAt: checkInDate, - appliedAt: needsAcknowledgement ? checkInDate : - (existing?.appliedAt ?? checkInDate), - lastAppliedAssignmentChangeID: frontierID, - lastDiscardedAssignmentFrontierToken: requiredCleanupToken, status: status, ) try await store.perform(expectedDataEpochID: epoch.id) { @@ -671,87 +455,49 @@ public actor DeviceRecordingController { preconditionFailure("A required recording check-in was not created.") } - // Only a durable acknowledgement opens physical authority. This ordering also prevents - // an outbox drain from committing samples when the check-in write fails. - if isEnabled { - if authorization.allowsBackgroundTracking { - try await ingestor.start( - effectiveAt: effectiveAt, - dataEpochID: epoch.id, - ) - } else { - // Keep foreground fill-in fixes authorized for When-In-Use while pausing - // background monitoring. - try await ingestor.authorizeRecording( - effectiveAt: effectiveAt, - dataEpochID: epoch.id, - ) - await ingestor.stop() - } - } - let configuration = RecordingDeviceConfiguration( device: RecordingDevice( profile: profile, - nicknameChange: nickname, + nicknameChange: Self.latestMetadata( + for: currentDevice.id, + field: .nickname, + in: snapshot.metadataChanges, + ), checkIn: checkIn, - archive: snapshot.archives.first(where: { $0.deviceID == currentDevice.id }), + removal: nil, ), - assignmentResolution: resolution, - assignmentFrontierID: frontierID, - isAssignmentAcknowledged: checkIn.lastAppliedAssignmentChangeID == frontierID - && checkIn.lastDiscardedAssignmentFrontierToken == requiredCleanupToken, - isArchived: false, + isCurrentDevice: true, + localAutomaticRecordingEnabled: automaticRecordingEnabled, ) + needsReconciliation = false publishRuntimeState(.applied(configuration)) return configuration } private func configurationsLocked( - includeArchived: Bool, + includeRemoved: Bool, ) async throws -> [RecordingDeviceConfiguration] { - try await store.readSnapshot { - async let devices = store.recordingDevices() - async let assignments = store.recordingAssignmentChanges() - async let archives = store.recordingDeviceArchives() - let (resolvedDevices, resolvedAssignments, resolvedArchives) = try await ( - devices, - assignments, - archives, - ) - let resolution = RecordingAssignmentChange.resolve(resolvedAssignments) - let assignmentFrontierID = RecordingAssignmentChange.frontierToken( - in: resolvedAssignments, - ) - let archivedIDs = Set(resolvedArchives.map(\.deviceID)) - return resolvedDevices - .map { device in - RecordingDeviceConfiguration( - device: device, - assignmentResolution: resolution, - assignmentFrontierID: assignmentFrontierID, - isAssignmentAcknowledged: device.lastAppliedAssignmentChangeID - == assignmentFrontierID, - isArchived: archivedIDs.contains(device.id), - ) - } - .filter { - includeArchived - || (!archivedIDs.contains($0.id) && !$0.isArchived) - || $0.id == currentDevice.id - } - .sorted { lhs, rhs in - if lhs.id == currentDevice.id { return true } - if rhs.id == currentDevice.id { return false } - if lhs.device.lastSeenAt != rhs.device.lastSeenAt { - return lhs.device.lastSeenAt > rhs.device.lastSeenAt - } - return lhs.id.storeURL.absoluteString < rhs.id.storeURL.absoluteString + try await store.recordingDevices() + .filter { includeRemoved || $0.removedAt == nil || $0.id == currentDevice.id } + .map { device in + let isCurrent = device.id == currentDevice.id + return RecordingDeviceConfiguration( + device: device, + isCurrentDevice: isCurrent, + localAutomaticRecordingEnabled: isCurrent ? automaticRecordingEnabled : nil, + ) + } + .sorted { lhs, rhs in + if lhs.isCurrentDevice { return true } + if rhs.isCurrentDevice { return false } + if lhs.device.lastSeenAt != rhs.device.lastSeenAt { + return lhs.device.lastSeenAt > rhs.device.lastSeenAt } - } + return lhs.id.storeURL.absoluteString < rhs.id.storeURL.absoluteString + } } - private func applyObservedAssignmentChange() async { + private func applyObservedChange() async { await beginExclusive() guard acceptsOperations else { endExclusive() @@ -759,55 +505,35 @@ public actor DeviceRecordingController { } do { let snapshot = try await storeSnapshot() - let epoch = snapshot.epoch - let checkIns = snapshot.checkIns - let existingProfile = snapshot.profiles.first { $0.id == currentDevice.id } - let hasExpectedProfile = existingProfile.map { - $0 == expectedProfile(registrationEpochID: $0.registrationEpochID) - } ?? false - let hasAssignment = snapshot.assignmentChanges.isEmpty == false - let latestCurrentAssignmentID = RecordingAssignmentChange.frontierToken( - in: snapshot.assignmentChanges, - ) - let requiredCleanupToken: RecordingAssignmentCleanupToken? = epoch.isDestructive - ? RecordingAssignmentCleanupToken(rawValue: epoch.id.rawValue) - : nil - let acknowledgedCurrentAssignmentID = checkIns.first(where: { - $0.deviceID == currentDevice.id - })?.lastAppliedAssignmentChangeID - let currentCheckIn = checkIns.first { $0.deviceID == currentDevice.id } - let acknowledgedCleanupToken = currentCheckIn? - .lastDiscardedAssignmentFrontierToken + let currentCheckIn = snapshot.checkIns.first { $0.deviceID == currentDevice.id } + let removalExists = snapshot.removals.contains { $0.deviceID == currentDevice.id } let heartbeatDue = currentCheckIn.map { now().timeIntervalSince($0.lastSeenAt) >= Self.checkInInterval } ?? true - let shouldReconcile = needsAssignmentReconciliation - || !hasExpectedProfile - || !hasAssignment - || latestCurrentAssignmentID != lastAppliedCurrentAssignmentID - || acknowledgedCurrentAssignmentID != latestCurrentAssignmentID - || acknowledgedCleanupToken != requiredCleanupToken - || heartbeatDue - if shouldReconcile { - try await registerLocked( - initialAssignmentChangeID: initialRecordingChoice.assignmentChangeID, - initialEnabled: initialRecordingChoice.isEnabled, - ) + let expectedStatus: RecordingDeviceStatus = await automaticRecordingEnabled + ? ((ingestor.authorizationStatus()).allowsBackgroundTracking + ? .recording : .permissionRequired) + : .off + let profileMatches = snapshot.profiles.first(where: { $0.id == currentDevice.id }).map { + $0 == expectedProfile(registrationEpochID: $0.registrationEpochID) + } ?? false + if needsReconciliation || removalExists || heartbeatDue + || currentCheckIn?.status != expectedStatus || !profileMatches + { let authorization = await ingestor.authorizationStatus() - let reconciliation = try await reconcileLocked(authorization: authorization) - lastAppliedCurrentAssignmentID = reconciliation.latestAssignmentChangeID - needsAssignmentReconciliation = false + _ = try await registerAndReconcileLocked(authorization: authorization) } - endExclusive() + } catch RecordingPersistenceError.currentDeviceRemoved { + // `reconcileLocked` already stopped ingestion and published the terminal state. } catch { - needsAssignmentReconciliation = true + needsReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) - endExclusive() Self.logger(attachments: [.error(error, name: "policy-observation-error")]) { .policyObservationFailed(description: error.localizedDescription) } } + endExclusive() } private func storeSnapshot() async throws -> StoreSnapshot { @@ -816,23 +542,14 @@ public actor DeviceRecordingController { async let profiles = store.recordingDeviceProfiles() async let metadataChanges = store.recordingDeviceMetadataChanges() async let checkIns = store.recordingDeviceCheckIns() - async let assignmentChanges = store.recordingAssignmentChanges() - async let archives = store.recordingDeviceArchives() - let values = try await ( - epoch, - profiles, - metadataChanges, - checkIns, - assignmentChanges, - archives, - ) + async let removals = store.recordingDeviceRemovals() + let values = try await (epoch, profiles, metadataChanges, checkIns, removals) return StoreSnapshot( epoch: values.0, profiles: values.1, metadataChanges: values.2, checkIns: values.3, - assignmentChanges: values.4, - archives: values.5, + removals: values.4, ) } } @@ -865,9 +582,7 @@ public actor DeviceRecordingController { ) throws -> Int64 { guard let revision else { return 0 } let (next, overflow) = revision.addingReportingOverflow(1) - guard !overflow else { - throw RecordingPersistenceError.revisionExhausted(deviceID) - } + guard !overflow else { throw RecordingPersistenceError.revisionExhausted(deviceID) } return next } @@ -886,9 +601,7 @@ public actor DeviceRecordingController { private func beginExclusive() async { if isExclusive { - await withCheckedContinuation { continuation in - waiters.append(continuation) - } + await withCheckedContinuation { continuation in waiters.append(continuation) } } else { isExclusive = true } diff --git a/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift index 3c007896..df7cd7d2 100644 --- a/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift +++ b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift @@ -5,66 +5,58 @@ import Foundation /// The whole value is persisted outside backed-up preferences. A restored device /// therefore gets a new identity and must confirm its own initial recording /// choice, while repeated launches of the same installation reuse both the -/// identity and the complete immutable payload inputs for its first synced -/// device profile and assignment event. +/// identity and its explicitly chosen local automatic-recording preference. public struct InstallationRecordingContext: Sendable, Hashable { - /// The explicitly confirmed first policy for this installation, including - /// the timestamp reused whenever its immutable event must be recreated. - public struct InitialRecordingChoice: Sendable, Hashable { - public let isEnabled: Bool - public let assignmentChangeID: UUID - public let confirmedAt: Date - - public init( - isEnabled: Bool, - assignmentChangeID: UUID, - confirmedAt: Date, - ) { - self.isEnabled = isEnabled - self.assignmentChangeID = assignmentChangeID - self.confirmedAt = confirmedAt - } - } - public let currentDevice: CurrentRecordingDevice /// Stable creation time for this installation's immutable device profile. public let registeredAt: Date - public let initialRecordingChoice: InitialRecordingChoice? + /// This installation's explicit local choice. `nil` means onboarding has not confirmed it. + public let automaticRecordingEnabled: Bool? + /// Whether this identity was created by the explicit rejoin flow. + public let isRejoining: Bool public init( currentDevice: CurrentRecordingDevice, registeredAt: Date, - initialRecordingChoice: InitialRecordingChoice?, + automaticRecordingEnabled: Bool?, + isRejoining: Bool, ) { self.currentDevice = currentDevice self.registeredAt = registeredAt - self.initialRecordingChoice = initialRecordingChoice + self.automaticRecordingEnabled = automaticRecordingEnabled + self.isRejoining = isRejoining } /// The safe default shown until this installation confirms a choice. public var recommendedRecordingEnabled: Bool { - currentDevice.kind.recommendsAutomaticRecording + !isRejoining && currentDevice.kind.recommendsAutomaticRecording } - /// Return the confirmed form of a newly proposed context, freezing every - /// value needed to recreate the first assignment event byte-for-byte. - public func confirmingInitialRecording( - isEnabled: Bool, - assignmentChangeID: UUID, - confirmedAt: Date, - ) -> InstallationRecordingContext { + /// Return the confirmed form of a newly proposed context. + public func confirmingInitialRecording(isEnabled: Bool) -> InstallationRecordingContext { precondition( - initialRecordingChoice == nil, + automaticRecordingEnabled == nil, "An installation's initial recording choice can only be confirmed once.", ) return InstallationRecordingContext( currentDevice: currentDevice, registeredAt: registeredAt, - initialRecordingChoice: InitialRecordingChoice( - isEnabled: isEnabled, - assignmentChangeID: assignmentChangeID, - confirmedAt: confirmedAt, - ), + automaticRecordingEnabled: isEnabled, + isRejoining: false, + ) + } + + /// Return a copy carrying a later local Settings choice. + public func settingAutomaticRecordingEnabled(_ isEnabled: Bool) -> Self { + precondition( + automaticRecordingEnabled != nil, + "Automatic recording must be confirmed before Settings can change it.", + ) + return InstallationRecordingContext( + currentDevice: currentDevice, + registeredAt: registeredAt, + automaticRecordingEnabled: isEnabled, + isRejoining: false, ) } @@ -79,11 +71,8 @@ public struct InstallationRecordingContext: Sendable, Hashable { kind: .phone, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - initialRecordingChoice: InitialRecordingChoice( - isEnabled: true, - assignmentChangeID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!, - confirmedAt: Date(timeIntervalSinceReferenceDate: 1), - ), + automaticRecordingEnabled: true, + isRejoining: false, ) /// Deterministic context for tests and previews that do not care which @@ -98,10 +87,7 @@ public struct InstallationRecordingContext: Sendable, Hashable { kind: .phone, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - initialRecordingChoice: InitialRecordingChoice( - isEnabled: true, - assignmentChangeID: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, - confirmedAt: Date(timeIntervalSinceReferenceDate: 1), - ), + automaticRecordingEnabled: true, + isRejoining: false, ) } diff --git a/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift b/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift index d53de8f6..f3a77685 100644 --- a/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift +++ b/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift @@ -12,11 +12,15 @@ public protocol InstallationRecordingContextStoring: AnyObject { /// value for the lifetime of the store object. func resolve() throws -> InstallationRecordingContext - /// Persist the first explicit choice and its immutable event time beside - /// the installation identity and immutable profile time. Later calls return - /// that frozen choice; subsequent intent changes belong in the synced assignment stream. + /// Persist the first explicit local choice beside the installation identity. func confirmInitialRecording(isEnabled: Bool) throws -> InstallationRecordingContext + /// Persist a later Settings choice locally. The installation must already be confirmed. + func setAutomaticRecordingEnabled(_ isEnabled: Bool) throws + + /// Replace a removed installation identity without touching synced account data or recovery. + func rejoin() throws -> InstallationRecordingContext + /// Durable two-phase state for an import started by this installation. Kept beside the /// identity so a recreated service layer cannot forget a committed cleanup or onboarding /// acknowledgement boundary. diff --git a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift index 9422af3a..f44fde94 100644 --- a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift +++ b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift @@ -13,14 +13,14 @@ public struct LocationHistoryReader: Sendable { public func samples(in interval: DateInterval) async throws -> [LocationSample] { try await store.readSnapshot { async let samples = store.samples(in: interval) - async let assignmentChanges = store.recordingAssignmentChanges() - let (resolvedSamples, resolvedAssignmentChanges) = try await ( + async let removals = store.recordingDeviceRemovals() + let (resolvedSamples, resolvedRemovals) = try await ( samples, - assignmentChanges, + removals, ) - return RecordingAssignmentFilter.visibleSamples( + return RecordingDeviceRemovalFilter.visibleSamples( resolvedSamples, - assignmentChanges: resolvedAssignmentChanges, + removals: resolvedRemovals, ) } } diff --git a/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift b/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift deleted file mode 100644 index f64ae881..00000000 --- a/Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift +++ /dev/null @@ -1,315 +0,0 @@ -import CryptoKit -import Foundation - -/// The account-wide automatic-recording assignment. -/// -/// A missing device means recording is explicitly Off. Exactly one installation can otherwise -/// hold the assignment; every installation remains able to edit history and attach evidence. -public struct RecordingAssignment: Sendable, Hashable { - public let deviceID: RecordingDeviceID? - - public static let off = RecordingAssignment(deviceID: nil) - - public static func device(_ deviceID: RecordingDeviceID) -> RecordingAssignment { - RecordingAssignment(deviceID: deviceID) - } - - private init(deviceID: RecordingDeviceID?) { - self.deviceID = deviceID - } -} - -/// Why an account-wide recording assignment was appended. -public enum RecordingAssignmentReason: String, Codable, Sendable, Hashable { - case onboarding - case userCommand - case backupMerge - case accountReset - case backupReplace -} - -/// One append-only command in the account-wide automatic-recording assignment DAG. -/// -/// Commands name every maximal event observed by their writer. This permits CloudKit writers to -/// converge without comparing clocks: an Off head wins concurrent assignment, identical -/// assignments coalesce, and concurrent assignments to different devices fail closed. -public struct RecordingAssignmentChange: Identifiable, Codable, Sendable, Hashable { - public let id: UUID - public let parentIDs: [UUID] - public let revision: Int64 - public let issuedAt: Date - public let issuedByDeviceID: RecordingDeviceID - public let effectiveAt: Date - public let assignedDeviceID: RecordingDeviceID? - public let reason: RecordingAssignmentReason - - public var assignment: RecordingAssignment { - assignedDeviceID.map(RecordingAssignment.device) ?? .off - } - - public init( - id: UUID, - parentIDs: [UUID], - revision: Int64, - issuedAt: Date, - issuedByDeviceID: RecordingDeviceID, - effectiveAt: Date, - assignedDeviceID: RecordingDeviceID?, - reason: RecordingAssignmentReason, - ) { - precondition(revision >= 0, "A recording-assignment revision cannot be negative.") - precondition( - (revision == 0) == parentIDs.isEmpty, - "Only a recording-assignment root may omit its parents.", - ) - let canonicalParentIDs = parentIDs.sorted { $0.uuidString < $1.uuidString } - precondition( - Set(canonicalParentIDs).count == canonicalParentIDs.count, - "A recording-assignment command cannot name the same parent twice.", - ) - precondition( - canonicalParentIDs.contains(id) == false, - "A recording-assignment command cannot parent itself.", - ) - precondition( - Self.isValid(reason: reason, assignedDeviceID: assignedDeviceID), - "The recording assignment is invalid for its reason.", - ) - self.id = id - self.parentIDs = canonicalParentIDs - self.revision = revision - self.issuedAt = issuedAt - self.issuedByDeviceID = issuedByDeviceID - self.effectiveAt = effectiveAt - self.assignedDeviceID = assignedDeviceID - self.reason = reason - } -} - -/// Fail-closed resolution of the global assignment graph. -public enum RecordingAssignmentResolution: Sendable, Hashable { - case unconfigured - case resolved(RecordingAssignment) - case conflict(Set) - case invalid - - public var assignment: RecordingAssignment? { - guard case let .resolved(assignment) = self else { return nil } - return assignment - } - - public func permitsRecording(on deviceID: RecordingDeviceID) -> Bool { - assignment?.deviceID == deviceID - } -} - -extension RecordingAssignmentChange { - private struct CausalGraph { - let heads: [RecordingAssignmentChange] - } - - static func persisted( - id: UUID, - parentIDs: [UUID], - revision: Int64, - issuedAt: Date, - issuedByDeviceID: RecordingDeviceID, - effectiveAt: Date, - assignedDeviceID: RecordingDeviceID?, - reason: RecordingAssignmentReason, - ) -> RecordingAssignmentChange? { - guard revision >= 0, - (revision == 0) == parentIDs.isEmpty, - Set(parentIDs).count == parentIDs.count, - parentIDs.contains(id) == false, - isValid(reason: reason, assignedDeviceID: assignedDeviceID) - else { return nil } - return RecordingAssignmentChange( - id: id, - parentIDs: parentIDs, - revision: revision, - issuedAt: issuedAt, - issuedByDeviceID: issuedByDeviceID, - effectiveAt: effectiveAt, - assignedDeviceID: assignedDeviceID, - reason: reason, - ) - } - - public static func resolve( - _ changes: [RecordingAssignmentChange], - ) -> RecordingAssignmentResolution { - resolveValidated(changes) - } - - public static func resolve( - _ changes: [RecordingAssignmentChange], - at date: Date, - ) -> RecordingAssignmentResolution { - guard changes.isEmpty || causalGraph(in: changes) != nil else { return .invalid } - return resolveValidated(changes.filter { $0.effectiveAt <= date }) - } - - public static func maximalHeads( - in changes: [RecordingAssignmentChange], - ) -> [RecordingAssignmentChange]? { - guard changes.isEmpty == false else { return [] } - return causalGraph(in: changes)?.heads.sorted(by: isOrderedBefore) - } - - /// Stable acknowledgement identity for the complete maximal frontier. - static func frontierToken(in changes: [RecordingAssignmentChange]) -> UUID? { - guard let heads = maximalHeads(in: changes), heads.isEmpty == false else { return nil } - guard heads.count > 1 else { return heads[0].id } - var hasher = SHA256() - hasher.update(data: Data("com.stuff.where.recording-assignment-frontier.v1".utf8)) - for head in heads { - hasher.update(data: Data("\n\(head.id.uuidString)".utf8)) - } - let digest = Array(hasher.finalize().prefix(16)) - return UUID(uuid: ( - digest[0], - digest[1], - digest[2], - digest[3], - digest[4], - digest[5], - digest[6], - digest[7], - digest[8], - digest[9], - digest[10], - digest[11], - digest[12], - digest[13], - digest[14], - digest[15], - )) - } - - public static func appendingCommand( - to changes: [RecordingAssignmentChange], - assignment: RecordingAssignment, - issuedAt: Date, - issuedByDeviceID: RecordingDeviceID, - effectiveAt: Date, - reason: RecordingAssignmentReason, - ) throws -> RecordingAssignmentChange { - guard let heads = maximalHeads(in: changes) else { - throw RecordingPersistenceError.incompleteAssignmentHistory - } - let revision: Int64 - if let maximumRevision = heads.map(\.revision).max() { - let (next, overflow) = maximumRevision.addingReportingOverflow(1) - guard overflow == false else { - throw RecordingPersistenceError.assignmentRevisionExhausted - } - revision = next - } else { - revision = 0 - } - return RecordingAssignmentChange( - id: UUID(), - parentIDs: heads.map(\.id), - revision: revision, - issuedAt: issuedAt, - issuedByDeviceID: issuedByDeviceID, - effectiveAt: heads.reduce(effectiveAt) { max($0, $1.effectiveAt) }, - assignedDeviceID: assignment.deviceID, - reason: reason, - ) - } - - static func formValidPersistedTimeline(_ changes: [RecordingAssignmentChange]) -> Bool { - changes.isEmpty || causalGraph(in: changes) != nil - } - - static func isCanonicalBefore( - _ lhs: RecordingAssignmentChange, - _ rhs: RecordingAssignmentChange, - ) -> Bool { - if lhs.parentIDs != rhs.parentIDs { - return lhs.parentIDs.map(\.uuidString).joined(separator: ",") - < rhs.parentIDs.map(\.uuidString).joined(separator: ",") - } - if lhs.revision != rhs.revision { return lhs.revision < rhs.revision } - if lhs.issuedAt != rhs.issuedAt { return lhs.issuedAt < rhs.issuedAt } - if lhs.issuedByDeviceID != rhs.issuedByDeviceID { - return lhs.issuedByDeviceID.storeURL.absoluteString - < rhs.issuedByDeviceID.storeURL.absoluteString - } - if lhs.effectiveAt != rhs.effectiveAt { return lhs.effectiveAt < rhs.effectiveAt } - if lhs.assignedDeviceID != rhs.assignedDeviceID { - return (lhs.assignedDeviceID?.storeURL.absoluteString ?? "") - < (rhs.assignedDeviceID?.storeURL.absoluteString ?? "") - } - return lhs.reason.rawValue < rhs.reason.rawValue - } - - private static func resolveValidated( - _ changes: [RecordingAssignmentChange], - ) -> RecordingAssignmentResolution { - guard changes.isEmpty == false else { return .unconfigured } - guard let heads = causalGraph(in: changes)?.heads else { return .invalid } - if heads.contains(where: { $0.assignedDeviceID == nil }) { - return .resolved(.off) - } - let targets = Set(heads.compactMap(\.assignedDeviceID)) - guard targets.count == 1, let target = targets.first else { - return .conflict(targets) - } - return .resolved(.device(target)) - } - - private static func causalGraph( - in changes: [RecordingAssignmentChange], - ) -> CausalGraph? { - let groupedByID = Dictionary(grouping: changes, by: \.id) - guard groupedByID.values.allSatisfy({ $0.count == 1 }) else { return nil } - let byID = groupedByID.compactMapValues(\.first) - var parentIDs = Set() - for change in changes { - guard change.revision >= 0, - isValid(reason: change.reason, assignedDeviceID: change.assignedDeviceID) - else { return nil } - if change.revision == 0 { - guard change.parentIDs.isEmpty else { return nil } - continue - } - guard change.parentIDs.isEmpty == false, - change.parentIDs == change.parentIDs - .sorted(by: { $0.uuidString < $1.uuidString }), - Set(change.parentIDs).count == change.parentIDs.count, - change.parentIDs.contains(change.id) == false - else { return nil } - let parents = change.parentIDs.compactMap { byID[$0] } - guard parents.count == change.parentIDs.count, - let maximumRevision = parents.map(\.revision).max(), - maximumRevision < Int64.max, - change.revision == maximumRevision + 1, - parents.allSatisfy({ change.effectiveAt >= $0.effectiveAt }) - else { return nil } - parentIDs.formUnion(change.parentIDs) - } - let heads = changes.filter { parentIDs.contains($0.id) == false } - return heads.isEmpty ? nil : CausalGraph(heads: heads) - } - - private static func isValid( - reason: RecordingAssignmentReason, - assignedDeviceID: RecordingDeviceID?, - ) -> Bool { - switch reason { - case .onboarding, .userCommand, .backupMerge: true - case .accountReset, .backupReplace: assignedDeviceID == nil - } - } - - private static func isOrderedBefore( - _ lhs: RecordingAssignmentChange, - _ rhs: RecordingAssignmentChange, - ) -> Bool { - if lhs.revision != rhs.revision { return lhs.revision < rhs.revision } - return lhs.id.uuidString < rhs.id.uuidString - } -} diff --git a/Where/WhereCore/Sources/Devices/RecordingAssignmentFilter.swift b/Where/WhereCore/Sources/Devices/RecordingAssignmentFilter.swift deleted file mode 100644 index e8a36904..00000000 --- a/Where/WhereCore/Sources/Devices/RecordingAssignmentFilter.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -/// Applies the account-wide recording assignment to raw location samples. -/// -/// Assignment changes are append-only and evaluated at each sample timestamp. -/// Legacy samples without a device ID remain visible because no installation -/// can be attributed to them safely. -public enum RecordingAssignmentFilter { - public static func visibleSamples( - _ samples: [LocationSample], - assignmentChanges: [RecordingAssignmentChange], - ) -> [LocationSample] { - samples.filter { sample in - guard sample.source.isGPS, let deviceID = sample.recordingDeviceID else { - return true - } - return RecordingAssignmentChange.resolve( - assignmentChanges, - at: sample.timestamp, - ).permitsRecording(on: deviceID) - } - } -} diff --git a/Where/WhereCore/Sources/Devices/RecordingAuthoritySnapshot.swift b/Where/WhereCore/Sources/Devices/RecordingAuthoritySnapshot.swift deleted file mode 100644 index 1202c1c7..00000000 --- a/Where/WhereCore/Sources/Devices/RecordingAuthoritySnapshot.swift +++ /dev/null @@ -1,16 +0,0 @@ -/// Account-wide recording authority plus the installations that can be assigned. -public struct RecordingAuthoritySnapshot: Sendable, Hashable { - public let resolution: RecordingAssignmentResolution - public let devices: [RecordingDevice] - public let archivedDeviceIDs: Set - - public init( - resolution: RecordingAssignmentResolution, - devices: [RecordingDevice], - archivedDeviceIDs: Set, - ) { - self.resolution = resolution - self.devices = devices - self.archivedDeviceIDs = archivedDeviceIDs - } -} diff --git a/Where/WhereCore/Sources/Devices/RecordingDevice.swift b/Where/WhereCore/Sources/Devices/RecordingDevice.swift index d9ca4499..3529769e 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDevice.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDevice.swift @@ -18,7 +18,7 @@ public enum RecordingDeviceKind: String, Codable, Sendable, Hashable { } } -/// The last effective recording state acknowledged by a device. +/// The latest advisory recording status reported by a device. public enum RecordingDeviceStatus: String, Codable, Sendable, Hashable { /// The profile arrived before this installation's first check-in. case unknown @@ -29,9 +29,9 @@ public enum RecordingDeviceStatus: String, Codable, Sendable, Hashable { /// Read model for one device assembled from independently synced records. /// -/// The immutable profile, append-only nickname timeline, archive tombstone, and target-owned -/// check-in have -/// deliberately separate persistence rows. This aggregate is never written back wholesale: +/// The immutable profile, append-only nickname timeline, removal tombstone, and target-owned +/// check-in have deliberately separate persistence rows. This aggregate is never written back +/// wholesale: /// doing so would let CloudKit's last writer overwrite fields owned by another device. public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { public let id: RecordingDeviceID @@ -40,14 +40,7 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { public let kind: RecordingDeviceKind public let registeredAt: Date public let lastSeenAt: Date - public let archivedAt: Date? - public let lastAppliedAssignmentChangeID: UUID? - /// Stable storage field; see `RecordingDeviceCheckIn.lastDiscardedAssignmentChangeID`. - public let lastDiscardedAssignmentChangeID: UUID? - - var lastDiscardedAssignmentFrontierToken: RecordingAssignmentCleanupToken? { - lastDiscardedAssignmentChangeID.map(RecordingAssignmentCleanupToken.init(rawValue:)) - } + public let removedAt: Date? public let status: RecordingDeviceStatus @@ -58,8 +51,7 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { kind: RecordingDeviceKind, registeredAt: Date, lastSeenAt: Date, - archivedAt: Date?, - lastAppliedAssignmentChangeID: UUID?, + removedAt: Date?, status: RecordingDeviceStatus, ) { self.id = id @@ -68,9 +60,7 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { self.kind = kind self.registeredAt = registeredAt self.lastSeenAt = lastSeenAt - self.archivedAt = archivedAt - self.lastAppliedAssignmentChangeID = lastAppliedAssignmentChangeID - lastDiscardedAssignmentChangeID = nil + self.removedAt = removedAt self.status = status } @@ -83,7 +73,7 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { profile: RecordingDeviceProfile, nicknameChange: RecordingDeviceMetadataChange?, checkIn: RecordingDeviceCheckIn?, - archive: RecordingDeviceArchive?, + removal: RecordingDeviceRemoval?, ) { id = profile.id systemName = profile.systemName @@ -91,9 +81,7 @@ public struct RecordingDevice: Identifiable, Codable, Sendable, Hashable { kind = profile.kind registeredAt = profile.registeredAt lastSeenAt = checkIn?.lastSeenAt ?? profile.registeredAt - archivedAt = archive?.archivedAt - lastAppliedAssignmentChangeID = checkIn?.lastAppliedAssignmentChangeID - lastDiscardedAssignmentChangeID = checkIn?.lastDiscardedAssignmentChangeID + removedAt = removal?.removedAt status = checkIn?.status ?? .unknown } } diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceArchive.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceArchive.swift deleted file mode 100644 index 44ca76d3..00000000 --- a/Where/WhereCore/Sources/Devices/RecordingDeviceArchive.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -/// Irreversible, append-only tombstone hiding an installation from active device UI. -public struct RecordingDeviceArchive: Identifiable, Codable, Sendable, Hashable { - public let id: UUID - public let deviceID: RecordingDeviceID - public let archivedAt: Date - public let archivedByDeviceID: RecordingDeviceID - - public init( - id: UUID, - deviceID: RecordingDeviceID, - archivedAt: Date, - archivedByDeviceID: RecordingDeviceID, - ) { - self.id = id - self.deviceID = deviceID - self.archivedAt = archivedAt - self.archivedByDeviceID = archivedByDeviceID - } -} diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift index 42eb60aa..a545c066 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift @@ -1,18 +1,10 @@ import Foundation -/// Stable proof key for the destructive policy frontier whose outbox cleanup completed. -/// -/// A single barrier uses its event id; concurrent barriers use a deterministic digest of every -/// frontier event id, so this is deliberately not modeled as one policy-change identity. -struct RecordingAssignmentCleanupToken: RawRepresentable, Hashable { - let rawValue: UUID -} - -/// Latest policy acknowledgement and activity heartbeat written by one installation. +/// Latest recording state and activity heartbeat written by one installation. /// /// The target installation is the sole live writer for its check-in. Keeping this row apart /// from user-editable metadata prevents a local acknowledgement from reverting a remote rename -/// or recording authority, and vice versa. +/// or another installation's recording consent, and vice versa. public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable { public var id: RecordingDeviceID { deviceID @@ -22,45 +14,12 @@ public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable /// Monotonic sequence written only by the target installation. public let revision: Int64 public let lastSeenAt: Date - public let appliedAt: Date - public let lastAppliedAssignmentChangeID: UUID - /// Persisted UUID backing the destructive-frontier cleanup proof. It is an event id for a - /// singleton frontier and a deterministic digest for concurrent barriers. The legacy storage - /// name remains stable; domain code uses ``lastDiscardedAssignmentFrontierToken``. - public let lastDiscardedAssignmentChangeID: UUID? - - var lastDiscardedAssignmentFrontierToken: RecordingAssignmentCleanupToken? { - lastDiscardedAssignmentChangeID.map(RecordingAssignmentCleanupToken.init(rawValue:)) - } - public let status: RecordingDeviceStatus public init( deviceID: RecordingDeviceID, revision: Int64, lastSeenAt: Date, - appliedAt: Date, - lastAppliedAssignmentChangeID: UUID, - status: RecordingDeviceStatus, - ) { - precondition(revision >= 0, "A recording-device check-in revision cannot be negative.") - precondition(status != .unknown, "A persisted device check-in must have a known status.") - self.deviceID = deviceID - self.revision = revision - self.lastSeenAt = lastSeenAt - self.appliedAt = appliedAt - self.lastAppliedAssignmentChangeID = lastAppliedAssignmentChangeID - lastDiscardedAssignmentChangeID = nil - self.status = status - } - - init( - deviceID: RecordingDeviceID, - revision: Int64, - lastSeenAt: Date, - appliedAt: Date, - lastAppliedAssignmentChangeID: UUID, - lastDiscardedAssignmentFrontierToken: RecordingAssignmentCleanupToken?, status: RecordingDeviceStatus, ) { precondition(revision >= 0, "A recording-device check-in revision cannot be negative.") @@ -68,9 +27,6 @@ public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable self.deviceID = deviceID self.revision = revision self.lastSeenAt = lastSeenAt - self.appliedAt = appliedAt - self.lastAppliedAssignmentChangeID = lastAppliedAssignmentChangeID - lastDiscardedAssignmentChangeID = lastDiscardedAssignmentFrontierToken?.rawValue self.status = status } @@ -78,17 +34,6 @@ public struct RecordingDeviceCheckIn: Identifiable, Codable, Sendable, Hashable if lhs.revision != rhs.revision { return lhs.revision < rhs.revision } - if lhs.lastAppliedAssignmentChangeID != rhs.lastAppliedAssignmentChangeID { - return lhs.lastAppliedAssignmentChangeID.uuidString - < rhs.lastAppliedAssignmentChangeID.uuidString - } - if lhs.lastDiscardedAssignmentChangeID != rhs.lastDiscardedAssignmentChangeID { - return (lhs.lastDiscardedAssignmentChangeID?.uuidString ?? "") - < (rhs.lastDiscardedAssignmentChangeID?.uuidString ?? "") - } - if lhs.appliedAt != rhs.appliedAt { - return lhs.appliedAt < rhs.appliedAt - } if lhs.lastSeenAt != rhs.lastSeenAt { return lhs.lastSeenAt < rhs.lastSeenAt } diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift index fde607a4..72f39b72 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift @@ -1,43 +1,30 @@ import Foundation -/// One installation row paired with the account-wide assignment resolution. +/// One synced device row plus the local preference available only for this installation. public struct RecordingDeviceConfiguration: Identifiable, Sendable, Hashable { public let device: RecordingDevice - public let assignmentResolution: RecordingAssignmentResolution - public let assignmentFrontierID: UUID? - public let isAssignmentAcknowledged: Bool - public let isArchived: Bool + public let isCurrentDevice: Bool + public let localAutomaticRecordingEnabled: Bool? public var id: RecordingDeviceID { device.id } - /// Whether this row is the one assigned recorder. Nil means authority is not safely resolved. - public var isEnabled: Bool? { - guard let assignment = assignmentResolution.assignment else { return nil } - return assignment.deviceID == id - } - - public var latestAssignmentChangeID: UUID? { - assignmentFrontierID - } - - public var isPending: Bool { - guard assignmentResolution.assignment != nil else { return true } - return isEnabled == true && isAssignmentAcknowledged == false + public var isRemoved: Bool { + device.removedAt != nil } public init( device: RecordingDevice, - assignmentResolution: RecordingAssignmentResolution, - assignmentFrontierID: UUID?, - isAssignmentAcknowledged: Bool, - isArchived: Bool, + isCurrentDevice: Bool, + localAutomaticRecordingEnabled: Bool?, ) { + precondition( + isCurrentDevice || localAutomaticRecordingEnabled == nil, + "A remote device cannot expose another installation's local preference.", + ) self.device = device - self.assignmentResolution = assignmentResolution - self.assignmentFrontierID = assignmentFrontierID - self.isAssignmentAcknowledged = isAssignmentAcknowledged - self.isArchived = isArchived + self.isCurrentDevice = isCurrentDevice + self.localAutomaticRecordingEnabled = localAutomaticRecordingEnabled } } diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift index 9d9869e1..657ed20e 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift @@ -7,8 +7,8 @@ public enum RecordingDeviceMetadataField: String, Codable, Sendable, Hashable { /// Append-only nickname edit for one recording installation. /// -/// Recording authority deliberately does not live here; the account-wide assignment and -/// irreversible archive tombstones own it. +/// Recording consent deliberately does not live here; it stays installation-local while +/// irreversible removal tombstones sync separately. public struct RecordingDeviceMetadataChange: Identifiable, Codable, Sendable, Hashable { public let id: UUID public let deviceID: RecordingDeviceID diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceRemoval.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceRemoval.swift new file mode 100644 index 00000000..2af2ae7e --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceRemoval.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Irreversible, append-only tombstone retiring one installation identity. +public struct RecordingDeviceRemoval: Identifiable, Codable, Sendable, Hashable { + public let id: UUID + public let deviceID: RecordingDeviceID + public let removedAt: Date + public let removedByDeviceID: RecordingDeviceID + + public init( + id: UUID, + deviceID: RecordingDeviceID, + removedAt: Date, + removedByDeviceID: RecordingDeviceID, + ) { + self.id = id + self.deviceID = deviceID + self.removedAt = removedAt + self.removedByDeviceID = removedByDeviceID + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceRemovalFilter.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceRemovalFilter.swift new file mode 100644 index 00000000..219a5f5b --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceRemovalFilter.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Applies permanent device-removal cutoffs to raw location samples. +public enum RecordingDeviceRemovalFilter { + public static func visibleSamples( + _ samples: [LocationSample], + removals: [RecordingDeviceRemoval], + ) -> [LocationSample] { + let cutoffs = Dictionary(grouping: removals, by: \.deviceID) + .compactMapValues { $0.map(\.removedAt).min() } + return samples.filter { sample in + guard sample.source.isGPS, let deviceID = sample.recordingDeviceID else { + return true + } + guard let cutoff = cutoffs[deviceID] else { return true } + return sample.timestamp < cutoff + } + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingDeviceRuntimeState.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceRuntimeState.swift index ed44b86f..68451074 100644 --- a/Where/WhereCore/Sources/Devices/RecordingDeviceRuntimeState.swift +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceRuntimeState.swift @@ -1,8 +1,10 @@ /// Honest physical state of automatic recording on the current installation. public enum RecordingDeviceRuntimeState: Sendable, Hashable { - /// Desired policy, physical monitoring, and durable acknowledgement agree. + /// Local consent, physical monitoring, and the advisory check-in agree. case applied(RecordingDeviceConfiguration) - /// Core stopped monitoring because it could not prove or persist the applicable policy. + /// This installation identity was globally removed and cannot record again. + case removed + /// Core stopped monitoring because it could not read or persist the applicable state. case unavailable } diff --git a/Where/WhereCore/Sources/Devices/RecordingOnboardingRecommendation.swift b/Where/WhereCore/Sources/Devices/RecordingOnboardingRecommendation.swift new file mode 100644 index 00000000..d3d08a1e --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingOnboardingRecommendation.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Advisory first-run choice derived from recently synced device status. +public struct RecordingOnboardingRecommendation: Sendable, Hashable { + public static let recentActivityWindow: TimeInterval = 24 * 60 * 60 + + public let isEnabled: Bool + public let recentRecordingDevice: RecordingDevice? + + public init(isEnabled: Bool, recentRecordingDevice: RecordingDevice?) { + self.isEnabled = isEnabled + self.recentRecordingDevice = recentRecordingDevice + } + + public init( + for installation: CurrentRecordingDevice, + devices: [RecordingDevice], + now: Date, + ) { + let cutoff = now.addingTimeInterval(-Self.recentActivityWindow) + let recent = devices + .filter { + $0.id != installation.id + && $0.removedAt == nil + && $0.lastSeenAt >= cutoff + && ($0.status == .recording || $0.status == .permissionRequired) + } + .max { $0.lastSeenAt < $1.lastSeenAt } + recentRecordingDevice = recent + isEnabled = installation.kind.recommendsAutomaticRecording && recent == nil + } +} diff --git a/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift b/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift index d957fde1..8e92574f 100644 --- a/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift +++ b/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift @@ -2,12 +2,11 @@ import Foundation /// Honest failures from the append-only recording persistence boundary. public enum RecordingPersistenceError: Error, LocalizedError, Sendable, Hashable { - case incompleteAssignmentHistory - case assignmentRevisionExhausted + case incompleteRemovalHistory case conflictingImmutableRecord(id: UUID) case deviceNotFound(RecordingDeviceID) case currentDeviceNotRegistered(RecordingDeviceID) - case currentDeviceAssignmentUnknown(RecordingDeviceID) + case currentDeviceRemoved(RecordingDeviceID) case revisionExhausted(RecordingDeviceID) case incompleteDataEpochHistory case dataEpochRevisionExhausted @@ -16,17 +15,15 @@ public enum RecordingPersistenceError: Error, LocalizedError, Sendable, Hashable public var errorDescription: String? { switch self { - case .incompleteAssignmentHistory: + case .incompleteRemovalHistory: String(localized: .recordingErrorIncompletePolicyHistory) - case .assignmentRevisionExhausted: - String(localized: .recordingErrorRevisionExhausted) case .conflictingImmutableRecord: String(localized: .recordingErrorConflictingImmutableRecord) case .deviceNotFound: String(localized: .recordingErrorDeviceNotFound) case .currentDeviceNotRegistered: String(localized: .recordingErrorCurrentDeviceNotRegistered) - case .currentDeviceAssignmentUnknown: + case .currentDeviceRemoved: String(localized: .recordingErrorCurrentDevicePolicyUnknown) case .revisionExhausted: String(localized: .recordingErrorRevisionExhausted) diff --git a/Where/WhereCore/Sources/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index 53560f27..a1ad11f8 100644 --- a/Where/WhereCore/Sources/Journal/DayJournal.swift +++ b/Where/WhereCore/Sources/Journal/DayJournal.swift @@ -247,21 +247,21 @@ public actor DayJournal { let resetAt = now() try await Self.logger.measure(.eraseAllData, budget: .seconds(10)) { try await store.perform { - let epoch = try await store.rotateDataEpoch( + let deviceIDs = try await Set(store.recordingDeviceProfiles().map(\.id)) + .union([currentDeviceID]) + _ = try await store.rotateDataEpoch( reason: .accountReset, changedBy: currentDeviceID, at: resetAt, ) - try await store.addRecordingAssignmentChange(RecordingAssignmentChange( - id: UUID(), - parentIDs: [], - revision: 0, - issuedAt: resetAt, - issuedByDeviceID: currentDeviceID, - effectiveAt: epoch.changedAt, - assignedDeviceID: nil, - reason: .accountReset, - )) + for deviceID in deviceIDs { + try await store.addRecordingDeviceRemoval(RecordingDeviceRemoval( + id: UUID(), + deviceID: deviceID, + removedAt: resetAt, + removedByDeviceID: currentDeviceID, + )) + } } } await reconcileAfterDayChange() diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index f13ce680..73886e4b 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -70,7 +70,7 @@ public actor LocationIngestor { /// of background monitoring: an enabled When-In-Use device may take a foreground fix while /// monitoring remains paused. private var acceptsSamples = false - /// Logical generation whose recording authority opened the sample gate. Every persist and + /// Logical generation whose local recording choice opened the sample gate. Every persist and /// retry uses this as an expected-epoch token, so a remote reset cannot restamp an in-flight /// old-authority sample into the new generation. private var authorizedDataEpochID: WhereDataEpochID? @@ -209,7 +209,7 @@ public actor LocationIngestor { } // Rows written before device provenance existed intentionally remain unstamped and // legacy-visible. Re-attributing them to this installation would make them depend on - // an assignment event that did not exist when they were captured. + // a device identity that did not exist when they were captured. retryQueue = restored + retryQueue didLoadDurableBacklog = true } @@ -382,7 +382,7 @@ public actor LocationIngestor { guard let sample = fix else { return } // The ~10s fix may have straddled a `pause()`; re-check the gate before // persisting, mirroring `ingest(_:)`. The guard and the `capturePersistTask` - // assignment are synchronous (no `await` between), so a concurrent + // assignment to the capture task is synchronous (no `await` between), so a concurrent // `pause()` either sees `acceptsSamples == false` here (we skip) or sees // the handle already set (it awaits us) — never neither. guard !Task.isCancelled, accepts(sample) else { return } diff --git a/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift index 58115315..4ab5c251 100644 --- a/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift +++ b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift @@ -1,6 +1,6 @@ import PeriscopeCore -/// Structured failures from background recording-assignment reconciliation. +/// Structured failures from local recording and synced-removal reconciliation. enum DeviceRecordingControllerLog: LogEvent { case policyObservationFailed(description: String) case rollbackRecoveryFailed(description: String) @@ -15,7 +15,7 @@ enum DeviceRecordingControllerLog: LogEvent { var message: String { switch self { case let .policyObservationFailed(description): - "Failed to apply a synced recording assignment; recording was stopped: \(description)" + "Failed to reconcile recording state; recording was stopped: \(description)" case let .rollbackRecoveryFailed(description): "Failed to restore recording after an operation rolled back: \(description)" case let .importRecoveryFailed(description): diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index c6d2721b..d98e59f8 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -257,8 +257,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { SDRecordingDeviceProfile.self, SDRecordingDeviceMetadataChange.self, SDRecordingDeviceCheckIn.self, - SDRecordingAssignmentChange.self, - SDRecordingDeviceArchive.self, + SDRecordingDeviceRemoval.self, ] } @@ -458,8 +457,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { profiles: [RecordingDeviceProfile], metadataChanges: [RecordingDeviceMetadataChange], checkIns: [RecordingDeviceCheckIn], - assignmentChanges: [RecordingAssignmentChange], - archives: [RecordingDeviceArchive], + removals: [RecordingDeviceRemoval], ) async throws { try await perform(sendsChange: false, expectedDataEpochID: nil) { for profile in profiles { @@ -471,11 +469,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { for checkIn in checkIns { try await self.setRecordingDeviceCheckIn(checkIn) } - for assignmentChange in assignmentChanges { - try await self.addRecordingAssignmentChange(assignmentChange) - } - for archive in archives { - try await self.addRecordingDeviceArchive(archive) + for removal in removals { + try await self.addRecordingDeviceRemoval(removal) } } } @@ -844,12 +839,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { { context.delete(record) } - for record in try context.fetch(FetchDescriptor()) - where belongs(record.epochID, to: epochID) - { - context.delete(record) - } - for record in try context.fetch(FetchDescriptor()) + for record in try context.fetch(FetchDescriptor()) where belongs(record.epochID, to: epochID) { context.delete(record) @@ -922,12 +912,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { async let profiles = recordingDeviceProfiles() async let metadataChanges = recordingDeviceMetadataChanges() async let checkIns = recordingDeviceCheckIns() - async let archives = recordingDeviceArchives() - let (resolvedProfiles, resolvedMetadata, resolvedCheckIns, resolvedArchives) = try await ( + async let removals = recordingDeviceRemovals() + let (resolvedProfiles, resolvedMetadata, resolvedCheckIns, resolvedRemovals) = try await ( profiles, metadataChanges, checkIns, - archives, + removals, ) let latestNicknames = Dictionary( grouping: resolvedMetadata.filter { $0.field == .nickname }, @@ -937,15 +927,15 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let checkInsByDevice = Dictionary(uniqueKeysWithValues: resolvedCheckIns.map { ($0.deviceID, $0) }) - let archivesByDevice = Dictionary(grouping: resolvedArchives, by: \.deviceID) - .compactMapValues { $0.min(by: { $0.archivedAt < $1.archivedAt }) } + let removalsByDevice = Dictionary(grouping: resolvedRemovals, by: \.deviceID) + .compactMapValues { $0.min(by: { $0.removedAt < $1.removedAt }) } return resolvedProfiles .map { RecordingDevice( profile: $0, nicknameChange: latestNicknames[$0.id], checkIn: checkInsByDevice[$0.id], - archive: archivesByDevice[$0.id], + removal: removalsByDevice[$0.id], ) } .sorted { @@ -1110,11 +1100,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } - public func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { + public func recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] { let context = readContext() let epochID = try readEpochID(in: context) - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.revision), SortDescriptor(\.id)], + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.removedAt), SortDescriptor(\.id)], ) descriptor.includePendingChanges = true let records = try context.fetch(descriptor) @@ -1122,75 +1112,41 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let values = try records.map { record in guard let value = record.toValue() else { Self.logFault(forCorrupt: record) - throw RecordingPersistenceError.incompleteAssignmentHistory + throw RecordingPersistenceError.incompleteRemovalHistory } return value } - let canonicalValues = try Dictionary(grouping: values, by: \.id).map { id, duplicates in - guard let canonical = duplicates.first else { - preconditionFailure("A grouped assignment event must contain at least one value.") + return try Dictionary(grouping: values, by: \.id) + .map { id, duplicates in + guard let canonical = duplicates.first else { + preconditionFailure("A grouped removal must contain at least one value.") + } + guard duplicates.allSatisfy({ $0 == canonical }) else { + Self.logImmutableConflict( + type: String(describing: RecordingDeviceRemoval.self), + id: id.uuidString, + count: duplicates.count, + ) + throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + } + return canonical } - guard duplicates.allSatisfy({ $0 == canonical }) else { - Self.logImmutableConflict( - type: String(describing: RecordingAssignmentChange.self), - id: id.uuidString, - count: duplicates.count, - ) - throw RecordingPersistenceError.conflictingImmutableRecord(id: id) + .sorted { + $0.removedAt == $1.removedAt + ? $0.id.uuidString < $1.id.uuidString + : $0.removedAt < $1.removedAt } - return canonical - } - guard RecordingAssignmentChange.formValidPersistedTimeline(canonicalValues) else { - throw RecordingPersistenceError.incompleteAssignmentHistory - } - return canonicalValues.sorted { - $0.revision == $1.revision - ? $0.id.uuidString < $1.id.uuidString - : $0.revision < $1.revision - } - } - - public func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { - let context = mutationContext() - let epochID = mutationEpochID() - let id = change.id - let existing = try context.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == id }), - ).filter { Self.belongs($0.epochID, to: epochID) } - guard existing.isEmpty == false else { - context.insert(SDRecordingAssignmentChange(value: change, epochID: epochID)) - return - } - guard existing.allSatisfy({ $0.toValue() == change }) else { - throw RecordingPersistenceError.conflictingImmutableRecord(id: id) - } - for duplicate in existing.dropFirst() { - context.delete(duplicate) - } - } - - public func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] { - let context = readContext() - let epochID = try readEpochID(in: context) - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.archivedAt), SortDescriptor(\.id)], - ) - descriptor.includePendingChanges = true - let records: [SDRecordingDeviceArchive] = try context.fetch(descriptor) - return records - .filter { Self.belongs($0.epochID, to: epochID) } - .compactMap { $0.toValue() } } - public func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws { + public func addRecordingDeviceRemoval(_ archive: RecordingDeviceRemoval) async throws { let context = mutationContext() let epochID = mutationEpochID() let id = archive.id let existing = try context.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == id }), + FetchDescriptor(predicate: #Predicate { $0.id == id }), ).filter { Self.belongs($0.epochID, to: epochID) } guard existing.isEmpty == false else { - context.insert(SDRecordingDeviceArchive(value: archive, epochID: epochID)) + context.insert(SDRecordingDeviceRemoval(value: archive, epochID: epochID)) return } guard existing.allSatisfy({ $0.toValue() == archive }) else { @@ -2125,7 +2081,7 @@ final class SDRecordingDeviceMetadataChange { } } -/// Target-owned acknowledgement/check-in row. No other installation writes this row during +/// Target-owned status/check-in row. No other installation writes this row during /// normal operation, so a whole-value update cannot clobber user metadata. @Model final class SDRecordingDeviceCheckIn { @@ -2133,11 +2089,6 @@ final class SDRecordingDeviceCheckIn { var deviceID: UUID? var revision: Int64? var lastSeenAt: Date? - var appliedAt: Date? - var lastAppliedAssignmentChangeID: UUID? - /// Singleton destructive event id or deterministic multi-head frontier digest. The field name - /// predates multi-parent policy and remains stable for CloudKit compatibility. - var lastDiscardedAssignmentChangeID: UUID? var statusRaw: String? init() {} @@ -2152,9 +2103,6 @@ final class SDRecordingDeviceCheckIn { deviceID = value.deviceID.rawValue revision = value.revision lastSeenAt = value.lastSeenAt - appliedAt = value.appliedAt - lastAppliedAssignmentChangeID = value.lastAppliedAssignmentChangeID - lastDiscardedAssignmentChangeID = value.lastDiscardedAssignmentChangeID statusRaw = value.status.rawValue } @@ -2163,8 +2111,6 @@ final class SDRecordingDeviceCheckIn { let revision, revision >= 0, let lastSeenAt, - let appliedAt, - let lastAppliedAssignmentChangeID, let statusRaw, let status = RecordingDeviceStatus(rawValue: statusRaw), status != .unknown @@ -2173,94 +2119,38 @@ final class SDRecordingDeviceCheckIn { deviceID: RecordingDeviceID(rawValue: deviceID), revision: revision, lastSeenAt: lastSeenAt, - appliedAt: appliedAt, - lastAppliedAssignmentChangeID: lastAppliedAssignmentChangeID, - lastDiscardedAssignmentFrontierToken: lastDiscardedAssignmentChangeID.map { - RecordingAssignmentCleanupToken(rawValue: $0) - }, status: status, ) } } -/// Account-wide automatic-recording assignment command. -@Model -final class SDRecordingAssignmentChange { - var epochID: UUID? - var id: UUID? - var parentIDs: [UUID]? - var revision: Int64? - var issuedAt: Date? - var issuedByDeviceID: UUID? - var effectiveAt: Date? - var assignedDeviceID: UUID? - var reasonRaw: String? - - init() {} - - convenience init(value: RecordingAssignmentChange, epochID: WhereDataEpochID) { - self.init() - self.epochID = epochID.rawValue - id = value.id - parentIDs = value.parentIDs - revision = value.revision - issuedAt = value.issuedAt - issuedByDeviceID = value.issuedByDeviceID.rawValue - effectiveAt = value.effectiveAt - assignedDeviceID = value.assignedDeviceID?.rawValue - reasonRaw = value.reason.rawValue - } - - func toValue() -> RecordingAssignmentChange? { - guard let id, - let parentIDs, - let revision, - let issuedAt, - let issuedByDeviceID, - let effectiveAt, - let reasonRaw, - let reason = RecordingAssignmentReason(rawValue: reasonRaw) - else { return nil } - return RecordingAssignmentChange.persisted( - id: id, - parentIDs: parentIDs, - revision: revision, - issuedAt: issuedAt, - issuedByDeviceID: RecordingDeviceID(rawValue: issuedByDeviceID), - effectiveAt: effectiveAt, - assignedDeviceID: assignedDeviceID.map(RecordingDeviceID.init(rawValue:)), - reason: reason, - ) - } -} - -/// Irreversible installation archive tombstone. +/// Irreversible installation removal tombstone. @Model -final class SDRecordingDeviceArchive { +final class SDRecordingDeviceRemoval { var epochID: UUID? var id: UUID? var deviceID: UUID? - var archivedAt: Date? - var archivedByDeviceID: UUID? + var removedAt: Date? + var removedByDeviceID: UUID? init() {} - convenience init(value: RecordingDeviceArchive, epochID: WhereDataEpochID) { + convenience init(value: RecordingDeviceRemoval, epochID: WhereDataEpochID) { self.init() self.epochID = epochID.rawValue id = value.id deviceID = value.deviceID.rawValue - archivedAt = value.archivedAt - archivedByDeviceID = value.archivedByDeviceID.rawValue + removedAt = value.removedAt + removedByDeviceID = value.removedByDeviceID.rawValue } - func toValue() -> RecordingDeviceArchive? { - guard let id, let deviceID, let archivedAt, let archivedByDeviceID else { return nil } - return RecordingDeviceArchive( + func toValue() -> RecordingDeviceRemoval? { + guard let id, let deviceID, let removedAt, let removedByDeviceID else { return nil } + return RecordingDeviceRemoval( id: id, deviceID: RecordingDeviceID(rawValue: deviceID), - archivedAt: archivedAt, - archivedByDeviceID: RecordingDeviceID(rawValue: archivedByDeviceID), + removedAt: removedAt, + removedByDeviceID: RecordingDeviceID(rawValue: removedByDeviceID), ) } } diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 07fc2928..f3bfa28a 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -72,7 +72,7 @@ public protocol WhereStore: Sendable { /// Atomically erase the active epoch's synced rows and append a fresh destructive epoch. /// Every subsequent write in the same transaction is stamped into the returned epoch. /// Immutable device profiles remain global so a late/offline installation can still be - /// identified, but its old assignment and user-data rows cannot affect the new generation. + /// identified, but its old user-data rows cannot affect the new generation. func rotateDataEpoch( reason: WhereDataEpochReason, changedBy deviceID: RecordingDeviceID, @@ -104,7 +104,7 @@ public protocol WhereStore: Sendable { func samples(in interval: DateInterval) async throws -> [LocationSample] func allSamples() async throws -> [LocationSample] - /// Every assembled synced device read model, including archived devices. + /// Every assembled synced device read model, including removed devices. func recordingDevices() async throws -> [RecordingDevice] /// Immutable installation profiles. @@ -127,17 +127,11 @@ public protocol WhereStore: Sendable { /// Must run inside `perform { ... }`. func setRecordingDeviceCheckIn(_ checkIn: RecordingDeviceCheckIn) async throws - /// Complete account-wide automatic-recording assignment history for the active epoch. - func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] - - /// Insert one immutable global assignment command. Must run inside `perform { ... }`. - func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws - /// Irreversible device tombstones in the active epoch. - func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] + func recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] - /// Insert an immutable archive tombstone. Must run inside `perform { ... }`. - func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws + /// Insert an immutable removal tombstone. Must run inside `perform { ... }`. + func addRecordingDeviceRemoval(_ removal: RecordingDeviceRemoval) async throws func write(evidence: Evidence, blob: Data?) async throws func evidence(in interval: DateInterval) async throws -> [Evidence] diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 39630478..67ab5e6c 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -155,7 +155,7 @@ public struct WhereServices: Sendable { ) let liveAttribution = attributor as? RegionAttribution let reconcileAllDerivedData: @Sendable () async -> Void = { - // Remote, backup, and recording-assignment writes can change the tracked set at the + // Remote, backup, and device-ledger writes can change the tracked set at the // same // time as the data being rebuilt. Await the shared live attributor first so every // downstream projection starts from current attribution instead of racing its diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 4ca872f9..9df6da4c 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -92,8 +92,6 @@ struct BackupCoordinatorTests { private static let recordingDeviceID = RecordingDeviceID( rawValue: UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")!, ) - private static let recordingAssignmentID = - UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")! /// Seed every persisted domain directly into a store so backup tests don't /// depend on the journal or recording controller. @@ -126,19 +124,13 @@ struct BackupCoordinatorTests { deviceID: recordingDeviceID, revision: 0, lastSeenAt: dismissal.dismissedAt, - appliedAt: dismissal.dismissedAt, - lastAppliedAssignmentChangeID: recordingAssignmentID, status: .recording, )) - try await store.addRecordingAssignmentChange(RecordingAssignmentChange( - id: recordingAssignmentID, - parentIDs: [], - revision: 0, - issuedAt: dismissal.dismissedAt, - issuedByDeviceID: recordingDeviceID, - effectiveAt: dismissal.dismissedAt, - assignedDeviceID: recordingDeviceID, - reason: .onboarding, + try await store.addRecordingDeviceRemoval(RecordingDeviceRemoval( + id: UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")!, + deviceID: recordingDeviceID, + removedAt: dismissal.dismissedAt, + removedByDeviceID: recordingDeviceID, )) } } @@ -158,7 +150,7 @@ struct BackupCoordinatorTests { #expect(summary.manualDayCount == 1) #expect(summary.dismissedIssueCount == 1) #expect(summary.recordingDeviceCount == 1) - #expect(summary.recordingAssignmentChangeCount == 1) + #expect(summary.recordingDeviceRemovalCount == 1) #expect(try await destination.store.allSamples() == source.store.allSamples()) #expect(try await destination.store.allEvidence() == source.store.allEvidence()) @@ -171,15 +163,11 @@ struct BackupCoordinatorTests { .recordingDeviceProfiles()) #expect(try await destination.store.recordingDeviceMetadataChanges() == source.store .recordingDeviceMetadataChanges()) - // Check-ins prove that a particular installation applied policy and cleared its own - // outbox. A backup cannot safely reproduce that proof on another installation. + // Check-ins are live advisory status from a particular installation. A backup cannot + // safely reproduce that status on another installation. #expect(try await destination.store.recordingDeviceCheckIns().isEmpty) - let assignments = try await destination.store.recordingAssignmentChanges() - let sourceAssignments = try await source.store.recordingAssignmentChanges() - #expect(Array(assignments.dropLast()) == sourceAssignments) - #expect(assignments.last?.parentIDs == [Self.recordingAssignmentID]) - #expect(assignments.last?.assignedDeviceID == nil) - #expect(assignments.last?.reason == .backupMerge) + #expect(try await destination.store.recordingDeviceRemovals() == source.store + .recordingDeviceRemovals()) #expect(try await destination.store.evidenceBlob(for: Self.evidence.id) == Self.blob) // An import that lands new data runs the post-commit hook once. #expect(await destination.didCommit.count == 1) @@ -209,6 +197,7 @@ struct BackupCoordinatorTests { defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let destination = try Self.makeHarness() + let previouslyRemovedDeviceID = RecordingDeviceID(rawValue: UUID()) try await destination.store.perform { try await destination.store.add(sample: Self.sample(at: "2026-01-01T09:00:00-08:00")) try await destination.store.setManualDay(DayPresence( @@ -222,6 +211,12 @@ struct BackupCoordinatorTests { id: .missingDays(start: CalendarDay(year: 2026, month: 1, day: 2)), dismissedAt: Date(timeIntervalSince1970: 1), )) + try await destination.store.addRecordingDeviceRemoval(RecordingDeviceRemoval( + id: UUID(), + deviceID: previouslyRemovedDeviceID, + removedAt: Date(timeIntervalSinceReferenceDate: 500), + removedByDeviceID: Self.recordingDeviceID, + )) } _ = try await destination.coordinator.importBackup(from: url, strategy: .replace) @@ -231,6 +226,10 @@ struct BackupCoordinatorTests { #expect(try await destination.store.allDismissedIssues() == source.store .allDismissedIssues()) #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) + #expect(try await Set(destination.store.recordingDeviceRemovals().map(\.deviceID)) == [ + Self.recordingDeviceID, + previouslyRemovedDeviceID, + ]) } @Test func replaceImportRestoresTheArchivesTrackedRegions() async throws { @@ -471,8 +470,7 @@ struct BackupCoordinatorTests { manualDays: [], recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: [], - recordingDeviceArchives: [], + recordingDeviceRemovals: [], blobs: [:], ) defer { try? FileManager.default.removeItem(at: secondURL.deletingLastPathComponent()) } diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index fd5fd7d1..6ed52103 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -13,8 +13,6 @@ struct BackupServiceTests { private static let recordingDeviceID = RecordingDeviceID( rawValue: UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!, ) - private static let recordingAssignmentID = - UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")! private static func sampleFixtures() -> [LocationSample] { [ @@ -70,31 +68,12 @@ struct BackupServiceTests { deviceID: recordingDeviceID, revision: 0, lastSeenAt: exportDate, - appliedAt: exportDate, - lastAppliedAssignmentChangeID: recordingAssignmentID, status: .recording, ), ] } - private static func recordingAssignmentFixtures() -> [RecordingAssignmentChange] { - [ - RecordingAssignmentChange( - id: recordingAssignmentID, - parentIDs: [], - revision: 0, - issuedAt: exportDate, - issuedByDeviceID: recordingDeviceID, - effectiveAt: exportDate, - assignedDeviceID: recordingDeviceID, - reason: .onboarding, - ), - ] - } - - private static func archive( - recordingAssignmentChanges: [RecordingAssignmentChange], - ) -> BackupArchive { + private static func archive() -> BackupArchive { BackupArchive( exportedAt: exportDate, samples: [], @@ -105,8 +84,7 @@ struct BackupServiceTests { primaryRegions: [], recordingDeviceProfiles: recordingDeviceProfileFixtures(), recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: recordingAssignmentChanges, - recordingDeviceArchives: [], + recordingDeviceRemovals: [], assets: [], ) } @@ -165,12 +143,11 @@ struct BackupServiceTests { let dismissedIssues = Self.dismissedIssueFixtures() let recordingDeviceProfiles = Self.recordingDeviceProfileFixtures() let recordingDeviceMetadataChanges = Self.recordingDeviceMetadataFixtures() - let recordingAssignments = Self.recordingAssignmentFixtures() - let deviceArchive = RecordingDeviceArchive( + let deviceArchive = RecordingDeviceRemoval( id: UUID(), deviceID: Self.recordingDeviceID, - archivedAt: Self.exportDate, - archivedByDeviceID: Self.recordingDeviceID, + removedAt: Self.exportDate, + removedByDeviceID: Self.recordingDeviceID, ) let url = try service.makeArchiveFile( @@ -180,8 +157,7 @@ struct BackupServiceTests { dismissedIssues: dismissedIssues, recordingDeviceProfiles: recordingDeviceProfiles, recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, - recordingAssignmentChanges: recordingAssignments, - recordingDeviceArchives: [deviceArchive], + recordingDeviceRemovals: [deviceArchive], blobs: blobs, exportedAt: Self.exportDate, ) @@ -201,13 +177,11 @@ struct BackupServiceTests { #expect(result.archive.dismissedIssues == dismissedIssues) #expect(result.archive.recordingDeviceProfiles == recordingDeviceProfiles) #expect(result.archive.recordingDeviceMetadataChanges == recordingDeviceMetadataChanges) - #expect(result.archive.recordingAssignmentChanges == recordingAssignments) - #expect(result.archive.recordingDeviceArchives == [deviceArchive]) + #expect(result.archive.recordingDeviceRemovals == [deviceArchive]) let encodedManifest = try #require(String( data: BackupService.makeEncoder().encode(result.archive), encoding: .utf8, )) - #expect(encodedManifest.contains("\"reason\" : \"onboarding\"")) #expect(encodedManifest.contains("\"isEnabled\"") == false) #expect(encodedManifest.contains("\"registrationEpochID\"")) #expect(encodedManifest.contains("00000000-0000-0000-0000-0000000000E0")) @@ -216,54 +190,6 @@ struct BackupServiceTests { #expect(result.blobs == blobs) } - @Test func rapidAssignmentChangesPreserveTheirSubsecondOrder() throws { - let service = BackupService() - let firstDate = Date(timeIntervalSince1970: 1_700_000_000.123_456) - let assignments = try [ - RecordingAssignmentChange( - id: #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")), - parentIDs: [], - revision: 0, - issuedAt: firstDate, - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: firstDate, - assignedDeviceID: Self.recordingDeviceID, - reason: .onboarding, - ), - RecordingAssignmentChange( - id: #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")), - parentIDs: [#require(UUID( - uuidString: "11111111-1111-1111-1111-111111111111", - ))], - revision: 1, - issuedAt: firstDate.addingTimeInterval(0.000_001), - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: firstDate.addingTimeInterval(0.000_001), - assignedDeviceID: nil, - reason: .userCommand, - ), - ] - let url = try service.makeArchiveFile( - samples: [], - evidence: [], - manualDays: [], - recordingDeviceProfiles: [], - recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: assignments, - recordingDeviceArchives: [], - blobs: [:], - exportedAt: Self.exportDate, - ) - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - - let restored = try service.readArchive(at: url).archive.recordingAssignmentChanges - - #expect(restored == assignments) - let first = try #require(restored.first) - let last = try #require(restored.last) - #expect(first.effectiveAt < last.effectiveAt) - } - @Test func decoderAcceptsLegacyWholeSecondISO8601Dates() throws { let archive = BackupArchive( exportedAt: Self.exportDate, @@ -275,8 +201,7 @@ struct BackupServiceTests { primaryRegions: [], recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: [], - recordingDeviceArchives: [], + recordingDeviceRemovals: [], assets: [], ) let legacyEncoder = JSONEncoder() @@ -303,7 +228,7 @@ struct BackupServiceTests { @Test func currentFormatDoesNotSilentlyBackfillAMissingProfileEpoch() throws { let data = try BackupService.makeEncoder().encode( - Self.archive(recordingAssignmentChanges: Self.recordingAssignmentFixtures()), + Self.archive(), ) var manifest = try #require( JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -329,8 +254,8 @@ struct BackupServiceTests { } } - @Test func validationRejectsANegativeAssignmentRevisionDecodedFromABackup() throws { - let archive = try BackupArchive( + @Test func decoderRejectsANegativeMetadataRevisionFromABackup() throws { + let archive = BackupArchive( exportedAt: Self.exportDate, samples: [], evidence: [], @@ -339,9 +264,8 @@ struct BackupServiceTests { trackedRegions: [], primaryRegions: [], recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), - recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: [#require(Self.recordingAssignmentFixtures().first)], - recordingDeviceArchives: [], + recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), + recordingDeviceRemovals: [], assets: [], ) var json = try #require(String( @@ -350,39 +274,13 @@ struct BackupServiceTests { )) let revision = try #require(json.range(of: "\"revision\" : 0")) json.replaceSubrange(revision, with: "\"revision\" : -1") - let decoded = try BackupService.makeDecoder().decode( - BackupArchive.self, - from: Data(json.utf8), - ) - do { - try BackupService.validateRecordingData(decoded) - Issue.record("Expected the negative assignment revision to be rejected.") - } catch BackupService.BackupError.invalidRecordingData { - // Expected. - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func validationRejectsAGapInADeviceAssignmentTimeline() throws { - let initial = try #require(Self.recordingAssignmentFixtures().first) - let skippedRevision = try RecordingAssignmentChange( - id: #require(UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")), - parentIDs: [initial.id], - revision: 2, - issuedAt: Self.exportDate.addingTimeInterval(1), - issuedByDeviceID: Self.recordingDeviceID, - effectiveAt: Self.exportDate.addingTimeInterval(1), - assignedDeviceID: nil, - reason: .userCommand, - ) - let archive = Self.archive(recordingAssignmentChanges: [initial, skippedRevision]) - - do { - try BackupService.validateRecordingData(archive) - Issue.record("Expected the incomplete assignment timeline to be rejected.") - } catch BackupService.BackupError.invalidRecordingData { + _ = try BackupService.makeDecoder().decode( + BackupArchive.self, + from: Data(json.utf8), + ) + Issue.record("Expected the negative metadata revision to be rejected.") + } catch DecodingError.dataCorrupted { // Expected. } catch { Issue.record("Unexpected error: \(error)") @@ -397,8 +295,7 @@ struct BackupServiceTests { manualDays: [], recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: [], - recordingDeviceArchives: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -429,8 +326,7 @@ struct BackupServiceTests { manualDays: manualDays, recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: [], - recordingDeviceArchives: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -451,8 +347,7 @@ struct BackupServiceTests { trackedRegions: [.california, texas], recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: [], - recordingDeviceArchives: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -485,8 +380,7 @@ struct BackupServiceTests { primaryRegions: primary, recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: [], - recordingDeviceArchives: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -523,8 +417,7 @@ struct BackupServiceTests { manualDays: manualDays, recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: [], - recordingDeviceArchives: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -557,8 +450,7 @@ struct BackupServiceTests { ], recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), - recordingAssignmentChanges: Self.recordingAssignmentFixtures(), - recordingDeviceArchives: [], + recordingDeviceRemovals: [], assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, filename: "assets/\(Self.evidenceWithBlobId.uuidString)", diff --git a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift index f59c1b95..f54fe926 100644 --- a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift +++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift @@ -4,171 +4,111 @@ import Testing struct DeviceRecordingControllerTests { private static let now = Date(timeIntervalSinceReferenceDate: 1000) - private static let initialAssignmentID = UUID( - uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", - )! - private static let remoteDeviceID = RecordingDeviceID( - rawValue: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, - ) - private static func makeServices( + private func makeController( + enabled: Bool, authorization: LocationAuthorizationStatus = .always, - initialEnabled: Bool = true, - remoteChanges: ScriptedStoreRemoteChangeSource? = nil, - ) throws -> (WhereServices, SwiftDataStore) { - let store = try if let remoteChanges { - SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) - } else { - SwiftDataStore.inMemory() - } - let services = WhereServices( + ) throws -> (DeviceRecordingController, SwiftDataStore, LocationIngestor) { + let store = try SwiftDataStore.inMemory() + let ingestor = LocationIngestor( store: store, locationSource: ScriptedLocationSource(authorizationStatus: authorization), - installationContext: InstallationRecordingContext( - currentDevice: .preview, - registeredAt: Self.now, - initialRecordingChoice: .init( - isEnabled: initialEnabled, - assignmentChangeID: Self.initialAssignmentID, - confirmedAt: Self.now, - ), + recordingDeviceID: InstallationRecordingContext.testing.currentDevice.id, + calendar: WhereCoreTestSupport.calendar(), + outbox: NoOpLocationOutbox(), + retryQueueCapacity: 1000, + onPersisted: { _ in }, + ) + let context = InstallationRecordingContext( + currentDevice: InstallationRecordingContext.testing.currentDevice, + registeredAt: Self.now.addingTimeInterval(-100), + automaticRecordingEnabled: enabled, + isRejoining: false, + ) + return ( + DeviceRecordingController( + store: store, + ingestor: ingestor, + installationContext: context, + now: { Self.now }, + onPolicyChanged: {}, ), - now: { Self.now }, + store, + ingestor, ) - return (services, store) - } - - private static func addRemoteProfile(to store: SwiftDataStore) async throws { - try await store.perform { - try await store.addRecordingDeviceProfile(RecordingDeviceProfile( - id: remoteDeviceID, - systemName: "iPad", - kind: .tablet, - registeredAt: now, - registrationEpochID: .initial, - )) - } } - @Test func registrationPersistsOneGlobalAssignmentAndAcknowledgesIt() async throws { - let (services, store) = try Self.makeServices() + @Test func registrationAppliesLocalChoiceAndWritesAdvisoryStatus() async throws { + let (controller, store, ingestor) = try makeController(enabled: true) - let configuration = try await services.recording.register(authorization: .always) + let configuration = try await controller.register(authorization: .always) - #expect(configuration.id == CurrentRecordingDevice.preview.id) - #expect(configuration.isEnabled == true) - #expect(configuration.isPending == false) + #expect(configuration.localAutomaticRecordingEnabled == true) #expect(configuration.device.status == .recording) - #expect(await services.ingestor.isActive) + #expect(await ingestor.isActive) #expect(try await store.recordingDeviceProfiles().count == 1) - #expect(try await store.recordingAssignmentChanges() - .map(\.id) == [Self.initialAssignmentID]) - - _ = try await services.recording.register(authorization: .always) - #expect(try await store.recordingAssignmentChanges().count == 1) - } - - @Test func assignmentWithoutAlwaysPermissionIsAcknowledgedAsPermissionRequired() async throws { - let (services, _) = try Self.makeServices(authorization: .whenInUse) - - let configuration = try await services.recording.register(authorization: .whenInUse) - - #expect(configuration.isEnabled == true) - #expect(configuration.isPending == false) - #expect(configuration.device.status == .permissionRequired) - #expect(await services.ingestor.isActive == false) + #expect(try await store.recordingDeviceCheckIns().first?.status == .recording) } - @Test func transferMovesTheOnlyAssignmentAndStopsThisDevice() async throws { - let (services, store) = try Self.makeServices() - _ = try await services.recording.register(authorization: .always) - try await Self.addRemoteProfile(to: store) + @Test func localSettingsChoiceStopsAndRestartsOnlyThisInstallation() async throws { + let (controller, _, ingestor) = try makeController(enabled: true) + _ = try await controller.register(authorization: .always) - let configurations = try await services.recording.setEnabled( - true, - for: Self.remoteDeviceID, - ) - - let current = try #require(configurations.first { - $0.id == CurrentRecordingDevice.preview.id - }) - let remote = try #require(configurations.first { $0.id == Self.remoteDeviceID }) - #expect(current.isEnabled == false) - #expect(remote.isEnabled == true) - #expect(remote.isPending) - #expect(await services.ingestor.isActive == false) - #expect( - try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) - == .resolved(.device(Self.remoteDeviceID)), - ) - } - - @Test func offClosesRecording() async throws { - let (services, store) = try Self.makeServices() - _ = try await services.recording.register(authorization: .always) - - _ = try await services.recording.setEnabled( + let off = try await controller.setAutomaticRecordingEnabled( false, - for: CurrentRecordingDevice.preview.id, + authorization: .always, ) + #expect(off.localAutomaticRecordingEnabled == false) + #expect(await ingestor.isActive == false) - #expect(await services.ingestor.isActive == false) - #expect( - try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) - == .resolved(.off), + let on = try await controller.setAutomaticRecordingEnabled( + true, + authorization: .always, ) + #expect(on.localAutomaticRecordingEnabled == true) + #expect(await ingestor.isActive) } - @Test func concurrentDifferentAssignmentsFailClosed() async throws { - let (services, store) = try Self.makeServices(initialEnabled: false) - _ = try await services.recording.register(authorization: .always) - try await Self.addRemoteProfile(to: store) - let local = RecordingAssignmentChange( - id: UUID(), - parentIDs: [Self.initialAssignmentID], - revision: 1, - issuedAt: Self.now, - issuedByDeviceID: CurrentRecordingDevice.preview.id, - effectiveAt: Self.now, - assignedDeviceID: CurrentRecordingDevice.preview.id, - reason: .userCommand, - ) - let remote = RecordingAssignmentChange( - id: UUID(), - parentIDs: [Self.initialAssignmentID], - revision: 1, - issuedAt: Self.now, - issuedByDeviceID: Self.remoteDeviceID, - effectiveAt: Self.now, - assignedDeviceID: Self.remoteDeviceID, - reason: .userCommand, - ) + @Test func removalStopsCurrentIdentityAndPublishesTerminalState() async throws { + let (controller, store, ingestor) = try makeController(enabled: true) + _ = try await controller.register(authorization: .always) + let deviceID = controller.currentDevice.id try await store.perform { - try await store.addRecordingAssignmentChange(local) - try await store.addRecordingAssignmentChange(remote) + try await store.addRecordingDeviceRemoval(RecordingDeviceRemoval( + id: UUID(), + deviceID: deviceID, + removedAt: Self.now, + removedByDeviceID: RecordingDeviceID(rawValue: UUID()), + )) } - await #expect(throws: RecordingPersistenceError.incompleteAssignmentHistory) { - try await services.recording.reconcile(authorization: .always) + await #expect(throws: RecordingPersistenceError.self) { + try await controller.reconcile(authorization: .always) } - #expect(await services.ingestor.isActive == false) - #expect(try await services.recording.authoritySnapshot().resolution - == .conflict([CurrentRecordingDevice.preview.id, Self.remoteDeviceID])) + + #expect(await ingestor.isActive == false) + #expect(await controller.currentRuntimeUpdate()?.state == .removed) } - @Test func archivingTheRecorderAppendsATombstoneAndTurnsRecordingOff() async throws { - let (services, store) = try Self.makeServices() - _ = try await services.recording.register(authorization: .always) - try await Self.addRemoteProfile(to: store) - _ = try await services.recording.setEnabled(true, for: Self.remoteDeviceID) + @Test func remoteRowsNeverExposeALocalPreference() async throws { + let (controller, store, _) = try makeController(enabled: false) + _ = try await controller.register(authorization: .always) + let remoteID = RecordingDeviceID(rawValue: UUID()) + try await store.perform { + try await store.addRecordingDeviceProfile(RecordingDeviceProfile( + id: remoteID, + systemName: "iPad", + kind: .tablet, + registeredAt: Self.now, + registrationEpochID: .initial, + )) + } - let remaining = try await services.recording.archive(Self.remoteDeviceID) + let devices = try await controller.devices() - #expect(remaining.contains { $0.id == Self.remoteDeviceID } == false) - #expect(try await store.recordingDeviceArchives().map(\.deviceID) == [Self.remoteDeviceID]) - #expect( - try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) - == .resolved(.off), - ) + #expect(devices.first(where: { $0.id == controller.currentDevice.id })? + .localAutomaticRecordingEnabled == false) + #expect(devices.first(where: { $0.id == remoteID })? + .localAutomaticRecordingEnabled == nil) } } diff --git a/Where/WhereCore/Tests/InstallationRecordingContextTests.swift b/Where/WhereCore/Tests/InstallationRecordingContextTests.swift index ee876c6d..d4b5709a 100644 --- a/Where/WhereCore/Tests/InstallationRecordingContextTests.swift +++ b/Where/WhereCore/Tests/InstallationRecordingContextTests.swift @@ -11,23 +11,15 @@ struct InstallationRecordingContextTests { #expect(tablet.recommendedRecordingEnabled == false) } - @Test func confirmationPreservesIdentityAndCarriesAStablePolicyToken() throws { + @Test func confirmationAndLaterSettingsChangePreserveIdentity() { let proposed = context(kind: .tablet) - let assignmentChangeID = try #require( - UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"), - ) - - let confirmed = proposed.confirmingInitialRecording( - isEnabled: false, - assignmentChangeID: assignmentChangeID, - confirmedAt: Self.confirmedAt, - ) + let confirmed = proposed.confirmingInitialRecording(isEnabled: false) + let updated = confirmed.settingAutomaticRecordingEnabled(true) - #expect(confirmed.currentDevice == proposed.currentDevice) - #expect(confirmed.registeredAt == proposed.registeredAt) - #expect(confirmed.initialRecordingChoice?.isEnabled == false) - #expect(confirmed.initialRecordingChoice?.assignmentChangeID == assignmentChangeID) - #expect(confirmed.initialRecordingChoice?.confirmedAt == Self.confirmedAt) + #expect(updated.currentDevice == proposed.currentDevice) + #expect(updated.registeredAt == proposed.registeredAt) + #expect(confirmed.automaticRecordingEnabled == false) + #expect(updated.automaticRecordingEnabled == true) } private func context(kind: RecordingDeviceKind) -> InstallationRecordingContext { @@ -38,10 +30,10 @@ struct InstallationRecordingContextTests { kind: kind, ), registeredAt: Self.registeredAt, - initialRecordingChoice: nil, + automaticRecordingEnabled: nil, + isRejoining: false, ) } private static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) - private static let confirmedAt = Date(timeIntervalSinceReferenceDate: 200) } diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index 91c8a8eb..1b3f3931 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -812,7 +812,7 @@ private final class GatedLocationSource: LocationSource, @unchecked Sendable { } /// Models Core Location delivering a requested one-shot fix through both the direct callback and -/// the passive sample stream. The stream echo must still obey recording authority. +/// the passive sample stream. The stream echo must still obey the local recording gate. private final class EchoingLocationSource: LocationSource, @unchecked Sendable { let sampleStream: AsyncStream var authorizationUpdates: AsyncStream { @@ -1001,20 +1001,12 @@ private actor ToggleFailingStore: WhereStore { try await backing.setRecordingDeviceCheckIn(checkIn) } - func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { - try await backing.recordingAssignmentChanges() + func recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] { + try await backing.recordingDeviceRemovals() } - func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { - try await backing.addRecordingAssignmentChange(change) - } - - func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] { - try await backing.recordingDeviceArchives() - } - - func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws { - try await backing.addRecordingDeviceArchive(archive) + func addRecordingDeviceRemoval(_ archive: RecordingDeviceRemoval) async throws { + try await backing.addRecordingDeviceRemoval(archive) } func write(evidence: Evidence, blob: Data?) async throws { diff --git a/Where/WhereCore/Tests/RecordingAssignmentChangeTests.swift b/Where/WhereCore/Tests/RecordingAssignmentChangeTests.swift deleted file mode 100644 index 5008451f..00000000 --- a/Where/WhereCore/Tests/RecordingAssignmentChangeTests.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation -import Testing -@testable import WhereCore - -struct RecordingAssignmentChangeTests { - private static let phone = device("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") - private static let tablet = device("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB") - private static let writer = device("CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC") - private static let date = Date(timeIntervalSinceReferenceDate: 1000) - - @Test func emptyHistoryIsUnconfigured() { - #expect(RecordingAssignmentChange.resolve([]) == .unconfigured) - } - - @Test func concurrentAssignmentsToTheSameDeviceCoalesce() { - let first = Self.change(id: 1, assignedDeviceID: Self.phone) - let second = Self.change(id: 2, assignedDeviceID: Self.phone) - - #expect( - RecordingAssignmentChange.resolve([first, second]) - == .resolved(.device(Self.phone)), - ) - } - - @Test func concurrentAssignmentsToDifferentDevicesFailClosed() { - let first = Self.change(id: 1, assignedDeviceID: Self.phone) - let second = Self.change(id: 2, assignedDeviceID: Self.tablet) - - #expect( - RecordingAssignmentChange.resolve([first, second]) - == .conflict([Self.phone, Self.tablet]), - ) - #expect(RecordingAssignmentChange.resolve([first, second]) - .permitsRecording(on: Self.phone) == false) - #expect(RecordingAssignmentChange.resolve([first, second]) - .permitsRecording(on: Self.tablet) == false) - } - - @Test func concurrentOffWinsAnAssignment() { - let assigned = Self.change(id: 1, assignedDeviceID: Self.phone) - let off = Self.change(id: 2, assignedDeviceID: nil) - - #expect(RecordingAssignmentChange.resolve([assigned, off]) == .resolved(.off)) - } - - @Test func commandJoinsEveryObservedHeadAndUsesTheLatestCutoff() throws { - let first = Self.change(id: 1, effectiveAt: Self.date, assignedDeviceID: Self.phone) - let second = Self.change( - id: 2, - effectiveAt: Self.date.addingTimeInterval(10), - assignedDeviceID: Self.tablet, - ) - - let command = try RecordingAssignmentChange.appendingCommand( - to: [first, second], - assignment: .device(Self.phone), - issuedAt: Self.date.addingTimeInterval(20), - issuedByDeviceID: Self.writer, - effectiveAt: Self.date.addingTimeInterval(5), - reason: .userCommand, - ) - - #expect(command.parentIDs == [first.id, second.id]) - #expect(command.revision == 1) - #expect(command.effectiveAt == second.effectiveAt) - #expect(RecordingAssignmentChange - .resolve([first, second, command]) == .resolved(.device(Self.phone))) - } - - @Test func historicalResolutionUsesTheAssignmentAtTheSampleTime() throws { - let root = Self.change(id: 1, assignedDeviceID: Self.phone) - let transfer = try RecordingAssignmentChange.appendingCommand( - to: [root], - assignment: .device(Self.tablet), - issuedAt: Self.date.addingTimeInterval(10), - issuedByDeviceID: Self.writer, - effectiveAt: Self.date.addingTimeInterval(10), - reason: .userCommand, - ) - - #expect( - RecordingAssignmentChange.resolve([root, transfer], at: Self.date.addingTimeInterval(5)) - == .resolved(.device(Self.phone)), - ) - #expect( - RecordingAssignmentChange.resolve( - [root, transfer], - at: Self.date.addingTimeInterval(10), - ) - == .resolved(.device(Self.tablet)), - ) - } - - @Test func aMissingParentInvalidatesTheWholeGraph() { - let invalid = RecordingAssignmentChange( - id: Self.uuid(2), - parentIDs: [Self.uuid(1)], - revision: 1, - issuedAt: Self.date, - issuedByDeviceID: Self.writer, - effectiveAt: Self.date, - assignedDeviceID: Self.phone, - reason: .userCommand, - ) - - #expect(RecordingAssignmentChange.resolve([invalid]) == .invalid) - } - - private static func change( - id: Int, - effectiveAt: Date = date, - assignedDeviceID: RecordingDeviceID?, - ) -> RecordingAssignmentChange { - RecordingAssignmentChange( - id: uuid(id), - parentIDs: [], - revision: 0, - issuedAt: effectiveAt, - issuedByDeviceID: writer, - effectiveAt: effectiveAt, - assignedDeviceID: assignedDeviceID, - reason: .userCommand, - ) - } - - private static func device(_ value: String) -> RecordingDeviceID { - RecordingDeviceID(rawValue: UUID(uuidString: value)!) - } - - private static func uuid(_ value: Int) -> UUID { - UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", value))! - } -} diff --git a/Where/WhereCore/Tests/RecordingAssignmentFilterTests.swift b/Where/WhereCore/Tests/RecordingAssignmentFilterTests.swift deleted file mode 100644 index ccc08b12..00000000 --- a/Where/WhereCore/Tests/RecordingAssignmentFilterTests.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -import RegionKit -import Testing -@testable import WhereCore - -struct RecordingAssignmentFilterTests { - private static let phone = device("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") - private static let tablet = device("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB") - private static let start = Date(timeIntervalSinceReferenceDate: 1000) - - @Test func transferMovesVisibilityAtItsEffectiveDate() throws { - let initial = Self.change(id: 1, assignedDeviceID: Self.phone) - let transfer = try RecordingAssignmentChange.appendingCommand( - to: [initial], - assignment: .device(Self.tablet), - issuedAt: Self.start.addingTimeInterval(10), - issuedByDeviceID: Self.phone, - effectiveAt: Self.start.addingTimeInterval(10), - reason: .userCommand, - ) - let samples = [ - Self.sample(deviceID: Self.phone, offset: 5), - Self.sample(deviceID: Self.phone, offset: 15), - Self.sample(deviceID: Self.tablet, offset: 5), - Self.sample(deviceID: Self.tablet, offset: 15), - ] - - let visible = RecordingAssignmentFilter.visibleSamples( - samples, - assignmentChanges: [initial, transfer], - ) - - #expect(visible.map(\.timestamp) == [samples[0].timestamp, samples[3].timestamp]) - } - - @Test func offAndConflictsFailClosed() { - let phoneClaim = Self.change(id: 1, assignedDeviceID: Self.phone) - let tabletClaim = Self.change(id: 2, assignedDeviceID: Self.tablet) - let gps = Self.sample(deviceID: Self.phone, offset: 5) - - #expect(RecordingAssignmentFilter.visibleSamples( - [gps], - assignmentChanges: [Self.change(id: 3, assignedDeviceID: nil)], - ).isEmpty) - #expect(RecordingAssignmentFilter.visibleSamples( - [gps], - assignmentChanges: [phoneClaim, tabletClaim], - ).isEmpty) - } - - @Test func unattributedAndManualSamplesRemainVisible() { - let unattributed = LocationSample( - timestamp: Self.start, - coordinate: Coordinate(latitude: 1, longitude: 2), - horizontalAccuracy: 3, - source: .gpsVisit, - ) - let manual = LocationSample( - timestamp: Self.start, - coordinate: Coordinate(latitude: 1, longitude: 2), - horizontalAccuracy: 3, - source: .manual, - recordingDeviceID: Self.phone, - ) - - #expect(RecordingAssignmentFilter.visibleSamples( - [unattributed, manual], - assignmentChanges: [], - ).count == 2) - } - - private static func change( - id: Int, - assignedDeviceID: RecordingDeviceID?, - ) -> RecordingAssignmentChange { - RecordingAssignmentChange( - id: uuid(id), - parentIDs: [], - revision: 0, - issuedAt: start, - issuedByDeviceID: phone, - effectiveAt: start, - assignedDeviceID: assignedDeviceID, - reason: .userCommand, - ) - } - - private static func sample( - deviceID: RecordingDeviceID, - offset: TimeInterval, - ) -> LocationSample { - LocationSample( - timestamp: start.addingTimeInterval(offset), - coordinate: Coordinate(latitude: 1, longitude: 2), - horizontalAccuracy: 3, - source: .gpsVisit, - recordingDeviceID: deviceID, - ) - } - - private static func device(_ value: String) -> RecordingDeviceID { - RecordingDeviceID(rawValue: UUID(uuidString: value)!) - } - - private static func uuid(_ value: Int) -> UUID { - UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", value))! - } -} diff --git a/Where/WhereCore/Tests/RecordingDeviceArchiveTests.swift b/Where/WhereCore/Tests/RecordingDeviceArchiveTests.swift deleted file mode 100644 index 8c5c7104..00000000 --- a/Where/WhereCore/Tests/RecordingDeviceArchiveTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import Testing -@testable import WhereCore - -struct RecordingDeviceArchiveTests { - @Test func archiveRetainsItsIndependentWriterAndTarget() { - let target = RecordingDeviceID(rawValue: UUID()) - let writer = RecordingDeviceID(rawValue: UUID()) - let date = Date(timeIntervalSinceReferenceDate: 1000) - let archive = RecordingDeviceArchive( - id: UUID(), - deviceID: target, - archivedAt: date, - archivedByDeviceID: writer, - ) - - #expect(archive.deviceID == target) - #expect(archive.archivedByDeviceID == writer) - #expect(archive.archivedAt == date) - } -} diff --git a/Where/WhereCore/Tests/RecordingDeviceRemovalFilterTests.swift b/Where/WhereCore/Tests/RecordingDeviceRemovalFilterTests.swift new file mode 100644 index 00000000..e4102e5a --- /dev/null +++ b/Where/WhereCore/Tests/RecordingDeviceRemovalFilterTests.swift @@ -0,0 +1,73 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct RecordingDeviceRemovalFilterTests { + private static let phone = RecordingDeviceID(rawValue: UUID()) + private static let tablet = RecordingDeviceID(rawValue: UUID()) + private static let cutoff = Date(timeIntervalSinceReferenceDate: 1000) + + @Test func hidesTargetSamplesAtAndAfterTheEarliestRemoval() { + let samples = [ + Self.sample(deviceID: Self.phone, offset: -1), + Self.sample(deviceID: Self.phone, offset: 0), + Self.sample(deviceID: Self.phone, offset: 1), + Self.sample(deviceID: Self.tablet, offset: 1), + ] + let removals = [ + Self.removal(deviceID: Self.phone, offset: 10), + Self.removal(deviceID: Self.phone, offset: 0), + ] + + let visible = RecordingDeviceRemovalFilter.visibleSamples(samples, removals: removals) + + #expect(visible.map(\.id) == [samples[0].id, samples[3].id]) + } + + @Test func unattributedAndManualSamplesRemainVisible() { + let unattributed = LocationSample( + timestamp: Self.cutoff, + coordinate: Coordinate(latitude: 1, longitude: 2), + horizontalAccuracy: 3, + source: .gpsVisit, + ) + let manual = LocationSample( + timestamp: Self.cutoff, + coordinate: Coordinate(latitude: 1, longitude: 2), + horizontalAccuracy: 3, + source: .manual, + recordingDeviceID: Self.phone, + ) + + #expect(RecordingDeviceRemovalFilter.visibleSamples( + [unattributed, manual], + removals: [Self.removal(deviceID: Self.phone, offset: 0)], + ).count == 2) + } + + private static func removal( + deviceID: RecordingDeviceID, + offset: TimeInterval, + ) -> RecordingDeviceRemoval { + RecordingDeviceRemoval( + id: UUID(), + deviceID: deviceID, + removedAt: cutoff.addingTimeInterval(offset), + removedByDeviceID: tablet, + ) + } + + private static func sample( + deviceID: RecordingDeviceID, + offset: TimeInterval, + ) -> LocationSample { + LocationSample( + timestamp: cutoff.addingTimeInterval(offset), + coordinate: Coordinate(latitude: 1, longitude: 2), + horizontalAccuracy: 3, + source: .gpsVisit, + recordingDeviceID: deviceID, + ) + } +} diff --git a/Where/WhereCore/Tests/RecordingDeviceRemovalTests.swift b/Where/WhereCore/Tests/RecordingDeviceRemovalTests.swift new file mode 100644 index 00000000..e91157a9 --- /dev/null +++ b/Where/WhereCore/Tests/RecordingDeviceRemovalTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import WhereCore + +struct RecordingDeviceRemovalTests { + @Test func removalRetainsItsIndependentWriterAndTarget() { + let target = RecordingDeviceID(rawValue: UUID()) + let writer = RecordingDeviceID(rawValue: UUID()) + let date = Date(timeIntervalSinceReferenceDate: 1000) + let removal = RecordingDeviceRemoval( + id: UUID(), + deviceID: target, + removedAt: date, + removedByDeviceID: writer, + ) + + #expect(removal.deviceID == target) + #expect(removal.removedByDeviceID == writer) + #expect(removal.removedAt == date) + } +} diff --git a/Where/WhereCore/Tests/RecordingOnboardingRecommendationTests.swift b/Where/WhereCore/Tests/RecordingOnboardingRecommendationTests.swift new file mode 100644 index 00000000..10076b58 --- /dev/null +++ b/Where/WhereCore/Tests/RecordingOnboardingRecommendationTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing +@testable import WhereCore + +struct RecordingOnboardingRecommendationTests { + private static let now = Date(timeIntervalSinceReferenceDate: 100_000) + private static let currentID = RecordingDeviceID(rawValue: UUID()) + private static let otherID = RecordingDeviceID(rawValue: UUID()) + + @Test func phoneDefaultsOnWithoutAnotherRecentRecorder() { + let recommendation = RecordingOnboardingRecommendation( + for: installation(kind: .phone), + devices: [], + now: Self.now, + ) + + #expect(recommendation.isEnabled) + #expect(recommendation.recentRecordingDevice == nil) + } + + @Test(arguments: [RecordingDeviceStatus.recording, .permissionRequired]) + func recentRecorderDefaultsPhoneOff(status: RecordingDeviceStatus) { + let recent = device(status: status, lastSeenAt: Self.now.addingTimeInterval(-60)) + + let recommendation = RecordingOnboardingRecommendation( + for: installation(kind: .phone), + devices: [recent], + now: Self.now, + ) + + #expect(recommendation.isEnabled == false) + #expect(recommendation.recentRecordingDevice?.id == Self.otherID) + } + + @Test func activityAtTheTwentyFourHourBoundaryIsRecent() { + let recent = device( + status: .recording, + lastSeenAt: Self.now.addingTimeInterval(-RecordingOnboardingRecommendation + .recentActivityWindow), + ) + + let recommendation = RecordingOnboardingRecommendation( + for: installation(kind: .phone), + devices: [recent], + now: Self.now, + ) + + #expect(recommendation.isEnabled == false) + } + + @Test func staleAndRemovedRecordersDoNotSuppressThePhoneDefault() { + let stale = device( + status: .recording, + lastSeenAt: Self.now.addingTimeInterval( + -RecordingOnboardingRecommendation.recentActivityWindow - 1, + ), + ) + let removed = device( + status: .recording, + lastSeenAt: Self.now, + removedAt: Self.now.addingTimeInterval(-1), + ) + + let recommendation = RecordingOnboardingRecommendation( + for: installation(kind: .phone), + devices: [stale, removed], + now: Self.now, + ) + + #expect(recommendation.isEnabled) + } + + @Test(arguments: [RecordingDeviceKind.tablet, .other]) + func nonPhoneDefaultsOff(kind: RecordingDeviceKind) { + let recommendation = RecordingOnboardingRecommendation( + for: installation(kind: kind), + devices: [], + now: Self.now, + ) + + #expect(recommendation.isEnabled == false) + } + + private func installation(kind: RecordingDeviceKind) -> CurrentRecordingDevice { + CurrentRecordingDevice(id: Self.currentID, systemName: "Current", kind: kind) + } + + private func device( + status: RecordingDeviceStatus, + lastSeenAt: Date, + removedAt: Date? = nil, + ) -> RecordingDevice { + RecordingDevice( + id: Self.otherID, + systemName: "Other iPhone", + nickname: nil, + kind: .phone, + registeredAt: Self.now.addingTimeInterval(-100_000), + lastSeenAt: lastSeenAt, + removedAt: removedAt, + status: status, + ) + } +} diff --git a/Where/WhereCore/Tests/ReportReaderTests.swift b/Where/WhereCore/Tests/ReportReaderTests.swift index dc9078c1..29ca5f85 100644 --- a/Where/WhereCore/Tests/ReportReaderTests.swift +++ b/Where/WhereCore/Tests/ReportReaderTests.swift @@ -47,16 +47,6 @@ struct ReportReaderTests { rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) try await store.perform { - try await store.addRecordingAssignmentChange(RecordingAssignmentChange( - id: UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!, - parentIDs: [], - revision: 0, - issuedAt: WhereCoreTestSupport.iso("2026-01-01T00:00:00-08:00"), - issuedByDeviceID: deviceID, - effectiveAt: WhereCoreTestSupport.iso("2026-01-01T00:00:00-08:00"), - assignedDeviceID: deviceID, - reason: .onboarding, - )) try await store.add(sample: LocationSample( timestamp: WhereCoreTestSupport.iso("2026-01-10T12:00:00-08:00"), coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), @@ -71,15 +61,11 @@ struct ReportReaderTests { source: .gpsVisit, recordingDeviceID: deviceID, )) - try await store.addRecordingAssignmentChange(RecordingAssignmentChange( + try await store.addRecordingDeviceRemoval(RecordingDeviceRemoval( id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, - parentIDs: [UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!], - revision: 1, - issuedAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"), - issuedByDeviceID: deviceID, - effectiveAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"), - assignedDeviceID: nil, - reason: .userCommand, + deviceID: deviceID, + removedAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"), + removedByDeviceID: deviceID, )) } diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index b4ca6dcd..01859cc0 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -9,13 +9,6 @@ import Testing /// covered by `StoreChangeBroadcasterTests`; here we assert the *store* fires it /// on a committed `perform` and stays silent on a rolled-back one. struct SwiftDataStoreTests { - enum AssignmentMalformation: CaseIterable { - case negativeRevision - case duplicateParent - case selfParent - case resetTargetsDevice - } - @Test func inspectorStoreURLUsesTheResolvedAppGroupRoot() { let groupURL = FileManager.default.temporaryDirectory.appending( path: "where-group-\(UUID().uuidString)", @@ -156,12 +149,11 @@ struct SwiftDataStoreTests { #expect(await !firstPing(stream, within: .milliseconds(200))) } - @Test func recordingDeviceAndAssignmentRowsRoundTripWithoutDuplicateLogicalRows() async throws { + @Test func recordingDeviceRowsRoundTripWithoutDuplicateLogicalRows() async throws { let store = try SwiftDataStore.inMemory() let deviceID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) - let assignmentID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) let date = Date(timeIntervalSinceReferenceDate: 100) let profile = RecordingDeviceProfile( id: deviceID, @@ -182,20 +174,8 @@ struct SwiftDataStoreTests { deviceID: deviceID, revision: 0, lastSeenAt: date, - appliedAt: date, - lastAppliedAssignmentChangeID: assignmentID, status: .off, ) - let assignment = RecordingAssignmentChange( - id: assignmentID, - parentIDs: [], - revision: 0, - issuedAt: date, - issuedByDeviceID: deviceID, - effectiveAt: date, - assignedDeviceID: nil, - reason: .onboarding, - ) try await store.perform { try await store.addRecordingDeviceProfile(profile) @@ -204,8 +184,6 @@ struct SwiftDataStoreTests { try await store.addRecordingDeviceMetadataChange(nicknameMetadata) try await store.setRecordingDeviceCheckIn(checkIn) try await store.setRecordingDeviceCheckIn(checkIn) - try await store.addRecordingAssignmentChange(assignment) - try await store.addRecordingAssignmentChange(assignment) } #expect(try await store.recordingDeviceProfiles() == [profile]) @@ -218,11 +196,9 @@ struct SwiftDataStoreTests { kind: .tablet, registeredAt: date, lastSeenAt: date, - archivedAt: nil, - lastAppliedAssignmentChangeID: assignmentID, + removedAt: nil, status: .off, )]) - #expect(try await store.recordingAssignmentChanges() == [assignment]) } /// A remote import (simulated via a scripted source) re-pings the same @@ -242,151 +218,52 @@ struct SwiftDataStoreTests { #expect(await firstPing(stream, within: .seconds(2))) } - @Test func unreadableAssignmentHeadInvalidatesTheWholeTimeline() async throws { + @Test func unreadableRemovalFailsClosed() async throws { let container = try SwiftDataStore.makeContainer(storage: .inMemory) let context = ModelContext(container) - let deviceID = RecordingDeviceID(rawValue: UUID()) - let date = Date(timeIntervalSinceReferenceDate: 100) - let initial = RecordingAssignmentChange( - id: UUID(), - parentIDs: [], - revision: 0, - issuedAt: date, - issuedByDeviceID: deviceID, - effectiveAt: date, - assignedDeviceID: deviceID, - reason: .onboarding, - ) - context.insert(SDRecordingAssignmentChange(value: initial, epochID: .initial)) - let unreadableOff = SDRecordingAssignmentChange() - unreadableOff.epochID = WhereDataEpochID.initial.rawValue - unreadableOff.id = UUID() - unreadableOff.parentIDs = [initial.id] - unreadableOff.revision = 1 - unreadableOff.issuedAt = date - unreadableOff.issuedByDeviceID = deviceID.rawValue - unreadableOff.effectiveAt = date - unreadableOff.assignedDeviceID = nil - unreadableOff.reasonRaw = nil - context.insert(unreadableOff) + let row = SDRecordingDeviceRemoval() + row.epochID = WhereDataEpochID.initial.rawValue + row.id = UUID() + row.deviceID = UUID() + row.removedAt = Date(timeIntervalSinceReferenceDate: 100) + context.insert(row) try context.save() let store = SwiftDataStore(modelContainer: container) - - await #expect(throws: RecordingPersistenceError.incompleteAssignmentHistory) { - try await store.recordingAssignmentChanges() + await #expect(throws: RecordingPersistenceError.incompleteRemovalHistory) { + try await store.recordingDeviceRemovals() } } - @Test func identicalPhysicalAssignmentRowsCollapseBeforeTimelineValidation() async throws { + @Test func identicalRemovalRowsCanonicalizeAndConflictsFailClosed() async throws { let container = try SwiftDataStore.makeContainer(storage: .inMemory) let context = ModelContext(container) - let deviceID = RecordingDeviceID(rawValue: UUID()) - let date = Date(timeIntervalSinceReferenceDate: 100) - let assignment = RecordingAssignmentChange( + let removal = RecordingDeviceRemoval( id: UUID(), - parentIDs: [], - revision: 0, - issuedAt: date, - issuedByDeviceID: deviceID, - effectiveAt: date, - assignedDeviceID: deviceID, - reason: .onboarding, - ) - context.insert(SDRecordingAssignmentChange(value: assignment, epochID: .initial)) - context.insert(SDRecordingAssignmentChange(value: assignment, epochID: .initial)) - try context.save() - - let store = SwiftDataStore(modelContainer: container) - - #expect(try await store.recordingAssignmentChanges() == [assignment]) - } - - @Test func conflictingPhysicalAssignmentRowsFailClosed() async throws { - let container = try SwiftDataStore.makeContainer(storage: .inMemory) - let context = ModelContext(container) - let deviceID = RecordingDeviceID(rawValue: UUID()) - let id = UUID() - let date = Date(timeIntervalSinceReferenceDate: 100) - let first = RecordingAssignmentChange( - id: id, - parentIDs: [], - revision: 0, - issuedAt: date, - issuedByDeviceID: deviceID, - effectiveAt: date, - assignedDeviceID: deviceID, - reason: .onboarding, - ) - let conflicting = RecordingAssignmentChange( - id: id, - parentIDs: [], - revision: 0, - issuedAt: date, - issuedByDeviceID: deviceID, - effectiveAt: date, - assignedDeviceID: nil, - reason: .onboarding, + deviceID: RecordingDeviceID(rawValue: UUID()), + removedAt: Date(timeIntervalSinceReferenceDate: 100), + removedByDeviceID: RecordingDeviceID(rawValue: UUID()), ) - context.insert(SDRecordingAssignmentChange(value: first, epochID: .initial)) - context.insert(SDRecordingAssignmentChange(value: conflicting, epochID: .initial)) + context.insert(SDRecordingDeviceRemoval(value: removal, epochID: .initial)) + context.insert(SDRecordingDeviceRemoval(value: removal, epochID: .initial)) try context.save() - let store = SwiftDataStore(modelContainer: container) - - await #expect(throws: RecordingPersistenceError.conflictingImmutableRecord(id: id)) { - try await store.recordingAssignmentChanges() - } - } - - @Test(arguments: AssignmentMalformation.allCases) - func malformedPersistedAssignmentsFailClosed( - _ malformation: AssignmentMalformation, - ) async throws { - let container = try SwiftDataStore.makeContainer(storage: .inMemory) - let context = ModelContext(container) - let deviceID = RecordingDeviceID(rawValue: UUID()) - let date = Date(timeIntervalSinceReferenceDate: 100) - let initial = RecordingAssignmentChange( - id: UUID(), - parentIDs: [], - revision: 0, - issuedAt: date, - issuedByDeviceID: deviceID, - effectiveAt: date, - assignedDeviceID: deviceID, - reason: .onboarding, + #expect(try await store.recordingDeviceRemovals() == [removal]) + + let conflictContext = ModelContext(container) + let conflicting = RecordingDeviceRemoval( + id: removal.id, + deviceID: removal.deviceID, + removedAt: removal.removedAt.addingTimeInterval(1), + removedByDeviceID: removal.removedByDeviceID, ) - context.insert(SDRecordingAssignmentChange(value: initial, epochID: .initial)) - let malformed = SDRecordingAssignmentChange() - let malformedID = UUID() - malformed.epochID = WhereDataEpochID.initial.rawValue - malformed.id = malformedID - malformed.parentIDs = [initial.id] - malformed.revision = 1 - malformed.issuedAt = date - malformed.issuedByDeviceID = deviceID.rawValue - malformed.effectiveAt = date - malformed.assignedDeviceID = nil - malformed.reasonRaw = RecordingAssignmentReason.userCommand.rawValue - switch malformation { - case .negativeRevision: - malformed.revision = -1 - case .duplicateParent: - malformed.parentIDs = [initial.id, initial.id] - case .selfParent: - malformed.parentIDs = [malformedID] - case .resetTargetsDevice: - malformed.assignedDeviceID = deviceID.rawValue - malformed.reasonRaw = RecordingAssignmentReason.accountReset.rawValue - } - context.insert(malformed) - try context.save() - - let store = SwiftDataStore(modelContainer: container) + conflictContext.insert(SDRecordingDeviceRemoval(value: conflicting, epochID: .initial)) + try conflictContext.save() - await #expect(throws: RecordingPersistenceError.incompleteAssignmentHistory) { - try await store.recordingAssignmentChanges() + await #expect(throws: RecordingPersistenceError + .conflictingImmutableRecord(id: removal.id)) + { + try await store.recordingDeviceRemovals() } } @@ -397,7 +274,6 @@ struct SwiftDataStoreTests { let deviceID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) - let assignmentID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) let date = Date(timeIntervalSinceReferenceDate: 100) let profile = RecordingDeviceProfile( id: deviceID, @@ -418,27 +294,14 @@ struct SwiftDataStoreTests { deviceID: deviceID, revision: 0, lastSeenAt: date, - appliedAt: date, - lastAppliedAssignmentChangeID: assignmentID, status: .recording, ) - let assignment = RecordingAssignmentChange( - id: assignmentID, - parentIDs: [], - revision: 0, - issuedAt: date, - issuedByDeviceID: deviceID, - effectiveAt: date, - assignedDeviceID: deviceID, - reason: .onboarding, - ) try await store.simulateRemoteRecordingImport( profiles: [profile], metadataChanges: [metadata], checkIns: [checkIn], - assignmentChanges: [assignment], - archives: [], + removals: [], ) // The seam suppresses the ordinary local-commit ping: observers must @@ -451,11 +314,9 @@ struct SwiftDataStoreTests { #expect(try await store.recordingDeviceProfiles() == [profile]) #expect(try await store.recordingDeviceMetadataChanges() == [metadata]) #expect(try await store.recordingDeviceCheckIns() == [checkIn]) - #expect(try await store.recordingAssignmentChanges() == [assignment]) let device = try #require(try await store.recordingDevices().first) #expect(device.nickname == "Travel iPad") #expect(device.status == .recording) - #expect(device.lastAppliedAssignmentChangeID == assignmentID) } @Test func newerCheckInRevisionWinsEvenWhenItsWallClockMovedBackward() async throws { @@ -463,22 +324,16 @@ struct SwiftDataStoreTests { let deviceID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) - let firstPolicyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) - let secondPolicyID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")) let first = RecordingDeviceCheckIn( deviceID: deviceID, revision: 0, lastSeenAt: Date(timeIntervalSinceReferenceDate: 200), - appliedAt: Date(timeIntervalSinceReferenceDate: 200), - lastAppliedAssignmentChangeID: firstPolicyID, status: .recording, ) let causallyLater = RecordingDeviceCheckIn( deviceID: deviceID, revision: 1, lastSeenAt: Date(timeIntervalSinceReferenceDate: 100), - appliedAt: Date(timeIntervalSinceReferenceDate: 100), - lastAppliedAssignmentChangeID: secondPolicyID, status: .off, ) @@ -516,8 +371,6 @@ struct SwiftDataStoreTests { checkIn.deviceID = deviceID checkIn.revision = -1 checkIn.lastSeenAt = date - checkIn.appliedAt = date - checkIn.lastAppliedAssignmentChangeID = eventID checkIn.statusRaw = RecordingDeviceStatus.off.rawValue context.insert(negativeMetadata) @@ -571,7 +424,6 @@ struct SwiftDataStoreTests { let deviceID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) - let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) let date = Date(timeIntervalSinceReferenceDate: 100) let sample = LocationSample( timestamp: date, @@ -616,20 +468,8 @@ struct SwiftDataStoreTests { deviceID: deviceID, revision: 0, lastSeenAt: date, - appliedAt: date, - lastAppliedAssignmentChangeID: policyID, status: .recording, ) - let assignment = RecordingAssignmentChange( - id: policyID, - parentIDs: [], - revision: 0, - issuedAt: date, - issuedByDeviceID: deviceID, - effectiveAt: date, - assignedDeviceID: deviceID, - reason: .onboarding, - ) let epoch = try await store.perform { try await store.addRecordingDeviceProfile(profile) @@ -655,7 +495,6 @@ struct SwiftDataStoreTests { remoteContext.insert(SDTrackedRegion(regionID: "us-TX", epochID: .initial)) remoteContext.insert(SDRecordingDeviceMetadataChange(value: metadata, epochID: .initial)) remoteContext.insert(SDRecordingDeviceCheckIn(value: checkIn, epochID: .initial)) - remoteContext.insert(SDRecordingAssignmentChange(value: assignment, epochID: .initial)) try remoteContext.save() let reader = SwiftDataStore(modelContainer: container) @@ -669,7 +508,6 @@ struct SwiftDataStoreTests { #expect(try await reader.recordingDeviceProfiles() == [profile]) #expect(try await reader.recordingDeviceMetadataChanges().isEmpty) #expect(try await reader.recordingDeviceCheckIns().isEmpty) - #expect(try await reader.recordingAssignmentChanges().isEmpty) } @Test func expectedEpochTransactionRejectsStaleAuthorityWithoutWriting() async throws { @@ -1063,7 +901,7 @@ struct SwiftDataStoreTests { let deviceID = try RecordingDeviceID( rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), ) - let policyID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) + let removalID = try #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")) let metadataID = try #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")) let sampleID = try #require(UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")) let date = Date(timeIntervalSinceReferenceDate: 200) @@ -1083,15 +921,11 @@ struct SwiftDataStoreTests { changedByDeviceID: deviceID, nickname: "Home iPad", ) - let assignment = RecordingAssignmentChange( - id: policyID, - parentIDs: [], - revision: 0, - issuedAt: date, - issuedByDeviceID: deviceID, - effectiveAt: date, - assignedDeviceID: nil, - reason: .onboarding, + let removal = RecordingDeviceRemoval( + id: removalID, + deviceID: deviceID, + removedAt: date, + removedByDeviceID: deviceID, ) let currentEpoch = try await store.perform { try await store.rotateDataEpoch( @@ -1104,13 +938,13 @@ struct SwiftDataStoreTests { let remoteContext = ModelContext(container) remoteContext.insert(SDLocationSample(value: sample, epochID: .initial)) remoteContext.insert(SDRecordingDeviceMetadataChange(value: metadata, epochID: .initial)) - remoteContext.insert(SDRecordingAssignmentChange(value: assignment, epochID: .initial)) + remoteContext.insert(SDRecordingDeviceRemoval(value: removal, epochID: .initial)) try remoteContext.save() try await store.perform(expectedDataEpochID: currentEpoch.id) { try await store.add(sample: sample) try await store.addRecordingDeviceMetadataChange(metadata) - try await store.addRecordingAssignmentChange(assignment) + try await store.addRecordingDeviceRemoval(removal) } let inspectionContext = ModelContext(container) @@ -1122,9 +956,9 @@ struct SwiftDataStoreTests { $0.id == metadataID }), ) - let assignmentRows = try inspectionContext.fetch( - FetchDescriptor(predicate: #Predicate { - $0.id == policyID + let removalRows = try inspectionContext.fetch( + FetchDescriptor(predicate: #Predicate { + $0.id == removalID }), ) let expectedEpochIDs = Set([ @@ -1136,11 +970,11 @@ struct SwiftDataStoreTests { #expect(Set(sampleRows.compactMap(\.epochID)) == expectedEpochIDs) #expect(metadataRows.count == 2) #expect(Set(metadataRows.compactMap(\.epochID)) == expectedEpochIDs) - #expect(assignmentRows.count == 2) - #expect(Set(assignmentRows.compactMap(\.epochID)) == expectedEpochIDs) + #expect(removalRows.count == 2) + #expect(Set(removalRows.compactMap(\.epochID)) == expectedEpochIDs) #expect(try await store.allSamples() == [sample]) #expect(try await store.recordingDeviceMetadataChanges() == [metadata]) - #expect(try await store.recordingAssignmentChanges() == [assignment]) + #expect(try await store.recordingDeviceRemovals() == [removal]) } @Test func duplicateProfilesResolveDeterministicallyByRegistrationEpoch() async throws { diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 2cb0f448..95a2cb8d 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -737,87 +737,6 @@ struct WhereServicesTests { #expect(await outbox.persistedSamples.isEmpty) } - @Test func backupMergeCannotTurnAnOffInstallationOn() async throws { - let context = InstallationRecordingContext.testing - let currentDeviceID = context.currentDevice.id - let initialChoice = try #require(context.initialRecordingChoice) - let initial = RecordingAssignmentChange( - id: initialChoice.assignmentChangeID, - parentIDs: [], - revision: 0, - issuedAt: initialChoice.confirmedAt, - issuedByDeviceID: currentDeviceID, - effectiveAt: initialChoice.confirmedAt, - assignedDeviceID: currentDeviceID, - reason: .onboarding, - ) - let off = try RecordingAssignmentChange( - id: #require(UUID(uuidString: "10000000-0000-0000-0000-000000000000")), - parentIDs: [initial.id], - revision: 1, - issuedAt: Date(timeIntervalSinceReferenceDate: 2), - issuedByDeviceID: currentDeviceID, - effectiveAt: Date(timeIntervalSinceReferenceDate: 2), - assignedDeviceID: nil, - reason: .userCommand, - ) - let importedOn = try RecordingAssignmentChange( - id: #require(UUID(uuidString: "20000000-0000-0000-0000-000000000000")), - parentIDs: [off.id], - revision: 2, - issuedAt: Date(timeIntervalSinceReferenceDate: 3), - issuedByDeviceID: currentDeviceID, - effectiveAt: Date(timeIntervalSinceReferenceDate: 3), - assignedDeviceID: currentDeviceID, - reason: .userCommand, - ) - let profile = RecordingDeviceProfile( - id: currentDeviceID, - systemName: context.currentDevice.systemName, - kind: context.currentDevice.kind, - registeredAt: context.registeredAt, - registrationEpochID: .initial, - ) - let url = try BackupService().makeArchiveFile( - samples: [], - evidence: [], - manualDays: [], - recordingDeviceProfiles: [profile], - recordingDeviceMetadataChanges: [], - recordingAssignmentChanges: [initial, off, importedOn], - recordingDeviceArchives: [], - blobs: [:], - ) - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - - let store = try SwiftDataStore.inMemory() - try await store.perform { - try await store.addRecordingDeviceProfile(profile) - try await store.addRecordingAssignmentChange(initial) - try await store.addRecordingAssignmentChange(off) - } - let destination = WhereServices( - store: store, - locationSource: ScriptedLocationSource(authorizationStatus: .always), - installationContext: context, - ) - let before = try await destination.recording.register(authorization: .always) - #expect(before.isEnabled == false) - #expect(await destination.ingestor.isActive == false) - - _ = try await destination.backup.importBackup(from: url, strategy: .merge) - - let assignments = try await store.recordingAssignmentChanges() - let head = try #require(RecordingAssignmentChange.maximalHeads(in: assignments)?.first) - #expect(head.parentIDs == [importedOn.id]) - #expect(head.assignedDeviceID == nil) - #expect(head.reason == .backupMerge) - #expect(try await store.recordingDeviceCheckIns().first? - .lastAppliedAssignmentChangeID == head - .id) - #expect(await destination.ingestor.isActive == false) - } - @Test func failedBackupMergePreservesThePendingLocationThroughRollback() async throws { let (sourceServices, _, _) = try Self.makeServices() try await seedBackupData(sourceServices) @@ -873,10 +792,10 @@ struct WhereServicesTests { #expect(try await backing.allSamples().contains(where: { $0.id == pending.id }) == false) #expect(await destination.ingestor.retryQueueDepth == 0) #expect(await outbox.persistedSamples.isEmpty) - #expect(await destination.ingestor.isActive == false) + #expect(await destination.ingestor.isActive) } - @Test func backupReplaceNeverRestoresRecordingConsent() async throws { + @Test func backupReplacePreservesLocalRecordingConsent() async throws { let (source, _, _) = try Self.makeServices() _ = try await source.recording.register(authorization: .always) #expect(await source.ingestor.isActive) @@ -888,11 +807,8 @@ struct WhereServicesTests { #expect(await destination.ingestor.isActive) _ = try await destination.backup.importBackup(from: url, strategy: .replace) - let assignments = try await store.recordingAssignmentChanges() - #expect(assignments.map(\.assignedDeviceID) == [CurrentRecordingDevice.preview.id, nil]) - #expect(assignments.last?.reason == .backupReplace) - #expect(try await store.recordingDeviceCheckIns().first?.status == .off) - #expect(await destination.ingestor.isActive == false) + #expect(try await store.recordingDeviceCheckIns().first?.status == .recording) + #expect(await destination.ingestor.isActive) } @Test func replaceCleanupFailureReportsCommittedPartialSuccessAndStaysOff() async throws { @@ -916,10 +832,6 @@ struct WhereServicesTests { #expect(await outbox.persistedSamples == [pending]) #expect(await destination.ingestor.isActive == false) #expect(try await store.dataEpoch().reason == .backupReplace) - #expect( - try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) - == .resolved(.off), - ) } @Test func resetCleanupFailureKeepsTheOldInstallationForSafeRetry() async throws { @@ -944,10 +856,9 @@ struct WhereServicesTests { #expect(await services.ingestor.isActive == false) #expect(try await store.recordingDeviceProfiles().count == 1) #expect(try await store.recordingDeviceCheckIns().isEmpty) - #expect( - try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) - == .resolved(.off), - ) + #expect(try await store.recordingDeviceRemovals().map(\.deviceID) == [ + CurrentRecordingDevice.preview.id, + ]) #expect(try await store.dataEpoch().reason == .accountReset) // A retained installation context must not mistake the reset-empty generation for first @@ -956,8 +867,10 @@ struct WhereServicesTests { store: store, locationSource: ScriptedLocationSource(authorizationStatus: .always), ) - let configuration = try await relaunched.recording.register(authorization: .always) - #expect(configuration.isEnabled == false) + await #expect(throws: RecordingPersistenceError.self) { + try await relaunched.recording.register(authorization: .always) + } + #expect(await relaunched.recording.currentRuntimeUpdate()?.state == .removed) #expect(await relaunched.ingestor.isActive == false) } @@ -971,16 +884,26 @@ struct WhereServicesTests { locationOutbox: outbox, ) _ = try await services.recording.register(authorization: .always) + let remoteDeviceID = RecordingDeviceID(rawValue: UUID()) + try await store.perform { + try await store.addRecordingDeviceProfile(RecordingDeviceProfile( + id: remoteDeviceID, + systemName: "iPad", + kind: .tablet, + registeredAt: pending.timestamp, + registrationEpochID: .initial, + )) + } await outbox.save([LocationOutboxEntry(sample: pending, dataEpochID: .initial)]) try await services.reset() #expect(await outbox.persistedSamples.isEmpty) - #expect(try await store.recordingDeviceProfiles().count == 1) - #expect( - try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) - == .resolved(.off), - ) + #expect(try await store.recordingDeviceProfiles().count == 2) + #expect(try await Set(store.recordingDeviceRemovals().map(\.deviceID)) == [ + CurrentRecordingDevice.preview.id, + remoteDeviceID, + ]) #expect(try await store.dataEpoch().reason == .accountReset) #expect(await services.ingestor.isActive == false) } @@ -1005,16 +928,12 @@ struct WhereServicesTests { authorization: .always, ) - #expect(configuration.isEnabled == false) + #expect(configuration.localAutomaticRecordingEnabled == false) #expect(configuration.device.status == .off) #expect(await destination.ingestor.isActive == false) - #expect( - try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) - == .resolved(.off), - ) } - @Test func failedBackupTransactionRestoresThePreviousRecordingAuthority() async throws { + @Test func failedBackupTransactionRestoresTheLocalRecordingChoice() async throws { let (source, _, _) = try Self.makeServices() try await seedBackupData(source) let url = try await source.backup.exportBackup() @@ -1164,7 +1083,6 @@ struct WhereServicesTests { regions: [.california], ) let deviceID = CurrentRecordingDevice.preview.id - let policyID = try #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")) try await store.perform { try await store.add(sample: seedSample) try await store.write(evidence: Self.backupEvidence, blob: Self.backupBlob) @@ -1180,20 +1098,8 @@ struct WhereServicesTests { deviceID: deviceID, revision: 0, lastSeenAt: seedSample.timestamp, - appliedAt: seedSample.timestamp, - lastAppliedAssignmentChangeID: policyID, status: .recording, )) - try await store.addRecordingAssignmentChange(RecordingAssignmentChange( - id: policyID, - parentIDs: [], - revision: 0, - issuedAt: seedSample.timestamp, - issuedByDeviceID: deviceID, - effectiveAt: seedSample.timestamp, - assignedDeviceID: deviceID, - reason: .onboarding, - )) } try await store.perform { @@ -1208,7 +1114,7 @@ struct WhereServicesTests { #expect(try await store.allEvidence().isEmpty) #expect(try await store.allManualDays().isEmpty) #expect(try await store.recordingDevices().count == 1) - #expect(try await store.recordingAssignmentChanges().isEmpty) + #expect(try await store.recordingDeviceCheckIns().isEmpty) } // MARK: - Logging reminders @@ -1898,20 +1804,12 @@ private actor ToggleFailingStore: WhereStore { try await backing.setRecordingDeviceCheckIn(checkIn) } - func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { - try await backing.recordingAssignmentChanges() - } - - func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { - try await backing.addRecordingAssignmentChange(change) - } - - func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] { - try await backing.recordingDeviceArchives() + func recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] { + try await backing.recordingDeviceRemovals() } - func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws { - try await backing.addRecordingDeviceArchive(archive) + func addRecordingDeviceRemoval(_ archive: RecordingDeviceRemoval) async throws { + try await backing.addRecordingDeviceRemoval(archive) } func write(evidence: Evidence, blob: Data?) async throws { diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png index 124fb257..d8d02f53 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b3d5b853dc818db56c3d852381859f5be1ee3c740b813bd22c299957b09f6d70 -size 375403 +oid sha256:79a9c8de5c13507607f2e61493b3a93ea5dce109e5b9786bc25a2a6488f814e4 +size 336902 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png index 40ec1fc8..3d25b34b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df8281dda395118ab5122e9f048e4807c7d39caa9e9dd32de674fdb134b23661 -size 697625 +oid sha256:3bb2859ed75fc04bca756c80afae90b50c1bcf06ddfc132188420f243d2e5508 +size 613127 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png index e54db458..3942adad 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d26cfb9f59e1ff2e3e02cb49101f304775c2647f4670685990fb02105e3f90a9 -size 512094 +oid sha256:29ab4e9452c62120c2452daddf9a211c1c7e07b5e55bb69f7b8c6e7347a4a17e +size 498299 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png index f0ce136d..b66787af 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e2770c08e06701b3440abd6bb7b8cbf27a9d06b557b530621f35a951a865b41 -size 377030 +oid sha256:656ef69702bb62acbfaa037345d815235f05ae810b935222d894c82ca74db0c9 +size 338042 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png index 44fd0a5c..d5ab036c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d41dcbbb6ba4179e5f4b89097daf1c138871ae8f190efae8858adfda7de0fb91 -size 382815 +oid sha256:da600de0b835e3a1ad47aa4ce39d3ac5ec7d1b546bba820d6746d6c1c8d73b38 +size 341387 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png index ddd1792c..f94290f2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:768f6f387b758c9f32d4ee60e47c8c2376ca2c68d0be009215cf2f6f24adc93d -size 237419 +oid sha256:e80afbe4ae1681a3f631d87fdc3f77cb1486c82722bde77274afd73c86c143f6 +size 219782 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png index 0e93792f..1e421024 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1556ea09d3109a522204f760e3eef5ce3ba2853cd93a217bd0b228a50500518 -size 491463 +oid sha256:1a8b5071149ff874b393352175b7e47c67fdf657a51255d6f44eaa03f4f95c01 +size 457742 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png index 4d0dadbe..c6c72a77 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0de4750b943e38ba1ce15c555a27b4df9e0d351da3cd591f1fcca2e195cf47b -size 234989 +oid sha256:b8e5111a3bf9e7f7a087b7992213d0808880ab61b6ce3a53a7f7118ca57b972b +size 233527 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png index 65223f18..c1c2e36f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:89fa9191fcfb3087e4ee3556da2f6148e338431fd7dc59de10d71743d5b5ef5c -size 240537 +oid sha256:ce6b05b544d894fe5d10420dadb3436ae7eccc27aed9078954126dec00efb2e4 +size 222989 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png index 8dda6820..1e0e234b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DevicesSettingsViewSnapshotTests/devices.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:95e84671ccacda4f1808b5cd3f331789e62e8740c5fd4b91a5d93f0427f8dfe3 -size 242084 +oid sha256:bf9290b7dffb974b23f7b16d61f79eec851007292d140fa862234328985b5001 +size 222992 diff --git a/Where/WhereUI/Sources/Devices/RemovedDeviceView.swift b/Where/WhereUI/Sources/Devices/RemovedDeviceView.swift new file mode 100644 index 00000000..d02f675a --- /dev/null +++ b/Where/WhereUI/Sources/Devices/RemovedDeviceView.swift @@ -0,0 +1,50 @@ +import LifecycleKitUI +import SwiftUI + +/// Blocking recovery shown when CloudKit retires this installation identity. +struct RemovedDeviceView: View { + @Environment(\.lifecycle) private var lifecycle + @Environment(\.stylesheet) private var stylesheet + + let model: WhereModel + let session: WhereSession + + var body: some View { + VStack(spacing: stylesheet.spacing.xxLarge) { + Image(systemName: "iphone.slash") + .font(.largeTitle) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(spacing: stylesheet.spacing.medium) { + Text(String(localized: .deviceRemovedTitle)) + .font(.title.bold()) + .multilineTextAlignment(.center) + Text(String(localized: .deviceRemovedDescription)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + Button(String(localized: .deviceRemovedRejoin)) { + Task { + await lifecycle.teardown( + WhereLaunch.rejoinPlan(for: model), + input: session, + ) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding(stylesheet.spacing.xxxLarge) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#if DEBUG + #Preview { + RemovedDeviceView( + model: PreviewSupport.loadedModel(), + session: PreviewSupport.loadedSession(), + ) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift index ab0e5c74..db52980c 100644 --- a/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift +++ b/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift @@ -41,12 +41,17 @@ public final class InMemoryInstallationRecordingContextStore: public func confirmInitialRecording( isEnabled: Bool, ) throws -> InstallationRecordingContext { - if onboardingContext.initialRecordingChoice != nil { return onboardingContext } - onboardingContext = onboardingContext.confirmingInitialRecording( - isEnabled: isEnabled, - assignmentChangeID: makeUUID(), - confirmedAt: now(), - ) + if onboardingContext.automaticRecordingEnabled != nil { return onboardingContext } + onboardingContext = onboardingContext.confirmingInitialRecording(isEnabled: isEnabled) + return onboardingContext + } + + public func setAutomaticRecordingEnabled(_ isEnabled: Bool) throws { + onboardingContext = onboardingContext.settingAutomaticRecordingEnabled(isEnabled) + } + + public func rejoin() throws -> InstallationRecordingContext { + onboardingContext = proposedContext(isRejoining: true) return onboardingContext } @@ -65,14 +70,19 @@ public final class InMemoryInstallationRecordingContextStore: public func reset() throws { backupImportRecovery = nil onboardingImportCompletion = nil - onboardingContext = InstallationRecordingContext( + onboardingContext = proposedContext(isRejoining: false) + } + + private func proposedContext(isRejoining: Bool) -> InstallationRecordingContext { + InstallationRecordingContext( currentDevice: CurrentRecordingDevice( id: RecordingDeviceID(rawValue: makeUUID()), systemName: onboardingContext.currentDevice.systemName, kind: onboardingContext.currentDevice.kind, ), registeredAt: now(), - initialRecordingChoice: nil, + automaticRecordingEnabled: nil, + isRejoining: isRejoining, ) } } diff --git a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift index a3864f1c..97001ea4 100644 --- a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift +++ b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift @@ -8,9 +8,9 @@ import WhereCore /// cannot clone the source installation's identity or recording consent. A new /// context stays in memory until onboarding confirms its first choice, which /// keeps merely viewing onboarding or entering demo mode free of durable writes. -/// The sidecar also freezes the timestamps used by the first immutable device -/// profile and assignment event, and retains active import recovery plus terminal -/// onboarding-import authority, so retries and cold-launch repair are deterministic. +/// The sidecar also freezes the timestamp used by the immutable device profile +/// and retains active import recovery plus terminal onboarding-import authority, +/// so retries and cold-launch repair are deterministic. @MainActor public final class FileInstallationRecordingContextStore: InstallationRecordingContextStoring @@ -44,18 +44,6 @@ public final class FileInstallationRecordingContextStore: } private struct StoredContext: Codable { - struct InitialRecordingChoice: Codable { - let isEnabled: Bool - let assignmentChangeID: UUID - let confirmedAt: Date - - enum CodingKeys: String, CodingKey { - case isEnabled - case assignmentChangeID - case confirmedAt - } - } - struct BackupImportRecovery: Codable { enum Strategy: String, Codable { case merge = "backup-merge" @@ -74,7 +62,7 @@ public final class FileInstallationRecordingContextStore: let dismissedIssueCount: Int let trackedRegionCount: Int let recordingDeviceCount: Int - let recordingAssignmentChangeCount: Int + let recordingDeviceRemovalCount: Int init(_ summary: BackupCoordinator.ImportSummary) { sampleCount = summary.sampleCount @@ -83,7 +71,7 @@ public final class FileInstallationRecordingContextStore: dismissedIssueCount = summary.dismissedIssueCount trackedRegionCount = summary.trackedRegionCount recordingDeviceCount = summary.recordingDeviceCount - recordingAssignmentChangeCount = summary.recordingAssignmentChangeCount + recordingDeviceRemovalCount = summary.recordingDeviceRemovalCount } var value: BackupCoordinator.ImportSummary { @@ -94,7 +82,7 @@ public final class FileInstallationRecordingContextStore: dismissedIssueCount: dismissedIssueCount, trackedRegionCount: trackedRegionCount, recordingDeviceCount: recordingDeviceCount, - recordingAssignmentChangeCount: recordingAssignmentChangeCount, + recordingDeviceRemovalCount: recordingDeviceRemovalCount, ) } } @@ -166,7 +154,8 @@ public final class FileInstallationRecordingContextStore: let systemName: String let kind: RecordingDeviceKind let registeredAt: Date - let initialRecordingChoice: InitialRecordingChoice? + let automaticRecordingEnabled: Bool? + let isRejoining: Bool? let backupImportRecovery: BackupImportRecovery? let onboardingImportCompletionID: UUID? @@ -175,7 +164,8 @@ public final class FileInstallationRecordingContextStore: case systemName case kind case registeredAt - case initialRecordingChoice + case automaticRecordingEnabled + case isRejoining case backupImportRecovery case onboardingImportCompletionID } @@ -189,13 +179,8 @@ public final class FileInstallationRecordingContextStore: systemName = context.currentDevice.systemName kind = context.currentDevice.kind registeredAt = context.registeredAt - initialRecordingChoice = context.initialRecordingChoice.map { - InitialRecordingChoice( - isEnabled: $0.isEnabled, - assignmentChangeID: $0.assignmentChangeID, - confirmedAt: $0.confirmedAt, - ) - } + automaticRecordingEnabled = context.automaticRecordingEnabled + isRejoining = context.isRejoining self.backupImportRecovery = backupImportRecovery.map(BackupImportRecovery.init) onboardingImportCompletionID = onboardingImportCompletion?.transactionID } @@ -208,13 +193,8 @@ public final class FileInstallationRecordingContextStore: kind: kind, ), registeredAt: registeredAt, - initialRecordingChoice: initialRecordingChoice.map { - InstallationRecordingContext.InitialRecordingChoice( - isEnabled: $0.isEnabled, - assignmentChangeID: $0.assignmentChangeID, - confirmedAt: $0.confirmedAt, - ) - }, + automaticRecordingEnabled: automaticRecordingEnabled, + isRejoining: isRejoining ?? false, ) } } @@ -327,6 +307,7 @@ public final class FileInstallationRecordingContextStore: kind: kind, id: makeUUID(), registeredAt: now(), + isRejoining: false, ) if let initialFailure { resolution = .failed(initialFailure, proposed: proposed) @@ -368,15 +349,9 @@ public final class FileInstallationRecordingContextStore: isEnabled: Bool, ) throws -> InstallationRecordingContext { let context = try resolution.get() - // Confirmation freezes one immutable assignment event. A later UI retry cannot rewrite - // that event under the same id; subsequent changes belong in the synced assignment stream. - if context.initialRecordingChoice != nil { return context } - - let confirmed = context.confirmingInitialRecording( - isEnabled: isEnabled, - assignmentChangeID: makeUUID(), - confirmedAt: now(), - ) + if context.automaticRecordingEnabled != nil { return context } + + let confirmed = context.confirmingInitialRecording(isEnabled: isEnabled) try persist( confirmed, backupImportRecovery: backupImportRecovery, @@ -386,6 +361,33 @@ public final class FileInstallationRecordingContextStore: return confirmed } + public func setAutomaticRecordingEnabled(_ isEnabled: Bool) throws { + let updated = try resolution.get().settingAutomaticRecordingEnabled(isEnabled) + try persist( + updated, + backupImportRecovery: backupImportRecovery, + onboardingImportCompletion: onboardingImportCompletion, + ) + resolution = .resolved(updated) + } + + public func rejoin() throws -> InstallationRecordingContext { + let proposed = Self.proposedContext( + systemName: systemName, + kind: kind, + id: makeUUID(), + registeredAt: now(), + isRejoining: true, + ) + try persist( + proposed, + backupImportRecovery: backupImportRecovery, + onboardingImportCompletion: onboardingImportCompletion, + ) + resolution = .resolved(proposed) + return proposed + } + public func setBackupImportRecovery( _ recovery: BackupCoordinator.DurableImportRecovery?, ) throws { @@ -425,6 +427,7 @@ public final class FileInstallationRecordingContextStore: kind: kind, id: makeUUID(), registeredAt: now(), + isRejoining: false, ) wasAlreadyCommitted = false } @@ -484,6 +487,7 @@ public final class FileInstallationRecordingContextStore: kind: RecordingDeviceKind, id: UUID, registeredAt: Date, + isRejoining: Bool, ) -> InstallationRecordingContext { InstallationRecordingContext( currentDevice: CurrentRecordingDevice( @@ -492,7 +496,8 @@ public final class FileInstallationRecordingContextStore: kind: kind, ), registeredAt: registeredAt, - initialRecordingChoice: nil, + automaticRecordingEnabled: nil, + isRejoining: isRejoining, ) } diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index aeb7f679..49596b60 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -43,7 +43,7 @@ public enum LaunchStepID: String, Sendable { /// Republish the widget snapshot from whatever is already on disk. case widgetSnapshot = "widget-snapshot" - /// Reset teardown: pause GPS, erase synced user data, retire recording authority, + /// Reset teardown: pause GPS, erase synced user data, remove old device identities, /// discard pending fixes, and drop the session. case eraseData = "erase-data" /// Reset teardown: clear the installation context and persisted preferences @@ -52,6 +52,8 @@ public enum LaunchStepID: String, Sendable { /// Demo teardown: drop the demo world and hand the real one its durable /// log sink back. case exitDemo = "exit-demo" + /// Retire a removed local identity, then re-drive onboarding with a fresh identity. + case rejoinDevice = "rejoin-device" } /// Assembles the Where app's cold-launch plan and the `LifecycleRunner` that @@ -195,6 +197,12 @@ public enum WhereLaunch { { LaunchPlan(ExitDemoStep(model: model).measured()) } + + public static func rejoinPlan(for model: WhereModel) + -> LaunchPlan + { + LaunchPlan(RejoinDeviceStep(model: model).measured()) + } } /// Assembles the outside-world pieces a real `WhereScope` is built from: the @@ -216,9 +224,9 @@ public protocol WhereScopeAssembling { /// **one** store open. func makeServices() async throws -> WhereServices - /// Open and retain the real store while onboarding remains dormant, then read the synced - /// recording assignment without constructing services or activating location/App Intents. - func discoverRecordingAssignment() async throws -> RecordingAssignmentResolution + /// Open and retain the real store while onboarding remains dormant, then read synced device + /// status without constructing services or activating location/App Intents. + func discoverRecordingDevices() async throws -> [RecordingDevice] /// Open the durable log store the scope's records persist to, or `nil` for /// an assembly with no durable logging — previews and tests, which log @@ -228,8 +236,8 @@ public protocol WhereScopeAssembling { } extension WhereScopeAssembling { - public func discoverRecordingAssignment() async throws -> RecordingAssignmentResolution { - .unconfigured + public func discoverRecordingDevices() async throws -> [RecordingDevice] { + [] } } @@ -292,7 +300,7 @@ public final class WhereBootstrap: WhereScopeAssembling { do { let installationContext = try installationContextStore.resolve() precondition( - installationContext.initialRecordingChoice != nil, + installationContext.automaticRecordingEnabled != nil, "A real scope cannot open before this installation confirms recording.", ) let store = try await prepareStore() @@ -322,14 +330,14 @@ public final class WhereBootstrap: WhereScopeAssembling { } } - public func discoverRecordingAssignment() async throws -> RecordingAssignmentResolution { + public func discoverRecordingDevices() async throws -> [RecordingDevice] { let readiness = CloudKitImportReadiness() if storeStorage == .cloudKit { readiness.start() } let store = try await prepareStore() if storeStorage == .cloudKit, await readiness.waitForImport() == false { throw CloudKitImportReadiness.Timeout() } - return try await RecordingAssignmentChange.resolve(store.recordingAssignmentChanges()) + return try await store.recordingDevices() } private func prepareStore() async throws -> SwiftDataStore { diff --git a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift index fcc1e43e..34e64f09 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift @@ -53,6 +53,17 @@ struct OnboardingGate: LifecycleGate { } } +struct RejoinDeviceStep: BudgetedLaunchStep { + let model: WhereModel + let id = LaunchStepID.rejoinDevice + let budget: Duration = .seconds(5) + + func run(_ session: WhereSession, _: LifecycleStepContext) async throws { + try await session.prepareDeviceRejoin() + try await model.rejoinInstallation() + } +} + /// Resolve the scope the rest of the launch runs against: the one the user's /// choice at the gate activated, or — for someone who onboarded on an earlier /// launch — their real scope, opening the app's **one** store on the way (see @@ -203,7 +214,7 @@ struct WidgetSnapshotStep: BudgetedLaunchStep { // MARK: - Reset teardown steps -/// Retire recording authority, erase synced user data, discard pending fixes, +/// Remove old device identities, erase synced user data, discard pending fixes, /// and log out. Takes the session being erased as /// the teardown plan's root input — handed in by Settings, not re-read from an /// optional. If the erase throws the runner parks in `.failed` (terminally — diff --git a/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift b/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift index 2982cb74..f3c56574 100644 --- a/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift +++ b/Where/WhereUI/Sources/Launch/WhereLifecycleFailureView.swift @@ -131,7 +131,7 @@ struct WhereLifecycleFailureView: View { dismissedIssueCount: 2, trackedRegionCount: 5, recordingDeviceCount: 2, - recordingAssignmentChangeCount: 4, + recordingDeviceRemovalCount: 4, ) } diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 51ac7649..72872efe 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -176,17 +176,34 @@ public final class WhereModel { /// in backed-up preferences. Restoring onto a new device therefore makes /// this false even when `hasOnboarded` arrived in the backup. public var hasConfirmedRecordingChoice: Bool { - installationRecordingContext.initialRecordingChoice != nil - } - - /// Discover existing synced authority while the app remains logged out. The bootstrap keeps - /// this exact store instance for `resolveScope()`, so onboarding never opens two containers. - public func discoverRecordingAssignment() async throws -> RecordingAssignmentResolution { + installationRecordingContext.automaticRecordingEnabled != nil + } + + /// Derive an advisory local default from synced device status while the app remains logged out. + func discoverRecordingRecommendation( + for context: InstallationRecordingContext, + ) async throws + -> RecordingOnboardingRecommendation + { + if context.isRejoining { + return RecordingOnboardingRecommendation( + isEnabled: false, + recentRecordingDevice: nil, + ) + } guard case let .loggedOut(bootstrap) = scopeState else { - guard let scope = activeScope else { return .unconfigured } - return try await scope.services.recording.authoritySnapshot().resolution + let devices = try await activeScope?.services.recording.devices() ?? [] + return RecordingOnboardingRecommendation( + for: context.currentDevice, + devices: devices.map(\.device), + now: now(), + ) } - return try await bootstrap.discoverRecordingAssignment() + return try await RecordingOnboardingRecommendation( + for: context.currentDevice, + devices: bootstrap.discoverRecordingDevices(), + now: now(), + ) } /// Whether the sidecar says onboarding crossed or may have crossed an import commit. The @@ -360,7 +377,11 @@ public final class WhereModel { self.now = now initialSelectedYear = selectedYear initialReport = report - session = WhereSession(scope: scope, now: now) + session = WhereSession( + scope: scope, + installationContextStore: installationContextStore, + now: now, + ) } /// Record a log store on the active scope for the developer surface to @@ -492,7 +513,11 @@ public final class WhereModel { /// session here over the retained (now-erased) scope. func startSession(scope: WhereScope) -> WhereSession { if let session { return session } - let session = WhereSession(scope: scope, now: now) + let session = WhereSession( + scope: scope, + installationContextStore: scope.kind == .real ? installationContextStore : nil, + now: now, + ) self.session = session Self.logger { .startedSession(year: initialSelectedYear) } return session @@ -507,6 +532,12 @@ public final class WhereModel { Self.logger { .endedSession } } + func rejoinInstallation() async throws { + _ = try installationContextStore.rejoin() + await logOut() + Self.logger { .endedSession } + } + /// Release whatever scope is active and return to logged out, ready to /// build a new one. /// diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index a79ef58a..ed10b5f8 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -51,6 +51,10 @@ public final class WhereSession { return configuration.device.status == .recording } + public var isCurrentDeviceRemoved: Bool { + if case .removed = recordingRuntimeState { true } else { false } + } + /// Stable installation identity used by the Devices settings screen to mark /// the current row and prevent archiving it. public var currentRecordingDeviceID: RecordingDeviceID { @@ -70,6 +74,7 @@ public final class WhereSession { /// `MainTabs` / the tabs can build their scoped models from the injected /// coordinator. let services: WhereServices + private let installationContextStore: any InstallationRecordingContextStoring /// The persisted user intent (tracking, reminder/summary schedules) the /// coordinator applies at launch/foreground. Owned by `WhereModel` and shared @@ -122,6 +127,9 @@ public final class WhereSession { /// Whether this session has performed its explicit, idempotent registration operation. private var didRegisterRecordingDevice = false + /// Orders local recording intents across permission prompts; a newer Off must not be + /// overwritten when an earlier On resumes after the prompt. + private var recordingIntentSequence: UInt64 = 0 /// Last controller-ordered runtime emission applied to presentation state. private var lastRecordingRuntimeSequence: UInt64? @@ -129,7 +137,7 @@ public final class WhereSession { /// and acknowledges it. This gates foreground capture without a second mutable mirror. private var recordingEnabled: Bool { guard case let .applied(configuration) = recordingRuntimeState else { return false } - return configuration.isEnabled == true + return configuration.localAutomaticRecordingEnabled == true } /// A process-unique session identity. A typed token rather than a raw `Int` @@ -151,11 +159,24 @@ public final class WhereSession { /// Build a coordinator over the scope the app is logged in to. The /// designated initializer: taking the whole scope is what guarantees the /// services and the preferences a session reads belong to the same world. - init(scope: WhereScope, now: @escaping @Sendable () -> Date = { Date() }) { + init( + scope: WhereScope, + installationContextStore: (any InstallationRecordingContextStoring)? = nil, + now: @escaping @Sendable () -> Date = { Date() }, + ) { id = Self.mintID() services = scope.services preferences = scope.preferences self.now = now + self.installationContextStore = installationContextStore + ?? InMemoryInstallationRecordingContextStore( + context: InstallationRecordingContext( + currentDevice: scope.services.recording.currentDevice, + registeredAt: now(), + automaticRecordingEnabled: true, + isRejoining: false, + ), + ) } /// Build a coordinator over a loose service layer, wrapping it in a scope. @@ -321,7 +342,7 @@ public final class WhereSession { } /// Observe Core's focused policy reconciliation output. The controller emits only after - /// physical GPS state and its target-owned acknowledgement agree, so this mirror never has + /// physical GPS state and its target-owned advisory check-in agree, so this mirror never has /// to infer state from an arbitrary store-change notification. private func observeRecordingConfigurationChanges() { guard recordingConfigurationTask == nil else { return } @@ -341,7 +362,7 @@ public final class WhereSession { observeRecordingConfigurationChanges() let wasTracking = isTracking do { - await services.recording.startMonitoringAssignmentChanges() + await services.recording.startMonitoringChanges() if didRegisterRecordingDevice { _ = try await services.recording.reconcile( authorization: authorizationStatus, @@ -383,6 +404,8 @@ public final class WhereSession { recordingRuntimeState = update.state if case .unavailable = update.state { didRegisterRecordingDevice = false + } else if case .removed = update.state { + didRegisterRecordingDevice = false } } @@ -425,7 +448,7 @@ public final class WhereSession { /// hard denial the Settings alert is surfaced. public func startTracking() async { do { - try await setRecordingEnabled(true, for: currentRecordingDeviceID) + try await setRecordingEnabled(true) } catch { Self.logger(attachments: [.error(error, name: "recording-enable-error")]) { .recordingReconcileFailed(description: error.localizedDescription) @@ -435,7 +458,7 @@ public final class WhereSession { public func stopTracking() async { do { - try await setRecordingEnabled(false, for: currentRecordingDeviceID) + try await setRecordingEnabled(false) } catch { Self.logger(attachments: [.error(error, name: "recording-disable-error")]) { .recordingReconcileFailed(description: error.localizedDescription) @@ -448,40 +471,12 @@ public final class WhereSession { try await services.recording.devices() } - public func recordingAuthoritySnapshot() async throws -> RecordingAuthoritySnapshot { - try await services.recording.authoritySnapshot() - } - - public func assignAutomaticRecording(to deviceID: RecordingDeviceID) async throws { - _ = try await services.recording.assignAutomaticRecording(to: deviceID) - if deviceID == currentRecordingDeviceID { - do { - try await services.ingestor.requestPermission() - } catch { - permissionDenied = true - } - await syncAuthorization() - _ = try await services.recording.reconcile(authorization: authorizationStatus) - } - await synchronizeRecordingRuntimeState() - } - - public func turnOffAutomaticRecording() async throws { - _ = try await services.recording.turnOffAutomaticRecording() - await synchronizeRecordingRuntimeState() - } - - /// Set automatic recording for any installation. The current device also - /// runs the permission flow and updates the session's live tracking mirror. - public func setRecordingEnabled( - _ enabled: Bool, - for deviceID: RecordingDeviceID, - ) async throws { - var devices = try await services.recording.setEnabled( - enabled, - for: deviceID, - ) - guard deviceID == currentRecordingDeviceID else { return } + /// Persist and apply this installation's local recording choice. + public func setRecordingEnabled(_ enabled: Bool) async throws { + let (sequence, overflow) = recordingIntentSequence.addingReportingOverflow(1) + precondition(!overflow, "Recording intent sequence exhausted UInt64.") + recordingIntentSequence = sequence + try installationContextStore.setAutomaticRecordingEnabled(enabled) var permissionRequestFailed = false if enabled { @@ -491,25 +486,17 @@ public final class WhereSession { permissionRequestFailed = true } await syncAuthorization() - _ = try await services.recording.reconcile( - authorization: authorizationStatus, - ) - // The permission prompt is an actor suspension point. Re-read after - // it because a later Off action may have won while the prompt was - // visible; reconciliation honors that latest policy rather than - // appending another On event. - devices = try await services.recording.devices() - } - - guard let current = devices.first(where: { $0.id == deviceID }) else { return } - guard let resolvedEnabled = current.isEnabled else { - throw RecordingPersistenceError.currentDeviceAssignmentUnknown(deviceID) } + guard sequence == recordingIntentSequence else { return } + let configuration = try await services.recording.setAutomaticRecordingEnabled( + enabled, + authorization: authorizationStatus, + ) await synchronizeRecordingRuntimeState() - permissionDenied = resolvedEnabled && permissionRequestFailed - if resolvedEnabled, isTracking { + permissionDenied = enabled && permissionRequestFailed + if configuration.localAutomaticRecordingEnabled == true, isTracking { Self.logger { .trackingEnabled } - } else if !resolvedEnabled { + } else if configuration.localAutomaticRecordingEnabled == false { Self.logger { .stoppedBackgroundTracking } } } @@ -521,10 +508,17 @@ public final class WhereSession { _ = try await services.recording.rename(deviceID, to: nickname) } - public func archiveRecordingDevice( + public func removeRecordingDevice( _ deviceID: RecordingDeviceID, ) async throws { - _ = try await services.recording.archive(deviceID) + _ = try await services.recording.remove(deviceID) + } + + func prepareDeviceRejoin() async throws { + authorizationTask?.cancel() + recordingConfigurationTask?.cancel() + regionStyleTask?.cancel() + try await services.recording.retireForRejoin() } /// Push the persisted reminder intent to the reminder reconciler and warn if @@ -597,7 +591,7 @@ public final class WhereSession { /// Erase synced user data and reset the coordinator's observable state to a /// clean slate. A thin pass-through to `WhereServices.reset()`, which owns - /// *what* gets cleared (recording authority + user-data transaction + pending + /// *what* gets cleared (device identities + user-data transaction + pending /// fixes + derived-state reconciliation); the coordinator only mirrors the outcome. The /// scene's `YearReportModel` is torn down and rebuilt by the relaunch, so no /// report/issue state needs clearing here. The data half of the reset/erase @@ -618,7 +612,7 @@ public final class WhereSession { try await services.reset() } catch let error as WhereServices.ResetCleanupError { // Synced erasure already committed. Keep the old installation context available to - // a later cleanup retry, but never revive this session's observers or authority: the + // a later cleanup retry, but never revive this session's observers or recording: the // teardown step must release the scope and App Intents before surfacing the terminal // partial-success state. recordingRuntimeState = .unavailable diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 4af6f89d..22319e21 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -48,8 +48,7 @@ public struct OnboardingView: View { @State private var page = 0 @State private var selection = PrimaryRegionSelectionModel() @State private var recordingEnabled: Bool - @State private var preserveExistingAssignment = false - @State private var assignmentDiscovery: AssignmentDiscovery = .idle + @State private var deviceDiscovery: DeviceDiscovery = .idle @State private var isFinishing = false @State private var restoreSelection = OnboardingRestoreSelection() @@ -73,10 +72,10 @@ public struct OnboardingView: View { private static let logger = WhereLog.session(OnboardingViewLog.self) - private enum AssignmentDiscovery: Equatable { + private enum DeviceDiscovery: Equatable { case idle case loading - case ready(RecordingAssignmentResolution) + case ready(RecordingOnboardingRecommendation) case failed(String) } @@ -104,7 +103,7 @@ public struct OnboardingView: View { self.installationContext = installationContext _phase = State(initialValue: startsAtRecordingChoice ? .location : .intro) _recordingEnabled = State( - initialValue: installationContext.initialRecordingChoice?.isEnabled + initialValue: installationContext.automaticRecordingEnabled ?? installationContext.recommendedRecordingEnabled, ) } @@ -319,31 +318,16 @@ public struct OnboardingView: View { VStack(spacing: stylesheet.spacing.large) { VStack(alignment: .leading, spacing: stylesheet.spacing.small) { - switch assignmentDiscovery { + switch deviceDiscovery { case .idle, .loading: - ProgressView("Checking your other devices…") - case let .ready(.resolved(existing)): - if existing.deviceID != nil, - restoreSelection.permitsPreservingExistingRecorder - { - Toggle( - "Keep the current recorder", - isOn: $preserveExistingAssignment, + ProgressView(String(localized: .onboardingRecordingChecking)) + case let .ready(recommendation): + if recommendation.recentRecordingDevice != nil { + Label( + String(localized: .onboardingRecordingRecent), + systemImage: "iphone.radiowaves.left.and.right", ) - Text( - "Choose this to leave the device already recording unchanged.", - ) - .font(.subheadline) - .foregroundStyle(.secondary) } - case let .ready(.conflict(deviceIDs)): - Label( - "Choose one recorder to resolve a conflict between \(deviceIDs.count) devices.", - systemImage: "exclamationmark.triangle.fill", - ) - .foregroundStyle(.orange) - case .ready(.unconfigured), .ready(.invalid): - EmptyView() case let .failed(description): Label(description, systemImage: "icloud.slash") .foregroundStyle(.secondary) @@ -352,7 +336,6 @@ public struct OnboardingView: View { String(localized: .settingsDevicesAutomaticRecording), isOn: $recordingEnabled, ) - .disabled(preserveExistingAssignment) Text(recordingRecommendation) .font(.subheadline) .foregroundStyle(.secondary) @@ -363,10 +346,7 @@ public struct OnboardingView: View { // Request Always-location only after the user confirms an // enabled choice; the launch's reconcile step picks up // whatever the system grants. - finish( - enableLocation: recordingEnabled, - preserveExistingAssignment: preserveExistingAssignment, - ) + finish(enableLocation: recordingEnabled) } label: { Text(String(localized: .onboardingContinue)) .frame(maxWidth: .infinity) @@ -374,7 +354,7 @@ public struct OnboardingView: View { .buttonStyle(.borderedProminent) .controlSize(.large) } - .disabled(isFinishing || assignmentDiscovery == .loading) + .disabled(isFinishing || deviceDiscovery == .loading) } .padding(.horizontal, stylesheet.spacing.xxxLarge) .padding(.bottom, stylesheet.spacing.xxxLarge) @@ -382,7 +362,7 @@ public struct OnboardingView: View { } .scrollBounceBehavior(.basedOnSize) } - .task { await discoverRecordingAssignment() } + .task { await discoverRecordingDevices() } } private var recordingTitle: LocalizedStringResource { @@ -394,10 +374,15 @@ public struct OnboardingView: View { } private var recordingRecommendation: LocalizedStringResource { - if deviceKind.recommendsAutomaticRecording { - .onboardingRecordingRecommendationOn + let recommendsEnabled = if case let .ready(recommendation) = deviceDiscovery { + recommendation.isEnabled + } else { + installationContext.recommendedRecordingEnabled + } + if recommendsEnabled { + return .onboardingRecordingRecommendationOn } else { - .onboardingRecordingRecommendationOff + return .onboardingRecordingRecommendationOff } } @@ -408,10 +393,7 @@ public struct OnboardingView: View { /// A store that won't open fails the gate rather than stranding the user /// on a dead intro: the runner lands on the failure surface, which is /// where an unopenable store has always surfaced. - private func finish( - enableLocation: Bool, - preserveExistingAssignment: Bool = false, - ) { + private func finish(enableLocation: Bool) { guard !isFinishing else { return } let readyImport = restoreSelection.readyImport if restoreSelection.selectedURL != nil { @@ -427,12 +409,10 @@ public struct OnboardingView: View { phase = .intro } isFinishing = true - let shouldPreserveExistingAssignment = preserveExistingAssignment - && restoreSelection.permitsPreservingExistingRecorder Task { do { let context = try model.confirmInitialRecordingChoice(isEnabled: enableLocation) - guard context.initialRecordingChoice != nil else { + guard context.automaticRecordingEnabled != nil else { preconditionFailure("A confirmed installation context must carry its choice.") } } catch { @@ -538,7 +518,7 @@ public struct OnboardingView: View { do { let authorization = await scope.services.ingestor.authorizationStatus() try await scope.services.recording.registerForOnboarding( - desiredEnabled: shouldPreserveExistingAssignment ? nil : enableLocation, + desiredEnabled: enableLocation, authorization: authorization, ) } catch { @@ -560,7 +540,7 @@ public struct OnboardingView: View { return } - if enableLocation, shouldPreserveExistingAssignment == false { + if enableLocation { await enableTracking(in: scope) } // Only commit when the user actually picked regions in the manual @@ -588,17 +568,19 @@ public struct OnboardingView: View { } } - private func discoverRecordingAssignment() async { - guard assignmentDiscovery == .idle else { return } - assignmentDiscovery = .loading + private func discoverRecordingDevices() async { + guard deviceDiscovery == .idle else { return } + deviceDiscovery = .loading do { - let resolution = try await model.discoverRecordingAssignment() - assignmentDiscovery = .ready(resolution) - if case let .resolved(assignment) = resolution, assignment.deviceID != nil { - preserveExistingAssignment = true + let recommendation = try await model.discoverRecordingRecommendation( + for: installationContext, + ) + deviceDiscovery = .ready(recommendation) + if installationContext.automaticRecordingEnabled == nil { + recordingEnabled = recommendation.isEnabled } } catch { - assignmentDiscovery = .failed(error.localizedDescription) + deviceDiscovery = .failed(error.localizedDescription) } } @@ -673,9 +655,6 @@ public struct OnboardingView: View { return } restoreSelection.choose(strategy) - if restoreSelection.permitsPreservingExistingRecorder == false { - preserveExistingAssignment = false - } phase = .location } @@ -839,7 +818,8 @@ struct OnboardingPage: Identifiable { kind: .tablet, ), registeredAt: InstallationRecordingContext.testing.registeredAt, - initialRecordingChoice: nil, + automaticRecordingEnabled: nil, + isRejoining: false, ), startsAtRecordingChoice: true, ) diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 7cc499ece..607f247f 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -115,16 +115,8 @@ WhereSession(services: previewServices(), preferences: previewPreferences()) } - /// Current + left-behind device rows for the Devices screen. The iPad's - /// off policy is intentionally unacknowledged so previews pin the - /// cross-device "waiting" state as well as the current happy path. + /// Current + left-behind device rows for the Devices screen. public static func recordingDeviceConfigurations() -> [RecordingDeviceConfiguration] { - let currentPolicyID = UUID( - uuidString: "10000000-0000-0000-0000-000000000001", - )! - let remoteLatestPolicyID = UUID( - uuidString: "20000000-0000-0000-0000-000000000002", - )! let remoteID = RecordingDeviceID( rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, ) @@ -137,16 +129,11 @@ kind: .phone, registeredAt: referenceNow.addingTimeInterval(-90 * 24 * 60 * 60), lastSeenAt: referenceNow, - archivedAt: nil, - lastAppliedAssignmentChangeID: currentPolicyID, + removedAt: nil, status: .recording, ), - assignmentResolution: .resolved(.device( - InstallationRecordingContext.testing.currentDevice.id, - )), - assignmentFrontierID: currentPolicyID, - isAssignmentAcknowledged: true, - isArchived: false, + isCurrentDevice: true, + localAutomaticRecordingEnabled: true, ), RecordingDeviceConfiguration( device: RecordingDevice( @@ -156,16 +143,11 @@ kind: .tablet, registeredAt: referenceNow.addingTimeInterval(-60 * 24 * 60 * 60), lastSeenAt: referenceNow.addingTimeInterval(-2 * 24 * 60 * 60), - archivedAt: nil, - lastAppliedAssignmentChangeID: nil, + removedAt: nil, status: .off, ), - assignmentResolution: .resolved(.device( - InstallationRecordingContext.testing.currentDevice.id, - )), - assignmentFrontierID: remoteLatestPolicyID, - isAssignmentAcknowledged: true, - isArchived: false, + isCurrentDevice: false, + localAutomaticRecordingEnabled: nil, ), ] } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 903d6be3..1974c69b 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -2524,6 +2524,39 @@ } } }, + "device.removed.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic recording has stopped. Rejoin to create a new device identity and choose whether this device should record." + } + } + } + }, + "device.removed.rejoin" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rejoin This Device" + } + } + } + }, + "device.removed.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device was removed from Where" + } + } + } + }, "evidence.add" : { "extractionState" : "manual", "localizations" : { @@ -3565,6 +3598,17 @@ } } }, + "onboarding.recording.checking" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking your other devices…" + } + } + } + }, "onboarding.recording.description" : { "extractionState" : "manual", "localizations" : { @@ -3598,6 +3642,17 @@ } } }, + "onboarding.recording.recent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Another device was recording recently" + } + } + } + }, "onboarding.recording.recommendation.off" : { "extractionState" : "manual", "localizations" : { @@ -5722,145 +5777,145 @@ } } }, - "settings.devices.archive" : { + "settings.devices.automaticRecording" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Archive Device" + "value" : "Automatic Recording" } } } }, - "settings.devices.archive.confirm.message" : { + "settings.devices.current.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Where will hide this device now. Automatic recording stops after that device next connects and syncs. Its existing history is kept." + "value" : "This device applies changes immediately. Always location access is required for background recording." } } } }, - "settings.devices.archive.confirm.title" : { + "settings.devices.error.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Archive this device?" + "value" : "Couldn’t Update Devices" } } } }, - "settings.devices.automaticRecording" : { + "settings.devices.grant" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Automatic Recording" + "value" : "Grant location access" } } } }, - "settings.devices.current.footer" : { + "settings.devices.keywords.name" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "This device applies changes immediately. Always location access is required for background recording." + "value" : "device, name, nickname, iphone, ipad" } } } }, - "settings.devices.error.title" : { + "settings.devices.keywords.recording" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Couldn’t Update Devices" + "value" : "location, gps, tracking, background, automatic, device, travel" } } } }, - "settings.devices.grant" : { + "settings.devices.lastActive" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Grant location access" + "value" : "Last Active" } } } }, - "settings.devices.keywords.name" : { + "settings.devices.loadFailed" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "device, name, nickname, iphone, ipad" + "value" : "Devices Unavailable" } } } }, - "settings.devices.keywords.recording" : { + "settings.devices.name" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "location, gps, tracking, background, automatic, device, travel" + "value" : "Device Name" } } } }, - "settings.devices.lastActive" : { + "settings.devices.remote.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Last Active" + "value" : "Recording choices are local to each device. Remove this device to stop it when it next syncs and hide later automatic locations." } } } }, - "settings.devices.loadFailed" : { + "settings.devices.remove" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Devices Unavailable" + "value" : "Remove from Where" } } } }, - "settings.devices.name" : { + "settings.devices.remove.confirm.message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Device Name" + "value" : "Automatic recording stops when that device reconnects. Earlier history stays visible. Use Apple Lost Mode or remote erase for a missing device." } } } }, - "settings.devices.remote.footer" : { + "settings.devices.remove.confirm.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Turning recording off hides new locations from the cutoff immediately. The device physically stops when it next syncs." + "value" : "Remove this device from Where?" } } } diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index ba6b56cf..16ff009f 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -134,12 +134,16 @@ public struct RootView: View { // monotonic `id` (never reused within the process) rather than // its address, so a rebuilt session can't collide with a freed // one and skip the rebuild. - MainTabs( - session: session, - initialReport: model.initialReport, - selectedYear: model.initialSelectedYear, - ) - .id(session.id) + if session.isCurrentDeviceRemoved { + RemovedDeviceView(model: model, session: session) + } else { + MainTabs( + session: session, + initialReport: model.initialReport, + selectedYear: model.initialSelectedYear, + ) + .id(session.id) + } } // Extend the app content's safe area by the floating HUD's footprint so // scroll views behind the non-modal window inset and their last rows diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift index 873717d4..6a3b9aca 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift @@ -6,23 +6,10 @@ import WhereCore @MainActor @Observable final class DeviceSettingsRowModel: Identifiable { - /// Why the desired recording setting is not yet settled. A missing policy - /// is still arriving through CloudKit; a resolved policy can instead be - /// waiting for its target installation to acknowledge it. - enum AssignmentPresentationState: Equatable { - case syncingAssignment - case resolved(isAcknowledged: Bool) - } - - struct EditableValues: Equatable { - var nickname: String - var isEnabled: Bool? - } - enum Operation: Equatable { case setRecordingEnabled(Bool) case rename(String) - case archive + case remove } struct OperationFailure: Identifiable, Equatable { @@ -37,110 +24,59 @@ final class DeviceSettingsRowModel: Identifiable { case failed(OperationFailure) } - private enum PendingAction: Hashable { - case saveNickname - case archive - } - let id: RecordingDeviceID let systemName: String let kind: RecordingDeviceKind let isCurrent: Bool - private var confirmedValues: EditableValues - private var draftValues: EditableValues - private var pendingActions: Set = [] + private var confirmedNickname: String + private var confirmedRecordingEnabled: Bool? + private var recordingEnabled: Bool + private var pendingRecordingIntent = false + private var wantsNicknameSave = false + private var wantsRemoval = false + + var nickname: String private(set) var operationState: OperationState = .idle private(set) var status: RecordingDeviceStatus private(set) var lastSeenAt: Date - private(set) var assignmentPresentationState: AssignmentPresentationState - init(configuration: RecordingDeviceConfiguration, isCurrent: Bool) { + init(configuration: RecordingDeviceConfiguration) { id = configuration.id systemName = configuration.device.systemName kind = configuration.device.kind - self.isCurrent = isCurrent + isCurrent = configuration.isCurrentDevice let nickname = configuration.device.nickname ?? "" - let editableValues = EditableValues( - nickname: nickname, - isEnabled: configuration.isEnabled, - ) - confirmedValues = editableValues - draftValues = editableValues + self.nickname = nickname + confirmedNickname = nickname + confirmedRecordingEnabled = configuration.localAutomaticRecordingEnabled + recordingEnabled = configuration.localAutomaticRecordingEnabled ?? false status = configuration.device.status lastSeenAt = configuration.device.lastSeenAt - assignmentPresentationState = Self.assignmentPresentationState(for: configuration) - } - - var nickname: String { - get { draftValues.nickname } - set { - guard draftValues.nickname != newValue else { return } - draftValues.nickname = newValue - clearFailure(for: .rename(newValue)) - } } var isEnabled: Bool { - get { draftValues.isEnabled ?? false } + get { recordingEnabled } set { - guard draftValues.isEnabled != nil else { - assertionFailure("An unresolved recording policy cannot be edited.") - return - } - guard draftValues.isEnabled != newValue else { return } - draftValues.isEnabled = newValue - clearFailure(for: .setRecordingEnabled(newValue)) - } - } - - var hasResolvedRecordingAssignment: Bool { - draftValues.isEnabled != nil - } - - var isSyncingRecordingAssignment: Bool { - if case .syncingAssignment = assignmentPresentationState { true } else { false } - } - - var isPending: Bool { - switch assignmentPresentationState { - case .syncingAssignment: true - case let .resolved(isAcknowledged): !isAcknowledged + guard isCurrent, recordingEnabled != newValue else { return } + recordingEnabled = newValue + pendingRecordingIntent = true + clearFailure(matching: .setRecordingEnabled(newValue)) } } var hasUnsavedNickname: Bool { - normalizedNickname != confirmedValues.nickname + normalizedNickname != confirmedNickname } var canSaveNickname: Bool { - guard hasUnsavedNickname else { return false } - return switch operationState { - case .saving: false - case .idle, .failed: true - } - } - - var disablesRecordingControl: Bool { - switch operationState { - case .saving(.rename), .saving(.archive): true - case .idle, .saving(.setRecordingEnabled), .failed: false - } + hasUnsavedNickname && !isSaving } - var disablesNicknameControl: Bool { + var isSaving: Bool { if case .saving = operationState { true } else { false } } - var disablesDestructiveActions: Bool { - guard hasResolvedRecordingAssignment else { return true } - return if case .saving = operationState { true } else { false } - } - - var isApplyingRecordingChange: Bool { - operationState.isSavingRecording - } - var displayName: String { let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? systemName : trimmed @@ -150,118 +86,61 @@ final class DeviceSettingsRowModel: Identifiable { kind.systemImage } - /// Marks the current nickname draft as an explicit save request. Typing - /// alone never writes, while a request made during another save is retained - /// and processed when that operation finishes. func requestNicknameSave() { - pendingActions.insert(.saveNickname) + wantsNicknameSave = true } - func requestArchive() { - pendingActions.insert(.archive) + func requestRemoval() { + precondition(!isCurrent, "The current device cannot remove itself.") + wantsRemoval = true } - /// Claims the next accepted intent for the owning model's single writer - /// loop. Recording uses the live draft, so a second toggle made while the - /// first write is suspended becomes the next operation instead of being - /// discarded by a busy guard. func beginNextOperation() -> Operation? { - if case .saving = operationState { return nil } - - if pendingActions.remove(.archive) != nil { - return begin(.archive) + guard !isSaving else { return nil } + if wantsRemoval { + wantsRemoval = false + return begin(.remove) } - - if let desiredEnabled = draftValues.isEnabled, - desiredEnabled != confirmedValues.isEnabled - { - return begin(.setRecordingEnabled(desiredEnabled)) + if pendingRecordingIntent { + pendingRecordingIntent = false + return begin(.setRecordingEnabled(recordingEnabled)) } - - if pendingActions.remove(.saveNickname) != nil { - let nickname = normalizedNickname - guard nickname != confirmedValues.nickname else { - draftValues.nickname = confirmedValues.nickname - operationState = .idle - return nil - } - return begin(.rename(nickname)) + if wantsNicknameSave, hasUnsavedNickname { + wantsNicknameSave = false + return begin(.rename(normalizedNickname)) } - - operationState = .idle + wantsNicknameSave = false return nil } func finish(_ operation: Operation) { - guard operationState == .saving(operation) else { - assertionFailure("Finished a device operation that was not active.") - return - } - if case let .rename(savedNickname) = operation, - normalizedNickname == savedNickname - { - draftValues.nickname = confirmedValues.nickname + switch operation { + case let .setRecordingEnabled(enabled): + confirmedRecordingEnabled = enabled + case let .rename(nickname): + confirmedNickname = nickname + case .remove: + break } operationState = .idle } func fail(_ operation: Operation, error: any Error) -> OperationFailure { - guard operationState == .saving(operation) else { - assertionFailure("Failed a device operation that was not active.") - return OperationFailure( - operation: operation, - message: error.localizedDescription, - ) - } - - switch operation { - case .setRecordingEnabled: - // A failed toggle must display the last confirmed value rather - // than leave an optimistic value looking successfully saved. - draftValues.isEnabled = confirmedValues.isEnabled - case .rename: - // Preserve the draft so the explicit Save button is a reliable - // retry path and a failed write never destroys user input. - break - case .archive: - break - } - - let failure = OperationFailure( - operation: operation, - message: error.localizedDescription, - ) + let failure = OperationFailure(operation: operation, message: error.localizedDescription) operationState = .failed(failure) return failure } - func dismiss(_ failure: OperationFailure) { - guard operationState == .failed(failure) else { return } - operationState = .idle - } - func update(from configuration: RecordingDeviceConfiguration) { - let previousConfirmedValues = confirmedValues - let updatedNickname = configuration.device.nickname ?? "" - let updatedValues = EditableValues( - nickname: updatedNickname, - isEnabled: configuration.isEnabled, - ) - - let preservesNicknameDraft = draftValues.nickname != previousConfirmedValues.nickname - || operationState.isSavingNickname - let preservesRecordingDraft = draftValues.isEnabled != previousConfirmedValues.isEnabled - || operationState.isSavingRecording - confirmedValues = updatedValues - if !preservesNicknameDraft { - draftValues.nickname = updatedValues.nickname - } - if !preservesRecordingDraft { - draftValues.isEnabled = updatedValues.isEnabled + let newNickname = configuration.device.nickname ?? "" + if !hasUnsavedNickname { nickname = newNickname } + confirmedNickname = newNickname + if let enabled = configuration.localAutomaticRecordingEnabled { + if !pendingRecordingIntent { recordingEnabled = enabled } + confirmedRecordingEnabled = enabled } status = configuration.device.status lastSeenAt = configuration.device.lastSeenAt - assignmentPresentationState = Self.assignmentPresentationState(for: configuration) } private var normalizedNickname: String { @@ -273,34 +152,15 @@ final class DeviceSettingsRowModel: Identifiable { return operation } - private func clearFailure(for operation: Operation) { + private func clearFailure(matching operation: Operation) { guard case let .failed(failure) = operationState else { return } switch (failure.operation, operation) { case (.setRecordingEnabled, .setRecordingEnabled), (.rename, .rename): operationState = .idle - case (.archive, _), (.setRecordingEnabled, _), (.rename, _): + case (.remove, _), (.setRecordingEnabled, _), (.rename, _): break } } - - private static func assignmentPresentationState( - for configuration: RecordingDeviceConfiguration, - ) -> AssignmentPresentationState { - guard configuration.assignmentResolution.assignment != nil else { - return .syncingAssignment - } - return .resolved(isAcknowledged: !configuration.isPending) - } -} - -extension DeviceSettingsRowModel.OperationState { - fileprivate var isSavingNickname: Bool { - if case .saving(.rename) = self { true } else { false } - } - - fileprivate var isSavingRecording: Bool { - if case .saving(.setRecordingEnabled) = self { true } else { false } - } } extension RecordingDeviceKind { diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift index 9832be87..49a2c58b 100644 --- a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift @@ -1,7 +1,7 @@ import SwiftUI import WhereCore -/// Form section for one installation's identity, activity, permission, and archive controls. +/// Form section for one installation's identity, status, local preference, and removal controls. struct DeviceSettingsSection: View { let model: DevicesSettingsModel @Bindable var row: DeviceSettingsRowModel @@ -9,7 +9,7 @@ struct DeviceSettingsSection: View { @Environment(WhereSession.self) private var session @Environment(\.openURL) private var openURL @Environment(\.stylesheet) private var stylesheet - @State private var isConfirmingArchive = false + @State private var isConfirmingRemoval = false var body: some View { Section { @@ -28,7 +28,7 @@ struct DeviceSettingsSection: View { .disabled(!row.canSaveNickname) } } - .disabled(row.disablesNicknameControl) + .disabled(row.isSaving) .settingsRow(DevicesSettingsView.Item.deviceName, when: row.isCurrent) LabeledContent(String(localized: .settingsDevicesStatus)) { @@ -54,6 +54,17 @@ struct DeviceSettingsSection: View { } if row.isCurrent { + Toggle( + String(localized: .settingsDevicesAutomaticRecording), + isOn: $row.isEnabled, + ) + .disabled(row.isSaving) + .settingsRow(DevicesSettingsView.Item.automaticRecording) + .onChange(of: row.isEnabled) { oldValue, newValue in + guard oldValue != newValue else { return } + Task { await model.recordingPreferenceChanged(for: row) } + } + LocationStatusRow( status: session.authorizationStatus, isTracking: session.isTracking, @@ -81,24 +92,26 @@ struct DeviceSettingsSection: View { } } } else { - Button( - String(localized: .settingsDevicesArchive), - systemImage: "archivebox", - role: .destructive, - ) { - isConfirmingArchive = true + Button(role: .destructive) { + isConfirmingRemoval = true + } label: { + HStack { + Image(systemName: "trash") + Text(String(localized: .settingsDevicesRemove)) + } + .foregroundStyle(.red) } - .disabled(row.disablesDestructiveActions) + .disabled(row.isSaving) .confirmationDialog( - String(localized: .settingsDevicesArchiveConfirmTitle), - isPresented: $isConfirmingArchive, + String(localized: .settingsDevicesRemoveConfirmTitle), + isPresented: $isConfirmingRemoval, titleVisibility: .visible, ) { - Button(String(localized: .settingsDevicesArchive), role: .destructive) { - Task { await model.archive(row) } + Button(String(localized: .settingsDevicesRemove), role: .destructive) { + Task { await model.remove(row) } } } message: { - Text(String(localized: .settingsDevicesArchiveConfirmMessage)) + Text(String(localized: .settingsDevicesRemoveConfirmMessage)) } } } header: { @@ -137,12 +150,6 @@ struct DeviceSettingsSection: View { if row.isCurrent, case .unavailable = session.recordingRuntimeState { return String(localized: .settingsDevicesStatusUnavailable) } - if row.isSyncingRecordingAssignment { - return String(localized: .settingsDevicesStatusSyncing) - } - if row.isPending || row.isApplyingRecordingChange { - return String(localized: .settingsDevicesStatusPending) - } switch row.status { case .unknown: return String(localized: .settingsDevicesStatusPending) case .recording: return String(localized: .settingsDevicesStatusRecording) @@ -156,12 +163,6 @@ struct DeviceSettingsSection: View { if row.isCurrent, case .unavailable = session.recordingRuntimeState { return "exclamationmark.triangle" } - if row.isSyncingRecordingAssignment { - return "icloud.and.arrow.down" - } - if row.isPending || row.isApplyingRecordingChange { - return "clock.arrow.trianglehead.counterclockwise.rotate.90" - } return switch row.status { case .unknown: "clock.arrow.trianglehead.counterclockwise.rotate.90" case .recording: "location.fill" @@ -177,13 +178,12 @@ struct DeviceSettingsSection: View { true } return row.status == .recording && runtimeIsAvailable - && !row.isPending && !row.isApplyingRecordingChange ? .primary : .secondary } private var showGrantButton: Bool { - guard row.isEnabled else { return false } + guard row.isCurrent, row.isEnabled else { return false } return switch session.authorizationStatus { case .notDetermined, .whenInUse: true case .restricted, .denied, .always: false @@ -191,7 +191,7 @@ struct DeviceSettingsSection: View { } private var showOpenSettingsButton: Bool { - guard row.isEnabled else { return false } + guard row.isCurrent, row.isEnabled else { return false } return switch session.authorizationStatus { case .denied, .restricted, .whenInUse: true case .notDetermined, .always: false diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift index 643e8cad..3bc1d8f8 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift @@ -2,20 +2,14 @@ import Foundation import Observation import WhereCore -/// The Settings-specific surface of the session. Commands report completion only; every row -/// snapshot is obtained through the model's single ordered refresh path. @MainActor protocol DevicesSettingsSession: AnyObject { var currentRecordingDeviceID: RecordingDeviceID { get } - func recordingDeviceUpdates() -> AsyncStream func recordingDevices() async throws -> [RecordingDeviceConfiguration] - func recordingAuthoritySnapshot() async throws -> RecordingAuthoritySnapshot - func assignAutomaticRecording(to deviceID: RecordingDeviceID) async throws - func turnOffAutomaticRecording() async throws - func setRecordingEnabled(_ enabled: Bool, for deviceID: RecordingDeviceID) async throws + func setRecordingEnabled(_ enabled: Bool) async throws func renameRecordingDevice(_ deviceID: RecordingDeviceID, to nickname: String) async throws - func archiveRecordingDevice(_ deviceID: RecordingDeviceID) async throws + func removeRecordingDevice(_ deviceID: RecordingDeviceID) async throws func requestPermission() async } @@ -25,51 +19,15 @@ extension WhereSession: DevicesSettingsSession { } } -extension DevicesSettingsSession { - func recordingAuthoritySnapshot() async throws -> RecordingAuthoritySnapshot { - let configurations = try await recordingDevices() - let enabled = configurations.filter { $0.isEnabled == true }.map(\.id) - let resolution: RecordingAssignmentResolution = switch enabled.count { - case 0: .resolved(.off) - case 1: .resolved(.device(enabled[0])) - default: .conflict(Set(enabled)) - } - return RecordingAuthoritySnapshot( - resolution: resolution, - devices: configurations.map(\.device), - archivedDeviceIDs: [], - ) - } - - func assignAutomaticRecording(to deviceID: RecordingDeviceID) async throws { - try await setRecordingEnabled(true, for: deviceID) - } - - func turnOffAutomaticRecording() async throws { - try await setRecordingEnabled(false, for: currentRecordingDeviceID) - } -} - -/// View-scoped Devices settings state. All mutations await the serialized Core -/// controller. Each row owns one operation state and this model drains its -/// accepted intents in order, including a newer toggle made while a write is -/// suspended. +/// View-scoped Devices settings state. Refreshes are read-only; commands originate only from +/// explicit row intents, so a CloudKit update can never submit a local recording choice. @MainActor @Observable final class DevicesSettingsModel { - enum RecordingSelection: Hashable { - case unresolved - case off - case device(RecordingDeviceID) - } - struct Failure: Identifiable, Equatable { enum Context: Equatable { case initialLoad - case operation( - deviceID: RecordingDeviceID, - failure: DeviceSettingsRowModel.OperationFailure, - ) + case operation(deviceID: RecordingDeviceID) case refresh } @@ -90,31 +48,15 @@ final class DevicesSettingsModel { } } - private struct RefreshFailure { - let generation: UInt64 - let error: any Error - } - private let session: any DevicesSettingsSession private(set) var state: LoadState = .idle private(set) var rows: [DeviceSettingsRowModel] = [] - private(set) var authorityResolution: RecordingAssignmentResolution = .unconfigured - var recordingSelection = RecordingSelection.unresolved private(set) var presentedFailure: Failure? - @ObservationIgnored private var refreshTask: Task? - @ObservationIgnored private var requestedRefreshGeneration: UInt64 = 0 - @ObservationIgnored private var completedRefreshGeneration: UInt64 = 0 - @ObservationIgnored private var lastRefreshFailure: RefreshFailure? - @ObservationIgnored private var committedOperationsAwaitingRefresh: [ - RecordingDeviceID: DeviceSettingsRowModel.Operation - ] = [:] - @ObservationIgnored private var rowsNeedingOperationResume: Set = [] + @ObservationIgnored private var operationDeviceIDs: Set = [] var isShowingError: Bool { get { presentedFailure != nil } - set { - if !newValue { dismissPresentedFailure() } - } + set { if !newValue { presentedFailure = nil } } } var presentedFailureCanRetry: Bool { @@ -132,15 +74,10 @@ final class DevicesSettingsModel { ) { self.session = session apply(configurations) - let authority = Self.compatibilityAuthority(from: configurations) - authorityResolution = authority.resolution - recordingSelection = Self.recordingSelection(for: authority.resolution) state = configurations.isEmpty ? .empty : .loaded } #endif - /// Load once, then stay current with local commits and CloudKit imports - /// until the owning view disappears and SwiftUI cancels the task. func run() async { let updates = session.recordingDeviceUpdates() await load(showLoading: true) @@ -153,26 +90,21 @@ final class DevicesSettingsModel { await load(showLoading: true) } - /// Persist the row's latest recording draft. If another write is already - /// active, that writer will observe this draft before it exits and submit - /// it next; no accepted toggle is discarded. func recordingPreferenceChanged(for row: DeviceSettingsRowModel) async { + guard row.isCurrent else { + assertionFailure("A remote device cannot change another installation's preference.") + return + } await processPendingOperations(for: row) } - /// Mark the current nickname draft for an explicit save. A request made - /// while another row operation is active remains queued. func saveNickname(_ row: DeviceSettingsRowModel) async { row.requestNicknameSave() await processPendingOperations(for: row) } - func archive(_ row: DeviceSettingsRowModel) async { - guard !row.isCurrent else { - assertionFailure("The current recording device cannot be archived.") - return - } - row.requestArchive() + func remove(_ row: DeviceSettingsRowModel) async { + row.requestRemoval() await processPendingOperations(for: row) } @@ -181,158 +113,46 @@ final class DevicesSettingsModel { await load(showLoading: false) } - func recordingAssignmentChanged() async { - guard recordingSelection != Self.recordingSelection(for: authorityResolution) else { - return - } - do { - switch recordingSelection { - case .unresolved: - return - case .off: - try await session.turnOffAutomaticRecording() - case let .device(deviceID): - try await session.assignAutomaticRecording(to: deviceID) - } - await load(showLoading: false) - } catch { - surfaceLoadRefreshFailure(error) - await load(showLoading: false) - } - } - private func load(showLoading: Bool) async { - // Keep already rendered rows visible while a manual retry reconciles them. If that read - // fails again, the user gets another retryable alert instead of a permanent spinner. if showLoading, rows.isEmpty { state = .loading } do { - try await refreshConfigurations() - completeSuccessfulRefresh() - await resumeOperationsAfterRefresh() + try await apply(session.recordingDevices()) + state = rows.isEmpty ? .empty : .loaded + if presentedFailure?.context == .refresh { presentedFailure = nil } } catch { - if rows.isEmpty { - state = .failed(Failure( - context: .initialLoad, - message: error.localizedDescription, - )) - } else { - surfaceLoadRefreshFailure(error) - } + let failure = Failure( + context: rows.isEmpty ? .initialLoad : .refresh, + message: error.localizedDescription, + ) + if rows.isEmpty { state = .failed(failure) } else { presentedFailure = failure } } } private func processPendingOperations(for row: DeviceSettingsRowModel) async { + guard operationDeviceIDs.insert(row.id).inserted else { return } + defer { operationDeviceIDs.remove(row.id) } while let operation = row.beginNextOperation() { do { switch operation { case let .setRecordingEnabled(enabled): - try await session.setRecordingEnabled(enabled, for: row.id) + try await session.setRecordingEnabled(enabled) case let .rename(nickname): try await session.renameRecordingDevice(row.id, to: nickname) - case .archive: - try await session.archiveRecordingDevice(row.id) + case .remove: + try await session.removeRecordingDevice(row.id) } + row.finish(operation) + await load(showLoading: false) + if operation == .remove { return } } catch { let failure = row.fail(operation, error: error) - surface(failure, for: row.id) - // A write can fail after committing. Re-read so the controls - // show persisted truth while preserving an unsaved nickname - // draft as the explicit retry path. + presentedFailure = Failure( + context: .operation(deviceID: row.id), + message: failure.message, + ) await load(showLoading: false) return } - - committedOperationsAwaitingRefresh[row.id] = operation - do { - // Never apply the snapshot a command happened to observe. A CloudKit import can - // land while the command is suspended, so all truth is re-read through the same - // ordered path used by data-change updates. A failed read does not turn a command - // that already committed into a failed command or cause it to be issued again. - try await refreshConfigurations() - completeSuccessfulRefresh() - rowsNeedingOperationResume.remove(row.id) - await resumeOperationsAfterRefresh() - if operation == .archive { return } - } catch { - surfaceLoadRefreshFailure(error) - return - } - } - } - - /// Coalesce concurrent command and data-change refreshes without allowing their actor hops - /// to apply out of order. A request arriving during a read schedules another pass, ensuring - /// that pass observes the commit which emitted the request. - private func refreshConfigurations() async throws { - let targetGeneration = requestRefresh() - while completedRefreshGeneration < targetGeneration { - guard let refreshTask else { continue } - await refreshTask.value - } - if let failure = lastRefreshFailure, - failure.generation >= targetGeneration - { - throw failure.error - } - } - - private func requestRefresh() -> UInt64 { - let (generation, overflow) = requestedRefreshGeneration.addingReportingOverflow(1) - precondition(!overflow, "Devices Settings refresh generation exhausted UInt64.") - requestedRefreshGeneration = generation - if refreshTask == nil { - refreshTask = Task { @MainActor [weak self] in - await self?.drainRefreshes() - } - } - return generation - } - - private func drainRefreshes() async { - while completedRefreshGeneration < requestedRefreshGeneration { - let generation = requestedRefreshGeneration - do { - let configurations = try await session.recordingDevices() - let authority = try await session.recordingAuthoritySnapshot() - completedRefreshGeneration = generation - guard generation == requestedRefreshGeneration else { continue } - lastRefreshFailure = nil - apply(configurations) - authorityResolution = authority.resolution - recordingSelection = Self.recordingSelection(for: authority.resolution) - } catch { - completedRefreshGeneration = generation - guard generation == requestedRefreshGeneration else { continue } - lastRefreshFailure = RefreshFailure(generation: generation, error: error) - } - } - refreshTask = nil - } - - private static func compatibilityAuthority( - from configurations: [RecordingDeviceConfiguration], - ) -> RecordingAuthoritySnapshot { - let enabled = configurations.filter { $0.isEnabled == true }.map(\.id) - let resolution: RecordingAssignmentResolution = switch enabled.count { - case 0: .resolved(.off) - case 1: .resolved(.device(enabled[0])) - default: .conflict(Set(enabled)) - } - return RecordingAuthoritySnapshot( - resolution: resolution, - devices: configurations.map(\.device), - archivedDeviceIDs: [], - ) - } - - private static func recordingSelection( - for resolution: RecordingAssignmentResolution, - ) -> RecordingSelection { - switch resolution { - case .unconfigured, .conflict, .invalid: - .unresolved - case let .resolved(assignment): - assignment.deviceID.map(RecordingSelection.device) ?? .off } } @@ -343,69 +163,7 @@ final class DevicesSettingsModel { row.update(from: configuration) return row } - return DeviceSettingsRowModel( - configuration: configuration, - isCurrent: configuration.id == session.currentRecordingDeviceID, - ) - } - - let visibleDeviceIDs = Set(rows.map(\.id)) - let committedOperations = committedOperationsAwaitingRefresh - committedOperationsAwaitingRefresh.removeAll() - for (deviceID, operation) in committedOperations { - existing[deviceID]?.finish(operation) - if operation != .archive, visibleDeviceIDs.contains(deviceID) { - rowsNeedingOperationResume.insert(deviceID) - } - } - } - - private func completeSuccessfulRefresh() { - state = rows.isEmpty ? .empty : .loaded - if presentedFailure?.context == .refresh { - presentedFailure = nil - } - } - - private func resumeOperationsAfterRefresh() async { - let rowsToResume = rows.filter { rowsNeedingOperationResume.contains($0.id) } - rowsNeedingOperationResume.removeAll() - for row in rowsToResume { - await processPendingOperations(for: row) - } - } - - private func surface( - _ failure: DeviceSettingsRowModel.OperationFailure, - for deviceID: RecordingDeviceID, - ) { - presentedFailure = Failure( - context: .operation(deviceID: deviceID, failure: failure), - message: failure.message, - ) - } - - private func surfaceLoadRefreshFailure(_ error: any Error) { - guard presentedFailure == nil else { return } - presentedFailure = Failure( - context: .refresh, - message: error.localizedDescription, - ) - } - - private func dismissPresentedFailure() { - guard let presentedFailure else { return } - if case let .operation(deviceID, failure) = presentedFailure.context { - rows.first(where: { $0.id == deviceID })?.dismiss(failure) - } - self.presentedFailure = nil - if case .operation = presentedFailure.context, - let lastRefreshFailure - { - self.presentedFailure = Failure( - context: .refresh, - message: lastRefreshFailure.error.localizedDescription, - ) + return DeviceSettingsRowModel(configuration: configuration) } } } diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift index a2a244e4..b0de354a 100644 --- a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift @@ -2,8 +2,8 @@ import SnapshotKit import SwiftUI import WhereCore -/// Synced device-management screen. One authority card assigns automatic recording account-wide; -/// installation rows retain editable identity, acknowledgement, permission, and activity state. +/// Synced device-management screen. Only the current row edits local recording preference; +/// remote rows expose advisory status and irreversible removal. struct DevicesSettingsView: View { var focus: SettingsFocus? @@ -74,7 +74,6 @@ struct DevicesSettingsView: View { } } case .loaded: - RecordingAuthoritySection(model: model) ForEach(model.rows) { row in DeviceSettingsSection(model: model, row: row) } diff --git a/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift b/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift deleted file mode 100644 index 23c77ef7..00000000 --- a/Where/WhereUI/Sources/Settings/RecordingAuthoritySection.swift +++ /dev/null @@ -1,76 +0,0 @@ -import SwiftUI -import WhereCore - -/// The one account-wide automatic recorder. Device rows remain editors for identity, activity, -/// permission, and archival; assignment changes happen only here. -struct RecordingAuthoritySection: View { - @Bindable var model: DevicesSettingsModel - - var body: some View { - Section { - Picker("Automatic recording", selection: $model.recordingSelection) { - if model.recordingSelection == .unresolved { - Text("Choose a recorder") - .tag(DevicesSettingsModel.RecordingSelection.unresolved) - .disabled(true) - } - Text("Off").tag(DevicesSettingsModel.RecordingSelection.off) - ForEach(model.rows) { row in - Label(row.displayName, systemImage: row.systemImage) - .tag(DevicesSettingsModel.RecordingSelection.device(row.id)) - } - } - .onChange(of: model.recordingSelection) { oldValue, newValue in - guard oldValue != newValue else { return } - Task { await model.recordingAssignmentChanged() } - } - - switch model.authorityResolution { - case .unconfigured: - Label("Choose the one device that stays with you.", systemImage: "location") - .foregroundStyle(.secondary) - case let .resolved(assignment): - if assignment.deviceID == nil { - Label("Automatic recording is Off.", systemImage: "location.slash") - .foregroundStyle(.secondary) - } else { - Label( - "Only this device records location automatically.", - systemImage: "checkmark.shield", - ) - .foregroundStyle(.secondary) - } - case let .conflict(deviceIDs): - Label( - "Recording is paused because \(deviceIDs.count) devices were chosen at the same time. Pick one to resolve it.", - systemImage: "exclamationmark.triangle.fill", - ) - .foregroundStyle(.orange) - case .invalid: - Label( - "Recording is paused while device changes finish syncing.", - systemImage: "icloud.and.arrow.down", - ) - .foregroundStyle(.secondary) - } - } header: { - Text("Recorder") - } footer: { - Text( - "Every device can still correct your history and add evidence. Transferring the recorder takes effect immediately.", - ) - } - } -} - -#if DEBUG - #Preview { - let session = PreviewSupport.loadedSession() - let model = DevicesSettingsModel( - session: session, - configurations: PreviewSupport.recordingDeviceConfigurations(), - ) - Form { RecordingAuthoritySection(model: model) } - .environment(session) - } -#endif diff --git a/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift index 3b00709b..b8133c8d 100644 --- a/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift +++ b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift @@ -8,112 +8,57 @@ struct DeviceSettingsRowModelTests { private static let id = RecordingDeviceID( rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, ) - private static let policyID = UUID( - uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", - )! private static let date = Date(timeIntervalSinceReferenceDate: 100) - @Test func presentsNicknameKindAndAcknowledgement() { + @Test func presentsRemoteDeviceWithoutAnEditableRecordingChoice() { let row = DeviceSettingsRowModel( - configuration: configuration( - nickname: "Home iPad", - status: .recording, - appliedPolicyID: Self.policyID, - ), - isCurrent: false, + configuration: configuration(nickname: "Home iPad", status: .recording), ) #expect(row.displayName == "Home iPad") #expect(row.systemImage == "ipad") - #expect(row.isPending == false) + #expect(row.isCurrent == false) + #expect(row.beginNextOperation() == nil) } - @Test func updateKeepsEditableObjectIdentityAndAppliesRemoteState() { + @Test func refreshAppliesLocalChoiceWithoutCreatingAUserCommand() { let row = DeviceSettingsRowModel( - configuration: configuration( - nickname: nil, - status: .recording, - appliedPolicyID: Self.policyID, - ), - isCurrent: false, + configuration: configuration(status: .recording, isCurrent: true, enabled: true), ) - row.update(from: configuration( - nickname: "Desk", - status: .off, - appliedPolicyID: nil, - )) - #expect(row.id == Self.id) - #expect(row.displayName == "Desk") - #expect(row.status == .off) - #expect(row.isPending == false) - #expect(row.isSyncingRecordingAssignment == false) - #expect(row.assignmentPresentationState == .resolved(isAcknowledged: true)) + row.update(from: configuration(status: .off, isCurrent: true, enabled: false)) + #expect(row.isEnabled == false) + #expect(row.beginNextOperation() == nil) } - @Test func syncedRefreshDoesNotOverwriteAnUnsavedNickname() { + @Test func explicitLocalToggleCreatesOneRecordingCommand() { let row = DeviceSettingsRowModel( - configuration: configuration( - nickname: "Home", - status: .off, - appliedPolicyID: Self.policyID, - ), - isCurrent: false, + configuration: configuration(status: .recording, isCurrent: true, enabled: true), ) - row.nickname = "Home iPad" - row.update(from: configuration( - nickname: "Synced elsewhere", - status: .off, - appliedPolicyID: Self.policyID, - )) + row.isEnabled = false - #expect(row.nickname == "Home iPad") - #expect(row.hasUnsavedNickname) + #expect(row.beginNextOperation() == .setRecordingEnabled(false)) } - @Test func profileWithoutASyncedPolicyStaysUnresolved() { - let device = configuration( - nickname: "Home iPad", - status: .unknown, - appliedPolicyID: nil, - ).device + @Test func syncedRefreshDoesNotOverwriteAnUnsavedNickname() { let row = DeviceSettingsRowModel( - configuration: RecordingDeviceConfiguration( - device: device, - assignmentResolution: .unconfigured, - assignmentFrontierID: nil, - isAssignmentAcknowledged: false, - isArchived: false, - ), - isCurrent: false, + configuration: configuration(nickname: "Home", status: .off), ) + row.nickname = "Home iPad" - #expect(row.hasResolvedRecordingAssignment == false) - #expect(row.isPending) - #expect(row.isSyncingRecordingAssignment) - #expect(row.assignmentPresentationState == .syncingAssignment) - #expect(row.disablesDestructiveActions) - - row.update(from: configuration( - nickname: "Home iPad", - status: .off, - appliedPolicyID: Self.policyID, - )) + row.update(from: configuration(nickname: "Synced elsewhere", status: .off)) - #expect(row.hasResolvedRecordingAssignment) - #expect(row.isEnabled == false) - #expect(row.isPending == false) - #expect(row.isSyncingRecordingAssignment == false) - #expect(row.assignmentPresentationState == .resolved(isAcknowledged: true)) - #expect(row.disablesDestructiveActions == false) + #expect(row.nickname == "Home iPad") + #expect(row.hasUnsavedNickname) } private func configuration( - nickname: String?, + nickname: String? = nil, status: RecordingDeviceStatus, - appliedPolicyID: UUID?, + isCurrent: Bool = false, + enabled: Bool? = nil, ) -> RecordingDeviceConfiguration { RecordingDeviceConfiguration( device: RecordingDevice( @@ -123,16 +68,11 @@ struct DeviceSettingsRowModelTests { kind: .tablet, registeredAt: Self.date, lastSeenAt: Self.date, - archivedAt: nil, - lastAppliedAssignmentChangeID: appliedPolicyID, + removedAt: nil, status: status, ), - assignmentResolution: .resolved( - status == .off ? .off : .device(Self.id), - ), - assignmentFrontierID: Self.policyID, - isAssignmentAcknowledged: appliedPolicyID == Self.policyID, - isArchived: false, + isCurrentDevice: isCurrent, + localAutomaticRecordingEnabled: enabled, ) } } diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift index 3da6a6b2..475467a3 100644 --- a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -1,737 +1,164 @@ import Foundation import Testing -@_spi(Testing) import WhereCore +import WhereCore @testable import WhereUI @MainActor struct DevicesSettingsModelTests { - private static let now = Date(timeIntervalSinceReferenceDate: 1000) - private static let disabledInitialPolicyID = UUID( - uuidString: "00000000-0000-0000-0000-000000000003", - )! - - @Test func searchFocusWaitsUntilDeviceRowsAreLoaded() { - #expect(DevicesSettingsModel.LoadState.idle.isReadyForSearchFocus == false) - #expect(DevicesSettingsModel.LoadState.loading.isReadyForSearchFocus == false) - #expect(DevicesSettingsModel.LoadState.empty.isReadyForSearchFocus == false) - #expect(DevicesSettingsModel.LoadState.loaded.isReadyForSearchFocus) - } - - private func makeSubject() throws -> ( - model: DevicesSettingsModel, - session: WhereSession, - store: SwiftDataStore - ) { - let store = try SwiftDataStore.inMemory() - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(authorizationStatus: .always), - installationContext: .testing, - now: { Self.now }, - ) - let preferences = makePreferences() - let session = WhereSession(services: services, preferences: preferences) - return (DevicesSettingsModel(session: session), session, store) - } - - private func makeSubject( - store: any WhereStore, - initialRecordingEnabled: Bool = true, - ) -> ( - model: DevicesSettingsModel, - session: WhereSession - ) { - let installationContext = if initialRecordingEnabled { - InstallationRecordingContext.testing - } else { - InstallationRecordingContext( - currentDevice: InstallationRecordingContext.testing.currentDevice, - registeredAt: InstallationRecordingContext.testing.registeredAt, - initialRecordingChoice: .init( - isEnabled: false, - assignmentChangeID: Self.disabledInitialPolicyID, - confirmedAt: Date(timeIntervalSinceReferenceDate: 1), - ), - ) - } - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(authorizationStatus: .always), - installationContext: installationContext, - now: { Self.now }, - ) - let preferences = makePreferences() - let session = WhereSession(services: services, preferences: preferences) - return (DevicesSettingsModel(session: session), session) - } - - @Test func loadsTheCurrentDeviceAndAwaitsAToggle() async throws { - let subject = try makeSubject() - await subject.session.start() - await subject.model.retry() - let row = try #require(subject.model.rows.first) - #expect(row.isCurrent) - #expect(row.isEnabled) - #expect(row.status == .recording) - - row.isEnabled = false - await subject.model.recordingPreferenceChanged(for: row) - - #expect(row.isEnabled == false) - #expect(row.status == .off) - #expect(row.isPending == false) - #expect(subject.session.isTracking == false) - } - - @Test func emptyDeviceResultsRemainVisibleAndRetryable() async { - let session = ScriptedDevicesSettingsSession(hasDevice: false) - let model = DevicesSettingsModel(session: session) - - await model.retry() - - guard case .empty = model.state else { - Issue.record("Expected an empty device result to have a visible state.") - return - } - #expect(model.rows.isEmpty) - - session.hasDevice = true - await model.retry() - - guard case .loaded = model.state else { - Issue.record("Expected retry to load the now-available device.") - return - } - #expect(model.rows.count == 1) - #expect(session.recordingDevicesCallCount == 2) - } - - @Test func refreshFailureAfterACommittedToggleDoesNotFailOrRepeatTheToggle() async throws { - let session = ScriptedDevicesSettingsSession(isEnabled: false) - let model = DevicesSettingsModel(session: session) - await model.retry() - let row = try #require(model.rows.first) - session.failRecordingDevicesCall(2) - - row.isEnabled = true - await model.recordingPreferenceChanged(for: row) - - #expect(session.setEnabledCalls == [true]) - #expect(row.isEnabled) - #expect(row.isApplyingRecordingChange) - #expect(model.presentedFailure?.context == .refresh) - #expect(model.presentedFailureCanRetry) - - await model.retry() - - #expect(session.setEnabledCalls == [true]) - #expect(row.isEnabled) - #expect(row.operationState == .idle) - #expect(model.presentedFailure == nil) - } - - @Test func repeatedRefreshFailureKeepsExistingRowsVisible() async throws { - let session = ScriptedDevicesSettingsSession() + fileprivate static let currentID = RecordingDeviceID( + rawValue: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + ) + private static let remoteID = RecordingDeviceID( + rawValue: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, + ) + private static let date = Date(timeIntervalSinceReferenceDate: 100) + + @Test func loadsCurrentAndRemoteDeviceRows() async { + let session = Session(configurations: Self.configurations) let model = DevicesSettingsModel(session: session) - await model.retry() - _ = try #require(model.rows.first) - session.failRecordingDevicesCall(2) - session.failRecordingDevicesCall(3) - await model.retry() - guard case .loaded = model.state else { - Issue.record("Expected a failed reconciliation to preserve the loaded rows.") - return - } - #expect(model.presentedFailure?.context == .refresh) - - model.isShowingError = false - await model.retry() + await model.run() - guard case .loaded = model.state else { - Issue.record("Expected a repeated failure to preserve the loaded rows.") - return - } - #expect(model.rows.count == 1) - #expect(model.presentedFailure?.context == .refresh) + #expect(model.rows.map(\.id) == [Self.currentID, Self.remoteID]) + #expect(model.rows.first?.isCurrent == true) } - @Test func remoteConflictRefreshDoesNotSubmitAnOffCommand() async { - let session = ScriptedDevicesSettingsSession() + @Test func localToggleChangesOnlyTheCurrentInstallationsPreference() async throws { + let session = Session(configurations: Self.configurations) let model = DevicesSettingsModel(session: session) - await model.retry() - #expect(model.recordingSelection == .off) + await model.run() + let current = try #require(model.rows.first) - session.simulateRemoteAuthority(.conflict([session.currentRecordingDeviceID])) - await model.retry() - #expect(model.recordingSelection == .unresolved) + current.isEnabled = false + await model.recordingPreferenceChanged(for: current) - // SwiftUI observes the refreshed picker selection after the model applies it. The same - // callback used for a user gesture must recognize that persisted truth already matches. - await model.recordingAssignmentChanged() - - #expect(session.setEnabledCalls.isEmpty) + #expect(session.recordingChoices == [false]) } - @Test func explicitPickerSelectionStillSubmitsACommand() async { - let session = ScriptedDevicesSettingsSession() + @Test func remoteDeviceCanBeRenamedAndRemovedButNotToggled() async throws { + let session = Session(configurations: Self.configurations) let model = DevicesSettingsModel(session: session) - await model.retry() + await model.run() + let remote = try #require(model.rows.last) - model.recordingSelection = .device(session.currentRecordingDeviceID) - await model.recordingAssignmentChanged() + remote.nickname = "Kitchen iPad" + await model.saveNickname(remote) + await model.remove(remote) - #expect(session.setEnabledCalls == [true]) + #expect(session.renames.map(\.id) == [Self.remoteID]) + #expect(session.renames.map(\.nickname) == ["Kitchen iPad"]) + #expect(session.removals == [Self.remoteID]) + #expect(session.recordingChoices.isEmpty) } - @Test func refreshRetrySubmitsANewerToggleWithoutRepeatingTheCommittedToggle() async throws { - let session = ScriptedDevicesSettingsSession(isEnabled: false) + @Test func operationFailureRemainsVisibleAfterRefresh() async throws { + let session = Session(configurations: Self.configurations) + session.nextError = TestFailure() let model = DevicesSettingsModel(session: session) - await model.retry() - let row = try #require(model.rows.first) - session.failRecordingDevicesCall(2) - - row.isEnabled = true - await model.recordingPreferenceChanged(for: row) - row.isEnabled = false - await model.recordingPreferenceChanged(for: row) - - await model.retry() - - #expect(session.setEnabledCalls == [true, false]) - #expect(row.isEnabled == false) - #expect(row.operationState == .idle) - #expect(model.presentedFailure == nil) - } - - @Test func renamesAndArchivesARemoteDevice() async throws { - let subject = try makeSubject() - await subject.session.start() - let remoteID = try RecordingDeviceID( - rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), - ) - let remotePolicyID = try #require( - UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAB"), - ) - try await addRemoteDevice( - to: subject.store, - id: remoteID, - nickname: nil, - policyID: remotePolicyID, - enabled: false, - status: .off, - writerID: subject.session.currentRecordingDeviceID, - ) - await subject.model.retry() - let remote = try #require(subject.model.rows.first(where: { $0.id == remoteID })) - - remote.nickname = "Home iPad" - await subject.model.saveNickname(remote) - #expect(remote.displayName == "Home iPad") - #expect(try await subject.store.recordingDevices() - .first(where: { $0.id == remoteID })?.nickname == "Home iPad") - - await subject.model.archive(remote) - #expect(subject.model.rows.contains(where: { $0.id == remoteID }) == false) - #expect(try await subject.store.recordingDevices() - .first(where: { $0.id == remoteID })?.archivedAt == Self.now) - } - - @Test func newestToggleWinsWhileTheFirstWriteIsSuspended() async throws { - let store = try TestStore() - let subject = makeSubject(store: store, initialRecordingEnabled: false) - await subject.session.start() - await subject.model.retry() - let row = try #require(subject.model.rows.first) - #expect(row.isEnabled == false) - - await store.gateNextRecordingAssignmentWrite() - row.isEnabled = true - let firstWrite = Task { - await subject.model.recordingPreferenceChanged(for: row) - } - await store.awaitRecordingAssignmentWriteGate() - #expect(row.isApplyingRecordingChange) - - row.isEnabled = false - let newestIntent = Task { - await subject.model.recordingPreferenceChanged(for: row) - } - await newestIntent.value - await store.releaseRecordingAssignmentWriteGate() - await firstWrite.value - - #expect(row.isEnabled == false) - #expect(row.operationState == .idle) - #expect(row.isApplyingRecordingChange == false) - let assignmentChanges = try await store.recordingAssignmentChanges() - #expect(assignmentChanges.suffix(2).map(\.assignedDeviceID) - == [subject.session.currentRecordingDeviceID, nil]) - } - - @Test func remoteRefreshWinsWhileANicknameCommandIsSuspended() async throws { - let session = SuspendedDevicesSettingsSession() - let model = DevicesSettingsModel(session: session) - await model.retry() - let row = try #require(model.rows.first) - - row.nickname = "Local Name" - let save = Task { await model.saveNickname(row) } - await session.awaitRename() - - session.simulateRemoteNickname("Cloud Name") - await model.retry() - session.releaseRename() - await save.value - - #expect(row.nickname == "Cloud Name") - #expect(row.hasUnsavedNickname == false) - #expect(row.operationState == .idle) - } - - @Test func failedToggleRestoresConfirmedStateAndSurfacesTheFailure() async throws { - let store = try TestStore() - let subject = makeSubject(store: store) - await subject.session.start() - await subject.model.retry() - let row = try #require(subject.model.rows.first) - - await store.failNextRecordingAssignmentWrite() - row.isEnabled = false - await subject.model.recordingPreferenceChanged(for: row) - - #expect(row.isEnabled) - guard case .failed = row.operationState else { - Issue.record("Expected the row to retain its failed operation state.") - return - } - #expect(subject.model.presentedFailure != nil) - - subject.model.isShowingError = false - #expect(row.operationState == .idle) - #expect(subject.model.presentedFailure == nil) - } - - @Test func failedNicknameSavePreservesTheDraftForAnExplicitRetry() async throws { - let store = try TestStore() - let subject = makeSubject(store: store) - await subject.session.start() - await subject.model.retry() - let row = try #require(subject.model.rows.first) - - row.nickname = "Travel Phone" - await store.failNextRecordingDeviceWrite() - await subject.model.saveNickname(row) - - #expect(row.nickname == "Travel Phone") - #expect(row.hasUnsavedNickname) - #expect(subject.model.presentedFailure != nil) - - subject.model.isShowingError = false - await subject.model.saveNickname(row) - - #expect(row.nickname == "Travel Phone") - #expect(row.hasUnsavedNickname == false) - #expect(try await store.recordingDevices().first?.nickname == "Travel Phone") - } - - @Test func refreshesADeviceImportedFromAnotherDevice() async throws { - let remoteChanges = ScriptedStoreRemoteChangeSource() - let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) - let subject = makeSubject(store: store) - await subject.session.start() - - let runTask = Task { await subject.model.run() } - await waitUntil { - subject.model.rows.contains(where: \.isCurrent) - } - - let remoteID = try RecordingDeviceID( - rawValue: #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")), - ) - let policyID = try #require( - UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"), - ) - try await store.simulateRemoteRecordingImport( - profiles: [ - RecordingDeviceProfile( - id: remoteID, - systemName: "iPad", - kind: .tablet, - registeredAt: Self.now, - registrationEpochID: .initial, - ), - ], - metadataChanges: [ - RecordingDeviceMetadataChange( - id: #require( - UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCD"), - ), - deviceID: remoteID, - revision: 0, - changedAt: Self.now, - changedByDeviceID: subject.session.currentRecordingDeviceID, - nickname: "Home iPad", - ), - ], - checkIns: [ - RecordingDeviceCheckIn( - deviceID: remoteID, - revision: 0, - lastSeenAt: Self.now, - appliedAt: Self.now, - lastAppliedAssignmentChangeID: policyID, - status: .off, - ), - ], - assignmentChanges: [ - RecordingAssignmentChange( - id: policyID, - parentIDs: [], - revision: 0, - issuedAt: Self.now, - issuedByDeviceID: subject.session.currentRecordingDeviceID, - effectiveAt: Self.now, - assignedDeviceID: nil, - reason: .userCommand, - ), - ], - archives: [], - ) - - // The imported rows alone are intentionally silent: this assertion - // prevents a normal local `perform` ping from making the test pass. - #expect(subject.model.rows.contains(where: { $0.id == remoteID }) == false) - - remoteChanges.yield() + await model.run() + let current = try #require(model.rows.first) - await waitUntil { - subject.model.rows.contains(where: { $0.id == remoteID }) - } - runTask.cancel() - await runTask.value + current.isEnabled = false + await model.recordingPreferenceChanged(for: current) - let remote = try #require(subject.model.rows.first(where: { $0.id == remoteID })) - #expect(remote.displayName == "Home iPad") - #expect(remote.isEnabled == false) - #expect(remote.status == .off) - #expect(remote.isPending == false) + #expect(model.presentedFailure?.context == .operation(deviceID: Self.currentID)) } - @Test func remoteTargetAcknowledgementClearsThePendingRow() async throws { - let remoteChanges = ScriptedStoreRemoteChangeSource() - let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) - let subject = makeSubject(store: store) - await subject.session.start() - - let runTask = Task { await subject.model.run() } - defer { runTask.cancel() } - await waitUntil { - subject.model.rows.contains(where: \.isCurrent) - } - - let remoteID = try RecordingDeviceID( - rawValue: #require(UUID(uuidString: "ABABABAB-ABAB-ABAB-ABAB-ABABABABABAB")), - ) - let initialPolicyID = try #require( - UUID(uuidString: "CDCDCDCD-CDCD-CDCD-CDCD-CDCDCDCDCDCD"), - ) - try await addRemoteDevice( - to: store, - id: remoteID, - nickname: "Travel iPad", - policyID: initialPolicyID, - enabled: true, - status: .recording, - writerID: remoteID, - ) - await waitUntil { - subject.model.rows.contains(where: { $0.id == remoteID }) - } - let remote = try #require(subject.model.rows.first(where: { $0.id == remoteID })) - - #expect(remote.isEnabled) - #expect(remote.isPending) - #expect(remote.status == .recording) - let assignmentID = try #require(try await store.recordingAssignmentChanges().last?.id) - - try await store.simulateRemoteRecordingImport( - profiles: [], - metadataChanges: [], - checkIns: [RecordingDeviceCheckIn( - deviceID: remoteID, - revision: 1, - lastSeenAt: Self.now.addingTimeInterval(60), - appliedAt: Self.now.addingTimeInterval(60), - lastAppliedAssignmentChangeID: assignmentID, + private static var configurations: [RecordingDeviceConfiguration] { + [ + configuration( + id: currentID, + name: "iPhone", + kind: .phone, status: .recording, - )], - assignmentChanges: [], - archives: [], - ) - #expect(remote.isPending) - - remoteChanges.yield() - - await waitUntil { - remote.isPending == false && remote.status == .recording - } - #expect(remote.isEnabled) - #expect(remote.isPending == false) - #expect(remote.status == .recording) - runTask.cancel() - await runTask.value - } - - @Test func observesADeviceAddedDuringInitialLoad() async throws { - let store = try TestStore() - let subject = makeSubject(store: store) - await subject.session.start() - await store.gateRecordingDevices(afterCalls: 0) - - let runTask = Task { await subject.model.run() } - await store.awaitRecordingDevicesGate() - - let remoteID = try RecordingDeviceID( - rawValue: #require(UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")), - ) - let remotePolicyID = try #require( - UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBC"), - ) - try await addRemoteDevice( - to: store, - id: remoteID, - nickname: nil, - policyID: remotePolicyID, - enabled: false, - status: .off, - writerID: subject.session.currentRecordingDeviceID, - ) - await store.releaseRecordingDevicesGate() - - await waitUntil { - subject.model.rows.contains(where: { $0.id == remoteID }) - } - runTask.cancel() - await runTask.value - - #expect(subject.model.rows.contains(where: { $0.id == remoteID })) + isCurrent: true, + enabled: true, + ), + configuration( + id: remoteID, + name: "iPad", + kind: .tablet, + status: .off, + isCurrent: false, + enabled: nil, + ), + ] } - private func addRemoteDevice( - to store: any WhereStore, + private static func configuration( id: RecordingDeviceID, - nickname: String?, - policyID: UUID, - enabled: Bool, + name: String, + kind: RecordingDeviceKind, status: RecordingDeviceStatus, - writerID: RecordingDeviceID, - ) async throws { - let profile = RecordingDeviceProfile( - id: id, - systemName: "iPad", - kind: .tablet, - registeredAt: Self.now, - registrationEpochID: .initial, - ) - let metadata = nickname.map { - RecordingDeviceMetadataChange( - id: UUID(), - deviceID: id, - revision: 0, - changedAt: Self.now, - changedByDeviceID: writerID, - nickname: $0, - ) - } - let assignment = try await RecordingAssignmentChange.appendingCommand( - to: store.recordingAssignmentChanges(), - assignment: enabled ? .device(id) : .off, - issuedAt: Self.now, - issuedByDeviceID: writerID, - effectiveAt: Self.now, - reason: .userCommand, - ) - let checkIn = RecordingDeviceCheckIn( - deviceID: id, - revision: 0, - lastSeenAt: Self.now, - appliedAt: Self.now, - lastAppliedAssignmentChangeID: policyID, - status: status, - ) - try await store.perform { - try await store.addRecordingDeviceProfile(profile) - if let metadata { - try await store.addRecordingDeviceMetadataChange(metadata) - } - try await store.addRecordingAssignmentChange(assignment) - try await store.setRecordingDeviceCheckIn(checkIn) - } - } - - private func waitUntil( - timeout: Duration = .seconds(2), - _ predicate: () -> Bool, - ) async { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if predicate() { return } - try? await Task.sleep(for: .milliseconds(5)) - } - #expect(predicate(), "condition was not met before timeout") - } -} - -/// Deterministic command-vs-refresh race for the Settings session protocol. The command first -/// commits a local value, then suspends while a causally later remote value becomes readable. -@MainActor -private final class SuspendedDevicesSettingsSession: DevicesSettingsSession { - let currentRecordingDeviceID = CurrentRecordingDevice.preview.id - - private let policyID = UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")! - private var nickname = "iPhone" - private var renameReached = false - private var renameArrival: CheckedContinuation? - private var renameGate: CheckedContinuation? - - func recordingDeviceUpdates() -> AsyncStream { - AsyncStream { _ in } - } - - func recordingDevices() async throws -> [RecordingDeviceConfiguration] { - [configuration] - } - - func setRecordingEnabled(_: Bool, for _: RecordingDeviceID) async throws {} - - func renameRecordingDevice(_: RecordingDeviceID, to nickname: String) async throws { - self.nickname = nickname - renameReached = true - renameArrival?.resume() - renameArrival = nil - await withCheckedContinuation { renameGate = $0 } - } - - func archiveRecordingDevice(_: RecordingDeviceID) async throws {} - func requestPermission() async {} - - func awaitRename() async { - guard !renameReached else { return } - await withCheckedContinuation { renameArrival = $0 } - } - - func simulateRemoteNickname(_ nickname: String) { - self.nickname = nickname - } - - func releaseRename() { - renameGate?.resume() - renameGate = nil - } - - private var configuration: RecordingDeviceConfiguration { + isCurrent: Bool, + enabled: Bool?, + ) -> RecordingDeviceConfiguration { RecordingDeviceConfiguration( device: RecordingDevice( - id: currentRecordingDeviceID, - systemName: "iPhone", - nickname: nickname, - kind: .phone, - registeredAt: Date(timeIntervalSinceReferenceDate: 1000), - lastSeenAt: Date(timeIntervalSinceReferenceDate: 1000), - archivedAt: nil, - lastAppliedAssignmentChangeID: policyID, - status: .recording, + id: id, + systemName: name, + nickname: nil, + kind: kind, + registeredAt: date, + lastSeenAt: date, + removedAt: nil, + status: status, ), - assignmentResolution: .resolved(.device(currentRecordingDeviceID)), - assignmentFrontierID: policyID, - isAssignmentAcknowledged: true, - isArchived: false, + isCurrentDevice: isCurrent, + localAutomaticRecordingEnabled: enabled, ) } } -@MainActor -private final class ScriptedDevicesSettingsSession: DevicesSettingsSession { - enum ReadError: LocalizedError { - case unavailable +private struct TestFailure: Error {} - var errorDescription: String? { - "Device refresh unavailable" - } +@MainActor +private final class Session: DevicesSettingsSession { + struct Rename: Equatable { + let id: RecordingDeviceID + let nickname: String } - let currentRecordingDeviceID = CurrentRecordingDevice.preview.id - var hasDevice: Bool - private(set) var recordingDevicesCallCount = 0 - private(set) var setEnabledCalls: [Bool] = [] - - private let policyID = UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")! - private var isEnabled: Bool - private var authorityOverride: RecordingAssignmentResolution? - private var failingRecordingDevicesCalls: Set = [] + let currentRecordingDeviceID = DevicesSettingsModelTests.currentID + var configurations: [RecordingDeviceConfiguration] + var recordingChoices: [Bool] = [] + var renames: [Rename] = [] + var removals: [RecordingDeviceID] = [] + var nextError: (any Error)? - init(hasDevice: Bool = true, isEnabled: Bool = false) { - self.hasDevice = hasDevice - self.isEnabled = isEnabled + init(configurations: [RecordingDeviceConfiguration]) { + self.configurations = configurations } func recordingDeviceUpdates() -> AsyncStream { - AsyncStream { _ in } + AsyncStream { $0.finish() } } func recordingDevices() async throws -> [RecordingDeviceConfiguration] { - recordingDevicesCallCount += 1 - if failingRecordingDevicesCalls.remove(recordingDevicesCallCount) != nil { - throw ReadError.unavailable - } - return hasDevice ? [configuration] : [] + configurations } - func recordingAuthoritySnapshot() async throws -> RecordingAuthoritySnapshot { - RecordingAuthoritySnapshot( - resolution: authorityOverride ?? configuration.assignmentResolution, - devices: hasDevice ? [configuration.device] : [], - archivedDeviceIDs: [], - ) + func setRecordingEnabled(_ enabled: Bool) async throws { + try failIfNeeded() + recordingChoices.append(enabled) } - func setRecordingEnabled(_ enabled: Bool, for _: RecordingDeviceID) async throws { - setEnabledCalls.append(enabled) - isEnabled = enabled + func renameRecordingDevice(_ deviceID: RecordingDeviceID, to nickname: String) async throws { + try failIfNeeded() + renames.append(.init(id: deviceID, nickname: nickname)) } - func renameRecordingDevice(_: RecordingDeviceID, to _: String) async throws {} - func archiveRecordingDevice(_: RecordingDeviceID) async throws {} - func requestPermission() async {} - - func failRecordingDevicesCall(_ call: Int) { - failingRecordingDevicesCalls.insert(call) + func removeRecordingDevice(_ deviceID: RecordingDeviceID) async throws { + try failIfNeeded() + removals.append(deviceID) } - func simulateRemoteAuthority(_ resolution: RecordingAssignmentResolution) { - authorityOverride = resolution - } + func requestPermission() async {} - private var configuration: RecordingDeviceConfiguration { - RecordingDeviceConfiguration( - device: RecordingDevice( - id: currentRecordingDeviceID, - systemName: "iPhone", - nickname: nil, - kind: .phone, - registeredAt: Date(timeIntervalSinceReferenceDate: 1000), - lastSeenAt: Date(timeIntervalSinceReferenceDate: 1000), - archivedAt: nil, - lastAppliedAssignmentChangeID: policyID, - status: isEnabled ? .recording : .off, - ), - assignmentResolution: .resolved( - isEnabled ? .device(currentRecordingDeviceID) : .off, - ), - assignmentFrontierID: policyID, - isAssignmentAcknowledged: true, - isArchived: false, - ) + private func failIfNeeded() throws { + if let nextError { + self.nextError = nil + throw nextError + } } } diff --git a/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift index 9bd3546a..47ef1fdc 100644 --- a/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift +++ b/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift @@ -6,64 +6,86 @@ import Testing @MainActor struct InMemoryInstallationRecordingContextStoreTests { @Test func confirmationStaysInMemory() throws { - let context = InstallationRecordingContext( - currentDevice: CurrentRecordingDevice( - id: RecordingDeviceID(rawValue: Self.deviceID), - systemName: "iPad", - kind: .tablet, - ), - registeredAt: Self.registeredAt, - initialRecordingChoice: nil, - ) + let context = unconfirmedContext() let store = InMemoryInstallationRecordingContextStore( context: context, - makeUUID: { Self.assignmentChangeID }, - now: { Self.confirmedAt }, + makeUUID: { Self.replacementDeviceID }, + now: { Self.replacementRegisteredAt }, ) let confirmed = try store.confirmInitialRecording(isEnabled: false) - #expect(confirmed.initialRecordingChoice?.assignmentChangeID == Self.assignmentChangeID) + #expect(confirmed.automaticRecordingEnabled == false) #expect(confirmed.registeredAt == Self.registeredAt) - #expect(confirmed.initialRecordingChoice?.confirmedAt == Self.confirmedAt) #expect(try store.resolve() == confirmed) } - @Test func resetCreatesANewUnconfirmedIdentity() throws { - let store = InMemoryInstallationRecordingContextStore( - context: .testing, - makeUUID: { Self.resetDeviceID }, - now: { Self.resetRegisteredAt }, - ) + @Test func settingsCanChangeTheConfirmedLocalChoice() throws { + let store = InMemoryInstallationRecordingContextStore(context: .testing) + + try store.setAutomaticRecordingEnabled(false) + + #expect(try store.resolve().automaticRecordingEnabled == false) + } + + @Test func resetCreatesANewOrdinaryUnconfirmedIdentity() throws { + let store = makeStore(context: .testing) try store.reset() - #expect(store.onboardingContext.currentDevice.id.rawValue == Self.resetDeviceID) - #expect(store.onboardingContext.registeredAt == Self.resetRegisteredAt) - #expect(store.onboardingContext.initialRecordingChoice == nil) + #expect(store.onboardingContext.currentDevice.id.rawValue == Self.replacementDeviceID) + #expect(store.onboardingContext.registeredAt == Self.replacementRegisteredAt) + #expect(store.onboardingContext.automaticRecordingEnabled == nil) + #expect(store.onboardingContext.isRejoining == false) } - @Test func laterConfirmationCannotRewriteTheInitialPolicyEvent() throws { - let store = InMemoryInstallationRecordingContextStore( - context: .testing, - makeUUID: { Self.resetDeviceID }, - now: { Self.confirmedAt }, - ) + @Test func rejoinCreatesANewConservativeUnconfirmedIdentity() throws { + let store = makeStore(context: .testing) + + let rejoined = try store.rejoin() + + #expect(rejoined.currentDevice.id.rawValue == Self.replacementDeviceID) + #expect(rejoined.automaticRecordingEnabled == nil) + #expect(rejoined.isRejoining) + #expect(rejoined.recommendedRecordingEnabled == false) + } + + @Test func laterConfirmationCannotRewriteTheInitialChoice() throws { + let store = InMemoryInstallationRecordingContextStore(context: .testing) let repeated = try store.confirmInitialRecording(isEnabled: false) #expect(repeated == .testing) - #expect(repeated.initialRecordingChoice?.isEnabled == true) + #expect(repeated.automaticRecordingEnabled == true) + } + + private func makeStore( + context: InstallationRecordingContext, + ) -> InMemoryInstallationRecordingContextStore { + InMemoryInstallationRecordingContextStore( + context: context, + makeUUID: { Self.replacementDeviceID }, + now: { Self.replacementRegisteredAt }, + ) + } + + private func unconfirmedContext() -> InstallationRecordingContext { + InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: Self.deviceID), + systemName: "iPad", + kind: .tablet, + ), + registeredAt: Self.registeredAt, + automaticRecordingEnabled: nil, + isRejoining: false, + ) } private static let deviceID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! - private static let assignmentChangeID = UUID( - uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", - )! - private static let resetDeviceID = UUID( + private static let replacementDeviceID = UUID( uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", )! private static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) - private static let confirmedAt = Date(timeIntervalSinceReferenceDate: 200) - private static let resetRegisteredAt = Date(timeIntervalSinceReferenceDate: 300) + private static let replacementRegisteredAt = Date(timeIntervalSinceReferenceDate: 300) } diff --git a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift index d918bb34..0cc19be1 100644 --- a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift +++ b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift @@ -20,11 +20,11 @@ struct InstallationRecordingContextStoreTests { #expect(store.onboardingContext.currentDevice.id.rawValue == Self.deviceID) #expect(store.onboardingContext.registeredAt == Self.registeredAt) - #expect(store.onboardingContext.initialRecordingChoice == nil) + #expect(store.onboardingContext.automaticRecordingEnabled == nil) #expect(fixture.fileExists == false) } - @Test func confirmationPersistsIdentityChoiceAndPolicyTokenTogether() throws { + @Test func confirmationPersistsIdentityAndLocalChoiceTogether() throws { let fixture = try makeFixture() defer { fixture.cleanup() } let first = fixture.makeStore() @@ -36,9 +36,7 @@ struct InstallationRecordingContextStoreTests { #expect(restored == confirmed) #expect(restored.currentDevice.id.rawValue == Self.deviceID) #expect(restored.registeredAt == Self.registeredAt) - #expect(restored.initialRecordingChoice?.isEnabled == false) - #expect(restored.initialRecordingChoice?.assignmentChangeID == Self.assignmentChangeID) - #expect(restored.initialRecordingChoice?.confirmedAt == Self.confirmedAt) + #expect(restored.automaticRecordingEnabled == false) #expect( try fixture.fileURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) .isExcludedFromBackup == true, @@ -168,7 +166,7 @@ struct InstallationRecordingContextStoreTests { #expect(relaunchedStore.onboardingImportCompletion?.transactionID == details.transactionID) } - @Test func laterConfirmationCannotRewriteTheInitialPolicyEvent() throws { + @Test func laterConfirmationCannotRewriteTheInitialChoice() throws { let fixture = try makeFixture() defer { fixture.cleanup() } let store = fixture.makeStore() @@ -177,7 +175,7 @@ struct InstallationRecordingContextStoreTests { let repeated = try store.confirmInitialRecording(isEnabled: true) #expect(repeated == first) - #expect(repeated.initialRecordingChoice?.isEnabled == false) + #expect(repeated.automaticRecordingEnabled == false) #expect(try fixture.makeStore().resolve() == first) } @@ -197,8 +195,8 @@ struct InstallationRecordingContextStoreTests { @Test func completePendingReplacementWinsOverAnOlderAuthoritativeContext() throws { let oldFixture = try makeFixture() let newFixture = try makeFixture( - ids: [Self.resetDeviceID, Self.resetAssignmentChangeID], - dates: [Self.resetRegisteredAt, Self.resetConfirmedAt], + ids: [Self.resetDeviceID], + dates: [Self.resetRegisteredAt], ) defer { oldFixture.cleanup() @@ -229,11 +227,9 @@ struct InstallationRecordingContextStoreTests { @Test func resetRemovesTheSidecarAndRotatesTheInstallationIdentity() throws { let fixture = try makeFixture(ids: [ Self.deviceID, - Self.assignmentChangeID, Self.resetDeviceID, ], dates: [ Self.registeredAt, - Self.confirmedAt, Self.resetRegisteredAt, ]) defer { fixture.cleanup() } @@ -247,17 +243,34 @@ struct InstallationRecordingContextStoreTests { #expect(store.onboardingImportCompletion == nil) #expect(store.onboardingContext.currentDevice.id.rawValue == Self.resetDeviceID) #expect(store.onboardingContext.registeredAt == Self.resetRegisteredAt) - #expect(store.onboardingContext.initialRecordingChoice == nil) + #expect(store.onboardingContext.automaticRecordingEnabled == nil) + } + + @Test func rejoinPersistsANewIdentityWithRecordingDefaultedOff() throws { + let fixture = try makeFixture( + ids: [Self.deviceID, Self.resetDeviceID], + dates: [Self.registeredAt, Self.resetRegisteredAt], + ) + defer { fixture.cleanup() } + let store = fixture.makeStore() + _ = try store.confirmInitialRecording(isEnabled: true) + + let rejoined = try store.rejoin() + let relaunched = try fixture.makeStore().resolve() + + #expect(rejoined.currentDevice.id.rawValue == Self.resetDeviceID) + #expect(rejoined.automaticRecordingEnabled == nil) + #expect(rejoined.isRejoining) + #expect(rejoined.recommendedRecordingEnabled == false) + #expect(relaunched == rejoined) } @Test func committedResetCleanupRetriesWithoutRestoringTheOldContextOrRotatingAgain() throws { let fixture = try makeFixture(ids: [ Self.deviceID, - Self.assignmentChangeID, Self.resetDeviceID, ], dates: [ Self.registeredAt, - Self.confirmedAt, Self.resetRegisteredAt, ]) defer { fixture.cleanup() } @@ -287,23 +300,15 @@ struct InstallationRecordingContextStoreTests { private nonisolated static let deviceID = UUID( uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", )! - private nonisolated static let assignmentChangeID = UUID( - uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", - )! private nonisolated static let resetDeviceID = UUID( uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", )! - private nonisolated static let resetAssignmentChangeID = UUID( - uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD", - )! private nonisolated static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) - private nonisolated static let confirmedAt = Date(timeIntervalSinceReferenceDate: 200) private nonisolated static let resetRegisteredAt = Date(timeIntervalSinceReferenceDate: 300) - private nonisolated static let resetConfirmedAt = Date(timeIntervalSinceReferenceDate: 400) private func makeFixture( - ids: [UUID] = [Self.deviceID, Self.assignmentChangeID], - dates: [Date] = [Self.registeredAt, Self.confirmedAt], + ids: [UUID] = [Self.deviceID], + dates: [Date] = [Self.registeredAt], ) throws -> Fixture { let directory = FileManager.default.temporaryDirectory .appending(path: "InstallationRecordingContextStoreTests.\(UUID().uuidString)") diff --git a/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift index 22c2b2fe..60754674 100644 --- a/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift +++ b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift @@ -67,6 +67,6 @@ struct OnboardingRestoreSelectionTests { dismissedIssueCount: 4, trackedRegionCount: 5, recordingDeviceCount: 2, - recordingAssignmentChangeCount: 3, + recordingDeviceRemovalCount: 3, ) } diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index 57e731ab..df42632d 100644 --- a/Where/WhereUI/Tests/OnboardingTests.swift +++ b/Where/WhereUI/Tests/OnboardingTests.swift @@ -16,7 +16,7 @@ struct OnboardingModelTests { #expect(model.installationRecordingContext.recommendedRecordingEnabled) } - @Test func confirmationPersistsChoiceAndPolicyTokenOutsidePreferences() throws { + @Test func confirmationPersistsLocalChoiceOutsidePreferences() throws { let preferences = makePreferences() let contextStore = unconfirmedContextStore(kind: .tablet) let model = makeModel(preferences: preferences, contextStore: contextStore) @@ -26,8 +26,7 @@ struct OnboardingModelTests { #expect(model.hasOnboarded) #expect(model.hasConfirmedRecordingChoice) - #expect(confirmed.initialRecordingChoice?.isEnabled == false) - #expect(confirmed.initialRecordingChoice?.assignmentChangeID != nil) + #expect(confirmed.automaticRecordingEnabled == false) let relaunched = makeModel(preferences: preferences, contextStore: contextStore) #expect(relaunched.hasOnboarded) @@ -71,7 +70,8 @@ struct OnboardingModelTests { kind: kind, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - initialRecordingChoice: nil, + automaticRecordingEnabled: nil, + isRejoining: false, ), ) } diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 710a6fd2..42040bfb 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -10,7 +10,6 @@ struct SampleReadFailure: Error, Equatable {} /// Thrown by the Devices settings save-failure hooks below. struct RecordingDeviceSaveFailure: Error, Equatable {} -struct RecordingAssignmentSaveFailure: Error, Equatable {} /// Test `WhereStore` that forwards to an in-memory `SwiftDataStore` but adds /// hooks the view-model tests need: @@ -20,10 +19,8 @@ struct RecordingAssignmentSaveFailure: Error, Equatable {} /// of order (the stale-year race). /// - `gateRecordingDevices(afterCalls:)` suspends a selected device read after /// capturing its result, so a committed change can race an initial load. -/// - `gateNextRecordingAssignmentWrite()` suspends one recording-assignment write, so -/// the Devices model can accept a newer toggle while the first is in flight. -/// - `failNextRecordingDeviceWrite()` / `failNextRecordingAssignmentWrite()` make -/// one Devices save fail without contaminating later retry assertions. +/// - `failNextRecordingDeviceWrite()` makes one Devices save fail without +/// contaminating later retry assertions. /// - `failManualDays()` makes `setManualDay` throw, so manual-entry error /// handling is exercisable without a real persistence fault. /// @@ -41,15 +38,9 @@ actor TestStore: WhereStore { private var recordingDevicesGate: CheckedContinuation? private var recordingDevicesArrival: CheckedContinuation? - private var shouldGateNextRecordingAssignmentWrite = false - private var recordingAssignmentWriteGateReached = false - private var recordingAssignmentWriteGate: CheckedContinuation? - private var recordingAssignmentWriteArrival: CheckedContinuation? - private var shouldFailManualDay = false private var shouldFailSamples = false private var shouldFailNextRecordingDeviceWrite = false - private var shouldFailNextRecordingAssignmentWrite = false init() throws { backing = try SwiftDataStore.inMemory() @@ -89,29 +80,10 @@ actor TestStore: WhereStore { recordingDevicesGate = nil } - func gateNextRecordingAssignmentWrite() { - shouldGateNextRecordingAssignmentWrite = true - recordingAssignmentWriteGateReached = false - } - - func awaitRecordingAssignmentWriteGate() async { - guard !recordingAssignmentWriteGateReached else { return } - await withCheckedContinuation { recordingAssignmentWriteArrival = $0 } - } - - func releaseRecordingAssignmentWriteGate() { - recordingAssignmentWriteGate?.resume() - recordingAssignmentWriteGate = nil - } - func failNextRecordingDeviceWrite() { shouldFailNextRecordingDeviceWrite = true } - func failNextRecordingAssignmentWrite() { - shouldFailNextRecordingAssignmentWrite = true - } - func failManualDays() { shouldFailManualDay = true } @@ -230,31 +202,12 @@ actor TestStore: WhereStore { try await backing.setRecordingDeviceCheckIn(checkIn) } - func recordingAssignmentChanges() async throws -> [RecordingAssignmentChange] { - try await backing.recordingAssignmentChanges() - } - - func addRecordingAssignmentChange(_ change: RecordingAssignmentChange) async throws { - if shouldFailNextRecordingAssignmentWrite { - shouldFailNextRecordingAssignmentWrite = false - throw RecordingAssignmentSaveFailure() - } - if shouldGateNextRecordingAssignmentWrite { - shouldGateNextRecordingAssignmentWrite = false - recordingAssignmentWriteGateReached = true - recordingAssignmentWriteArrival?.resume() - recordingAssignmentWriteArrival = nil - await withCheckedContinuation { recordingAssignmentWriteGate = $0 } - } - try await backing.addRecordingAssignmentChange(change) - } - - func recordingDeviceArchives() async throws -> [RecordingDeviceArchive] { - try await backing.recordingDeviceArchives() + func recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] { + try await backing.recordingDeviceRemovals() } - func addRecordingDeviceArchive(_ archive: RecordingDeviceArchive) async throws { - try await backing.addRecordingDeviceArchive(archive) + func addRecordingDeviceRemoval(_ archive: RecordingDeviceRemoval) async throws { + try await backing.addRecordingDeviceRemoval(archive) } func write(evidence: Evidence, blob: Data?) async throws { diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index cf08342d..6b84b987 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -99,7 +99,7 @@ struct WhereFormatTests { dismissedIssueCount: 4, trackedRegionCount: 6, recordingDeviceCount: 2, - recordingAssignmentChangeCount: 7, + recordingDeviceRemovalCount: 7, ) let message = WhereFormat.backupImportCleanupMessage(summary) diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index 699ba7b7..9ef8d53e 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -536,7 +536,8 @@ struct WhereLaunchTests { kind: .tablet, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - initialRecordingChoice: nil, + automaticRecordingEnabled: nil, + isRejoining: false, ), ) let (model, bootstrap) = try makeLoggedOutModel( @@ -700,7 +701,8 @@ struct WhereLaunchTests { kind: .tablet, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - initialRecordingChoice: nil, + automaticRecordingEnabled: nil, + isRejoining: false, ) let (model, bootstrap) = try makeLoggedOutModel( status: .always, diff --git a/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift b/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift index 5d1620df..48801e17 100644 --- a/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift +++ b/Where/WhereUI/Tests/WhereLifecycleFailureViewTests.swift @@ -12,7 +12,7 @@ struct WhereLifecycleFailureViewTests { dismissedIssueCount: 4, trackedRegionCount: 6, recordingDeviceCount: 2, - recordingAssignmentChangeCount: 7, + recordingDeviceRemovalCount: 7, ) @Test func committedImportCleanupPreservesSummaryInADedicatedPresentation() throws { diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index d1882431..6c93abd0 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -516,6 +516,24 @@ private final class CommittedFailingResetInstallationContextStore: onboardingContext } + func setAutomaticRecordingEnabled(_ isEnabled: Bool) throws { + onboardingContext = onboardingContext.settingAutomaticRecordingEnabled(isEnabled) + } + + func rejoin() throws -> InstallationRecordingContext { + onboardingContext = InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: onboardingContext.currentDevice.systemName, + kind: onboardingContext.currentDevice.kind, + ), + registeredAt: Date(), + automaticRecordingEnabled: nil, + isRejoining: true, + ) + return onboardingContext + } + func setBackupImportRecovery( _ recovery: BackupCoordinator.DurableImportRecovery?, ) { @@ -538,7 +556,8 @@ private final class CommittedFailingResetInstallationContextStore: kind: onboardingContext.currentDevice.kind, ), registeredAt: Date(), - initialRecordingChoice: nil, + automaticRecordingEnabled: nil, + isRejoining: false, ) throw WhereServices.ResetCleanupError(underlying: CocoaError(.fileWriteUnknown)) } diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index 97bad033..7adf8ba0 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -3,18 +3,14 @@ import RegionKit import TestHostSupport import Testing @_spi(Testing) import WhereCore -import WhereUI +@_spi(Testing) @testable import WhereUI /// Covers the launch-time reconciliation that fixes the "toggle is always off" /// and "Grant does nothing" bugs: tracking and the authorization indicator must -/// reflect real authorization plus the synced recording policy, not just the -/// last tap. +/// reflect real authorization plus the installation-local recording choice, +/// not just the last tap. @MainActor struct WhereSessionTrackingTests { - private static let disabledInitialAssignmentChangeID = UUID( - uuidString: "00000000-0000-0000-0000-000000000003", - )! - private func makeSession( status: LocationAuthorizationStatus, preferences: WherePreferences, @@ -28,19 +24,26 @@ struct WhereSessionTrackingTests { preferences: WherePreferences, store: SwiftDataStore? = nil, installationContext: InstallationRecordingContext = .testing, + installationContextStore: InMemoryInstallationRecordingContextStore? = nil, ) throws -> (WhereSession, ScriptedLocationSource, SwiftDataStore) { let store = try store ?? SwiftDataStore.inMemory() let source = ScriptedLocationSource(authorizationStatus: status) + let resolvedContext = try installationContextStore?.resolve() ?? installationContext let services = WhereServices( store: store, locationSource: source, - installationContext: installationContext, + installationContext: resolvedContext, reminderScheduler: NoopLoggingReminderScheduler(), summaryScheduler: NoopDailySummaryScheduler(), issueAlertScheduler: NoopDataIssueAlertScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), ) - let session = WhereSession(services: services, preferences: preferences) + let contextStore = installationContextStore + ?? InMemoryInstallationRecordingContextStore(context: resolvedContext) + let session = WhereSession( + scope: .fake(services: services, preferences: preferences, logSystem: .shared), + installationContextStore: contextStore, + ) return (session, source, store) } @@ -51,11 +54,8 @@ struct WhereSessionTrackingTests { return InstallationRecordingContext( currentDevice: InstallationRecordingContext.testing.currentDevice, registeredAt: InstallationRecordingContext.testing.registeredAt, - initialRecordingChoice: .init( - isEnabled: false, - assignmentChangeID: Self.disabledInitialAssignmentChangeID, - confirmedAt: Date(timeIntervalSinceReferenceDate: 1), - ), + automaticRecordingEnabled: false, + isRejoining: false, ) } @@ -96,9 +96,11 @@ struct WhereSessionTrackingTests { @Test func stoppingTrackingPersistsAcrossLaunches() async throws { let preferences = makePreferences() + let contextStore = InMemoryInstallationRecordingContextStore(context: .testing) let (session, _, store) = try makeSessionAndStore( status: .always, preferences: preferences, + installationContextStore: contextStore, ) await session.start() #expect(session.isTracking) @@ -112,6 +114,7 @@ struct WhereSessionTrackingTests { status: .always, preferences: preferences, store: store, + installationContextStore: contextStore, ) await relaunched.start() #expect(!relaunched.isTracking) @@ -125,21 +128,21 @@ struct WhereSessionTrackingTests { installationContext: installationContext(initialRecordingEnabled: false), ) let preferences = makePreferences() - let session = WhereSession(services: services, preferences: preferences) + let contextStore = InMemoryInstallationRecordingContextStore( + context: installationContext(initialRecordingEnabled: false), + ) + let session = WhereSession( + scope: .fake(services: services, preferences: preferences, logSystem: .shared), + installationContextStore: contextStore, + ) await session.start() let enabling = Task { - try await session.setRecordingEnabled( - true, - for: session.currentRecordingDeviceID, - ) + try await session.setRecordingEnabled(true) } await waitUntil { source.isAwaitingPermission } - try await session.setRecordingEnabled( - false, - for: session.currentRecordingDeviceID, - ) + try await session.setRecordingEnabled(false) source.resolvePermission(as: .always) try await enabling.value @@ -147,7 +150,7 @@ struct WhereSessionTrackingTests { try await session.recordingDevices() .first(where: { $0.id == session.currentRecordingDeviceID }), ) - #expect(current.isEnabled == false) + #expect(current.localAutomaticRecordingEnabled == false) #expect(current.device.status == .off) #expect(session.isTracking == false) } @@ -168,7 +171,7 @@ struct WhereSessionTrackingTests { #expect(session.isTracking) } - @Test func remoteOffPolicyStopsThisDeviceAndAcknowledgesIt() async throws { + @Test func remoteRemovalStopsThisDevice() async throws { let remoteChanges = ScriptedStoreRemoteChangeSource() let store = try SwiftDataStore.inMemory(remoteChangeSource: remoteChanges) let source = TrackingLocationSource() @@ -186,31 +189,25 @@ struct WhereSessionTrackingTests { #expect(session.isTracking) #expect(source.isMonitoring) - let assignmentID = try #require( + let removalID = try #require( UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"), ) - let parentID = try #require(try await store.recordingAssignmentChanges().first?.id) try await store.simulateRemoteRecordingImport( profiles: [], metadataChanges: [], checkIns: [], - assignmentChanges: [ - RecordingAssignmentChange( - id: assignmentID, - parentIDs: [parentID], - revision: 1, - issuedAt: now.addingTimeInterval(1), - issuedByDeviceID: RecordingDeviceID( + removals: [ + RecordingDeviceRemoval( + id: removalID, + deviceID: InstallationRecordingContext.testing.currentDevice.id, + removedAt: now.addingTimeInterval(1), + removedByDeviceID: RecordingDeviceID( rawValue: #require(UUID( uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", )), ), - effectiveAt: now.addingTimeInterval(1), - assignedDeviceID: nil, - reason: .userCommand, ), ], - archives: [], ) // Saving the imported row is not enough; the session must be responding @@ -229,8 +226,7 @@ struct WhereSessionTrackingTests { ) #expect(source.startCount == 1) #expect(source.stopCount == 1) - #expect(current.status == .off) - #expect(current.lastAppliedAssignmentChangeID == assignmentID) + #expect(current.removedAt == now.addingTimeInterval(1)) } @Test func foregroundLogsTodayWhenWantedAndAuthorized() async throws { From a7638472aa5b00bfb464b470a62f01e05ab093fa Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 10:20:58 -0700 Subject: [PATCH 23/31] Keep device removals global across epochs --- .../Sources/Persistence/SwiftDataStore.swift | 9 +------- .../WhereCore/Tests/SwiftDataStoreTests.swift | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index d98e59f8..c4e8a2f3 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -839,11 +839,6 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { { context.delete(record) } - for record in try context.fetch(FetchDescriptor()) - where belongs(record.epochID, to: epochID) - { - context.delete(record) - } } public func add(sample: LocationSample) async throws { @@ -1102,13 +1097,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] { let context = readContext() - let epochID = try readEpochID(in: context) var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.removedAt), SortDescriptor(\.id)], ) descriptor.includePendingChanges = true let records = try context.fetch(descriptor) - .filter { Self.belongs($0.epochID, to: epochID) } let values = try records.map { record in guard let value = record.toValue() else { Self.logFault(forCorrupt: record) @@ -1144,7 +1137,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let id = archive.id let existing = try context.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id }), - ).filter { Self.belongs($0.epochID, to: epochID) } + ) guard existing.isEmpty == false else { context.insert(SDRecordingDeviceRemoval(value: archive, epochID: epochID)) return diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 01859cc0..05efe87f 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -267,6 +267,28 @@ struct SwiftDataStoreTests { } } + @Test func removalTombstonesSurviveDataEpochRotation() async throws { + let store = try SwiftDataStore.inMemory() + let removingDeviceID = RecordingDeviceID(rawValue: UUID()) + let removal = RecordingDeviceRemoval( + id: UUID(), + deviceID: RecordingDeviceID(rawValue: UUID()), + removedAt: Date(timeIntervalSinceReferenceDate: 100), + removedByDeviceID: removingDeviceID, + ) + + try await store.perform { + try await store.addRecordingDeviceRemoval(removal) + _ = try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: removingDeviceID, + at: Date(timeIntervalSinceReferenceDate: 200), + ) + } + + #expect(try await store.recordingDeviceRemovals() == [removal]) + } + @Test func simulatedRemoteRecordingImportIsReadableAfterRemoteChange() async throws { let source = ScriptedStoreRemoteChangeSource() let store = try SwiftDataStore.inMemory(remoteChangeSource: source) From e1b5d12e65ca2846675378a7643ec464d2ab32f4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 10:27:33 -0700 Subject: [PATCH 24/31] Retire installations across account reset --- Where/WhereCore/AGENTS.md | 3 +- Where/WhereCore/README.md | 4 +- .../Devices/DeviceRecordingController.swift | 21 ++++++++ .../Sources/Persistence/SwiftDataStore.swift | 9 ++++ .../Sources/Persistence/WhereDataEpoch.swift | 52 ++++++++++++++++++ .../Sources/Persistence/WhereStore.swift | 6 +++ .../Tests/LocationIngestorTests.swift | 6 +++ .../WhereCore/Tests/WhereDataEpochTests.swift | 18 +++++++ .../WhereCore/Tests/WhereServicesTests.swift | 53 +++++++++++++++++++ Where/WhereUI/Tests/Support/TestStore.swift | 6 +++ 10 files changed, 176 insertions(+), 2 deletions(-) diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 4ee540c3..9bbe8e6e 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -37,7 +37,8 @@ internal shape. - **Resolve destructive generations as a multi-parent causal DAG.** A rotation names every real maximal head; two unjoined reset heads resolve to a deterministic empty UUIDv8 synthetic epoch until the next rotation joins them, and persisted epoch events must never use that reserved - namespace (`WhereDataEpoch.resolve(in:)`). + namespace. Retire a profile whose registration frontier omits any observed account-reset epoch + (`WhereDataEpochTests.resetBarrierRejectsEarlierRegistrationAndAcceptsLaterRegistration`). - **Each process opens its on-disk store once and injects it** — the app's launch opens it; the App Intents stack shares it via `WhereServices.forIntents(sharingStoreOf:)`. A second container over the diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index ea8b843f..a0ccb6a7 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -242,7 +242,9 @@ rotates to a Reset child epoch, and discards the retry queue only after commit. the target identity's samples at and after its timestamp and makes that installation stop when it next observes the change. Turning recording on or off affects only the installation where the user made the choice. Device check-ins are advisory status, not command acknowledgements; - Apple Lost Mode or remote erase remains the security boundary for a missing device. + Apple Lost Mode or remote erase remains the security boundary for a missing device. Account + Reset also retires an installation registered before its causal reset boundary, even when that + installation's profile did not reach the resetting device until later. - **Destructive operations are logical generations.** Old rows may remain in CloudKit as sync/audit history, but ordinary reads select only the resolved epoch. Concurrent unjoined resets select a synthetic empty generation; an diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index 6aed709e..731d9c80 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -42,6 +42,7 @@ public actor DeviceRecordingController { let metadataChanges: [RecordingDeviceMetadataChange] let checkIns: [RecordingDeviceCheckIn] let removals: [RecordingDeviceRemoval] + let currentDeviceResetBarrier: Date? } init( @@ -351,6 +352,17 @@ public actor DeviceRecordingController { let snapshot = try await storeSnapshot() let epoch = snapshot.epoch let existing = snapshot.profiles.first(where: { $0.id == currentDevice.id }) + if let existing, let resetAt = snapshot.currentDeviceResetBarrier { + try await store.perform(expectedDataEpochID: epoch.id) { + try await self.store.addRecordingDeviceRemoval(RecordingDeviceRemoval( + id: UUID(), + deviceID: existing.id, + removedAt: resetAt, + removedByDeviceID: self.currentDevice.id, + )) + } + return try await reconcileLocked(authorization: authorization) + } let expected = expectedProfile( registrationEpochID: existing?.registrationEpochID ?? epoch.id, ) @@ -544,12 +556,21 @@ public actor DeviceRecordingController { async let checkIns = store.recordingDeviceCheckIns() async let removals = store.recordingDeviceRemovals() let values = try await (epoch, profiles, metadataChanges, checkIns, removals) + let currentProfile = values.1.first { $0.id == currentDevice.id } + let resetBarrier: Date? = if let currentProfile { + try await store.recordingDeviceResetBarrier( + for: currentProfile.registrationEpochID, + ) + } else { + nil + } return StoreSnapshot( epoch: values.0, profiles: values.1, metadataChanges: values.2, checkIns: values.3, removals: values.4, + currentDeviceResetBarrier: resetBarrier, ) } } diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index c4e8a2f3..0a54596e 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -619,6 +619,15 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { return try Self.resolvedDataEpoch(in: readContext()) } + public func recordingDeviceResetBarrier( + for registrationEpochID: WhereDataEpochID, + ) async throws -> Date? { + try WhereDataEpoch.resetBarrier( + for: registrationEpochID, + in: Self.dataEpochHistory(in: readContext()), + ) + } + public func rotateDataEpoch( reason: WhereDataEpochReason, changedBy deviceID: RecordingDeviceID, diff --git a/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift b/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift index 37cb7a47..4185b647 100644 --- a/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift +++ b/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift @@ -224,6 +224,58 @@ public struct WhereDataEpoch: Identifiable, Codable, Sendable, Hashable { try resolve(in: changes).current } + /// Latest account reset that the installation's registration point did not observe. + /// Registrations normally name a persisted epoch. A registration made while concurrent + /// resets resolve to a synthetic epoch is recognized both while that conflict is current and + /// after a later destructive operation joins its real reset heads. + static func resetBarrier( + for registrationEpochID: WhereDataEpochID, + in changes: [WhereDataEpoch], + ) throws -> Date? { + let resets = changes.filter { $0.reason == .accountReset } + guard resets.isEmpty == false else { return nil } + + var byID = Dictionary(uniqueKeysWithValues: changes.map { ($0.id, $0) }) + byID[initial.id] = initial + + func ancestors(of epochIDs: [WhereDataEpochID]) -> Set? { + var result = Set() + var pending = epochIDs + while let id = pending.popLast() { + guard result.insert(id).inserted else { continue } + guard let epoch = byID[id] else { return nil } + pending.append(contentsOf: epoch.parentIDs) + } + return result + } + + let observed: Set? + if byID[registrationEpochID] != nil { + observed = ancestors(of: [registrationEpochID]) + } else { + let resolution = try resolve(in: changes) + if resolution.current.id == registrationEpochID { + observed = ancestors(of: resolution.current.parentIDs) + } else { + let joinedResetParents = changes.compactMap { epoch -> [WhereDataEpoch]? in + let parents = epoch.parentIDs.compactMap { byID[$0] } + let resetParents = parents.filter { $0.reason == .accountReset } + guard resetParents.count > 1, + resetConflictID(for: resetParents) == registrationEpochID + else { return nil } + return resetParents + }.first + observed = joinedResetParents.flatMap { ancestors(of: $0.map(\.id)) } + } + } + + let observedIDs = observed ?? [] + return resets + .filter { observedIDs.contains($0.id) == false } + .map(\.changedAt) + .max() + } + /// Versioned, domain-separated digest locked by `WhereDataEpochTests`. Only reset-head ids /// participate, keeping the synthetic empty generation stable when a weaker concurrent /// Replace arrives while still changing it for every newly relevant reset. diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index f3bfa28a..32b9e8f9 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -69,6 +69,12 @@ public protocol WhereStore: Sendable { /// sync/audit history and never participate in normal reads. func dataEpoch() async throws -> WhereDataEpoch + /// Reset boundary not causally observed when `registrationEpochID` was created. A non-nil + /// result retires that pre-reset installation at the returned account-reset timestamp. + func recordingDeviceResetBarrier( + for registrationEpochID: WhereDataEpochID, + ) async throws -> Date? + /// Atomically erase the active epoch's synced rows and append a fresh destructive epoch. /// Every subsequent write in the same transaction is stamped into the returned epoch. /// Immutable device profiles remain global so a late/offline installation can still be diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index 1b3f3931..51d119ab 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -931,6 +931,12 @@ private actor ToggleFailingStore: WhereStore { try await backing.dataEpoch() } + func recordingDeviceResetBarrier( + for registrationEpochID: WhereDataEpochID, + ) async throws -> Date? { + try await backing.recordingDeviceResetBarrier(for: registrationEpochID) + } + func rotateDataEpoch( reason: WhereDataEpochReason, changedBy deviceID: RecordingDeviceID, diff --git a/Where/WhereCore/Tests/WhereDataEpochTests.swift b/Where/WhereCore/Tests/WhereDataEpochTests.swift index 4cd19043..905a7a6a 100644 --- a/Where/WhereCore/Tests/WhereDataEpochTests.swift +++ b/Where/WhereCore/Tests/WhereDataEpochTests.swift @@ -15,6 +15,24 @@ struct WhereDataEpochTests { #expect(resolution.realHeads == [.initial]) } + @Test func resetBarrierRejectsEarlierRegistrationAndAcceptsLaterRegistration() throws { + let reset = Self.reset( + id: "10000000-0000-0000-0000-000000000000", + changedAt: Self.baseDate, + ) + let later = Self.epoch( + id: "20000000-0000-0000-0000-000000000000", + parentIDs: [reset.id], + revision: 2, + changedAt: Self.baseDate.addingTimeInterval(1), + reason: .backupReplace, + ) + + #expect(try WhereDataEpoch.resetBarrier(for: .initial, in: [reset, later]) == reset + .changedAt) + #expect(try WhereDataEpoch.resetBarrier(for: later.id, in: [reset, later]) == nil) + } + @Test func concurrentResetOutranksReplaceAtTheSameRevision() throws { let replacement = Self.epoch( id: "30000000-0000-0000-0000-000000000000", diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 95a2cb8d..536c4376 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -908,6 +908,53 @@ struct WhereServicesTests { #expect(await services.ingestor.isActive == false) } + @Test func profileArrivingAfterResetCannotResumeRecording() async throws { + let store = try SwiftDataStore.inMemory() + let resetterID = RecordingDeviceID(rawValue: UUID()) + let resetAt = WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00") + let oldDevice = CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: "Offline iPhone", + kind: .phone, + ) + let oldContext = InstallationRecordingContext( + currentDevice: oldDevice, + registeredAt: resetAt.addingTimeInterval(-100), + automaticRecordingEnabled: true, + isRejoining: false, + ) + + try await store.perform { + _ = try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: resetterID, + at: resetAt, + ) + try await store.addRecordingDeviceProfile(RecordingDeviceProfile( + id: oldDevice.id, + systemName: oldDevice.systemName, + kind: oldDevice.kind, + registeredAt: oldContext.registeredAt, + registrationEpochID: .initial, + )) + } + + let relaunched = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + installationContext: oldContext, + ) + await #expect(throws: RecordingPersistenceError.currentDeviceRemoved(oldDevice.id)) { + try await relaunched.recording.register(authorization: .always) + } + + #expect(await relaunched.ingestor.isActive == false) + let removal = try #require(await store.recordingDeviceRemovals().first { + $0.deviceID == oldDevice.id + }) + #expect(removal.removedAt == resetAt) + } + @Test func onboardingRestoreWaitsForTheLatestChoiceBeforeOpeningAuthority() async throws { let (source, _, _) = try Self.makeServices() let url = try await source.backup.exportBackup() @@ -1734,6 +1781,12 @@ private actor ToggleFailingStore: WhereStore { try await backing.dataEpoch() } + func recordingDeviceResetBarrier( + for registrationEpochID: WhereDataEpochID, + ) async throws -> Date? { + try await backing.recordingDeviceResetBarrier(for: registrationEpochID) + } + func rotateDataEpoch( reason: WhereDataEpochReason, changedBy deviceID: RecordingDeviceID, diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 42040bfb..af5d4e61 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -108,6 +108,12 @@ actor TestStore: WhereStore { try await backing.dataEpoch() } + func recordingDeviceResetBarrier( + for registrationEpochID: WhereDataEpochID, + ) async throws -> Date? { + try await backing.recordingDeviceResetBarrier(for: registrationEpochID) + } + func rotateDataEpoch( reason: WhereDataEpochReason, changedBy deviceID: RecordingDeviceID, From e1b6786024af0943ab0280d175713927fb68b328 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 10:29:14 -0700 Subject: [PATCH 25/31] Persist recording choice changes on onboarding retry --- Where/WhereUI/AGENTS.md | 4 ++-- Where/WhereUI/Sources/Model/WhereModel.swift | 13 +++++++++---- .../WhereUI/Sources/Onboarding/OnboardingView.swift | 5 ++--- Where/WhereUI/Tests/OnboardingTests.swift | 11 +++++++++++ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index ff082968..fd103850 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -22,8 +22,8 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) - Persist the installation identity, confirmed first recording choice, stable profile/policy IDs and timestamps, two-phase backup-import recovery, and the independent terminal onboarding-import tombstone together in the excluded-from-backup sidecar; never infer - confirmation from backed-up preferences, rewrite the confirmed first event, or migrate it from - `UserDefaults`. + confirmation from backed-up preferences or migrate it from `UserDefaults`. Persist an explicit + changed choice when onboarding retries after a later failure. - Retire the installation sidecar with an atomic directory rename before cleanup; retain the proposed replacement behind `ResetCleanupError` until tombstone deletion succeeds (`InstallationRecordingContextStoreTests`). diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 72872efe..b81c1a28 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -246,14 +246,19 @@ public final class WhereModel { } } - /// Persist this installation's first recording choice. + /// Persist this installation's explicit onboarding choice, including a changed retry. @discardableResult public func confirmInitialRecordingChoice( isEnabled: Bool, ) throws -> InstallationRecordingContext { - try installationContextStore.confirmInitialRecording( - isEnabled: isEnabled, - ) + let context = try installationContextStore.resolve() + if let existing = context.automaticRecordingEnabled { + if existing != isEnabled { + try installationContextStore.setAutomaticRecordingEnabled(isEnabled) + } + return try installationContextStore.resolve() + } + return try installationContextStore.confirmInitialRecording(isEnabled: isEnabled) } /// Mark the first-run app flow complete after its scope and selections have diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 22319e21..0c5bd049 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -512,9 +512,8 @@ public struct OnboardingView: View { } } - // A prior attempt may already have frozen a different immutable first choice. Honor - // what the user selected on this attempt by appending a causal follow-up before any - // physical authority opens; never silently snap back to the earlier value. + // Apply the choice persisted before this scope opened, then start physical recording + // only after the rest of onboarding or restore work has succeeded. do { let authorization = await scope.services.ingestor.authorizationStatus() try await scope.services.recording.registerForOnboarding( diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index df42632d..b364759e 100644 --- a/Where/WhereUI/Tests/OnboardingTests.swift +++ b/Where/WhereUI/Tests/OnboardingTests.swift @@ -34,6 +34,17 @@ struct OnboardingModelTests { #expect(relaunched.installationRecordingContext == confirmed) } + @Test func retryPersistsAChangedRecordingChoice() throws { + let contextStore = unconfirmedContextStore(kind: .phone) + let model = makeModel(preferences: makePreferences(), contextStore: contextStore) + + _ = try model.confirmInitialRecordingChoice(isEnabled: true) + let retried = try model.confirmInitialRecordingChoice(isEnabled: false) + + #expect(retried.automaticRecordingEnabled == false) + #expect(try contextStore.resolve().automaticRecordingEnabled == false) + } + @Test func restoredOnboardingFlagDoesNotConfirmANewInstallation() { let restoredPreferences = makePreferences() restoredPreferences.hasOnboarded = true From f11178b132d42f0a465f4b404d81173a11d9bfa3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 10:37:13 -0700 Subject: [PATCH 26/31] Persist the current recording enable cutoff --- Where/WhereCore/README.md | 3 +- .../Devices/DeviceRecordingController.swift | 2 +- .../InstallationRecordingContext.swift | 48 ++++++++++---- .../DeviceRecordingControllerTests.swift | 64 ++++++++++++++++--- .../InstallationRecordingContextTests.swift | 6 +- .../WhereCore/Tests/WhereServicesTests.swift | 2 +- Where/WhereUI/AGENTS.md | 2 +- ...oryInstallationRecordingContextStore.swift | 4 +- .../InstallationRecordingContextStore.swift | 19 ++++-- .../WhereUI/Sources/Model/WhereSession.swift | 12 ++-- .../Sources/Onboarding/OnboardingView.swift | 2 +- ...stallationRecordingContextStoreTests.swift | 2 +- ...stallationRecordingContextStoreTests.swift | 12 ++++ Where/WhereUI/Tests/OnboardingTests.swift | 2 +- Where/WhereUI/Tests/WhereLaunchTests.swift | 4 +- Where/WhereUI/Tests/WhereResetTests.swift | 9 ++- .../Tests/WhereSessionTrackingTests.swift | 2 +- 17 files changed, 151 insertions(+), 44 deletions(-) diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index a0ccb6a7..476f6e82 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -106,7 +106,8 @@ one it belongs to rather than to a god-object: also carries the data epoch that authorized it, so a pre-reset fix can be discarded but never written into the replacement generation. - **`DeviceRecordingController`** — applies this installation's local automatic-recording - preference to its physical `LocationIngestor`. Immutable profiles, nickname events, + preference and persisted current-On cutoff to its physical `LocationIngestor`, so a late visit + from an Off interval remains rejected after relaunch. Immutable profiles, nickname events, target-owned advisory check-ins, and global removal tombstones sync independently. Another installation can rename or remove a device identity, but cannot change its recording consent. - **`LocationHistoryReader`** — the shared removal-aware read boundary used by reports, widgets, diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index 731d9c80..831f676a 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -60,7 +60,7 @@ public actor DeviceRecordingController { currentDevice = installationContext.currentDevice registeredAt = installationContext.registeredAt self.automaticRecordingEnabled = automaticRecordingEnabled - enabledAt = automaticRecordingEnabled ? installationContext.registeredAt : nil + enabledAt = installationContext.recordingEnabledAt self.now = now self.onPolicyChanged = onPolicyChanged } diff --git a/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift index df7cd7d2..111d3ec8 100644 --- a/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift +++ b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift @@ -7,26 +7,46 @@ import Foundation /// choice, while repeated launches of the same installation reuse both the /// identity and its explicitly chosen local automatic-recording preference. public struct InstallationRecordingContext: Sendable, Hashable { + public enum RecordingChoice: Sendable, Hashable { + case unconfirmed + case off + case on(enabledAt: Date) + } + public let currentDevice: CurrentRecordingDevice /// Stable creation time for this installation's immutable device profile. public let registeredAt: Date - /// This installation's explicit local choice. `nil` means onboarding has not confirmed it. - public let automaticRecordingEnabled: Bool? + public let recordingChoice: RecordingChoice /// Whether this identity was created by the explicit rejoin flow. public let isRejoining: Bool public init( currentDevice: CurrentRecordingDevice, registeredAt: Date, - automaticRecordingEnabled: Bool?, + recordingChoice: RecordingChoice, isRejoining: Bool, ) { self.currentDevice = currentDevice self.registeredAt = registeredAt - self.automaticRecordingEnabled = automaticRecordingEnabled + self.recordingChoice = recordingChoice self.isRejoining = isRejoining } + /// This installation's explicit local choice. `nil` means onboarding has not confirmed it. + public var automaticRecordingEnabled: Bool? { + switch recordingChoice { + case .unconfirmed: nil + case .off: false + case .on: true + } + } + + /// Earliest historical location accepted for the current On interval. + public var recordingEnabledAt: Date? { + guard case let .on(enabledAt) = recordingChoice else { return nil } + return enabledAt + } + /// The safe default shown until this installation confirms a choice. public var recommendedRecordingEnabled: Bool { !isRejoining && currentDevice.kind.recommendsAutomaticRecording @@ -35,27 +55,33 @@ public struct InstallationRecordingContext: Sendable, Hashable { /// Return the confirmed form of a newly proposed context. public func confirmingInitialRecording(isEnabled: Bool) -> InstallationRecordingContext { precondition( - automaticRecordingEnabled == nil, + recordingChoice == .unconfirmed, "An installation's initial recording choice can only be confirmed once.", ) return InstallationRecordingContext( currentDevice: currentDevice, registeredAt: registeredAt, - automaticRecordingEnabled: isEnabled, + recordingChoice: isEnabled ? .on(enabledAt: registeredAt) : .off, isRejoining: false, ) } /// Return a copy carrying a later local Settings choice. - public func settingAutomaticRecordingEnabled(_ isEnabled: Bool) -> Self { + public func settingAutomaticRecordingEnabled(_ isEnabled: Bool, at date: Date) -> Self { precondition( - automaticRecordingEnabled != nil, + recordingChoice != .unconfirmed, "Automatic recording must be confirmed before Settings can change it.", ) + let updatedChoice: RecordingChoice = if isEnabled { + recordingEnabledAt.map(RecordingChoice.on(enabledAt:)) + ?? .on(enabledAt: date) + } else { + .off + } return InstallationRecordingContext( currentDevice: currentDevice, registeredAt: registeredAt, - automaticRecordingEnabled: isEnabled, + recordingChoice: updatedChoice, isRejoining: false, ) } @@ -71,7 +97,7 @@ public struct InstallationRecordingContext: Sendable, Hashable { kind: .phone, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - automaticRecordingEnabled: true, + recordingChoice: .on(enabledAt: Date(timeIntervalSinceReferenceDate: 0)), isRejoining: false, ) @@ -87,7 +113,7 @@ public struct InstallationRecordingContext: Sendable, Hashable { kind: .phone, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - automaticRecordingEnabled: true, + recordingChoice: .on(enabledAt: Date(timeIntervalSinceReferenceDate: 0)), isRejoining: false, ) } diff --git a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift index f54fe926..8bc1df96 100644 --- a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift +++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift @@ -1,28 +1,36 @@ import Foundation +import RegionKit import Testing @_spi(Testing) @testable import WhereCore struct DeviceRecordingControllerTests { + private struct WaitTimeout: Error {} + private static let now = Date(timeIntervalSinceReferenceDate: 1000) private func makeController( enabled: Bool, + enabledAt: Date? = nil, authorization: LocationAuthorizationStatus = .always, - ) throws -> (DeviceRecordingController, SwiftDataStore, LocationIngestor) { + ) throws + -> (DeviceRecordingController, SwiftDataStore, LocationIngestor, ScriptedLocationSource) + { let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: authorization) let ingestor = LocationIngestor( store: store, - locationSource: ScriptedLocationSource(authorizationStatus: authorization), + locationSource: source, recordingDeviceID: InstallationRecordingContext.testing.currentDevice.id, calendar: WhereCoreTestSupport.calendar(), outbox: NoOpLocationOutbox(), retryQueueCapacity: 1000, onPersisted: { _ in }, ) + let registeredAt = Self.now.addingTimeInterval(-100) let context = InstallationRecordingContext( currentDevice: InstallationRecordingContext.testing.currentDevice, - registeredAt: Self.now.addingTimeInterval(-100), - automaticRecordingEnabled: enabled, + registeredAt: registeredAt, + recordingChoice: enabled ? .on(enabledAt: enabledAt ?? registeredAt) : .off, isRejoining: false, ) return ( @@ -35,11 +43,12 @@ struct DeviceRecordingControllerTests { ), store, ingestor, + source, ) } @Test func registrationAppliesLocalChoiceAndWritesAdvisoryStatus() async throws { - let (controller, store, ingestor) = try makeController(enabled: true) + let (controller, store, ingestor, _) = try makeController(enabled: true) let configuration = try await controller.register(authorization: .always) @@ -50,8 +59,47 @@ struct DeviceRecordingControllerTests { #expect(try await store.recordingDeviceCheckIns().first?.status == .recording) } + @Test func registrationRestoresTheLatestEnableCutoff() async throws { + let enabledAt = Self.now.addingTimeInterval(-50) + let (controller, store, _, source) = try makeController( + enabled: true, + enabledAt: enabledAt, + ) + _ = try await controller.register(authorization: .always) + let beforeEnable = LocationSample( + timestamp: enabledAt.addingTimeInterval(-1), + coordinate: Coordinate(latitude: 37, longitude: -122), + horizontalAccuracy: 0, + source: .gpsVisit, + ) + let afterEnable = LocationSample( + timestamp: enabledAt.addingTimeInterval(1), + coordinate: Coordinate(latitude: 37, longitude: -122), + horizontalAccuracy: 0, + source: .gpsVisit, + ) + + source.emit(beforeEnable) + source.emit(afterEnable) + + try await waitUntil { + await (try? store.allSamples().count) == 1 + } + #expect(try await store.allSamples().map(\.id) == [afterEnable.id]) + } + + private func waitUntil( + _ predicate: () async -> Bool, + ) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while await predicate() == false { + guard ContinuousClock.now < deadline else { throw WaitTimeout() } + await Task.yield() + } + } + @Test func localSettingsChoiceStopsAndRestartsOnlyThisInstallation() async throws { - let (controller, _, ingestor) = try makeController(enabled: true) + let (controller, _, ingestor, _) = try makeController(enabled: true) _ = try await controller.register(authorization: .always) let off = try await controller.setAutomaticRecordingEnabled( @@ -70,7 +118,7 @@ struct DeviceRecordingControllerTests { } @Test func removalStopsCurrentIdentityAndPublishesTerminalState() async throws { - let (controller, store, ingestor) = try makeController(enabled: true) + let (controller, store, ingestor, _) = try makeController(enabled: true) _ = try await controller.register(authorization: .always) let deviceID = controller.currentDevice.id try await store.perform { @@ -91,7 +139,7 @@ struct DeviceRecordingControllerTests { } @Test func remoteRowsNeverExposeALocalPreference() async throws { - let (controller, store, _) = try makeController(enabled: false) + let (controller, store, _, _) = try makeController(enabled: false) _ = try await controller.register(authorization: .always) let remoteID = RecordingDeviceID(rawValue: UUID()) try await store.perform { diff --git a/Where/WhereCore/Tests/InstallationRecordingContextTests.swift b/Where/WhereCore/Tests/InstallationRecordingContextTests.swift index d4b5709a..72f2d845 100644 --- a/Where/WhereCore/Tests/InstallationRecordingContextTests.swift +++ b/Where/WhereCore/Tests/InstallationRecordingContextTests.swift @@ -14,12 +14,14 @@ struct InstallationRecordingContextTests { @Test func confirmationAndLaterSettingsChangePreserveIdentity() { let proposed = context(kind: .tablet) let confirmed = proposed.confirmingInitialRecording(isEnabled: false) - let updated = confirmed.settingAutomaticRecordingEnabled(true) + let enabledAt = Self.registeredAt.addingTimeInterval(100) + let updated = confirmed.settingAutomaticRecordingEnabled(true, at: enabledAt) #expect(updated.currentDevice == proposed.currentDevice) #expect(updated.registeredAt == proposed.registeredAt) #expect(confirmed.automaticRecordingEnabled == false) #expect(updated.automaticRecordingEnabled == true) + #expect(updated.recordingEnabledAt == enabledAt) } private func context(kind: RecordingDeviceKind) -> InstallationRecordingContext { @@ -30,7 +32,7 @@ struct InstallationRecordingContextTests { kind: kind, ), registeredAt: Self.registeredAt, - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: false, ) } diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 536c4376..e38294e6 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -920,7 +920,7 @@ struct WhereServicesTests { let oldContext = InstallationRecordingContext( currentDevice: oldDevice, registeredAt: resetAt.addingTimeInterval(-100), - automaticRecordingEnabled: true, + recordingChoice: .on(enabledAt: resetAt.addingTimeInterval(-100)), isRejoining: false, ) diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index fd103850..95665a07 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -19,7 +19,7 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) - Keep `FileInstallationRecordingContextStore` as the UIKit/FileManager adapter for Core's installation-context protocol; resolve one instance at the app root and inject it into both `WhereModel` and `WhereBootstrap`. -- Persist the installation identity, confirmed first recording choice, stable +- Persist the installation identity, recording choice with its current-On timestamp, stable profile/policy IDs and timestamps, two-phase backup-import recovery, and the independent terminal onboarding-import tombstone together in the excluded-from-backup sidecar; never infer confirmation from backed-up preferences or migrate it from `UserDefaults`. Persist an explicit diff --git a/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift index db52980c..8717ec67 100644 --- a/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift +++ b/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift @@ -47,7 +47,7 @@ public final class InMemoryInstallationRecordingContextStore: } public func setAutomaticRecordingEnabled(_ isEnabled: Bool) throws { - onboardingContext = onboardingContext.settingAutomaticRecordingEnabled(isEnabled) + onboardingContext = onboardingContext.settingAutomaticRecordingEnabled(isEnabled, at: now()) } public func rejoin() throws -> InstallationRecordingContext { @@ -81,7 +81,7 @@ public final class InMemoryInstallationRecordingContextStore: kind: onboardingContext.currentDevice.kind, ), registeredAt: now(), - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: isRejoining, ) } diff --git a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift index 97001ea4..098b8296 100644 --- a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift +++ b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift @@ -155,6 +155,7 @@ public final class FileInstallationRecordingContextStore: let kind: RecordingDeviceKind let registeredAt: Date let automaticRecordingEnabled: Bool? + let recordingEnabledAt: Date? let isRejoining: Bool? let backupImportRecovery: BackupImportRecovery? let onboardingImportCompletionID: UUID? @@ -165,6 +166,7 @@ public final class FileInstallationRecordingContextStore: case kind case registeredAt case automaticRecordingEnabled + case recordingEnabledAt case isRejoining case backupImportRecovery case onboardingImportCompletionID @@ -180,20 +182,29 @@ public final class FileInstallationRecordingContextStore: kind = context.currentDevice.kind registeredAt = context.registeredAt automaticRecordingEnabled = context.automaticRecordingEnabled + recordingEnabledAt = context.recordingEnabledAt isRejoining = context.isRejoining self.backupImportRecovery = backupImportRecovery.map(BackupImportRecovery.init) onboardingImportCompletionID = onboardingImportCompletion?.transactionID } var value: InstallationRecordingContext { - InstallationRecordingContext( + let recordingChoice: InstallationRecordingContext.RecordingChoice = + switch automaticRecordingEnabled { + case nil: .unconfirmed + case false?: .off + case true?: .on( + enabledAt: recordingEnabledAt ?? registeredAt, + ) + } + return InstallationRecordingContext( currentDevice: CurrentRecordingDevice( id: RecordingDeviceID(rawValue: deviceID), systemName: systemName, kind: kind, ), registeredAt: registeredAt, - automaticRecordingEnabled: automaticRecordingEnabled, + recordingChoice: recordingChoice, isRejoining: isRejoining ?? false, ) } @@ -362,7 +373,7 @@ public final class FileInstallationRecordingContextStore: } public func setAutomaticRecordingEnabled(_ isEnabled: Bool) throws { - let updated = try resolution.get().settingAutomaticRecordingEnabled(isEnabled) + let updated = try resolution.get().settingAutomaticRecordingEnabled(isEnabled, at: now()) try persist( updated, backupImportRecovery: backupImportRecovery, @@ -496,7 +507,7 @@ public final class FileInstallationRecordingContextStore: kind: kind, ), registeredAt: registeredAt, - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: isRejoining, ) } diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index ed10b5f8..0fa23cc6 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -168,15 +168,19 @@ public final class WhereSession { services = scope.services preferences = scope.preferences self.now = now - self.installationContextStore = installationContextStore - ?? InMemoryInstallationRecordingContextStore( + if let installationContextStore { + self.installationContextStore = installationContextStore + } else { + let registeredAt = now() + self.installationContextStore = InMemoryInstallationRecordingContextStore( context: InstallationRecordingContext( currentDevice: scope.services.recording.currentDevice, - registeredAt: now(), - automaticRecordingEnabled: true, + registeredAt: registeredAt, + recordingChoice: .on(enabledAt: registeredAt), isRejoining: false, ), ) + } } /// Build a coordinator over a loose service layer, wrapping it in a scope. diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 0c5bd049..af08f811 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -817,7 +817,7 @@ struct OnboardingPage: Identifiable { kind: .tablet, ), registeredAt: InstallationRecordingContext.testing.registeredAt, - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: false, ), startsAtRecordingChoice: true, diff --git a/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift index 47ef1fdc..bac941e8 100644 --- a/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift +++ b/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift @@ -77,7 +77,7 @@ struct InMemoryInstallationRecordingContextStoreTests { kind: .tablet, ), registeredAt: Self.registeredAt, - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: false, ) } diff --git a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift index 0cc19be1..27490989 100644 --- a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift +++ b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift @@ -59,6 +59,18 @@ struct InstallationRecordingContextStoreTests { #expect(second == first) } + @Test func latestEnableCutoffSurvivesStoreRecreation() throws { + let enabledAt = Self.registeredAt.addingTimeInterval(100) + let fixture = try makeFixture(dates: [Self.registeredAt, enabledAt]) + defer { fixture.cleanup() } + let store = fixture.makeStore() + + _ = try store.confirmInitialRecording(isEnabled: false) + try store.setAutomaticRecordingEnabled(true) + + #expect(try fixture.makeStore().resolve().recordingEnabledAt == enabledAt) + } + @Test func importRecoveryTransitionsSurviveStoreRecreation() throws { let fixture = try makeFixture() defer { fixture.cleanup() } diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index b364759e..c170740a 100644 --- a/Where/WhereUI/Tests/OnboardingTests.swift +++ b/Where/WhereUI/Tests/OnboardingTests.swift @@ -81,7 +81,7 @@ struct OnboardingModelTests { kind: kind, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: false, ), ) diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index 9ef8d53e..42b14165 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -536,7 +536,7 @@ struct WhereLaunchTests { kind: .tablet, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: false, ), ) @@ -701,7 +701,7 @@ struct WhereLaunchTests { kind: .tablet, ), registeredAt: Date(timeIntervalSinceReferenceDate: 0), - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: false, ) let (model, bootstrap) = try makeLoggedOutModel( diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 6c93abd0..8020c210 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -517,7 +517,10 @@ private final class CommittedFailingResetInstallationContextStore: } func setAutomaticRecordingEnabled(_ isEnabled: Bool) throws { - onboardingContext = onboardingContext.settingAutomaticRecordingEnabled(isEnabled) + onboardingContext = onboardingContext.settingAutomaticRecordingEnabled( + isEnabled, + at: Date(), + ) } func rejoin() throws -> InstallationRecordingContext { @@ -528,7 +531,7 @@ private final class CommittedFailingResetInstallationContextStore: kind: onboardingContext.currentDevice.kind, ), registeredAt: Date(), - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: true, ) return onboardingContext @@ -556,7 +559,7 @@ private final class CommittedFailingResetInstallationContextStore: kind: onboardingContext.currentDevice.kind, ), registeredAt: Date(), - automaticRecordingEnabled: nil, + recordingChoice: .unconfirmed, isRejoining: false, ) throw WhereServices.ResetCleanupError(underlying: CocoaError(.fileWriteUnknown)) diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index 7adf8ba0..b530074b 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -54,7 +54,7 @@ struct WhereSessionTrackingTests { return InstallationRecordingContext( currentDevice: InstallationRecordingContext.testing.currentDevice, registeredAt: InstallationRecordingContext.testing.registeredAt, - automaticRecordingEnabled: false, + recordingChoice: .off, isRejoining: false, ) } From 8bab2c3b1d1da16cfd4edc54b11164376379c346 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 10:40:28 -0700 Subject: [PATCH 27/31] Fail closed when recording cleanup fails --- .../Devices/DeviceRecordingController.swift | 22 +++++++++++-- .../DeviceRecordingControllerTests.swift | 32 ++++++++++++++++++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index 831f676a..72d7125d 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -30,6 +30,7 @@ public actor DeviceRecordingController { private var isRewritePaused = false private var observationTask: Task? private var needsReconciliation = false + private var pendingOffCleanup = false private var nextRuntimeSequence: UInt64 = 0 private var latestRuntimeUpdate: RecordingDeviceRuntimeUpdate? @@ -142,9 +143,8 @@ public actor DeviceRecordingController { automaticRecordingEnabled = enabled enabledAt = enabled ? now() : nil } - if !enabled { - await ingestor.revokeRecordingAuthorization() - try await ingestor.discardRetryBacklog() + if !enabled || pendingOffCleanup { + try await discardRetryBacklogForOffChoice() } return try await reconcileOrFailClosed(authorization: authorization) } @@ -397,9 +397,25 @@ public actor DeviceRecordingController { } } + private func discardRetryBacklogForOffChoice() async throws { + await ingestor.revokeRecordingAuthorization() + do { + try await ingestor.discardRetryBacklog() + pendingOffCleanup = false + } catch { + pendingOffCleanup = true + needsReconciliation = true + publishRuntimeState(.unavailable) + throw error + } + } + private func reconcileLocked( authorization: LocationAuthorizationStatus, ) async throws -> RecordingDeviceConfiguration { + if pendingOffCleanup { + try await discardRetryBacklogForOffChoice() + } let snapshot = try await storeSnapshot() guard let profile = snapshot.profiles.first(where: { $0.id == currentDevice.id }) else { throw RecordingPersistenceError.currentDeviceNotRegistered(currentDevice.id) diff --git a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift index 8bc1df96..2411f556 100644 --- a/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift +++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift @@ -12,6 +12,7 @@ struct DeviceRecordingControllerTests { enabled: Bool, enabledAt: Date? = nil, authorization: LocationAuthorizationStatus = .always, + outbox: any LocationOutbox = NoOpLocationOutbox(), ) throws -> (DeviceRecordingController, SwiftDataStore, LocationIngestor, ScriptedLocationSource) { @@ -22,7 +23,7 @@ struct DeviceRecordingControllerTests { locationSource: source, recordingDeviceID: InstallationRecordingContext.testing.currentDevice.id, calendar: WhereCoreTestSupport.calendar(), - outbox: NoOpLocationOutbox(), + outbox: outbox, retryQueueCapacity: 1000, onPersisted: { _ in }, ) @@ -117,6 +118,35 @@ struct DeviceRecordingControllerTests { #expect(await ingestor.isActive) } + @Test func failedOffCleanupPublishesUnavailableAndBlocksReenable() async throws { + let outbox = ScriptedLocationOutbox() + let (controller, _, ingestor, _) = try makeController( + enabled: true, + outbox: outbox, + ) + _ = try await controller.register(authorization: .always) + await outbox.setFailsToClear(true) + + await #expect(throws: (any Error).self) { + try await controller.setAutomaticRecordingEnabled(false, authorization: .always) + } + #expect(await controller.currentRuntimeUpdate()?.state == .unavailable) + #expect(await ingestor.isActive == false) + + await #expect(throws: (any Error).self) { + try await controller.setAutomaticRecordingEnabled(true, authorization: .always) + } + #expect(await ingestor.isActive == false) + + await outbox.setFailsToClear(false) + let recovered = try await controller.setAutomaticRecordingEnabled( + true, + authorization: .always, + ) + #expect(recovered.localAutomaticRecordingEnabled == true) + #expect(await ingestor.isActive) + } + @Test func removalStopsCurrentIdentityAndPublishesTerminalState() async throws { let (controller, store, ingestor, _) = try makeController(enabled: true) _ = try await controller.register(authorization: .always) From fea92b710d5341d95909af76b2900d76830b8484 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 10:41:58 -0700 Subject: [PATCH 28/31] Refresh multi-device validation documentation --- Where/Where/README.md | 23 ++++++++++++----------- Where/WhereUI/README.md | 21 +++++++++++---------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Where/Where/README.md b/Where/Where/README.md index e6b4f6aa..92a4b3a4 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -88,18 +88,19 @@ Before shipping a schema change: 3. On two devices signed into the same iCloud account, open Settings → Devices and verify both generic hardware profiles arrive; rename one and verify the nickname syncs. -4. From the carried device, turn automatic recording off for the left-behind - device. Verify its row says it is waiting, and that locations at/after the - cutoff disappear from reports as soon as the policy syncs. -5. Open the left-behind device. Verify it stops monitoring, acknowledges Off, - and the waiting state clears on the carried device. Re-enable it and verify - new locations appear again. -6. Archive the non-current device and verify it is hidden without losing older - report history. Export and replace-import a backup; verify history and names - round-trip, archived imported devices stay hidden, file-absent devices stay - retired, and every visible device is Off until explicitly re-enabled. +4. On each device, toggle only its own Automatic Recording switch. Verify the + local device starts or stops and its advisory status later updates on the + other device without changing that other installation's switch. +5. Remove the secondary device from the carried device. Verify its earlier + history remains visible, locations at and after the removal disappear, and + the secondary device stops when it next syncs. Rejoin it and verify it gets + a new identity with recording Off until explicitly enabled there. +6. Export a backup, then exercise Merge and Replace. Verify names and removals + round-trip, neither strategy changes this installation's recording choice, + and Replace discards pending pre-import locations before recording resumes. On a fresh install, onboarding recommends automatic recording On for an iPhone -and Off for an iPad/other device, then requires the user to confirm. Existing +only when no other device recently reported recording, and Off for an +iPad/other device or explicit rejoin, then requires the user to confirm. Existing installations created before that choice was introduced revisit only the final recording page once; enabling is the only path that asks for location access. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 5f4e2533..7b46df78 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -79,8 +79,9 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** (export/import plus a mirror of the scope-owned committed-cleanup gate), **`RemindersSettingsModel`** (notification prefs), and - **`DevicesSettingsModel`** (one recorder assignment plus synced installation names, status, and - archival). Each orchestrates `WhereServices`; none reimplements Core rules. + **`DevicesSettingsModel`** (installation-local recording choice plus synced names, advisory + status, and irreversible removal). Each orchestrates `WhereServices`; none reimplements Core + rules. ### Reusable views & styling @@ -89,10 +90,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's trunk, so there is no session behind it: a paged intro, then picking up to five primary US regions (map or searchable list) and giving each a look, then verifying this installation's automatic-recording - choice. The final page opens and retains the real store in a dormant state to discover synced - authority, offering to preserve the existing recorder or resolve a conflict before any services, - App Intents, or GPS are active. Phones recommend On; tablets/other devices recommend Off, and only - an enabled confirmation requests location permission. A restored device can + choice. The final page opens the real store in a dormant state to inspect recent synced advisory + status before any services, App Intents, or GPS are active. A phone recommends On only when no + other installation recently reported recording; tablets, other devices, and explicit rejoins + recommend Off. Only an enabled confirmation requests location permission. A restored device can inherit the backed-up onboarding flag but not the installation sidecar, so it skips straight to that final page. Finishing logs in to the real scope — the app promotes that same store into its one real scope — and commits the picks as the tracked-region set + @@ -114,10 +115,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's picker (segmented map/list) and per-region color/emoji/icon customization, backed by `PrimaryRegionSelectionModel`. Reused by onboarding and the Settings `RegionsSettingsView` editor. -- **`DevicesSettingsView`** — Settings’ one account-wide automatic-recorder card plus installation - rows for names, activity, permission, and archive. It distinguishes assignment from acknowledged physical state, - labels the current installation, permits synced nicknames, and archives only - remote devices while preserving their history. +- **`DevicesSettingsView`** — Settings’ installation rows for local recording choice, synced + nicknames, advisory activity/permission status, and irreversible removal. Only the current row + can toggle recording; remote rows can be renamed or removed while preserving their earlier + history. - **Widget views** — the shared renderers the **WhereWidgets** extension draws with: `TodayWidgetView`, `YearTotalsWidgetView`, and the accessory family (`TodayInlineAccessoryView`, `TodayCircularAccessoryView`, From 40eb37d4b7b982e742519624c9967550bcaa465e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 10:50:00 -0700 Subject: [PATCH 29/31] Align epoch upsert test with global removals --- Where/WhereCore/Tests/SwiftDataStoreTests.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 05efe87f..c0a68e18 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -914,10 +914,9 @@ struct SwiftDataStoreTests { #expect(try await store.evidenceBlob(for: evidence.id) == nil) } - /// Reusing an inactive row would also reuse its CloudKit record identity. A delayed - /// tombstone from the old generation could then delete current restored data, so every - /// same-id write must preserve the inactive row and create a current-epoch record. - @Test func inactiveSameIDRowsRemainSeparateFromCurrentEpochUpserts() async throws { + /// Epoch-scoped data needs a new CloudKit record in the current generation. Global removal + /// tombstones instead retain one identity across rotations so a delayed sync remains active. + @Test func scopedRowsRemainSeparateWhileGlobalRemovalsCanonicalize() async throws { let container = try SwiftDataStore.makeContainer(storage: .inMemory) let store = SwiftDataStore(modelContainer: container) let deviceID = try RecordingDeviceID( @@ -992,8 +991,8 @@ struct SwiftDataStoreTests { #expect(Set(sampleRows.compactMap(\.epochID)) == expectedEpochIDs) #expect(metadataRows.count == 2) #expect(Set(metadataRows.compactMap(\.epochID)) == expectedEpochIDs) - #expect(removalRows.count == 2) - #expect(Set(removalRows.compactMap(\.epochID)) == expectedEpochIDs) + #expect(removalRows.count == 1) + #expect(removalRows.first?.epochID == WhereDataEpochID.initial.rawValue) #expect(try await store.allSamples() == [sample]) #expect(try await store.recordingDeviceMetadataChanges() == [metadata]) #expect(try await store.recordingDeviceRemovals() == [removal]) From 27bf4efd20c9dc3c585d67a1c15ece64f19fa791 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 11:55:46 -0700 Subject: [PATCH 30/31] Verify current tracking reconciliation protocol Replace the proposed coalescing-worker model with the shipped generation-token and exclusive-controller-lane design. Exercise authorized, denied, repeated, and reversed commands; retain the old race as a negative control; and prove the stale-permission branch is reachable. --- .../TrackingReconciliation/Broken.cfg | 2 +- .../{Coalesced.cfg => Current.cfg} | 6 +- .../TrackingReconciliation/CurrentDenied.cfg | 15 ++ .../CurrentRepeated.cfg | 15 ++ .../CurrentReversed.cfg | 15 ++ .../CurrentStaleReachability.cfg | 12 ++ .../TrackingReconciliation/README.md | 153 +++++++++-------- .../TrackingReconciliation.tla | 154 ++++++++++++------ .../TrackingReconciliation/check | 57 +++++-- 9 files changed, 296 insertions(+), 133 deletions(-) rename Where/Specifications/TrackingReconciliation/{Coalesced.cfg => Current.cfg} (66%) create mode 100644 Where/Specifications/TrackingReconciliation/CurrentDenied.cfg create mode 100644 Where/Specifications/TrackingReconciliation/CurrentRepeated.cfg create mode 100644 Where/Specifications/TrackingReconciliation/CurrentReversed.cfg create mode 100644 Where/Specifications/TrackingReconciliation/CurrentStaleReachability.cfg diff --git a/Where/Specifications/TrackingReconciliation/Broken.cfg b/Where/Specifications/TrackingReconciliation/Broken.cfg index 2ee19a25..14741304 100644 --- a/Where/Specifications/TrackingReconciliation/Broken.cfg +++ b/Where/Specifications/TrackingReconciliation/Broken.cfg @@ -11,4 +11,4 @@ INVARIANTS PROPERTY EventuallySettled -CHECK_DEADLOCK FALSE +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/TrackingReconciliation/Coalesced.cfg b/Where/Specifications/TrackingReconciliation/Current.cfg similarity index 66% rename from Where/Specifications/TrackingReconciliation/Coalesced.cfg rename to Where/Specifications/TrackingReconciliation/Current.cfg index da5b921e..c279178f 100644 --- a/Where/Specifications/TrackingReconciliation/Coalesced.cfg +++ b/Where/Specifications/TrackingReconciliation/Current.cfg @@ -1,15 +1,15 @@ SPECIFICATION Spec CONSTANTS - Implementation = "coalesced" + Implementation = "current" Commands <- EnableThenDisable Authorized = TRUE INVARIANTS TypeOK - FixedIntentIsImmediate + CurrentIntentIsImmediate CorrectAtQuiescence PROPERTY EventuallySettled -CHECK_DEADLOCK FALSE +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/TrackingReconciliation/CurrentDenied.cfg b/Where/Specifications/TrackingReconciliation/CurrentDenied.cfg new file mode 100644 index 00000000..d7507fc9 --- /dev/null +++ b/Where/Specifications/TrackingReconciliation/CurrentDenied.cfg @@ -0,0 +1,15 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Commands <- EnableThenDisable + Authorized = FALSE + +INVARIANTS + TypeOK + CurrentIntentIsImmediate + CorrectAtQuiescence + +PROPERTY EventuallySettled + +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/TrackingReconciliation/CurrentRepeated.cfg b/Where/Specifications/TrackingReconciliation/CurrentRepeated.cfg new file mode 100644 index 00000000..3c6503a2 --- /dev/null +++ b/Where/Specifications/TrackingReconciliation/CurrentRepeated.cfg @@ -0,0 +1,15 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Commands <- EnableEnableDisable + Authorized = TRUE + +INVARIANTS + TypeOK + CurrentIntentIsImmediate + CorrectAtQuiescence + +PROPERTY EventuallySettled + +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/TrackingReconciliation/CurrentReversed.cfg b/Where/Specifications/TrackingReconciliation/CurrentReversed.cfg new file mode 100644 index 00000000..a61a025f --- /dev/null +++ b/Where/Specifications/TrackingReconciliation/CurrentReversed.cfg @@ -0,0 +1,15 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Commands <- DisableThenEnable + Authorized = TRUE + +INVARIANTS + TypeOK + CurrentIntentIsImmediate + CorrectAtQuiescence + +PROPERTY EventuallySettled + +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/TrackingReconciliation/CurrentStaleReachability.cfg b/Where/Specifications/TrackingReconciliation/CurrentStaleReachability.cfg new file mode 100644 index 00000000..1a1e5c7d --- /dev/null +++ b/Where/Specifications/TrackingReconciliation/CurrentStaleReachability.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Commands <- EnableThenDisable + Authorized = TRUE + +INVARIANTS + TypeOK + StalePermissionNotObserved + +CHECK_DEADLOCK FALSE diff --git a/Where/Specifications/TrackingReconciliation/README.md b/Where/Specifications/TrackingReconciliation/README.md index 5af70cf4..83e79759 100644 --- a/Where/Specifications/TrackingReconciliation/README.md +++ b/Where/Specifications/TrackingReconciliation/README.md @@ -1,66 +1,94 @@ # Tracking reconciliation TLA+ pilot -This is an executable design experiment for the tracking-toggle race in -[`WhereSession`](../../WhereUI/Sources/Model/WhereSession.swift). It asks one -small question: after rapid enable/disable commands and all asynchronous work -settles, do persisted intent, the real ingestor, and the UI's published state -all describe the latest command? +This model checks one narrow question about automatic-recording commands: after a finite sequence +of local enable/disable choices and all asynchronous work settles, do the installation sidecar, +Core controller, real ingestor, and published UI state all describe the latest choice? -It is deliberately not a repository-wide TLA+ convention, nor a proof of the -Swift implementation. The model is useful only while its state and transitions -remain visibly traceable to the production code and a deterministic test. +The model represents production revision `42f3025ab714d7ba7220facad9ba01d526571cde`. It is design +evidence for the stated bounds and assumptions, not proof that the Swift implementation is correct. +Relevant changes to the recording command, permission, controller-serialization, or publication +paths invalidate the result until this mapping is checked again. -## Model boundary +## Source correspondence -| Model state | Production counterpart | +| Model state or action | Production counterpart | | --- | --- | -| `desired` | Latest value assigned by the toggle | -| `persisted` | `WherePreferences.wantsTracking` | -| `ingestorActive` | `LocationIngestor.isActive` | -| `published` | `WhereSession.isTracking` | -| Broken command phase | Independent `Task` spawned by `trackingEnabled` | -| Coalesced worker / target | One serialized worker and the intent captured for its in-flight effect | - -The checked command sequence is `enable, disable`, authorization is fixed at -Always, and weak fairness forces the configured commands to arrive and each -enabled asynchronous phase eventually to return. Permission UI, authorization -changes, GPS samples, persistence failures, and task cancellation are outside -this first model. - -The safety condition is intentionally about quiescence: once every submitted -command has settled, persisted intent must equal the latest command, and the -ingestor and published UI state must equal that intent gated by authorization. -The liveness property says the system eventually reaches that matching state. - -## What it found - -[`Broken.cfg`](Broken.cfg) is expected to violate `CorrectAtQuiescence`. TLC -finds an ordering corresponding to the real actor-reentrancy boundary: - -1. Enable begins and enters `LocationIngestor.start()`. -2. The ingestor marks itself active before its `LocationSource.start()` await. -3. Disable runs during that await, persists `false`, stops the ingestor, and - publishes `false`. -4. The older enable resumes and unconditionally publishes `true`. - -The final state is therefore `desired = false`, `persisted = false`, and -`ingestorActive = false`, but `published = true`. The deterministic expected- -failure guard in -[`WhereSessionTrackingTests`](../../WhereUI/Tests/WhereSessionTrackingTests.swift) -holds the real implementation at exactly that await. - -[`Coalesced.cfg`](Coalesced.cfg) checks the proposed design: record intent -synchronously, allow at most one side effect in flight, and rerun the worker if -intent changed while it awaited. That model satisfies the type, -immediate-intent, quiescent-correctness, and eventual-settlement properties for -the same command sequence. The worker is a system-wide lane: toggle writes, -launch and foreground reconciliation, authorization observation, and permission -completion must all join it. Serializing only the toggle setter would not -implement the modeled design. - -This is design evidence, not yet the product fix. Implementing the worker should -make the Swift guard pass without `withKnownIssue`; changing the design should -change this model first so its assumptions remain explicit. +| `submitted` | The latest command's monotonic `WhereSession.recordingIntentSequence` | +| `desired` | The Boolean value carried by that latest `setRecordingEnabled(_:)` call | +| `persisted` | The installation-local choice written synchronously to `InstallationRecordingContextStoring` before the first suspension | +| `permission` phase | An enable command suspended in `LocationIngestor.requestPermission()` and `syncAuthorization()` | +| Stale permission rejection | The sequence guard immediately after the permission suspension | +| `queue` / `inFlight` | Calls waiting at, or holding, `DeviceRecordingController.beginExclusive()` | +| `controllerChoice` | `DeviceRecordingController.automaticRecordingEnabled` | +| `target` | The in-flight choice gated by the controller call's resolved authorization | +| `ingestorActive` | `LocationIngestor.isActive` after the physical transition | +| `published` | `WhereSession.isTracking`, derived from the controller's ordered runtime update | +| `CurrentBegin` | The controller admits the FIFO head and begins its store/physical transition | +| `CurrentComplete` | The exclusive controller transition succeeds and its runtime state is applied | + +The source entry points represented are `WhereSession.startTracking()`, `stopTracking()`, and +`setRecordingEnabled(_:)`. Launch, foreground, authorization observation, and CloudKit-change +reconciliation also enter the controller's exclusive lane, but do not change local consent; the +model permits them to delay a command without representing their choice-neutral work. Onboarding +registration happens before an active session can submit these commands and is outside this +protocol. + +The model splits the implementation at the permission suspension and the cross-actor controller +entry. It treats the controller's full transition as exclusive across its store, outbox, ingestor, +and check-in awaits, matching `beginExclusive()` / `endExclusive()`. Queue order is FIFO. Runtime +publication is abstracted into the successful transition completion: production additionally +orders emissions and ignores an update whose sequence is no newer than the last applied one. + +## Properties + +- `TypeOK` checks every model variable. +- `CurrentIntentIsImmediate` requires the sidecar choice to match the latest submitted command, + including while permission or a Core transition is suspended. +- `CorrectAtQuiescence` requires sidecar and controller intent to equal the latest command, and the + ingestor and UI state to equal that intent gated by authorization. +- `EventuallySettled` requires those facts to converge after the finite command list is submitted. +- `StalePermissionNotObserved` is deliberately violated by the reachability check, proving that TLC + explored the branch where an older enable completes after a newer command. +- Candidate configurations check deadlock freedom. The explicit quiescent stutter action models a + live process after this finite protocol has settled. + +Weak fairness assumes each configured command is eventually submitted, permission requests return, +and an admitted Core transition eventually completes. These correspond to the runtime progress +guarantees needed only for `EventuallySettled`; the safety invariants do not depend on fairness. + +## Bounds and exclusions + +The initial choice is Off. The checker exhausts these finite configurations: + +| Configuration | Commands | Authorized | Generated / distinct states | Depth | +| --- | --- | --- | --- | --- | +| `Current.cfg` | enable, disable | true | 22 / 16 | 8 | +| `CurrentDenied.cfg` | enable, disable | false | 22 / 16 | 8 | +| `CurrentRepeated.cfg` | enable, enable, disable | true | 96 / 56 | 12 | +| `CurrentReversed.cfg` | disable, enable | true | 17 / 12 | 8 | + +The model abstracts authorization to the value observed after permission returns. Permission +failure therefore takes the same state path as a returned unauthorized result. Store/outbox/check-in +failure, task cancellation, reset/import pause, device removal, process termination, GPS samples, +and unbounded command streams are excluded. Those paths fail closed or have separate lifecycle +contracts and are not evidence supplied by this pilot. + +## Controls and result + +`Broken.cfg` retains the old independent-task design as a negative control. TLC violates +`CorrectAtQuiescence` after 33 generated / 26 distinct states at depth 8: enable starts, disable +stops and publishes Off, then the older enable completion publishes On while persisted intent and +the ingestor remain Off. + +`CurrentStaleReachability.cfg` deliberately asserts that stale rejection was never observed. TLC +violates it after 5 generated / 5 distinct states at depth 4, demonstrating that the important +permission-race branch is reachable rather than vacuous. All four current configurations then +exhaust their complete state spaces without an invariant, temporal-property, or deadlock error. + +The deterministic software guard is +`WhereSessionTrackingTests.offWinsWhileAnEarlierEnableWaitsForPermission`. It parks the real +permission seam, submits Off, releases the older enable, and checks the sidecar-facing device +configuration, advisory status, and UI tracking state. ## Run it @@ -70,10 +98,7 @@ From this directory: ./check ``` -The checker pins TLC 1.7.4 by SHA-256 and Eclipse Temurin 21.0.8+9 through -`mise`. It caches both under the repository's ignored `.build/tla/` directory. -A clean first run needs network access and downloads about 350 MB, almost all of -it the JDK. Each run keeps its TLC log and state under `.build/tla/runs/`. A -successful run means the broken model failed for the expected invariant and the -coalesced model completed without an error. The pilot is opt-in and is not wired -into CI. +The checker pins TLC 1.7.4 by SHA-256 and Eclipse Temurin 21.0.8+9 through `mise`. It caches both +under the repository's ignored `.build/tla/` directory and keeps each run's logs and state in an +isolated `.build/tla/runs/` directory. A clean first run needs network access and downloads about +350 MB, almost all of it the JDK. The pilot is opt-in and is not wired into CI. diff --git a/Where/Specifications/TrackingReconciliation/TrackingReconciliation.tla b/Where/Specifications/TrackingReconciliation/TrackingReconciliation.tla index c8f20f8d..4622cbcc 100644 --- a/Where/Specifications/TrackingReconciliation/TrackingReconciliation.tla +++ b/Where/Specifications/TrackingReconciliation/TrackingReconciliation.tla @@ -3,37 +3,43 @@ EXTENDS Integers, Sequences CONSTANTS Implementation, Commands, Authorized -ASSUME /\ Implementation \in {"broken", "coalesced"} +ASSUME /\ Implementation \in {"broken", "current"} /\ Commands \in Seq(BOOLEAN) /\ Len(Commands) > 0 /\ Authorized \in BOOLEAN CommandIDs == 1..Len(Commands) -Phases == {"unsubmitted", "queued", "preparing", "starting", "stopping", "done"} -WorkerStates == {"idle", "ready", "starting", "stopping"} +Phases == {"unsubmitted", "queued", "preparing", "starting", "stopping", + "permission", "waiting", "transitioning", "done"} VARIABLES submitted, desired, persisted, + controllerChoice, ingestorActive, published, taskPhase, - worker, - target + queue, + inFlight, + target, + staleRejected -vars == <> +vars == <> Init == /\ submitted = 0 /\ desired = FALSE /\ persisted = FALSE + /\ controllerChoice = FALSE /\ ingestorActive = FALSE /\ published = FALSE /\ taskPhase = [i \in CommandIDs |-> "unsubmitted"] - /\ worker = "idle" + /\ queue = <<>> + /\ inFlight = 0 /\ target = FALSE + /\ staleRejected = FALSE Submit == /\ submitted < Len(Commands) @@ -43,20 +49,26 @@ Submit == /\ desired' = value /\ IF Implementation = "broken" THEN /\ taskPhase' = [taskPhase EXCEPT ![i] = "queued"] - /\ UNCHANGED <> - ELSE /\ taskPhase' = taskPhase - /\ persisted' = value - /\ worker' = IF worker = "idle" THEN "ready" ELSE worker - /\ UNCHANGED <> + /\ UNCHANGED <> + ELSE /\ persisted' = value + /\ IF value + THEN /\ taskPhase' = [taskPhase EXCEPT ![i] = "permission"] + /\ queue' = queue + ELSE /\ taskPhase' = [taskPhase EXCEPT ![i] = "waiting"] + /\ queue' = Append(queue, i) + /\ UNCHANGED <> BrokenBegin(i) == /\ Implementation = "broken" /\ taskPhase[i] = "queued" /\ persisted' = Commands[i] + /\ controllerChoice' = Commands[i] /\ taskPhase' = [taskPhase EXCEPT ![i] = IF Commands[i] THEN "preparing" ELSE "stopping"] /\ ingestorActive' = IF Commands[i] THEN ingestorActive ELSE FALSE - /\ UNCHANGED <> + /\ UNCHANGED <> BrokenReconcile(i) == /\ Implementation = "broken" @@ -65,53 +77,86 @@ BrokenReconcile(i) == ![i] = IF persisted /\ Authorized THEN "starting" ELSE "stopping"] + /\ controllerChoice' = persisted /\ ingestorActive' = persisted /\ Authorized - /\ UNCHANGED <> + /\ UNCHANGED <> BrokenCompleteStart(i) == /\ Implementation = "broken" /\ taskPhase[i] = "starting" /\ published' = TRUE /\ taskPhase' = [taskPhase EXCEPT ![i] = "done"] - /\ UNCHANGED <> + /\ UNCHANGED <> BrokenCompleteStop(i) == /\ Implementation = "broken" /\ taskPhase[i] = "stopping" /\ published' = FALSE /\ taskPhase' = [taskPhase EXCEPT ![i] = "done"] - /\ UNCHANGED <> - -FixedBegin == - /\ Implementation = "coalesced" - /\ worker = "ready" - /\ target' = desired /\ Authorized - /\ worker' = IF target' THEN "starting" ELSE "stopping" - /\ ingestorActive' = target' - /\ UNCHANGED <> - -FixedCompleteStart == - /\ Implementation = "coalesced" - /\ worker = "starting" - /\ published' = TRUE - /\ worker' = IF desired /\ Authorized = target THEN "idle" ELSE "ready" - /\ UNCHANGED <> + /\ UNCHANGED <> + +CurrentPermissionComplete(i) == + /\ Implementation = "current" + /\ taskPhase[i] = "permission" + /\ IF i = submitted + THEN /\ taskPhase' = [taskPhase EXCEPT ![i] = "waiting"] + /\ queue' = Append(queue, i) + /\ staleRejected' = staleRejected + ELSE /\ taskPhase' = [taskPhase EXCEPT ![i] = "done"] + /\ queue' = queue + /\ staleRejected' = TRUE + /\ UNCHANGED <> + +CurrentBegin == + /\ Implementation = "current" + /\ inFlight = 0 + /\ Len(queue) > 0 + /\ LET i == Head(queue) + effective == Commands[i] /\ Authorized + IN /\ taskPhase[i] = "waiting" + /\ queue' = Tail(queue) + /\ inFlight' = i + /\ taskPhase' = [taskPhase EXCEPT ![i] = "transitioning"] + /\ controllerChoice' = Commands[i] + /\ target' = effective + /\ ingestorActive' = effective + /\ UNCHANGED <> + +CurrentComplete == + /\ Implementation = "current" + /\ inFlight \in CommandIDs + /\ taskPhase[inFlight] = "transitioning" + /\ published' = target + /\ taskPhase' = [taskPhase EXCEPT ![inFlight] = "done"] + /\ inFlight' = 0 + /\ UNCHANGED <> -FixedCompleteStop == - /\ Implementation = "coalesced" - /\ worker = "stopping" - /\ published' = FALSE - /\ worker' = IF (desired /\ Authorized) = target THEN "idle" ELSE "ready" - /\ UNCHANGED <> +Quiescent == + /\ submitted = Len(Commands) + /\ \A i \in CommandIDs : taskPhase[i] = "done" + /\ IF Implementation = "current" + THEN /\ queue = <<>> + /\ inFlight = 0 + ELSE TRUE + +Done == + /\ Quiescent + /\ UNCHANGED vars Next == \/ Submit \/ \E i \in CommandIDs : BrokenBegin(i) \/ BrokenReconcile(i) \/ BrokenCompleteStart(i) \/ BrokenCompleteStop(i) - \/ FixedBegin - \/ FixedCompleteStart - \/ FixedCompleteStop + \/ CurrentPermissionComplete(i) + \/ CurrentBegin + \/ CurrentComplete + \/ Done Fairness == /\ WF_vars(Submit) @@ -120,45 +165,48 @@ Fairness == /\ WF_vars(BrokenReconcile(i)) /\ WF_vars(BrokenCompleteStart(i)) /\ WF_vars(BrokenCompleteStop(i)) - /\ WF_vars(FixedBegin) - /\ WF_vars(FixedCompleteStart) - /\ WF_vars(FixedCompleteStop) + /\ WF_vars(CurrentPermissionComplete(i)) + /\ WF_vars(CurrentBegin) + /\ WF_vars(CurrentComplete) Spec == Init /\ [][Next]_vars /\ Fairness DesiredEffective == desired /\ Authorized -Quiescent == - /\ submitted = Len(Commands) - /\ IF Implementation = "broken" - THEN \A i \in CommandIDs : taskPhase[i] = "done" - ELSE worker = "idle" - TypeOK == /\ submitted \in 0..Len(Commands) /\ desired \in BOOLEAN /\ persisted \in BOOLEAN + /\ controllerChoice \in BOOLEAN /\ ingestorActive \in BOOLEAN /\ published \in BOOLEAN /\ taskPhase \in [CommandIDs -> Phases] - /\ worker \in WorkerStates + /\ queue \in Seq(CommandIDs) + /\ inFlight \in 0..Len(Commands) /\ target \in BOOLEAN + /\ staleRejected \in BOOLEAN -FixedIntentIsImmediate == - Implementation = "coalesced" => persisted = desired +CurrentIntentIsImmediate == + Implementation = "current" => persisted = desired CorrectAtQuiescence == Quiescent => /\ persisted = desired + /\ controllerChoice = desired /\ ingestorActive = DesiredEffective /\ published = DesiredEffective EventuallySettled == submitted = Len(Commands) ~> (Quiescent /\ persisted = desired + /\ controllerChoice = desired /\ ingestorActive = DesiredEffective /\ published = DesiredEffective) +StalePermissionNotObserved == ~staleRejected + EnableThenDisable == <> +DisableThenEnable == <> +EnableEnableDisable == <> ==== diff --git a/Where/Specifications/TrackingReconciliation/check b/Where/Specifications/TrackingReconciliation/check index 3281de27..dd0ebb65 100755 --- a/Where/Specifications/TrackingReconciliation/check +++ b/Where/Specifications/TrackingReconciliation/check @@ -18,8 +18,10 @@ usage() { cat <<'EOF' Usage: ./check -Check that TLC finds the expected tracking race in Broken.cfg and accepts the -serialized worker in Coalesced.cfg. Downloads pinned tools into .build/tla/. +Check that TLC finds the expected tracking race in Broken.cfg, reaches the +stale-permission path, and accepts the current generation-token + exclusive- +lane design across the configured bounds. Downloads pinned tools into +.build/tla/. Set TLA_JAVA to a Java executable to bypass the pinned mise runtime. EOF @@ -41,7 +43,7 @@ mkdir -p "$jar_dir" "$runs_dir" run_dir="$(mktemp -d "$runs_dir/TrackingReconciliation.XXXXXX")" logs_dir="$run_dir/logs" states_dir="$run_dir/states" -mkdir -p "$logs_dir" "$states_dir/broken" "$states_dir/coalesced" +mkdir -p "$logs_dir" "$states_dir/broken" "$states_dir/stale-reachability" checksum() { shasum -a 256 "$1" | awk '{print $1}' @@ -91,7 +93,7 @@ run_tlc() { } broken_log="$logs_dir/broken.log" -coalesced_log="$logs_dir/coalesced.log" +stale_log="$logs_dir/stale-reachability.log" echo "Checking that the broken implementation produces the expected counterexample..." set +e @@ -109,17 +111,48 @@ if ! grep -Fq "Invariant CorrectAtQuiescence is violated." "$broken_log"; then exit 1 fi -echo "Checking that the coalesced worker satisfies the model..." -if ! run_tlc "$script_dir/Coalesced.cfg" "$states_dir/coalesced" >"$coalesced_log" 2>&1; then - echo "Coalesced.cfg failed; inspect $coalesced_log." >&2 - tail -n 80 "$coalesced_log" >&2 +echo "Checking that the stale permission-completion path is reachable..." +set +e +run_tlc \ + "$script_dir/CurrentStaleReachability.cfg" \ + "$states_dir/stale-reachability" >"$stale_log" 2>&1 +stale_status=$? +set -e + +if [[ $stale_status -eq 0 ]]; then + echo "CurrentStaleReachability.cfg did not reach stale rejection; inspect $stale_log." >&2 exit 1 fi -if ! grep -Fq "Model checking completed. No error has been found." "$coalesced_log"; then - echo "Coalesced.cfg did not report a clean model check; inspect $coalesced_log." >&2 - tail -n 80 "$coalesced_log" >&2 +if ! grep -Fq "Invariant StalePermissionNotObserved is violated." "$stale_log"; then + echo "CurrentStaleReachability.cfg failed for an unexpected reason; inspect $stale_log." >&2 + tail -n 80 "$stale_log" >&2 exit 1 fi -echo "TLA+ pilot passed: the race is reproduced and the coalesced design checks clean." +current_configs=( + Current.cfg + CurrentDenied.cfg + CurrentRepeated.cfg + CurrentReversed.cfg +) + +for config in "${current_configs[@]}"; do + name="${config%.cfg}" + log="$logs_dir/${name}.log" + metadir="$states_dir/${name}" + mkdir -p "$metadir" + echo "Checking ${config}..." + if ! run_tlc "$script_dir/$config" "$metadir" >"$log" 2>&1; then + echo "${config} failed; inspect $log." >&2 + tail -n 80 "$log" >&2 + exit 1 + fi + if ! grep -Fq "Model checking completed. No error has been found." "$log"; then + echo "${config} did not report a clean model check; inspect $log." >&2 + tail -n 80 "$log" >&2 + exit 1 + fi +done + +echo "TLA+ pilot passed: the old race is reproduced, stale rejection is reachable, and the current design checks clean." echo "TLC run artifacts: $run_dir" From 99bfe6c14fd6ebdcca152ac9b95bed7d0160ab2c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 22:02:11 -0700 Subject: [PATCH 31/31] Persist location outbox with JournalKit --- Package.swift | 1 + Where/TODOs.md | 3 +- Where/WhereCore/AGENTS.md | 7 +- Where/WhereCore/README.md | 4 + .../Sources/Location/LocationIngestor.swift | 15 +- .../Sources/Location/LocationOutbox.swift | 254 ++++++++++-------- .../Sources/Logging/LocationIngestorLog.swift | 7 +- .../Sources/Logging/LocationOutboxLog.swift | 5 +- .../Tests/LocationIngestorTests.swift | 35 ++- .../WhereCore/Tests/LocationOutboxTests.swift | 77 +++++- .../Tests/WhereCoreTestSupport.swift | 2 +- .../WhereCore/Tests/WhereServicesTests.swift | 4 +- Where/WhereUI/Tests/BackupModelTests.swift | 2 +- Where/WhereUI/Tests/WhereLaunchTests.swift | 2 +- Where/WhereUI/Tests/WhereResetTests.swift | 4 +- 15 files changed, 284 insertions(+), 138 deletions(-) diff --git a/Package.swift b/Package.swift index 7b258b6a..69ac5f55 100644 --- a/Package.swift +++ b/Package.swift @@ -140,6 +140,7 @@ let package = Package( name: "WhereCore", dependencies: [ .target(name: "CreditKit"), + .target(name: "JournalKit"), .target(name: "PeriscopeCore"), .target(name: "RegionKit"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), diff --git a/Where/TODOs.md b/Where/TODOs.md index 04f7a51a..41c4db64 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -28,8 +28,6 @@ The item format and the placement rule live in the root - fix(WhereCore) [needs-design]: `WhereServices.setPrimaryRegions(_:)` (`:285`) commits atomically but skips `DayJournal.reconcileAfterDayChange()` — widgets/reminders/summary don't refresh until foreground/configure. Route picker commits through the unified fan-out, or document the intentional deferral. (audit 2026-07-26) - fix(WhereCore) [needs-design]: Soft-delete untracked regions. `SwiftDataStore.setTrackedRegion(false)` (`:756`, in-source TODO at `:773`) and `setPrimaryRegions` (`:833`) hard-delete the row, which drops the region from the attributor's load set — so re-aggregating a past year re-attributes that region's GPS days to `.other`. The `SwiftDataStore` TODO filed this as "when the region picker ships"; it has shipped, and both the onboarding picker and the Settings region editor now reach the delete, so this is user-reachable rather than latent. Retain the row for attribution and hide it from the pickers instead. (audit 2026-07-26) - fix(WhereCore) [needs-design]: `DayJournal.ingest(_:)` (`:70`), the bulk ingest (`:82`), and `addManualSample` (`:93`) publish widgets but skip the reminder/issue reconcile, so a presence change made through them leaves the badge and reminders stale. Route them through the fan-out, or mark them `@_spi(Testing)` if they aren't production write paths. (audit 2026-07-26) -- fix(WhereCore) [needs-design]: A durable outbox save failure is logged and swallowed (`LocationOutbox.swift:86`, `LocationIngestor.swift:344`), so a process death loses the in-memory sample with nothing to replay on relaunch. Handle the degraded state honestly rather than continuing as though the sample were durable. (audit 2026-07-26) - - test(WhereCore) [quick-win]: Cover outbox *save* failure with a failing-outbox double; only load failure is covered today. (audit 2026-07-26) - fix(WhereCore) [needs-design]: The retry queue evicts FIFO at capacity and drops samples with a warning only (`LocationIngestor.swift:349`). Decide the capacity policy and whether eviction warrants user-visible degradation, then document it. (audit 2026-07-26) - fix(WhereUI) [quick-win]: `PresenceTimelineList` returns `[]` whenever `report.report` is nil (`:12`), so the Timeline segment of Your Year renders the "no stays" empty state while the year is still loading (and during a year switch) — unlike the Calendar segment beside it, which gates on `loadState`. (audit 2026-07-26) - refactor(WhereUI) [needs-design]: Extract a shared `ReportLoadGate`. The same `YearReportModel.loadState` gate is copy-pasted across `LocationsView.swift:60`, `ElsewhereView.swift:50`, `ResolutionView.swift:58`, and `CalendarContentView.swift:60`, and `PresenceTimelineList` skipped it entirely (above). One gate view would cover all five. (audit 2026-07-26) @@ -102,6 +100,7 @@ re-recording: # Completed issues +- fix(WhereCore): Make the raw-location retry outbox crash-safe and surface failed durable writes. (Resolved 2026-08-04: `LocationOutbox` now journals complete queue snapshots through JournalKit, recovers the newest intact snapshot after a torn tail, migrates the previous JSON format once, and stops recording with the sample retained in memory if the durable checkpoint fails. `LocationOutboxTests` and `LocationIngestorTests.failedOutboxWriteStopsRecordingWithTheSampleStillInMemory` cover recovery and failure.) - fix(WhereCore): Remove the cross-device assignment DAG and its clock-skew/compaction liabilities. (Resolved 2026-08-03: automatic-recording consent is now installation-local; CloudKit syncs only profiles, nickname events, advisory check-ins, and append-only removal tombstones. A removal retains the intentional remote history cutoff without a mutable authority timeline.) - fix(WhereUI): Serialize automatic-recording changes and separate desired from effective state. (Resolved 2026-08-03: the fire-and-forget binding became an awaited, installation-local intent persisted beside the backup-excluded device identity. Refreshes cannot manufacture commands, and the Devices UI renders remote status read-only.) - fix(WhereUI) [quick-win]: `resolution.Empty_iPhone` and `..._dark` baked in the **real-world date** and drifted every day — the reference read "Jan 1 – Jul 25 / 206 days" because that is when it was recorded, and it had been silently wrong every day since, passing only because two digit glyphs are 0.046% of the image. (Resolved: `PreviewSupport.previewServices()` now passes `now: { referenceNow }`, which `WhereServices` already threads into every collaborator including the `DataIssueScanner` that computes the missing-days range. `referenceNow`'s own doc comment names "missing-day math" as a reason it exists, so this was a fixture bug against a documented intent rather than a new pin. The two references were re-recorded once and now read "Jan 1 – Jul 14 / 195 days", derived from the pinned instant. Surfaced by `./test --review`, which reported it at max channel delta 255 while the suite still reported green.) diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 9bbe8e6e..0d99e81b 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -111,9 +111,12 @@ internal shape. `LocationHistoryReader` to every user-facing projection. Persist immutable profiles, nickname events, global removal tombstones, and target-owned advisory check-ins separately. A remote device may rename or remove an identity, but never change another installation's local consent. - Stamp every durable location-outbox entry with its authorizing data epoch and - never replay it into another generation; backups alone read lossless raw + Backups alone read lossless raw samples and device/removal timelines, excluding non-restorable check-ins. +- **Journal complete `LocationOutbox` snapshots through `JournalKit`.** Stamp every entry with its + authorizing data epoch, never replay it into another generation, keep the directory excluded + from device backups, and make a destructive clear durable before removing old segments; guards: + `LocationOutboxTests` and `LocationIngestorTests.failedOutboxWriteStopsRecordingWithTheSampleStillInMemory`. - **Tracked regions live in the store, not preferences** — one `SDTrackedRegion` row per region so cross-device edits merge; read as a `Set` defaulting to the four. `RegionAttribution` derives the attributor diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 476f6e82..e06b85a4 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -105,6 +105,10 @@ one it belongs to rather than to a god-object: the current installation's `RecordingDeviceID`. Every durable retry entry also carries the data epoch that authorized it, so a pre-reset fix can be discarded but never written into the replacement generation. +- **`LocationOutbox`** — a backup-excluded, JournalKit-backed sidecar for samples + SwiftData could not commit. It appends complete bounded queue snapshots, so a + crash-torn final write falls back to the preceding intact state; Reset and + Replace durably checkpoint an empty queue before deleting its raw bytes. - **`DeviceRecordingController`** — applies this installation's local automatic-recording preference and persisted current-On cutoff to its physical `LocationIngestor`, so a late visit from an Off interval remains rejected after relaunch. Immutable profiles, nickname events, diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index 73886e4b..262d044e 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -177,7 +177,7 @@ public actor LocationIngestor { let retained = retryQueue.filter { $0.dataEpochID == dataEpochID } if retained.count != retryQueue.count { retryQueue = retained - await outbox.save(retryQueue) + try await outbox.save(retryQueue) } // Flush anything that failed to persist before this session started, // before we (re)attach the stream consumer. @@ -469,7 +469,16 @@ public actor LocationIngestor { ) } enqueueForRetry(LocationOutboxEntry(sample: sample, dataEpochID: dataEpochID)) - await outbox.save(retryQueue) + do { + try await outbox.save(retryQueue) + } catch { + Self.logger(attachments: [.error(error, name: "outbox-persist-error")]) { + .retryBacklogPersistenceFailed(description: error.localizedDescription) + } + // Continuing to accept locations would make the in-memory queue the only copy; + // fail closed until reconciliation can reopen recording with durable storage. + await closeRecordingAuthority(ifAuthorizedFor: dataEpochID) + } } } @@ -533,7 +542,7 @@ public actor LocationIngestor { ) } } - await outbox.save(retryQueue) + try await outbox.save(retryQueue) if epochChanged { throw RecordingPersistenceError.dataEpochChanged } diff --git a/Where/WhereCore/Sources/Location/LocationOutbox.swift b/Where/WhereCore/Sources/Location/LocationOutbox.swift index c8bf5ee0..270d40e6 100644 --- a/Where/WhereCore/Sources/Location/LocationOutbox.swift +++ b/Where/WhereCore/Sources/Location/LocationOutbox.swift @@ -1,4 +1,5 @@ import Foundation +import JournalKit import PeriscopeCore /// One retryable raw sample together with the logical generation that authorized it. The epoch @@ -21,15 +22,15 @@ public struct LocationOutboxEntry: Codable, Sendable, Hashable { /// /// Deliberately separate from `WhereStore`: the store is the thing that's /// failing when samples land here, so the backlog must not depend on it. The -/// production implementation is a small atomically-written JSON file in the -/// app's own sandbox, explicitly excluded from device backups (the samples are -/// sensitive raw locations — not the App Group the widget reads). +/// production implementation journals complete queue snapshots in the app's +/// sandbox, explicitly excluded from device backups (the samples are sensitive +/// raw locations — not the App Group the widget reads). public protocol LocationOutbox: Sendable { /// The persisted backlog, or empty when none exists. A read/security/decoding failure throws; - /// callers must not treat an unreadable raw-location file as an empty successful load. + /// callers must not treat an unreadable raw-location journal as an empty successful load. func load() async throws -> [LocationOutboxEntry] /// Replace the persisted backlog with `entries`; an empty array clears it. - func save(_ entries: [LocationOutboxEntry]) async + func save(_ entries: [LocationOutboxEntry]) async throws /// Remove every persisted retry sample. Reset uses the throwing path so it cannot report a /// successful erase while raw locations remain able to repopulate the next installation. func clear() async throws @@ -44,25 +45,31 @@ public struct NoOpLocationOutbox: LocationOutbox { [] } - public func save(_: [LocationOutboxEntry]) async {} + public func save(_: [LocationOutboxEntry]) async throws {} public func clear() async throws {} } -/// File-backed `LocationOutbox`: the backlog is one atomically-written JSON file -/// (write-to-temp-then-rename), so a crash mid-write can never corrupt a -/// previously good backlog. An `actor` so its disk I/O runs off the -/// `LocationIngestor`'s executor. +private enum LocationOutboxRecoveryError: Error { + case noCompleteSnapshot +} + +/// Journal-backed `LocationOutbox`. Each entry is a complete bounded retry-queue +/// snapshot, so recovery needs only the newest intact entry and JournalKit may +/// discard older segments without changing queue semantics. public actor FileLocationOutbox: LocationOutbox { private static let directoryName = "LocationRetryOutbox" private static let fileName = "outbox.json" private static let legacyFileName = "location-retry-outbox.json" + private static let maximumJournalByteCount = 8 * 1024 * 1024 private let fileURL: URL + private let directoryURL: URL /// Retained when composing the production outbox so a failed legacy migration cannot leave /// raw locations outside the scope of a later reset. private let legacyFileURL: URL? private let readData: @Sendable (URL) throws -> Data private let excludeFromBackup: @Sendable (URL) throws -> Void + private var journal: Journal? private static let logger = WhereLog.location(LocationOutboxLog.self) @@ -91,6 +98,7 @@ public actor FileLocationOutbox: LocationOutbox { excludeFromBackup: @escaping @Sendable (URL) throws -> Void, ) { self.fileURL = fileURL + directoryURL = fileURL.deletingLastPathComponent() self.legacyFileURL = legacyFileURL self.readData = readData self.excludeFromBackup = excludeFromBackup @@ -129,121 +137,140 @@ public actor FileLocationOutbox: LocationOutbox { } public func load() async throws -> [LocationOutboxEntry] { - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { - return [] - } do { - try excludeFromBackup(fileURL.deletingLastPathComponent()) - try excludeFromBackup(fileURL) + try secureDirectoryIfPresent() + let recovered = try JournalRecovery.recover(directory: directoryURL) + if recovered.foundTornEntry { + Self.logger { .recoveredTornJournal } + } + if let payload = recovered.payloads.last { + return try Self.decodeEntries(from: payload) + } + if recovered.foundTornEntry { + throw LocationOutboxRecoveryError.noCompleteSnapshot + } + return try migrateLegacyJSONIfNeeded() } catch { - Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { - .excludeFromBackupFailed(description: error.localizedDescription) + Self.logger(attachments: [.error(error, name: "read-error")]) { + .readBacklogFailed(description: error.localizedDescription) } - Self.discardInsecureFile(at: fileURL) throw error } + } - let data: Data + public func save(_ entries: [LocationOutboxEntry]) async throws { + guard !entries.isEmpty else { + try await clear() + return + } do { - data = try readData(fileURL) + let data = try JSONEncoder().encode(entries) + try openJournal().append(data, sync: .processDeath) } catch { - // File protection and transient I/O failures can clear later. Preserve the only - // durable copy so a subsequent load can retry it. - Self.logger(attachments: [.error(error, name: "read-error")]) { - .readBacklogFailed(description: error.localizedDescription) + Self.logger(attachments: [.error(error, name: "persist-error")]) { + .persistBacklogFailed(description: error.localizedDescription) } throw error } + } + public func clear() async throws { do { - return try Self.decodeEntries(from: data) + if FileManager.default.fileExists(atPath: directoryURL.path(percentEncoded: false)) { + // The empty checkpoint becomes authoritative before removing old bytes. If the + // process dies during deletion, recovery still cannot resurrect an older queue. + try openJournal().append(JSONEncoder().encode([LocationOutboxEntry]()), sync: .full) + journal?.close() + journal = nil + try FileManager.default.removeItem(at: directoryURL) + } + if let legacyFileURL, + FileManager.default.fileExists(atPath: legacyFileURL.path(percentEncoded: false)) + { + try FileManager.default.removeItem(at: legacyFileURL) + } } catch { - // Decoding validly read bytes cannot recover without a format change. Drop them rather - // than crash-looping on the same corrupt backlog every launch. - Self.logger(attachments: [.error(error, name: "decode-error")]) { - .droppedUnreadableBacklog(description: error.localizedDescription) + Self.logger(attachments: [.error(error, name: "clear-error")]) { + .persistBacklogFailed(description: error.localizedDescription) } - Self.discardInsecureFile(at: fileURL) throw error } } - public func save(_ entries: [LocationOutboxEntry]) async { - guard !entries.isEmpty else { - do { - try await clear() - } catch { - Self.logger(attachments: [.error(error, name: "clear-error")]) { - .persistBacklogFailed(description: error.localizedDescription) - } - } + private func openJournal() throws -> Journal { + if let journal { return journal } + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + try excludeFromBackup(directoryURL) + let opened = try Journal( + directory: directoryURL, + configuration: .init(maximumByteCount: Self.maximumJournalByteCount), + ) + journal = opened + return opened + } + + private func secureDirectoryIfPresent() throws { + guard FileManager.default.fileExists(atPath: directoryURL.path(percentEncoded: false)) + else { return } - var publishedNewFile = false do { - let data = try JSONEncoder().encode(entries) - let directoryURL = fileURL.deletingLastPathComponent() - try FileManager.default.createDirectory( - at: directoryURL, - withIntermediateDirectories: true, - ) - // Secure the empty directory before writing either atomic-write scratch or pending - // bytes, closing the crash window between a completed write and per-file exclusion. try excludeFromBackup(directoryURL) - let pendingURL = fileURL.appendingPathExtension("pending") - if FileManager.default.fileExists(atPath: pendingURL.path(percentEncoded: false)) { - try FileManager.default.removeItem(at: pendingURL) - } - // Exclude the new inode before it acquires the authoritative path. A crash or - // exclusion failure can therefore never publish backup-eligible raw locations. - try data.write(to: pendingURL, options: .atomic) - try excludeFromBackup(pendingURL) - if FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) { - _ = try FileManager.default.replaceItemAt( - fileURL, - withItemAt: pendingURL, - backupItemName: nil, - options: .usingNewMetadataOnly, - ) - } else { - try FileManager.default.moveItem(at: pendingURL, to: fileURL) - } - publishedNewFile = true - try excludeFromBackup(fileURL) } catch { - Self.logger(attachments: [.error(error, name: "persist-error")]) { - .persistBacklogFailed(description: error.localizedDescription) - } - if publishedNewFile { - Self.discardInsecureFile(at: fileURL) + Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + .excludeFromBackupFailed(description: error.localizedDescription) } + journal?.close() + journal = nil + Self.discardInsecureDirectory(at: directoryURL) + throw error } } - public func clear() async throws { + /// Import the previous atomically-written JSON format exactly once. The journal snapshot is + /// fully durable before the legacy bytes are removed, so interruption can only leave both. + private func migrateLegacyJSONIfNeeded() throws -> [LocationOutboxEntry] { + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return [] + } + let data: Data + do { + data = try readData(fileURL) + } catch { + // File protection and transient I/O failures can clear later. Preserve the only + // durable copy so a subsequent load can retry it. + throw error + } + let entries: [LocationOutboxEntry] + do { + entries = try Self.decodeEntries(from: data) + } catch { + Self.logger(attachments: [.error(error, name: "decode-error")]) { + .droppedUnreadableBacklog(description: error.localizedDescription) + } + Self.discardInsecureFile(at: fileURL) + throw error + } + try openJournal().append(JSONEncoder().encode(entries), sync: .full) + try FileManager.default.removeItem(at: fileURL) let pendingURL = fileURL.appendingPathExtension("pending") - for url in [fileURL, pendingURL] + [legacyFileURL].compactMap(\.self) - where FileManager.default.fileExists( - atPath: url.path(percentEncoded: false), - ) - { - try FileManager.default.removeItem(at: url) + if FileManager.default.fileExists(atPath: pendingURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: pendingURL) } + return entries } /// Secure an outbox directory left by an interrupted write even when recording is Off and /// the ingestor never loads it. A complete pending file is the newest atomically-written - /// backlog, so promote it instead of dropping samples merely because the process died before - /// the final rename. If exclusion cannot be proven for either raw copy, privacy still wins - /// over that copy's retry durability and it is discarded. + /// legacy backlog, so promote it instead of dropping samples merely because the process died + /// before the final rename. private static func recoverExistingDirectory( containing fileURL: URL, fileManager: FileManager, excludeFromBackup: @Sendable (URL) throws -> Void, ) { let directoryURL = fileURL.deletingLastPathComponent() - guard fileManager.fileExists(atPath: directoryURL.path(percentEncoded: false)) - else { + guard fileManager.fileExists(atPath: directoryURL.path(percentEncoded: false)) else { return } let pendingURL = fileURL.appendingPathExtension("pending") @@ -251,9 +278,11 @@ public actor FileLocationOutbox: LocationOutbox { do { try excludeFromBackup(directoryURL) } catch { - Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + logger(attachments: [.error(error, name: "backup-exclusion-error")]) { .excludeFromBackupFailed(description: error.localizedDescription) } + discardInsecureDirectory(at: directoryURL) + return } func secureExistingFile(at url: URL) -> Bool { @@ -264,7 +293,7 @@ public actor FileLocationOutbox: LocationOutbox { try excludeFromBackup(url) return true } catch { - Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + logger(attachments: [.error(error, name: "backup-exclusion-error")]) { .excludeFromBackupFailed(description: error.localizedDescription) } discardInsecureFile(at: url) @@ -273,16 +302,14 @@ public actor FileLocationOutbox: LocationOutbox { } _ = secureExistingFile(at: fileURL) - guard secureExistingFile(at: pendingURL) else { - return - } + guard secureExistingFile(at: pendingURL) else { return } let pendingData: Data do { pendingData = try Data(contentsOf: pendingURL) } catch { - // File protection and transient I/O failures can clear later. Both copies are already - // excluded, so preserve the pending file for the next construction attempt. - Self.logger(attachments: [.error(error, name: "pending-read-error")]) { + // Both copies are already excluded, so a transient file-protection failure may retry + // next launch without sacrificing the newer pending snapshot. + logger(attachments: [.error(error, name: "pending-read-error")]) { .readBacklogFailed(description: error.localizedDescription) } return @@ -290,9 +317,7 @@ public actor FileLocationOutbox: LocationOutbox { do { _ = try decodeEntries(from: pendingData) } catch { - // Atomic write completion makes a decodable pending file safe to promote. Invalid bytes - // cannot become a backlog, so retain any older authoritative copy and drop only these. - Self.logger(attachments: [.error(error, name: "pending-decode-error")]) { + logger(attachments: [.error(error, name: "pending-decode-error")]) { .droppedUnreadableBacklog(description: error.localizedDescription) } discardInsecureFile(at: pendingURL) @@ -311,20 +336,16 @@ public actor FileLocationOutbox: LocationOutbox { try fileManager.moveItem(at: pendingURL, to: fileURL) } } catch { - // Both copies remain excluded. Keep them so another launch can retry publication rather - // than turning a recoverable rename failure into location loss. - Self.logger(attachments: [.error(error, name: "pending-promotion-error")]) { + logger(attachments: [.error(error, name: "pending-promotion-error")]) { .persistBacklogFailed(description: error.localizedDescription) } return } do { - // Moving preserves the pending inode and replacement metadata rules are subtle. Prove - // the final authoritative path is still excluded before allowing it to survive. try excludeFromBackup(fileURL) } catch { - Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + logger(attachments: [.error(error, name: "backup-exclusion-error")]) { .excludeFromBackupFailed(description: error.localizedDescription) } discardInsecureFile(at: fileURL) @@ -332,9 +353,8 @@ public actor FileLocationOutbox: LocationOutbox { } } - /// Move the former single-file outbox into the pre-excluded directory. This runs when the - /// app composes its outbox, independently of recording policy, so an Off device cannot leave - /// an older raw-location file backup-eligible indefinitely. + /// Move the former root-level file into the pre-excluded directory. This runs when the app + /// composes its outbox, independently of recording policy. private static func migrateLegacyFileIfNeeded( from legacyURL: URL, to fileURL: URL, @@ -356,8 +376,6 @@ public actor FileLocationOutbox: LocationOutbox { logger(attachments: [.error(error, name: "legacy-migration-error")]) { .persistBacklogFailed(description: error.localizedDescription) } - // If migration cannot complete, at least prove the old file is excluded. The helper - // deletes it when that cannot be guaranteed. secureExistingFile(at: legacyURL) } } @@ -417,11 +435,25 @@ public actor FileLocationOutbox: LocationOutbox { } } } + + private static func discardInsecureDirectory(at directoryURL: URL) { + guard FileManager.default.fileExists(atPath: directoryURL.path(percentEncoded: false)) + else { + return + } + do { + try FileManager.default.removeItem(at: directoryURL) + } catch { + logger(attachments: [.error(error, name: "insecure-discard-error")]) { + .discardInsecureBacklogFailed(description: error.localizedDescription) + } + } + } } #if DEBUG extension FileLocationOutbox { - /// Injects a deterministic file reader for testing transient read failures. + /// Injects a deterministic legacy-file reader for testing transient migration failures. @_spi(Testing) public init( fileURL: URL, @@ -449,5 +481,11 @@ public actor FileLocationOutbox: LocationOutbox { excludeFromBackup: excludeFromBackup, ) } + + /// Closes the current writer so a test can reproduce next-launch recovery over its bytes. + @_spi(Testing) public func closeJournalForTesting() { + journal?.close() + journal = nil + } } #endif diff --git a/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift b/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift index c3119466..422d3eb1 100644 --- a/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift +++ b/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift @@ -34,6 +34,7 @@ enum LocationIngestorLog: LogEvent { case foregroundCaptureReadFailed(description: String) case capturedForegroundFix case persistFailed(sampleID: String, description: String) + case retryBacklogPersistenceFailed(description: String) case retryQueueAtCapacity(capacity: Int) case retryStillFailing(sampleID: String, description: String) case drainedBacklog(sampleCount: Int, dayCount: Int) @@ -47,7 +48,7 @@ enum LocationIngestorLog: LogEvent { .info case .todayIntervalUnavailable, .foregroundCaptureReadFailed, .retryQueueAtCapacity: .warning - case .persistFailed, .retryStillFailing: + case .persistFailed, .retryBacklogPersistenceFailed, .retryStillFailing: .error } } @@ -70,6 +71,8 @@ enum LocationIngestorLog: LogEvent { "Captured one-shot foreground location for today" case let .persistFailed(sampleID, description): "Failed to persist GPS sample \(sampleID): \(description)" + case let .retryBacklogPersistenceFailed(description): + "Failed to durably persist the GPS retry backlog; stopping recording: \(description)" case let .retryQueueAtCapacity(capacity): "Retry queue at capacity (\(capacity)); dropping oldest queued GPS sample" case let .retryStillFailing(sampleID, description): @@ -85,7 +88,7 @@ enum LocationIngestorLog: LogEvent { WhereStoreID.sample(sampleID) case .monitoringStarted, .monitoringStopped, .restoredBacklog, .quiesced, .todayIntervalUnavailable, .foregroundCaptureReadFailed, .capturedForegroundFix, - .retryQueueAtCapacity, .drainedBacklog: + .retryBacklogPersistenceFailed, .retryQueueAtCapacity, .drainedBacklog: nil } } diff --git a/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift b/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift index 3cd337f0..9c0a48a0 100644 --- a/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift +++ b/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift @@ -8,6 +8,7 @@ enum LocationOutboxLog: LogEvent { case noApplicationSupport case droppedUnreadableBacklog(description: String) case readBacklogFailed(description: String) + case recoveredTornJournal case persistBacklogFailed(description: String) case excludeFromBackupFailed(description: String) case discardInsecureBacklogFailed(description: String) @@ -16,7 +17,7 @@ enum LocationOutboxLog: LogEvent { var level: LogLevel { switch self { - case .noApplicationSupport: .warning + case .noApplicationSupport, .recoveredTornJournal: .warning case .droppedUnreadableBacklog, .readBacklogFailed, .persistBacklogFailed, @@ -33,6 +34,8 @@ enum LocationOutboxLog: LogEvent { "Dropping unreadable location retry backlog: \(description)" case let .readBacklogFailed(description): "Failed to read location retry backlog; preserving it for retry: \(description)" + case .recoveredTornJournal: + "Recovered the last intact location retry snapshot after a torn journal entry" case let .persistBacklogFailed(description): "Failed to persist location retry backlog: \(description)" case let .excludeFromBackupFailed(description): diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index 51d119ab..fefa2a40 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -8,6 +8,7 @@ import Testing struct LocationIngestorTests { private enum OutboxFailure: Error { case clear + case save } private actor OutcomeRecorder { @@ -32,17 +33,24 @@ struct LocationIngestorTests { private actor SpyLocationOutbox: LocationOutbox { private(set) var entries: [LocationOutboxEntry] private let failsToClear: Bool + private let failsToSave: Bool - init(_ contents: [LocationSample] = [], failsToClear: Bool = false) { + init( + _ contents: [LocationSample] = [], + failsToClear: Bool = false, + failsToSave: Bool = false, + ) { entries = contents.map { LocationOutboxEntry(sample: $0, dataEpochID: .initial) } self.failsToClear = failsToClear + self.failsToSave = failsToSave } func load() async throws -> [LocationOutboxEntry] { entries } - func save(_ entries: [LocationOutboxEntry]) async { + func save(_ entries: [LocationOutboxEntry]) async throws { + if failsToSave { throw OutboxFailure.save } self.entries = entries } @@ -471,6 +479,27 @@ struct LocationIngestorTests { #expect(await outbox.contents.count == 1) } + @Test func failedOutboxWriteStopsRecordingWithTheSampleStillInMemory() async throws { + let backing = try SwiftDataStore.inMemory() + let store = ToggleFailingStore(backing: backing) + let source = ScriptedLocationSource(authorizationStatus: .always) + let outbox = SpyLocationOutbox(failsToSave: true) + let ingestor = Self.makeIngestor( + store: store, + source: source, + recorder: OutcomeRecorder(), + outbox: outbox, + ) + try await ingestor.start() + + await store.setShouldFail(true) + source.emit(sample(at: "2026-03-15T12:00:00-07:00")) + + try await waitUntil { await !ingestor.isActive } + #expect(await ingestor.retryQueueDepth == 1) + #expect(await outbox.contents.isEmpty) + } + @Test func durableBacklogDrainsOnTheNextLaunch() async throws { let backing = try SwiftDataStore.inMemory() let failing = ToggleFailingStore(backing: backing) @@ -544,7 +573,7 @@ struct LocationIngestorTests { ) } let outbox = SpyLocationOutbox() - await outbox.save([LocationOutboxEntry( + try await outbox.save([LocationOutboxEntry( sample: sample(at: "2026-03-15T12:00:00-07:00"), dataEpochID: .initial, )]) diff --git a/Where/WhereCore/Tests/LocationOutboxTests.swift b/Where/WhereCore/Tests/LocationOutboxTests.swift index 5089e7fd..0d3d0a3e 100644 --- a/Where/WhereCore/Tests/LocationOutboxTests.swift +++ b/Where/WhereCore/Tests/LocationOutboxTests.swift @@ -68,7 +68,7 @@ struct LocationOutboxTests { let outbox = FileLocationOutbox(fileURL: url) let samples = [sample("2026-03-15T12:00:00Z"), sample("2026-03-15T13:00:00Z")] - await outbox.save(entries(samples)) + try await outbox.save(entries(samples)) #expect(try await loadedSamples(from: outbox) == samples) } @@ -83,7 +83,7 @@ struct LocationOutboxTests { ))), ) - await outbox.save([entry]) + try await outbox.save([entry]) #expect(try await outbox.load() == [entry]) } @@ -93,9 +93,10 @@ struct LocationOutboxTests { defer { cleanup(url) } let outbox = FileLocationOutbox(fileURL: url) - await outbox.save(entries([sample("2026-03-15T12:00:00Z")])) + try await outbox.save(entries([sample("2026-03-15T12:00:00Z")])) - let values = try url.resourceValues(forKeys: [.isExcludedFromBackupKey]) + let values = try url.deletingLastPathComponent() + .resourceValues(forKeys: [.isExcludedFromBackupKey]) #expect(values.isExcludedFromBackup == true) } @@ -180,7 +181,10 @@ struct LocationOutboxTests { ) #expect(try await loadedSamples(from: outbox) == previous) - #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(try FileManager.default.contentsOfDirectory(atPath: url + .deletingLastPathComponent().path) + .contains { $0.hasSuffix(".journalsegment") }) #expect(FileManager.default.fileExists(atPath: pendingURL.path) == false) } @@ -208,11 +212,11 @@ struct LocationOutboxTests { defer { cleanup(url) } let outbox = FileLocationOutbox(fileURL: url) - await outbox.save(entries([sample("2026-03-15T12:00:00Z")])) - await outbox.save([]) + try await outbox.save(entries([sample("2026-03-15T12:00:00Z")])) + try await outbox.save([]) #expect(try await outbox.load().isEmpty) - #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(!FileManager.default.fileExists(atPath: url.deletingLastPathComponent().path)) } @Test func clearAlsoRemovesALegacyBacklogLeftByFailedMigration() async throws { @@ -256,8 +260,7 @@ struct LocationOutboxTests { let url = tempURL() defer { cleanup(url) } let samples = [sample("2026-03-15T12:00:00Z")] - let writer = FileLocationOutbox(fileURL: url) - await writer.save(entries(samples)) + try write(samples, to: url) let unavailableMarkerURL = url.appendingPathExtension("unavailable") try Data().write(to: unavailableMarkerURL) let outbox = FileLocationOutbox(fileURL: url) { fileURL in @@ -275,4 +278,58 @@ struct LocationOutboxTests { try FileManager.default.removeItem(at: unavailableMarkerURL) #expect(try await loadedSamples(from: outbox) == samples) } + + @Test func newestCompleteJournalSnapshotWinsAfterRelaunch() async throws { + let url = tempURL() + defer { cleanup(url) } + let first = [sample("2026-03-15T12:00:00Z")] + let second = first + [sample("2026-03-15T13:00:00Z")] + let writer = FileLocationOutbox(fileURL: url) + try await writer.save(entries(first)) + try await writer.save(entries(second)) + await writer.closeJournalForTesting() + + let recovered = FileLocationOutbox(fileURL: url) + + #expect(try await loadedSamples(from: recovered) == second) + } + + @Test func tornFinalJournalSnapshotFallsBackToPreviousCompleteSnapshot() async throws { + let url = tempURL() + defer { cleanup(url) } + let first = [sample("2026-03-15T12:00:00Z")] + let second = first + [sample("2026-03-15T13:00:00Z")] + let writer = FileLocationOutbox(fileURL: url) + try await writer.save(entries(first)) + try await writer.save(entries(second)) + await writer.closeJournalForTesting() + let segmentURL = try #require( + FileManager.default.contentsOfDirectory( + at: url.deletingLastPathComponent(), + includingPropertiesForKeys: nil, + ).first { $0.pathExtension == "journalsegment" }, + ) + let handle = try FileHandle(forWritingTo: segmentURL) + let byteCount = try handle.seekToEnd() + try handle.truncate(atOffset: byteCount - 4) + try handle.close() + + let recovered = FileLocationOutbox(fileURL: url) + + #expect(try await loadedSamples(from: recovered) == first) + } + + @Test func legacyJSONMigratesToJournalBeforeItIsRemoved() async throws { + let url = tempURL() + defer { cleanup(url) } + let samples = [sample("2026-03-15T12:00:00Z")] + try write(samples, to: url) + let outbox = FileLocationOutbox(fileURL: url) + + #expect(try await loadedSamples(from: outbox) == samples) + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(try FileManager.default.contentsOfDirectory(atPath: url + .deletingLastPathComponent().path) + .contains { $0.hasSuffix(".journalsegment") }) + } } diff --git a/Where/WhereCore/Tests/WhereCoreTestSupport.swift b/Where/WhereCore/Tests/WhereCoreTestSupport.swift index 6e1d6a58..3ad6a852 100644 --- a/Where/WhereCore/Tests/WhereCoreTestSupport.swift +++ b/Where/WhereCore/Tests/WhereCoreTestSupport.swift @@ -79,7 +79,7 @@ actor ScriptedLocationOutbox: LocationOutbox { return entries } - func save(_ entries: [LocationOutboxEntry]) async { + func save(_ entries: [LocationOutboxEntry]) async throws { self.entries = entries } diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index e38294e6..380bcc89 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -844,7 +844,7 @@ struct WhereServicesTests { locationOutbox: outbox, ) _ = try await services.recording.register(authorization: .always) - await outbox.save([LocationOutboxEntry(sample: pending, dataEpochID: .initial)]) + try await outbox.save([LocationOutboxEntry(sample: pending, dataEpochID: .initial)]) await outbox.setFailsToClear(true) let error = await #expect(throws: WhereServices.ResetCleanupError.self) { @@ -894,7 +894,7 @@ struct WhereServicesTests { registrationEpochID: .initial, )) } - await outbox.save([LocationOutboxEntry(sample: pending, dataEpochID: .initial)]) + try await outbox.save([LocationOutboxEntry(sample: pending, dataEpochID: .initial)]) try await services.reset() diff --git a/Where/WhereUI/Tests/BackupModelTests.swift b/Where/WhereUI/Tests/BackupModelTests.swift index 82dbfa2f..003e684b 100644 --- a/Where/WhereUI/Tests/BackupModelTests.swift +++ b/Where/WhereUI/Tests/BackupModelTests.swift @@ -169,7 +169,7 @@ private actor FailingClearLocationOutbox: LocationOutbox { [] } - func save(_: [LocationOutboxEntry]) async {} + func save(_: [LocationOutboxEntry]) async throws {} func clear() async throws { guard !failsToClear else { throw CleanupFailure() } } diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index 42b14165..c80e37c7 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -18,7 +18,7 @@ private actor LaunchImportOutbox: LocationOutbox { [] } - func save(_: [LocationOutboxEntry]) async {} + func save(_: [LocationOutboxEntry]) async throws {} func clear() async throws { clearCount += 1 diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 8020c210..525314bc 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -388,7 +388,7 @@ struct WhereResetTests { let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) await launcher.run() let session = try #require(model.session) - await outbox.save([LocationOutboxEntry( + try await outbox.save([LocationOutboxEntry( sample: LocationSample( timestamp: Date(), coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), @@ -477,7 +477,7 @@ private actor ResetLocationOutbox: LocationOutbox { entries } - func save(_ entries: [LocationOutboxEntry]) async { + func save(_ entries: [LocationOutboxEntry]) async throws { self.entries = entries }