diff --git a/.agents/skills/github-workflow/SKILL.md b/.agents/skills/github-workflow/SKILL.md index 28f9da4b..efa91da6 100644 --- a/.agents/skills/github-workflow/SKILL.md +++ b/.agents/skills/github-workflow/SKILL.md @@ -20,8 +20,8 @@ always-on commit and test invariants — this skill assumes those. - **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 unless the user explicitly asks to keep it uncommitted. - Push each commit as it lands once a PR is open. - **When working through a plan, open a PR once the plan is complete** — push the branch and open it ready-for-review rather than leaving finished work diff --git a/AGENTS.md b/AGENTS.md index 866bdfee..0bcd5a4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ The executables in the repo root are the dev scripts — `ide`, `test`, hand-rolling its job: `test` is the only way tests should be run (see [Running tests](#running-tests)), and `icons`, `attribution`, and `simulator` in particular own state that is easy to corrupt by hand — `./simulator` owns a per-checkout device (see the -[`running-tests`](../.agents/skills/running-tests/SKILL.md) skill). +[`running-tests`](.agents/skills/running-tests/SKILL.md) skill). ### Managing app icons @@ -264,7 +264,7 @@ A few files outside the module pair carry *state* rather than rules: it touches, up to root. Read that file before adding an item, and have a new area's file link to it rather than copying the header. Anything deliberately deferred is filed rather than dropped (see the - [`github-workflow`](../.agents/skills/github-workflow/SKILL.md) skill), and a completed + [`github-workflow`](.agents/skills/github-workflow/SKILL.md) skill), and a completed item moves to "Completed issues" — never deleted. - **`INBOX.md`** — the root drop-box for raw, unverified human notes. Agents **read from it and promote out of it**; they never file new items there @@ -528,7 +528,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 @@ -536,7 +538,7 @@ cannot exercise them; record skipped checks in the commit or PR validation. Semantic changes to configuration, scripts, generator inputs, executable examples, or app-rendered copy are not documentation-only. -Load the [`running-tests`](../.agents/skills/running-tests/SKILL.md) skill for +Load the [`running-tests`](.agents/skills/running-tests/SKILL.md) skill for test tiers, snapshot opt-in, why not `tuist test`, and per-checkout simulator management (`./simulator` resolves a UDID — never pass a device name to `simctl`). @@ -547,16 +549,18 @@ management (`./simulator` resolves a UDID — never pass a device name to every commit for one piece of work on that one branch. - **Validate in proportion to risk.** Follow [Running tests](#running-tests), never commit a known-red tree, and load the - [`running-tests`](../.agents/skills/running-tests/SKILL.md) skill to choose + [`running-tests`](.agents/skills/running-tests/SKILL.md) skill to choose the applicable checks. - **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 -Load the [`github-workflow`](../.agents/skills/github-workflow/SKILL.md) skill +Load the [`github-workflow`](.agents/skills/github-workflow/SKILL.md) skill for PRs, pushes, review feedback, CI, and posting as the user. Always-on: use `gh`; open PRs ready-for-review; mark AI-posted comments. @@ -636,5 +640,5 @@ being written off as untestable from a cloud agent. ### Full build & test (macOS only) Matches CI `.github/workflows/ci.yml` — see the -[`running-tests`](../.agents/skills/running-tests/SKILL.md) skill for simulator +[`running-tests`](.agents/skills/running-tests/SKILL.md) skill for simulator setup and the full validation recipe. 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/Project.swift b/Project.swift index be89a47c..c1691d63 100644 --- a/Project.swift +++ b/Project.swift @@ -42,6 +42,25 @@ 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([ + // 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"), + ]), + "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 +185,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 +200,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/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index e0cf975e..aed28a62 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -78,6 +78,9 @@ the build system, formatting, and global conventions. Read that first. sessions but skip ingest (ingest deletes journals; an extension launch must not eat the live app's). Concurrently live processes sharing one on-disk store is unsupported; see [`TODOs.md`](../TODOs.md). +- **Periscope storage is local-only.** Every on-disk `ModelConfiguration` + explicitly sets `cloudKitDatabase: .none`; a host app's iCloud entitlement + must never opt the logging schema into CloudKit implicitly. - **Payloads persist as versioned JSON** (`eventName` + `eventVersion`) — an event shape change must not require a SwiftData migration. While the app is pre-release, shape changes need no decode tolerance either: the store is diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index b2f2c607..865abb3f 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -139,6 +139,8 @@ Periscope.shared.startDefaultAmbientSources() Inspector runtime needs without starting a logging session or exposing the internal SwiftData model classes. The recovery URLs include the crash journals that would otherwise replay deleted history into a fresh store. + Periscope storage is always local-only; its model configurations disable + CloudKit explicitly even when the host application has iCloud entitlements. ## How it works diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift index 7f38de13..95e11b64 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift @@ -106,6 +106,7 @@ public actor PeriscopeStore: LogSink { "Periscope", schema: schema, isStoredInMemoryOnly: storage == .inMemory, + cloudKitDatabase: .none, ) } @@ -147,7 +148,11 @@ public actor PeriscopeStore: LogSink { session: LogSession, ) async throws -> PeriscopeStore { let schema = Schema(PeriscopeSchema.models) - let configuration = ModelConfiguration(schema: schema, url: databaseURL) + let configuration = ModelConfiguration( + schema: schema, + url: databaseURL, + cloudKitDatabase: .none, + ) let container = try ModelContainer(for: schema, configurations: [configuration]) let store = PeriscopeStore(modelContainer: container) await store.ingestRecoveredJournals() diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 97106ed7..be2051db 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -31,7 +31,7 @@ is not a `WhereScope` and must never construct regular app services. | 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 @@ -67,6 +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 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. @@ -119,10 +125,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 @@ -132,8 +137,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. 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/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" diff --git a/Where/TODOs.md b/Where/TODOs.md index 7c612564..41c4db64 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -17,7 +17,7 @@ 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) @@ -28,15 +28,10 @@ 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) - 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:461`), and `reconcileTracking()` reads intent before awaiting `ingestor.start()` then unconditionally publishes `isTracking = true` when the await returns (`:302`), so a newer stop can finish during that await and leave preferences + the ingestor off while the UI mirror says on. The executable [`TrackingReconciliation` TLA+ pilot](Specifications/TrackingReconciliation/README.md) reproduces that counterexample and checks a one-worker coalescing design; implement that design (or a generation token) and re-check intent before publishing. (audit 2026-07-26; modeled 2026-08-02) - - 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]: `WhereSessionTrackingTests.newerStopWinsOverInFlightStart` now holds the location source at the modeled await and deterministically reproduces the stale publication behind `withKnownIssue`; remove the known-issue wrapper when the worker fix lands. (audit 2026-07-26; guarded 2026-08-02) - 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.) @@ -65,6 +60,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) +- 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) @@ -104,6 +100,9 @@ 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.) - 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 new file mode 100644 index 00000000..196d369a --- /dev/null +++ b/Where/Tools/Tests/upgrade_backup_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../upgrade-backup" + +class UpgradeBackupTest < Minitest::Test + 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("recordingDeviceRemovals") + assert_nil upgraded.fetch("samples").first.fetch("recordingDeviceID") + end + + def test_v1_synthesizes_primary_regions_and_rekeys_legacy_ids + manifest = base_manifest(1).merge("trackedRegions" => ["california", "newYork"]) + + upgraded = upgrade_manifest(manifest) + + assert_equal ["us-CA", "us-NY"], upgraded.fetch("trackedRegions") + assert_equal [ + { "region" => "us-CA", "appearance" => nil, "order" => 0 }, + { "region" => "us-NY", "appearance" => nil, "order" => 1 }, + ], upgraded.fetch("primaryRegions") + end + + 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 }, + ], + ) + + assert_equal manifest["primaryRegions"], upgrade_manifest(manifest)["primaryRegions"] + end + + def test_v3_is_idempotent + once = upgrade_manifest(base_manifest(3)) + assert_equal once, upgrade_manifest(Marshal.load(Marshal.dump(once))) + end + + def test_rejects_branch_only_or_future_formats + error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(4)) } + assert_equal 1, error.status + end + + private + + def base_manifest(version) + { + "formatVersion" => version, + "exportedAt" => 0.0, + "samples" => [{ "id" => "sample" }], + "evidence" => [], + "manualDays" => [], + "dismissedIssues" => [], + "trackedRegions" => [], + "assets" => [], + } + end +end diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 4b2ed47e..ecd90abc 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -1,37 +1,9 @@ #!/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. -# - 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). -# -# Idempotent: re-running on an already-upgraded archive is a no-op (it only -# touches legacy `date` / `key` fields and unmapped region ids). -# -# 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" @@ -40,10 +12,9 @@ require "set" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 2 +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", @@ -52,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], @@ -74,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 @@ -113,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 @@ -125,59 +85,68 @@ 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 source_format_version(manifest) + version = manifest["formatVersion"] + 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 + version +end + def upgrade_manifest(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["recordingDeviceProfiles"] ||= [] + manifest["recordingDeviceMetadataChanges"] ||= [] + manifest["recordingDeviceRemovals"] ||= [] + manifest.delete("recordingDevices") + manifest.delete("recordingDeviceCheckIns") + manifest.delete("recordingPolicyChanges") manifest["formatVersion"] = CURRENT_FORMAT_VERSION warnings.uniq.each { |message| warn "warning: #{message}" } manifest @@ -189,6 +158,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 @@ -197,39 +177,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) +main(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index 06f4ce41..db856514 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -49,13 +49,21 @@ 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 *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`), 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 3ab663bc..92a4b3a4 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -65,3 +65,42 @@ 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`, 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 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. 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 + and verify both generic hardware profiles arrive; rename one and verify the + nickname syncs. +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 +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/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 0357a0d3..0d99e81b 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -27,11 +27,18 @@ 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. 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 @@ -42,14 +49,20 @@ 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 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`). +- **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 + 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)) → 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 @@ -90,6 +105,18 @@ internal shape. `ScriptedLocationSource` in tests/previews; `requestCurrentLocation()` returns `nil`, never throws, and backs `LocationIngestor.captureTodayIfNeeded(now:)`. +- **`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. + 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 a49033d6..e06b85a4 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,7 +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. + 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 + 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 @@ -84,7 +101,23 @@ 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`. 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, + 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 @@ -103,15 +136,31 @@ 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 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 because they are live advisory status. - **`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 +- **`InstallationRecordingContext`** — the device-local installation identity, + 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 / + 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 @@ -154,8 +203,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. @@ -179,9 +229,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 @@ -191,6 +243,17 @@ store so the retry queue can't repopulate it mid-erase. constructing `WhereServices`. - **Always-location.** Background day tracking needs Always; `requestPermission()` throws `LocationPermissionDeniedError` on denial / restriction. +- **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. 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 + 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 db37184e..5400c5a8 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -6,22 +6,25 @@ 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, so an export captures everything and an -/// import can upsert it back row-for-row. +/// their value-type representations, plus installation profiles, nickname events, the +/// 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 /// 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 provenance, immutable installation profiles, nickname changes, archive + /// 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 + /// `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 +43,12 @@ 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] + /// Immutable installation profiles. + public let recordingDeviceProfiles: [RecordingDeviceProfile] + /// Full append-only nickname history. + public let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] + /// 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] @@ -53,6 +62,9 @@ public struct BackupArchive: Codable, Sendable, Hashable { dismissedIssues: [DismissedIssue], trackedRegions: [Region], primaryRegions: [PrimaryRegion], + recordingDeviceProfiles: [RecordingDeviceProfile], + recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], + recordingDeviceRemovals: [RecordingDeviceRemoval], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -63,6 +75,9 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.dismissedIssues = dismissedIssues self.trackedRegions = trackedRegions self.primaryRegions = primaryRegions + self.recordingDeviceProfiles = recordingDeviceProfiles + self.recordingDeviceMetadataChanges = recordingDeviceMetadataChanges + self.recordingDeviceRemovals = recordingDeviceRemovals self.assets = assets } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index fd7b9743..5e5d9546 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -2,27 +2,22 @@ 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 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 /// 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. Local recording consent is not stored in the archive. 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. Local recording consent is + /// untouched, and existing removals are retained so restore cannot reactivate a device. case replace } @@ -33,6 +28,8 @@ public actor BackupCoordinator { public let manualDayCount: Int public let dismissedIssueCount: Int public let trackedRegionCount: Int + public let recordingDeviceCount: Int + public let recordingDeviceRemovalCount: Int public init( sampleCount: Int, @@ -40,24 +37,64 @@ public actor BackupCoordinator { manualDayCount: Int, dismissedIssueCount: Int, trackedRegionCount: Int, + recordingDeviceCount: Int = 0, + recordingDeviceRemovalCount: Int = 0, ) { self.sampleCount = sampleCount self.evidenceCount = evidenceCount self.manualDayCount = manualDayCount self.dismissedIssueCount = dismissedIssueCount self.trackedRegionCount = trackedRegionCount + self.recordingDeviceCount = recordingDeviceCount + self.recordingDeviceRemovalCount = recordingDeviceRemovalCount + } + } + + /// 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 @@ -68,10 +105,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 @@ -108,33 +151,42 @@ 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(), - ) - } - 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(), + recordingDeviceRemovals: store.recordingDeviceRemovals(), + ) + } + 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( @@ -145,7 +197,10 @@ public actor BackupCoordinator { // The bare ids ride alongside the primary regions for older readers. trackedRegions: tables.primaryRegions.map(\.region), primaryRegions: tables.primaryRegions, - blobs: blobs, + recordingDeviceProfiles: tables.recordingDeviceProfiles, + recordingDeviceMetadataChanges: tables.recordingDeviceMetadataChanges, + recordingDeviceRemovals: tables.recordingDeviceRemovals, + blobs: snapshot.blobs, ) }.value onProgress(1) @@ -162,6 +217,14 @@ public actor BackupCoordinator { let manualDays: [DayPresence] let dismissedIssues: [DismissedIssue] let primaryRegions: [PrimaryRegion] + let recordingDeviceProfiles: [RecordingDeviceProfile] + let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] + let recordingDeviceRemovals: [RecordingDeviceRemoval] + } + + private struct ExportSnapshot { + let tables: ExportTables + let blobs: [UUID: Data] } /// Delete the most recent export's staging directory now, rather than @@ -188,10 +251,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 @@ -200,20 +263,129 @@ 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 /// ``performExport(onProgress:)``. 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. @@ -226,75 +398,398 @@ public actor BackupCoordinator { }.value let archive = result.archive let blobs = result.blobs + 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, + recordingDeviceRemovalCount: archive.recordingDeviceRemovals.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.recordingDeviceProfiles.count + + archive.recordingDeviceMetadataChanges.count + + archive.recordingDeviceRemovals.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() + // 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 { + 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 preservedRemovals: [RecordingDeviceRemoval] = if strategy == .replace { + try await store.recordingDeviceRemovals() + } else { + [] + } + if strategy == .replace { + _ = try await store.rotateDataEpoch( + reason: .backupReplace, + changedBy: currentDeviceID, + at: importDate, + ) + } + // `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() + } + for removal in preservedRemovals { + try await store.addRecordingDeviceRemoval(removal) + } + for removal in archive.recordingDeviceRemovals { + try await store.addRecordingDeviceRemoval(removal) + 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 + // 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 item in archive.evidence { - try await store.write(evidence: item, blob: blobs[item.id]) - 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 day in archive.manualDays { - try await store.setManualDay(day) - 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 dismissal in archive.dismissedIssues { - try await store.restoreDismissedIssue(dismissal) - report() + cleanupPending = .committed( + details, + cleanupCompleted: false, + onboardingAcknowledged: details.purpose == .settings, + ) + do { + try await importRecoveryPersistence.save(cleanupPending) + } catch { + importRecoveryPhase = .recoveryRequired(recovery) + throw error } - // 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()) + 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 { - archivePrimary + 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) } - try await store.setPrimaryRegions(regionsToWrite) + } + + 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) } - // 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, - ) + 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 { + let committed = DurableImportRecovery.committed( + details, + cleanupCompleted: false, + onboardingAcknowledged: details.purpose == .settings, + ) + try await importRecoveryPersistence.save(committed) + importRecoveryPhase = .recoveryRequired(committed) + } + 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) + } + } } /// 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 5b80978b..376dd518 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 + /// a negative causal revision or incomplete removal history). + 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,10 +116,16 @@ public struct BackupService: Sendable { dismissedIssues: [DismissedIssue] = [], trackedRegions: [Region] = [], primaryRegions: [PrimaryRegion] = [], + recordingDeviceProfiles: [RecordingDeviceProfile], + recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], + recordingDeviceRemovals: [RecordingDeviceRemoval], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, ) throws -> URL { + try Self.validateRecordingData( + metadataChanges: recordingDeviceMetadataChanges, + ) let fileManager = FileManager.default let workRoot = fileManager.temporaryDirectory .appendingPathComponent("where-backup-\(UUID().uuidString)", isDirectory: true) @@ -116,6 +156,9 @@ public struct BackupService: Sendable { dismissedIssues: dismissedIssues, trackedRegions: trackedRegions, primaryRegions: primaryRegions, + recordingDeviceProfiles: recordingDeviceProfiles, + recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, + recordingDeviceRemovals: recordingDeviceRemovals, assets: assetEntries, ) try Self.logger.measure(.encodeManifest) { @@ -177,29 +220,64 @@ 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, + ) + } + + private static func validateRecordingData( + metadataChanges: [RecordingDeviceMetadataChange], + ) throws { + 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 new file mode 100644 index 00000000..72d7125d --- /dev/null +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -0,0 +1,654 @@ +import Foundation + +/// Owns this installation's local automatic-recording choice, synced device presence, and +/// physical GPS reconciliation. +/// +/// 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 configurationBroadcaster = RecordingConfigurationBroadcaster() + + 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 + private var recordingLifecycleStarted = false + private var shouldResumeAfterPause = false + 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? + + private static let checkInInterval: TimeInterval = 15 * 60 + private static let logger = WhereLog.root(DeviceRecordingControllerLog.self) + + private struct StoreSnapshot { + let epoch: WhereDataEpoch + let profiles: [RecordingDeviceProfile] + let metadataChanges: [RecordingDeviceMetadataChange] + let checkIns: [RecordingDeviceCheckIn] + let removals: [RecordingDeviceRemoval] + let currentDeviceResetBarrier: Date? + } + + init( + store: any WhereStore, + ingestor: LocationIngestor, + installationContext: InstallationRecordingContext, + now: @escaping @Sendable () -> Date, + onPolicyChanged: @escaping @Sendable () async -> Void, + ) { + 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.automaticRecordingEnabled = automaticRecordingEnabled + enabledAt = installationContext.recordingEnabledAt + self.now = now + self.onPolicyChanged = onPolicyChanged + } + + deinit { + observationTask?.cancel() + configurationBroadcaster.finishAll() + } + + public nonisolated func runtimeUpdates() -> AsyncStream { + configurationBroadcaster.subscribe() + } + + public func currentRuntimeUpdate() -> RecordingDeviceRuntimeUpdate? { + latestRuntimeUpdate + } + + /// Observe local commits and CloudKit imports for removal, status, and heartbeat changes. + public func startMonitoringChanges() { + recordingLifecycleStarted = true + guard observationTask == nil else { return } + let updates = store.changes() + observationTask = Task { [weak self] in + for await _ in updates { + guard let self else { break } + await applyObservedChange() + } + } + } + + @discardableResult + public func register( + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + await beginExclusive() + defer { endExclusive() } + try requireActive() + recordingLifecycleStarted = true + return try await registerAndReconcileLocked(authorization: authorization) + } + + @discardableResult + public func registerForOnboarding( + desiredEnabled: Bool?, + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + await beginExclusive() + defer { endExclusive() } + try requireActive() + recordingLifecycleStarted = true + if let desiredEnabled, desiredEnabled != automaticRecordingEnabled { + automaticRecordingEnabled = desiredEnabled + enabledAt = desiredEnabled ? now() : nil + } + return try await registerAndReconcileLocked(authorization: authorization) + } + + @discardableResult + public func reconcile( + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + await beginExclusive() + defer { endExclusive() } + try requireActive() + recordingLifecycleStarted = true + return try await reconcileOrFailClosed(authorization: authorization) + } + + /// Apply a choice already persisted by the installation sidecar. + @discardableResult + public func setAutomaticRecordingEnabled( + _ enabled: Bool, + authorization: LocationAuthorizationStatus, + ) async throws -> RecordingDeviceConfiguration { + await beginExclusive() + defer { endExclusive() } + try requireActive() + if automaticRecordingEnabled != enabled { + automaticRecordingEnabled = enabled + enabledAt = enabled ? now() : nil + } + if !enabled || pendingOffCleanup { + try await discardRetryBacklogForOffChoice() + } + return try await reconcileOrFailClosed(authorization: authorization) + } + + 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. + public func rename( + _ deviceID: RecordingDeviceID, + to nickname: String, + ) 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 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(includeRemoved: false) + } + 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(includeRemoved: false) + } + + /// 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 remove itself.") + await beginExclusive() + defer { endExclusive() } + try requireActive() + let snapshot = try await storeSnapshot() + guard snapshot.profiles.contains(where: { $0.id == deviceID }) else { + throw RecordingPersistenceError.deviceNotFound(deviceID) + } + guard snapshot.removals.contains(where: { $0.deviceID == deviceID }) == false else { + return try await configurationsLocked(includeRemoved: false) + } + 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(includeRemoved: false) + } + + func pause() async throws { + await beginExclusive() + defer { endExclusive() } + guard !isRewritePaused else { + throw RecordingPersistenceError.recordingRewriteInProgress + } + isRewritePaused = true + shouldResumeAfterPause = shouldResumeAfterPause || recordingLifecycleStarted + recordingLifecycleStarted = false + acceptsOperations = false + observationTask?.cancel() + observationTask = nil + await ingestor.pause() + } + + func resumeAfterFailedReset() async { + await beginExclusive() + defer { endExclusive() } + await resumeLocked() + } + + func resumeAfterImportRollback() async { + await beginExclusive() + defer { endExclusive() } + await resumeLocked() + } + + func resumeAfterImport(discardPendingSamples: Bool) async throws { + await beginExclusive() + acceptsOperations = true + let shouldResume = shouldResumeAfterPause + if discardPendingSamples { + do { + try await ingestor.discardRetryBacklog() + } catch { + isRewritePaused = false + acceptsOperations = false + needsReconciliation = true + publishRuntimeState(.unavailable) + endExclusive() + throw error + } + } + shouldResumeAfterPause = false + isRewritePaused = false + guard shouldResume else { + endExclusive() + return + } + startMonitoringChanges() + do { + let authorization = await ingestor.authorizationStatus() + _ = try await registerAndReconcileLocked(authorization: authorization) + } catch RecordingPersistenceError.currentDeviceRemoved { + endExclusive() + return + } catch { + needsReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + Self.logger(attachments: [.error(error, name: "import-recovery-error")]) { + .importRecoveryFailed(description: error.localizedDescription) + } + } + endExclusive() + } + + func finishReset() async throws { + await beginExclusive() + do { + try await ingestor.discardRetryBacklog() + } catch { + isRewritePaused = false + needsReconciliation = true + publishRuntimeState(.unavailable) + endExclusive() + throw error + } + shouldResumeAfterPause = false + isRewritePaused = false + endExclusive() + } + + /// 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 resumeLocked() async { + acceptsOperations = true + isRewritePaused = false + let shouldResume = shouldResumeAfterPause + shouldResumeAfterPause = false + guard shouldResume else { return } + startMonitoringChanges() + do { + let authorization = await ingestor.authorizationStatus() + _ = 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 }) + 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, + ) + 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 { + needsReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + throw error + } + } + + 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) + } + let removal = snapshot.removals + .filter { $0.deviceID == currentDevice.id } + .min { $0.removedAt < $1.removedAt } + + await ingestor.revokeRecordingAuthorization() + if removal != nil { + try await ingestor.discardRetryBacklog() + needsReconciliation = false + publishRuntimeState(.removed) + throw RecordingPersistenceError.currentDeviceRemoved(currentDevice.id) + } + + let epoch = snapshot.epoch + if preparedEpochID != epoch.id { + try await ingestor.discardRetryBacklog() + preparedEpochID = epoch.id + } + + 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 checkInDue = existing.map { + checkInDate.timeIntervalSince($0.lastSeenAt) >= Self.checkInInterval + } ?? true + let checkIn: RecordingDeviceCheckIn + if existing?.status != status || checkInDue { + checkIn = try RecordingDeviceCheckIn( + deviceID: currentDevice.id, + revision: Self.nextRevision(after: existing?.revision, for: currentDevice.id), + lastSeenAt: checkInDate, + status: status, + ) + try await store.perform(expectedDataEpochID: epoch.id) { + try await self.store.setRecordingDeviceCheckIn(checkIn) + } + } else if let existing { + checkIn = existing + } else { + preconditionFailure("A required recording check-in was not created.") + } + + let configuration = RecordingDeviceConfiguration( + device: RecordingDevice( + profile: profile, + nicknameChange: Self.latestMetadata( + for: currentDevice.id, + field: .nickname, + in: snapshot.metadataChanges, + ), + checkIn: checkIn, + removal: nil, + ), + isCurrentDevice: true, + localAutomaticRecordingEnabled: automaticRecordingEnabled, + ) + needsReconciliation = false + publishRuntimeState(.applied(configuration)) + return configuration + } + + private func configurationsLocked( + includeRemoved: Bool, + ) async throws -> [RecordingDeviceConfiguration] { + 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 applyObservedChange() async { + await beginExclusive() + guard acceptsOperations else { + endExclusive() + return + } + do { + let snapshot = try await storeSnapshot() + 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 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() + _ = try await registerAndReconcileLocked(authorization: authorization) + } + } catch RecordingPersistenceError.currentDeviceRemoved { + // `reconcileLocked` already stopped ingestion and published the terminal state. + } catch { + needsReconciliation = true + await ingestor.revokeRecordingAuthorization() + publishRuntimeState(.unavailable) + Self.logger(attachments: [.error(error, name: "policy-observation-error")]) { + .policyObservationFailed(description: error.localizedDescription) + } + } + endExclusive() + } + + 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 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, + ) + } + } + + private func expectedProfile( + registrationEpochID: WhereDataEpochID, + ) -> RecordingDeviceProfile { + RecordingDeviceProfile( + id: currentDevice.id, + systemName: currentDevice.systemName, + kind: currentDevice.kind, + registeredAt: registeredAt, + registrationEpochID: registrationEpochID, + ) + } + + 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) + } + + 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/InstallationRecordingContext.swift b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift new file mode 100644 index 00000000..111d3ec8 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/InstallationRecordingContext.swift @@ -0,0 +1,119 @@ +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 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 + public let recordingChoice: RecordingChoice + /// Whether this identity was created by the explicit rejoin flow. + public let isRejoining: Bool + + public init( + currentDevice: CurrentRecordingDevice, + registeredAt: Date, + recordingChoice: RecordingChoice, + isRejoining: Bool, + ) { + self.currentDevice = currentDevice + self.registeredAt = registeredAt + 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 + } + + /// Return the confirmed form of a newly proposed context. + public func confirmingInitialRecording(isEnabled: Bool) -> InstallationRecordingContext { + precondition( + recordingChoice == .unconfirmed, + "An installation's initial recording choice can only be confirmed once.", + ) + return InstallationRecordingContext( + currentDevice: currentDevice, + registeredAt: registeredAt, + recordingChoice: isEnabled ? .on(enabledAt: registeredAt) : .off, + isRejoining: false, + ) + } + + /// Return a copy carrying a later local Settings choice. + public func settingAutomaticRecordingEnabled(_ isEnabled: Bool, at date: Date) -> Self { + precondition( + 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, + recordingChoice: updatedChoice, + isRejoining: false, + ) + } + + /// 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), + recordingChoice: .on(enabledAt: Date(timeIntervalSinceReferenceDate: 0)), + isRejoining: false, + ) + + /// 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), + recordingChoice: .on(enabledAt: Date(timeIntervalSinceReferenceDate: 0)), + isRejoining: false, + ) +} diff --git a/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift b/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift new file mode 100644 index 00000000..f3a77685 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/InstallationRecordingContextStoring.swift @@ -0,0 +1,61 @@ +/// 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 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. + 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 new file mode 100644 index 00000000..f44fde94 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift @@ -0,0 +1,27 @@ +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] { + try await store.readSnapshot { + async let samples = store.samples(in: interval) + async let removals = store.recordingDeviceRemovals() + let (resolvedSamples, resolvedRemovals) = try await ( + samples, + removals, + ) + return RecordingDeviceRemovalFilter.visibleSamples( + resolvedSamples, + removals: resolvedRemovals, + ) + } + } +} 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 new file mode 100644 index 00000000..3529769e --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDevice.swift @@ -0,0 +1,112 @@ +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 + + /// 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 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 + case recording + case off + case permissionRequired +} + +/// Read model for one device assembled from independently synced records. +/// +/// 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 + public let systemName: String + public let nickname: String? + public let kind: RecordingDeviceKind + public let registeredAt: Date + public let lastSeenAt: Date + public let removedAt: Date? + + public let status: RecordingDeviceStatus + + public init( + id: RecordingDeviceID, + systemName: String, + nickname: String?, + kind: RecordingDeviceKind, + registeredAt: Date, + lastSeenAt: Date, + removedAt: Date?, + status: RecordingDeviceStatus, + ) { + self.id = id + self.systemName = systemName + self.nickname = nickname + self.kind = kind + self.registeredAt = registeredAt + self.lastSeenAt = lastSeenAt + self.removedAt = removedAt + self.status = status + } + + public var displayName: String { + let trimmed = nickname?.trimmingCharacters(in: .whitespacesAndNewlines) + return if let trimmed, !trimmed.isEmpty { trimmed } else { systemName } + } + + init( + profile: RecordingDeviceProfile, + nicknameChange: RecordingDeviceMetadataChange?, + checkIn: RecordingDeviceCheckIn?, + removal: RecordingDeviceRemoval?, + ) { + id = profile.id + systemName = profile.systemName + nickname = nicknameChange?.nickname + kind = profile.kind + registeredAt = profile.registeredAt + lastSeenAt = checkIn?.lastSeenAt ?? profile.registeredAt + removedAt = removal?.removedAt + status = checkIn?.status ?? .unknown + } +} + +/// 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. + @_spi(Testing) + 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/RecordingDeviceCheckIn.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift new file mode 100644 index 00000000..a545c066 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift @@ -0,0 +1,42 @@ +import Foundation + +/// 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 another installation's recording consent, 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 status: RecordingDeviceStatus + + public init( + deviceID: RecordingDeviceID, + revision: Int64, + lastSeenAt: Date, + 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.status = status + } + + static func isOlder(_ lhs: RecordingDeviceCheckIn, than rhs: RecordingDeviceCheckIn) -> Bool { + if lhs.revision != rhs.revision { + return lhs.revision < rhs.revision + } + 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 new file mode 100644 index 00000000..72f39b72 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceConfiguration.swift @@ -0,0 +1,30 @@ +import Foundation + +/// 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 isCurrentDevice: Bool + public let localAutomaticRecordingEnabled: Bool? + + public var id: RecordingDeviceID { + device.id + } + + public var isRemoved: Bool { + device.removedAt != nil + } + + public init( + device: RecordingDevice, + isCurrentDevice: Bool, + localAutomaticRecordingEnabled: Bool?, + ) { + precondition( + isCurrentDevice || localAutomaticRecordingEnabled == nil, + "A remote device cannot expose another installation's local preference.", + ) + self.device = device + self.isCurrentDevice = isCurrentDevice + self.localAutomaticRecordingEnabled = localAutomaticRecordingEnabled + } +} 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/RecordingDeviceMetadataChange.swift b/Where/WhereCore/Sources/Devices/RecordingDeviceMetadataChange.swift new file mode 100644 index 00000000..657ed20e --- /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 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 + 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/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 new file mode 100644 index 00000000..68451074 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingDeviceRuntimeState.swift @@ -0,0 +1,17 @@ +/// Honest physical state of automatic recording on the current installation. +public enum RecordingDeviceRuntimeState: Sendable, Hashable { + /// Local consent, physical monitoring, and the advisory check-in agree. + case applied(RecordingDeviceConfiguration) + /// 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 +} + +/// 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/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 new file mode 100644 index 00000000..8e92574f --- /dev/null +++ b/Where/WhereCore/Sources/Devices/RecordingPersistenceError.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Honest failures from the append-only recording persistence boundary. +public enum RecordingPersistenceError: Error, LocalizedError, Sendable, Hashable { + case incompleteRemovalHistory + case conflictingImmutableRecord(id: UUID) + case deviceNotFound(RecordingDeviceID) + case currentDeviceNotRegistered(RecordingDeviceID) + case currentDeviceRemoved(RecordingDeviceID) + case revisionExhausted(RecordingDeviceID) + case incompleteDataEpochHistory + case dataEpochRevisionExhausted + case dataEpochChanged + case recordingRewriteInProgress + + public var errorDescription: String? { + switch self { + case .incompleteRemovalHistory: + String(localized: .recordingErrorIncompletePolicyHistory) + case .conflictingImmutableRecord: + String(localized: .recordingErrorConflictingImmutableRecord) + case .deviceNotFound: + String(localized: .recordingErrorDeviceNotFound) + case .currentDeviceNotRegistered: + String(localized: .recordingErrorCurrentDeviceNotRegistered) + case .currentDeviceRemoved: + String(localized: .recordingErrorCurrentDevicePolicyUnknown) + 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/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index dfd63c04..a1ad11f8 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) } @@ -218,8 +244,25 @@ 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.clearAll() } + try await store.perform { + let deviceIDs = try await Set(store.recordingDeviceProfiles().map(\.id)) + .union([currentDeviceID]) + _ = try await store.rotateDataEpoch( + reason: .accountReset, + changedBy: currentDeviceID, + at: resetAt, + ) + for deviceID in deviceIDs { + try await store.addRecordingDeviceRemoval(RecordingDeviceRemoval( + id: UUID(), + deviceID: deviceID, + removedAt: resetAt, + removedByDeviceID: currentDeviceID, + )) + } + } } await reconcileAfterDayChange() Self.logger { .erasedAllData } @@ -228,7 +271,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 +291,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 +302,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 e0e2e768..262d044e 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -29,11 +29,13 @@ 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 /// 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? @@ -42,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? @@ -63,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 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? + + /// 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 @@ -89,6 +109,7 @@ public actor LocationIngestor { init( store: any WhereStore, locationSource: any LocationSource, + recordingDeviceID: RecordingDeviceID, calendar: Calendar, outbox: any LocationOutbox = NoOpLocationOutbox(), retryQueueCapacity: Int = 1000, @@ -97,6 +118,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 @@ -118,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 + 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 + try 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, @@ -146,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 device identity 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 @@ -153,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 @@ -169,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 } } @@ -237,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() @@ -262,8 +364,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. @@ -274,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 + // 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 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) @@ -305,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) @@ -320,13 +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 { - let drainedDays = await drainRetryQueue() + guard let dataEpochID = authorizedDataEpochID else { return } + let sample = sample.recorded(by: recordingDeviceID) 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)) { @@ -336,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 @@ -347,34 +468,61 @@ public actor LocationIngestor { description: error.localizedDescription, ) } - enqueueForRetry(sample) - await outbox.save(retryQueue) + enqueueForRetry(LocationOutboxEntry(sample: sample, dataEpochID: dataEpochID)) + 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) + } } } - 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( @@ -382,35 +530,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) + try 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..270d40e6 100644 --- a/Where/WhereCore/Sources/Location/LocationOutbox.swift +++ b/Where/WhereCore/Sources/Location/LocationOutbox.swift @@ -1,6 +1,20 @@ import Foundation +import JournalKit 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 @@ -8,14 +22,18 @@ 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). +/// 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 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 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 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 } /// A no-op outbox: nothing is persisted, so the retry queue is in-memory only @@ -23,24 +41,72 @@ 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 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) 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 + directoryURL = fileURL.deletingLastPathComponent() + 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 +124,368 @@ 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] { do { - return try JSONDecoder().decode([LocationSample].self, from: data) + 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 { - // 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: "read-error")]) { - .droppedUnreadableBacklog(description: error.localizedDescription) + .readBacklogFailed(description: error.localizedDescription) } - return [] + throw error } } - public func save(_ samples: [LocationSample]) async { - guard !samples.isEmpty else { - try? FileManager.default.removeItem(at: fileURL) + public func save(_ entries: [LocationOutboxEntry]) async throws { + guard !entries.isEmpty else { + try await clear() return } do { - let data = try JSONEncoder().encode(samples) - try data.write(to: fileURL, options: .atomic) + let data = try JSONEncoder().encode(entries) + try openJournal().append(data, sync: .processDeath) } catch { Self.logger(attachments: [.error(error, name: "persist-error")]) { .persistBacklogFailed(description: error.localizedDescription) } + throw error + } + } + + public func clear() async throws { + do { + 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 { + Self.logger(attachments: [.error(error, name: "clear-error")]) { + .persistBacklogFailed(description: error.localizedDescription) + } + throw error + } + } + + 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 + } + do { + try excludeFromBackup(directoryURL) + } catch { + Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + .excludeFromBackupFailed(description: error.localizedDescription) + } + journal?.close() + journal = nil + Self.discardInsecureDirectory(at: directoryURL) + throw error + } + } + + /// 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") + 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 + /// 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 { + return + } + let pendingURL = fileURL.appendingPathExtension("pending") + + do { + try excludeFromBackup(directoryURL) + } catch { + logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + .excludeFromBackupFailed(description: error.localizedDescription) + } + discardInsecureDirectory(at: directoryURL) + return + } + + func secureExistingFile(at url: URL) -> Bool { + guard fileManager.fileExists(atPath: url.path(percentEncoded: false)) else { + return false + } + do { + try excludeFromBackup(url) + return true + } catch { + 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 { + // 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 + } + do { + _ = try decodeEntries(from: pendingData) + } catch { + 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 { + logger(attachments: [.error(error, name: "pending-promotion-error")]) { + .persistBacklogFailed(description: error.localizedDescription) + } + return + } + + do { + try excludeFromBackup(fileURL) + } catch { + logger(attachments: [.error(error, name: "backup-exclusion-error")]) { + .excludeFromBackupFailed(description: error.localizedDescription) + } + discardInsecureFile(at: fileURL) + discardInsecureFile(at: pendingURL) + } + } + + /// 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, + 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) + } + 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) + } + } + } + + 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 legacy-file reader for testing transient migration 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, + ) + } + + /// 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/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/Logging/DeviceRecordingControllerLog.swift b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift new file mode 100644 index 00000000..4ab5c251 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift @@ -0,0 +1,25 @@ +import PeriscopeCore + +/// Structured failures from local recording and synced-removal 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 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): + "Backup committed, but recording could not be restored and was stopped: \(description)" + } + } +} 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 c5aff773..9c0a48a0 100644 --- a/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift +++ b/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift @@ -2,18 +2,27 @@ 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 recoveredTornJournal 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 .noApplicationSupport, .recoveredTornJournal: .warning + case .droppedUnreadableBacklog, + .readBacklogFailed, + .persistBacklogFailed, + .excludeFromBackupFailed, + .discardInsecureBacklogFailed: .error } } @@ -23,8 +32,16 @@ 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 .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): + "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/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/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 10704dd9..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,9 +215,10 @@ final class PersistentStoreRemoteChangeSource: StoreRemoteChangeSource, @uncheck @unchecked Sendable { let remoteChanges: AsyncStream + private let continuation: AsyncStream.Continuation - init() { + public init() { let (stream, continuation) = AsyncStream.makeStream( of: Void.self, bufferingPolicy: .bufferingNewest(1), @@ -105,11 +229,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 90939052..0a54596e 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,19 +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, + SDRecordingDeviceProfile.self, + SDRecordingDeviceMetadataChange.self, + SDRecordingDeviceCheckIn.self, + SDRecordingDeviceRemoval.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 @@ -278,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 @@ -298,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 @@ -321,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. @@ -359,12 +378,139 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public func perform( _ block: @Sendable () async throws -> T, ) async throws -> T { + 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 + /// 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( + profiles: [RecordingDeviceProfile], + metadataChanges: [RecordingDeviceMetadataChange], + checkIns: [RecordingDeviceCheckIn], + removals: [RecordingDeviceRemoval], + ) async throws { + 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 removal in removals { + try await self.addRecordingDeviceRemoval(removal) + } + } + } + + /// 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 @@ -372,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 @@ -397,10 +549,27 @@ 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. - 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 } } @@ -424,23 +593,284 @@ 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 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, + 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) + } } 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( @@ -459,6 +889,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 @@ -468,33 +899,294 @@ 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 } } + // MARK: - Recording devices + + public func recordingDevices() async throws -> [RecordingDevice] { + async let profiles = recordingDeviceProfiles() + async let metadataChanges = recordingDeviceMetadataChanges() + async let checkIns = recordingDeviceCheckIns() + async let removals = recordingDeviceRemovals() + let (resolvedProfiles, resolvedMetadata, resolvedCheckIns, resolvedRemovals) = try await ( + profiles, + metadataChanges, + checkIns, + removals, + ) + 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 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], + removal: removalsByDevice[$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(\.registeredAt)], + ) + descriptor.includePendingChanges = true + 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. 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 { 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 { $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 addRecordingDeviceMetadataChange( + _ change: RecordingDeviceMetadataChange, + ) 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(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 { + 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 recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] { + let context = readContext() + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.removedAt), SortDescriptor(\.id)], + ) + descriptor.includePendingChanges = true + let records = try context.fetch(descriptor) + let values = try records.map { record in + guard let value = record.toValue() else { + Self.logFault(forCorrupt: record) + throw RecordingPersistenceError.incompleteRemovalHistory + } + return 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 + } + .sorted { + $0.removedAt == $1.removedAt + ? $0.id.uuidString < $1.id.uuidString + : $0.removedAt < $1.removedAt + } + } + + 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 }), + ) + guard existing.isEmpty == false else { + context.insert(SDRecordingDeviceRemoval(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() 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( @@ -510,6 +1202,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 @@ -519,9 +1212,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 @@ -530,18 +1225,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 } @@ -551,20 +1252,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)) } } @@ -591,15 +1303,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 @@ -617,6 +1333,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 @@ -626,9 +1343,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 @@ -640,6 +1359,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( @@ -651,7 +1371,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( @@ -663,7 +1383,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 @@ -677,35 +1397,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) - } - } - 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 @@ -715,9 +1418,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 @@ -726,12 +1431,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) @@ -741,12 +1448,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, + )) } } @@ -754,9 +1468,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. @@ -784,10 +1502,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. @@ -797,7 +1517,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 @@ -816,9 +1536,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. @@ -861,10 +1583,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) } @@ -875,7 +1600,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() { @@ -883,7 +1608,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) @@ -893,12 +1618,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? @@ -913,15 +1730,19 @@ 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() {} - 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 @@ -930,6 +1751,7 @@ final class SDLocationSample { sourceRaw = value.source.discriminator evidenceId = value.source.evidenceId evidenceKindRaw = value.source.evidenceKind?.discriminator + recordingDeviceID = value.recordingDeviceID?.rawValue } func toValue() -> LocationSample? { @@ -947,12 +1769,14 @@ final class SDLocationSample { coordinate: Coordinate(latitude: latitude, longitude: longitude), horizontalAccuracy: horizontalAccuracy, source: source, + recordingDeviceID: recordingDeviceID.map(RecordingDeviceID.init(rawValue:)), ) } } @Model final class SDEvidence { + var epochID: UUID? var id: UUID? /// `EvidenceKind.discriminator` ("planeTicket", "other", etc.). var kindRaw: String? @@ -975,12 +1799,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 } @@ -1012,6 +1837,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 @@ -1037,12 +1863,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 @@ -1100,6 +1927,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. @@ -1108,7 +1936,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 } @@ -1135,6 +1964,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? @@ -1143,7 +1973,8 @@ final class SDTrackedRegion { init() {} - init(regionID: String) { + init(regionID: String, epochID: WhereDataEpochID) { + self.epochID = epochID.rawValue self.regionID = regionID } @@ -1164,3 +1995,164 @@ final class SDTrackedRegion { orderIndex = order } } + +/// 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 SDRecordingDeviceProfile { + var id: UUID? + var systemName: String? + var kindRaw: String? + var registeredAt: Date? + var registrationEpochID: UUID? + + init() {} + + convenience init(value: RecordingDeviceProfile) { + self.init() + id = value.id.rawValue + systemName = value.systemName + kindRaw = value.kind.rawValue + registeredAt = value.registeredAt + registrationEpochID = value.registrationEpochID.rawValue + } + + func toValue() -> RecordingDeviceProfile? { + guard let id, + let systemName, + let kindRaw, + let kind = RecordingDeviceKind(rawValue: kindRaw), + let registeredAt, + let registrationEpochID + else { return nil } + return RecordingDeviceProfile( + id: RecordingDeviceID(rawValue: id), + systemName: systemName, + 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 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 { + var epochID: UUID? + var deviceID: UUID? + var revision: Int64? + var lastSeenAt: Date? + 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 + statusRaw = value.status.rawValue + } + + func toValue() -> RecordingDeviceCheckIn? { + guard let deviceID, + let revision, + revision >= 0, + let lastSeenAt, + let statusRaw, + let status = RecordingDeviceStatus(rawValue: statusRaw), + status != .unknown + else { return nil } + return RecordingDeviceCheckIn( + deviceID: RecordingDeviceID(rawValue: deviceID), + revision: revision, + lastSeenAt: lastSeenAt, + status: status, + ) + } +} + +/// Irreversible installation removal tombstone. +@Model +final class SDRecordingDeviceRemoval { + var epochID: UUID? + var id: UUID? + var deviceID: UUID? + var removedAt: Date? + var removedByDeviceID: UUID? + + init() {} + + convenience init(value: RecordingDeviceRemoval, epochID: WhereDataEpochID) { + self.init() + self.epochID = epochID.rawValue + id = value.id + deviceID = value.deviceID.rawValue + removedAt = value.removedAt + removedByDeviceID = value.removedByDeviceID.rawValue + } + + func toValue() -> RecordingDeviceRemoval? { + guard let id, let deviceID, let removedAt, let removedByDeviceID else { return nil } + return RecordingDeviceRemoval( + id: id, + deviceID: RecordingDeviceID(rawValue: deviceID), + removedAt: removedAt, + removedByDeviceID: RecordingDeviceID(rawValue: removedByDeviceID), + ) + } +} diff --git a/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift b/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift new file mode 100644 index 00000000..4185b647 --- /dev/null +++ b/Where/WhereCore/Sources/Persistence/WhereDataEpoch.swift @@ -0,0 +1,320 @@ +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 + } + + /// 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. + 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 f7f1300c..32b9e8f9 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:)`, 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 /// production `SwiftDataStore` implementation traps with a @@ -28,6 +28,25 @@ 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 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( + _ 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,10 +59,86 @@ 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 + + /// 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 + /// identified, but its old 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 assembled synced device read model, including removed devices. + func recordingDevices() async throws -> [RecordingDevice] + + /// 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. + 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 + + /// Irreversible device tombstones in the active epoch. + func recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] + + /// 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] /// Every evidence record in the store, regardless of `capturedAt`. Used @@ -75,10 +170,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 @@ -128,6 +219,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 ddaf769b..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,13 +29,6 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.hasOnboarded.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 { @@ -100,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() { @@ -115,7 +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 wantsTracking = "where.wantsBackgroundTracking" case remindersEnabled = "where.remindersEnabled" case reminderHour = "where.reminderHour" case reminderMinute = "where.reminderMinute" 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/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 151fd5cb..afe3caca 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 { @@ -33,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 store.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, + ) + } } } @@ -53,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 store.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), + ) + } } } @@ -92,7 +101,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 +117,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 +128,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/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 144b64dd..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,22 +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(), - 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 @@ -49,10 +36,12 @@ extension WhereServices { try await make( store: store, locationSource: IdleLocationSource(), + 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 c7f6203c..67ab5e6c 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 @@ -29,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. @@ -65,6 +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 + /// 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 @@ -81,6 +91,7 @@ public struct WhereServices: Sendable { public init( store: any WhereStore, locationSource: any LocationSource, + installationContext: InstallationRecordingContext = .testing, attributor: any RegionAttributing = RegionAttributor.shared, aggregator: DayAggregator = DayAggregator(), reminderScheduler: any LoggingReminderScheduling = NoopLoggingReminderScheduler(), @@ -88,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 @@ -140,6 +153,22 @@ public struct WhereServices: Sendable { calendar: aggregator.calendar, now: now, ) + let liveAttribution = attributor as? RegionAttribution + let reconcileAllDerivedData: @Sendable () async -> Void = { + // 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 + // 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 @@ -148,6 +177,7 @@ public struct WhereServices: Sendable { let ingestor = LocationIngestor( store: store, locationSource: locationSource, + recordingDeviceID: currentDevice.id, calendar: aggregator.calendar, outbox: locationOutbox, onPersisted: { outcome in @@ -172,6 +202,17 @@ public struct WhereServices: Sendable { } }, ) + let recording = DeviceRecordingController( + store: store, + ingestor: ingestor, + 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, aggregator: aggregator, @@ -179,13 +220,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, @@ -203,6 +269,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 @@ -215,6 +282,8 @@ public struct WhereServices: Sendable { self.issueAlertScheduler = issueAlertScheduler self.widgetRefresher = widgetRefresher self.now = now + self.installationContext = installationContext + self.remoteDataChangeReconciler = remoteDataChangeReconciler } /// Assemble services whose attributor is derived from the store's **tracked @@ -230,18 +299,21 @@ public struct WhereServices: Sendable { public static func make( store: any WhereStore, locationSource: any LocationSource, + 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)), @@ -250,6 +322,7 @@ public struct WhereServices: Sendable { return WhereServices( store: store, locationSource: locationSource, + installationContext: installationContext, attributor: attribution, aggregator: aggregator, reminderScheduler: reminderScheduler, @@ -257,6 +330,7 @@ public struct WhereServices: Sendable { issueAlertScheduler: issueAlertScheduler, widgetRefresher: widgetRefresher, locationOutbox: locationOutbox, + importRecoveryPersistence: importRecoveryPersistence, activitySummaryGenerator: activitySummaryGenerator, now: now, ) @@ -292,32 +366,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 ingestor.quiesce() - try await journal.eraseAllData() - // `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. + try await recording.pause() + do { + try await journal.eraseAllData() + } catch { + await recording.resumeAfterFailedReset() + throw error + } + // 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 6879639f..d5586567 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, @@ -83,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 store.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 dbd2132e..9df6da4c 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( @@ -46,9 +89,12 @@ 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")!, + ) - /// 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,10 +105,37 @@ struct BackupCoordinatorTests { regions: [.newYork], )) try await store.restoreDismissedIssue(dismissal) + try await store.addRecordingDeviceProfile(RecordingDeviceProfile( + id: recordingDeviceID, + systemName: "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, + status: .recording, + )) + try await store.addRecordingDeviceRemoval(RecordingDeviceRemoval( + id: UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")!, + deviceID: recordingDeviceID, + removedAt: dismissal.dismissedAt, + removedByDeviceID: recordingDeviceID, + )) } } - @Test func exportThenMergeImportReproducesEveryTable() async throws { + @Test func exportThenMergeImportReproducesRestorableTables() async throws { let source = try Self.makeHarness() try await Self.seed(source.store) @@ -76,6 +149,8 @@ struct BackupCoordinatorTests { #expect(summary.evidenceCount == 1) #expect(summary.manualDayCount == 1) #expect(summary.dismissedIssueCount == 1) + #expect(summary.recordingDeviceCount == 1) + #expect(summary.recordingDeviceRemovalCount == 1) #expect(try await destination.store.allSamples() == source.store.allSamples()) #expect(try await destination.store.allEvidence() == source.store.allEvidence()) @@ -84,9 +159,18 @@ struct BackupCoordinatorTests { #expect(try await destination.store.allDismissedIssues() == source.store .allDismissedIssues()) #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) + #expect(try await destination.store.recordingDeviceProfiles() == source.store + .recordingDeviceProfiles()) + #expect(try await destination.store.recordingDeviceMetadataChanges() == source.store + .recordingDeviceMetadataChanges()) + // 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) + #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-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 mergeImportKeepsPreexistingRows() async throws { @@ -113,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( @@ -121,11 +206,17 @@ 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), )) + 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) @@ -135,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 { @@ -254,23 +349,184 @@ 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: [], + recordingDeviceRemovals: [], + 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 a0757de4..6ed52103 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -1,17 +1,18 @@ 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")! 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 func sampleFixtures() -> [LocationSample] { [ @@ -21,6 +22,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 +34,61 @@ struct BackupServiceTests { ] } + private static let recordingMetadataID = + UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")! + + private static func recordingDeviceProfileFixtures() -> [RecordingDeviceProfile] { + [ + RecordingDeviceProfile( + id: recordingDeviceID, + systemName: "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, + status: .recording, + ), + ] + } + + private static func archive() -> BackupArchive { + BackupArchive( + exportedAt: exportDate, + samples: [], + evidence: [], + manualDays: [], + dismissedIssues: [], + trackedRegions: [], + primaryRegions: [], + recordingDeviceProfiles: recordingDeviceProfileFixtures(), + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], + assets: [], + ) + } + private static func evidenceFixtures() -> [Evidence] { [ Evidence( @@ -84,12 +141,23 @@ struct BackupServiceTests { let blobs: [UUID: Data] = [Self.evidenceWithBlobId: Data("boarding-pass-pdf".utf8)] let dismissedIssues = Self.dismissedIssueFixtures() + let recordingDeviceProfiles = Self.recordingDeviceProfileFixtures() + let recordingDeviceMetadataChanges = Self.recordingDeviceMetadataFixtures() + let deviceArchive = RecordingDeviceRemoval( + id: UUID(), + deviceID: Self.recordingDeviceID, + removedAt: Self.exportDate, + removedByDeviceID: Self.recordingDeviceID, + ) let url = try service.makeArchiveFile( samples: samples, evidence: evidence, manualDays: manualDays, dismissedIssues: dismissedIssues, + recordingDeviceProfiles: recordingDeviceProfiles, + recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, + recordingDeviceRemovals: [deviceArchive], blobs: blobs, exportedAt: Self.exportDate, ) @@ -107,17 +175,127 @@ struct BackupServiceTests { #expect(result.archive.manualDays == manualDays) // Dismissals round-trip verbatim, id and timestamp. #expect(result.archive.dismissedIssues == dismissedIssues) + #expect(result.archive.recordingDeviceProfiles == recordingDeviceProfiles) + #expect(result.archive.recordingDeviceMetadataChanges == recordingDeviceMetadataChanges) + #expect(result.archive.recordingDeviceRemovals == [deviceArchive]) + let encodedManifest = try #require(String( + data: BackupService.makeEncoder().encode(result.archive), + encoding: .utf8, + )) + #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 decoderAcceptsLegacyWholeSecondISO8601Dates() throws { + let archive = BackupArchive( + exportedAt: Self.exportDate, + samples: [], + evidence: [], + manualDays: [], + dismissedIssues: [], + trackedRegions: [], + primaryRegions: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], + 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(), + ) + 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 decoderRejectsANegativeMetadataRevisionFromABackup() throws { + let archive = BackupArchive( + exportedAt: Self.exportDate, + samples: [], + evidence: [], + manualDays: [], + dismissedIssues: [], + trackedRegions: [], + primaryRegions: [], + recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), + recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), + recordingDeviceRemovals: [], + 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") + do { + _ = 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)") + } + } + @Test func archiveNameIsDateAndTimeStamped() throws { let service = BackupService() let url = try service.makeArchiveFile( samples: [], evidence: [], manualDays: [], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -146,6 +324,9 @@ struct BackupServiceTests { samples: [], evidence: [], manualDays: manualDays, + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -164,6 +345,9 @@ struct BackupServiceTests { evidence: [], manualDays: [], trackedRegions: [.california, texas], + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -194,6 +378,9 @@ struct BackupServiceTests { manualDays: [], trackedRegions: primary.map(\.region), primaryRegions: primary, + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -228,6 +415,9 @@ struct BackupServiceTests { samples: [], evidence: [], manualDays: manualDays, + recordingDeviceProfiles: [], + recordingDeviceMetadataChanges: [], + recordingDeviceRemovals: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -258,22 +448,20 @@ struct BackupServiceTests { ), PrimaryRegion(region: .newYork, appearance: nil, order: 1), ], + recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), + recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), + recordingDeviceRemovals: [], assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, filename: "assets/\(Self.evidenceWithBlobId.uuidString)", )], ) - 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 == 2) + #expect(decoded.formatVersion == BackupArchive.currentFormatVersion) } @Test func readingAFileThatIsNotAZipThrows() throws { @@ -287,4 +475,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 new file mode 100644 index 00000000..2411f556 --- /dev/null +++ b/Where/WhereCore/Tests/DeviceRecordingControllerTests.swift @@ -0,0 +1,192 @@ +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, + outbox: any LocationOutbox = NoOpLocationOutbox(), + ) throws + -> (DeviceRecordingController, SwiftDataStore, LocationIngestor, ScriptedLocationSource) + { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: authorization) + let ingestor = LocationIngestor( + store: store, + locationSource: source, + recordingDeviceID: InstallationRecordingContext.testing.currentDevice.id, + calendar: WhereCoreTestSupport.calendar(), + outbox: outbox, + retryQueueCapacity: 1000, + onPersisted: { _ in }, + ) + let registeredAt = Self.now.addingTimeInterval(-100) + let context = InstallationRecordingContext( + currentDevice: InstallationRecordingContext.testing.currentDevice, + registeredAt: registeredAt, + recordingChoice: enabled ? .on(enabledAt: enabledAt ?? registeredAt) : .off, + isRejoining: false, + ) + return ( + DeviceRecordingController( + store: store, + ingestor: ingestor, + installationContext: context, + now: { Self.now }, + onPolicyChanged: {}, + ), + store, + ingestor, + source, + ) + } + + @Test func registrationAppliesLocalChoiceAndWritesAdvisoryStatus() async throws { + let (controller, store, ingestor, _) = try makeController(enabled: true) + + let configuration = try await controller.register(authorization: .always) + + #expect(configuration.localAutomaticRecordingEnabled == true) + #expect(configuration.device.status == .recording) + #expect(await ingestor.isActive) + #expect(try await store.recordingDeviceProfiles().count == 1) + #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) + _ = try await controller.register(authorization: .always) + + let off = try await controller.setAutomaticRecordingEnabled( + false, + authorization: .always, + ) + #expect(off.localAutomaticRecordingEnabled == false) + #expect(await ingestor.isActive == false) + + let on = try await controller.setAutomaticRecordingEnabled( + true, + authorization: .always, + ) + #expect(on.localAutomaticRecordingEnabled == true) + #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) + let deviceID = controller.currentDevice.id + try await store.perform { + try await store.addRecordingDeviceRemoval(RecordingDeviceRemoval( + id: UUID(), + deviceID: deviceID, + removedAt: Self.now, + removedByDeviceID: RecordingDeviceID(rawValue: UUID()), + )) + } + + await #expect(throws: RecordingPersistenceError.self) { + try await controller.reconcile(authorization: .always) + } + + #expect(await ingestor.isActive == false) + #expect(await controller.currentRuntimeUpdate()?.state == .removed) + } + + @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 devices = try await controller.devices() + + #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/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..72f2d845 --- /dev/null +++ b/Where/WhereCore/Tests/InstallationRecordingContextTests.swift @@ -0,0 +1,41 @@ +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 confirmationAndLaterSettingsChangePreserveIdentity() { + let proposed = context(kind: .tablet) + let confirmed = proposed.confirmingInitialRecording(isEnabled: false) + 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 { + InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: kind == .tablet ? "iPad" : "iPhone", + kind: kind, + ), + registeredAt: Self.registeredAt, + recordingChoice: .unconfirmed, + isRejoining: false, + ) + } + + private static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) +} diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index cf4afc36..fefa2a40 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -6,6 +6,11 @@ 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 + case save + } + private actor OutcomeRecorder { private(set) var outcomes: [LocationIngestor.IngestOutcome] = [] @@ -26,18 +31,36 @@ 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 + private let failsToSave: Bool + + 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 + } - init(_ contents: [LocationSample] = []) { - self.contents = contents + func save(_ entries: [LocationOutboxEntry]) async throws { + if failsToSave { throw OutboxFailure.save } + self.entries = entries } - func load() async -> [LocationSample] { - contents + func clear() async throws { + if failsToClear { throw OutboxFailure.clear } + entries = [] } - func save(_ samples: [LocationSample]) async { - contents = samples + var contents: [LocationSample] { + entries.map(\.sample) } } @@ -51,6 +74,7 @@ struct LocationIngestorTests { LocationIngestor( store: store, locationSource: source, + recordingDeviceID: CurrentRecordingDevice.preview.id, calendar: WhereCoreTestSupport.calendar(), outbox: outbox, retryQueueCapacity: retryQueueCapacity, @@ -64,7 +88,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) @@ -105,6 +129,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. @@ -115,7 +140,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 { @@ -127,6 +154,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")) @@ -153,6 +181,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")) @@ -166,6 +195,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")) @@ -181,10 +211,12 @@ struct LocationIngestorTests { let ingestor = LocationIngestor( store: store, locationSource: source, + recordingDeviceID: CurrentRecordingDevice.preview.id, calendar: WhereCoreTestSupport.calendar(), 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. @@ -202,12 +234,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"), @@ -221,13 +336,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")) @@ -250,7 +390,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")) @@ -258,24 +398,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 @@ -296,7 +468,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")) @@ -307,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) @@ -321,7 +514,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 } @@ -336,13 +529,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() + try 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) @@ -354,7 +640,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")) @@ -363,11 +649,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) @@ -391,7 +727,7 @@ struct LocationIngestorTests { coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), horizontalAccuracy: 0, source: .gpsSignificantChange, - )) + ), dataEpochID: .initial) } #expect(await ingestor.retryQueueDepth == 20) @@ -401,7 +737,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) @@ -454,6 +790,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 @@ -473,11 +810,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 } @@ -485,6 +829,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 @@ -495,13 +840,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 the local recording gate. +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 @@ -511,14 +933,68 @@ 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 recordingDeviceResetBarrier( + for registrationEpochID: WhereDataEpochID, + ) async throws -> Date? { + try await backing.recordingDeviceResetBarrier(for: registrationEpochID) + } + + 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) @@ -532,6 +1008,42 @@ private actor ToggleFailingStore: WhereStore { try await backing.allSamples() } + func recordingDevices() async throws -> [RecordingDevice] { + try await backing.recordingDevices() + } + + 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 recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] { + try await backing.recordingDeviceRemovals() + } + + func addRecordingDeviceRemoval(_ archive: RecordingDeviceRemoval) async throws { + try await backing.addRecordingDeviceRemoval(archive) + } + func write(evidence: Evidence, blob: Data?) async throws { try await backing.write(evidence: evidence, blob: blob) } @@ -571,10 +1083,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..0d3d0a3e 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,274 @@ 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) + try 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", + ))), + ) + + try await outbox.save([entry]) + + #expect(try await outbox.load() == [entry]) } - @Test func savingEmptyClearsThePersistedBacklog() async { + @Test func persistedBacklogIsExcludedFromDeviceBackup() async throws { let url = tempURL() - defer { try? FileManager.default.removeItem(at: url) } + defer { cleanup(url) } let outbox = FileLocationOutbox(fileURL: url) - await outbox.save([sample("2026-03-15T12:00:00Z")]) - await outbox.save([]) + try await outbox.save(entries([sample("2026-03-15T12:00:00Z")])) + + let values = try url.deletingLastPathComponent() + .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 corruptPendingBacklogIsDroppedWithoutReplacingThePreviousCopy() 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 Data("not valid json".utf8).write(to: pendingURL, options: .atomic) + + let outbox = FileLocationOutbox(fileURL: url) - #expect(await outbox.load().isEmpty) + #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(try FileManager.default.contentsOfDirectory(atPath: url + .deletingLastPathComponent().path) + .contains { $0.hasSuffix(".journalsegment") }) + #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) + + 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.deletingLastPathComponent().path)) + } + + @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 { + @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")] + try write(samples, to: url) + 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) + } + + @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/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/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/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/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/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 6572fd3d..29ca5f85 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.addRecordingDeviceRemoval(RecordingDeviceRemoval( + id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, + deviceID: deviceID, + removedAt: WhereCoreTestSupport.iso("2026-01-11T00:00:00-08:00"), + removedByDeviceID: deviceID, + )) + } + + 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/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 641c3eaf..c0a68e18 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,6 +149,58 @@ struct SwiftDataStoreTests { #expect(await !firstPing(stream, within: .milliseconds(200))) } + @Test func recordingDeviceRowsRoundTripWithoutDuplicateLogicalRows() async throws { + let store = try SwiftDataStore.inMemory() + let deviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + let date = Date(timeIntervalSinceReferenceDate: 100) + let profile = RecordingDeviceProfile( + id: deviceID, + systemName: "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, + status: .off, + ) + + try await store.perform { + 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) + } + + #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, + removedAt: nil, + status: .off, + )]) + } + /// 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. @@ -128,6 +218,838 @@ struct SwiftDataStoreTests { #expect(await firstPing(stream, within: .seconds(2))) } + @Test func unreadableRemovalFailsClosed() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + 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.incompleteRemovalHistory) { + try await store.recordingDeviceRemovals() + } + } + + @Test func identicalRemovalRowsCanonicalizeAndConflictsFailClosed() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + let removal = RecordingDeviceRemoval( + id: UUID(), + deviceID: RecordingDeviceID(rawValue: UUID()), + removedAt: Date(timeIntervalSinceReferenceDate: 100), + removedByDeviceID: RecordingDeviceID(rawValue: UUID()), + ) + context.insert(SDRecordingDeviceRemoval(value: removal, epochID: .initial)) + context.insert(SDRecordingDeviceRemoval(value: removal, epochID: .initial)) + try context.save() + let store = SwiftDataStore(modelContainer: container) + #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, + ) + conflictContext.insert(SDRecordingDeviceRemoval(value: conflicting, epochID: .initial)) + try conflictContext.save() + + await #expect(throws: RecordingPersistenceError + .conflictingImmutableRecord(id: removal.id)) + { + try await store.recordingDeviceRemovals() + } + } + + @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) + let localWriteStream = store.changes() + let deviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + 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, + status: .recording, + ) + + try await store.simulateRemoteRecordingImport( + profiles: [profile], + metadataChanges: [metadata], + checkIns: [checkIn], + removals: [], + ) + + // 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]) + let device = try #require(try await store.recordingDevices().first) + #expect(device.nickname == "Travel iPad") + #expect(device.status == .recording) + } + + @Test func newerCheckInRevisionWinsEvenWhenItsWallClockMovedBackward() async throws { + let store = try SwiftDataStore.inMemory() + let deviceID = try RecordingDeviceID( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + let first = RecordingDeviceCheckIn( + deviceID: deviceID, + revision: 0, + lastSeenAt: Date(timeIntervalSinceReferenceDate: 200), + status: .recording, + ) + let causallyLater = RecordingDeviceCheckIn( + deviceID: deviceID, + revision: 1, + lastSeenAt: Date(timeIntervalSinceReferenceDate: 100), + 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.statusRaw = RecordingDeviceStatus.off.rawValue + + context.insert(negativeMetadata) + context.insert(combinedMetadata) + context.insert(checkIn) + try context.save() + let store = SwiftDataStore(modelContainer: container) + + #expect(try await store.recordingDeviceMetadataChanges().isEmpty) + #expect(try await store.recordingDeviceCheckIns().isEmpty) + } + + @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( + 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 + + #expect(epochRow.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 + + #expect(try #require(epochRow.toValue()).parentIDs == [.initial]) + } + + @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 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, + status: .recording, + ) + + 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)) + 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) + } + + @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) + } + + /// 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( + rawValue: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + 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) + 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 removal = RecordingDeviceRemoval( + id: removalID, + deviceID: deviceID, + removedAt: date, + removedByDeviceID: deviceID, + ) + 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(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.addRecordingDeviceRemoval(removal) + } + + 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 removalRows = try inspectionContext.fetch( + FetchDescriptor(predicate: #Predicate { + $0.id == removalID + }), + ) + 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(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]) + } + + @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 @@ -164,6 +1086,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) @@ -263,6 +1203,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 @@ -282,6 +1243,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..3ad6a852 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 throws { + 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..905a7a6a --- /dev/null +++ b/Where/WhereCore/Tests/WhereDataEpochTests.swift @@ -0,0 +1,271 @@ +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 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", + 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 new file mode 100644 index 00000000..49e5991f --- /dev/null +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -0,0 +1,25 @@ +import Testing +@testable import WhereCore + +struct WherePreferencesTests { + @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.reset() + + #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 fa127e1e..380bcc89 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,11 +57,13 @@ struct WhereServicesTests { let services = try await WhereServices.make( store: store, locationSource: ScriptedLocationSource(), + 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 { @@ -399,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) @@ -424,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"), @@ -464,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 } @@ -474,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) @@ -490,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() @@ -500,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 } @@ -515,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") @@ -567,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( @@ -651,6 +707,306 @@ 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 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) + } + + @Test func backupReplacePreservesLocalRecordingConsent() 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) + + #expect(try await store.recordingDeviceCheckIns().first?.status == .recording) + #expect(await destination.ingestor.isActive) + } + + @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) + } + + @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) + try 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.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 + // run and restore its original On choice after process restart. + let relaunched = WhereServices( + store: store, + locationSource: ScriptedLocationSource(authorizationStatus: .always), + ) + 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) + } + + @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) + 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, + )) + } + try await outbox.save([LocationOutboxEntry(sample: pending, dataEpochID: .initial)]) + + try await services.reset() + + #expect(await outbox.persistedSamples.isEmpty) + #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) + } + + @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), + recordingChoice: .on(enabledAt: resetAt.addingTimeInterval(-100)), + 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() + 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.localAutomaticRecordingEnabled == false) + #expect(configuration.device.status == .off) + #expect(await destination.ingestor.isActive == false) + } + + @Test func failedBackupTransactionRestoresTheLocalRecordingChoice() 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) + } + + try await waitUntil { 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) @@ -668,8 +1024,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()) } @@ -720,7 +1075,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( @@ -728,17 +1129,39 @@ struct WhereServicesTests { in: Self.pacificCalendar, regions: [.california], ) + let deviceID = CurrentRecordingDevice.preview.id 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.addRecordingDeviceProfile(RecordingDeviceProfile( + id: deviceID, + systemName: "iPhone", + kind: .phone, + registeredAt: seedSample.timestamp, + registrationEpochID: .initial, + )) + try await store.setRecordingDeviceCheckIn(RecordingDeviceCheckIn( + deviceID: deviceID, + revision: 0, + lastSeenAt: seedSample.timestamp, + status: .recording, + )) } - 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().count == 1) + #expect(try await store.recordingDeviceCheckIns().isEmpty) } // MARK: - Logging reminders @@ -801,7 +1224,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), @@ -827,7 +1250,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), @@ -1113,7 +1536,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 } @@ -1128,7 +1551,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 } @@ -1354,6 +1777,45 @@ private actor ToggleFailingStore: WhereStore { backing.changes() } + func dataEpoch() async throws -> WhereDataEpoch { + 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, + 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) @@ -1367,6 +1829,42 @@ private actor ToggleFailingStore: WhereStore { try await backing.allSamples() } + func recordingDevices() async throws -> [RecordingDevice] { + try await backing.recordingDevices() + } + + 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 recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] { + try await backing.recordingDeviceRemovals() + } + + func addRecordingDeviceRemoval(_ archive: RecordingDeviceRemoval) async throws { + try await backing.addRecordingDeviceRemoval(archive) + } + func write(evidence: Evidence, blob: Data?) async throws { try await backing.write(evidence: evidence, blob: blob) } @@ -1406,10 +1904,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 1dec361e..95665a07 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. @@ -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, 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 + 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`). +- 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 d9ccdb36..7b46df78 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -63,38 +63,62 @@ 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 flag, the active `WhereScope`, the owned `WhereSession`, and the - lifecycle intents (`activate(scope:)`, `startSession(scope:)` — which +- **`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()`). - **`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 plus a mirror of the scope-owned committed-cleanup gate), + **`RemindersSettingsModel`** (notification prefs), and + **`DevicesSettingsModel`** (installation-local recording choice plus synced names, advisory + status, and irreversible removal). 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 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 - 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. + giving each a look, then verifying this installation's automatic-recording + 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 + + 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 `RegionsSettingsView` editor. +- **`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`, 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/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 new file mode 100644 index 00000000..d8d02f53 --- /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: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 new file mode 100644 index 00000000..3d25b34b --- /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: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 new file mode 100644 index 00000000..3942adad --- /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: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 new file mode 100644 index 00000000..b66787af --- /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: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 new file mode 100644 index 00000000..d5ab036c --- /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: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 new file mode 100644 index 00000000..f94290f2 --- /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: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 new file mode 100644 index 00000000..1e421024 --- /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: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 new file mode 100644 index 00000000..c6c72a77 --- /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: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 new file mode 100644 index 00000000..c1c2e36f --- /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: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 new file mode 100644 index 00000000..1e0e234b --- /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:bf9290b7dffb974b23f7b16d61f79eec851007292d140fa862234328985b5001 +size 222992 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..71969e87 --- /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: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 new file mode 100644 index 00000000..6a927ff1 --- /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:48c1c2102938c7aa375e121eecd135fa787dbe318a36ba5f26da2463cda6e96b +size 1039714 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/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 5c3c1079..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, @@ -88,7 +89,7 @@ AddEvidenceView.flyoverData, LoggedDaysView.flyoverData, RegionsSettingsView.flyoverData, - LocationSettingsView.flyoverData, + DevicesSettingsView.flyoverData, AlertsSettingsView.flyoverData, AppearanceSettingsView.flyoverData, CardDesignerStudioView.flyoverData, 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 new file mode 100644 index 00000000..8717ec67 --- /dev/null +++ b/Where/WhereUI/Sources/Launch/InMemoryInstallationRecordingContextStore.swift @@ -0,0 +1,88 @@ +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.automaticRecordingEnabled != nil { return onboardingContext } + onboardingContext = onboardingContext.confirmingInitialRecording(isEnabled: isEnabled) + return onboardingContext + } + + public func setAutomaticRecordingEnabled(_ isEnabled: Bool) throws { + onboardingContext = onboardingContext.settingAutomaticRecordingEnabled(isEnabled, at: now()) + } + + public func rejoin() throws -> InstallationRecordingContext { + onboardingContext = proposedContext(isRejoining: true) + 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 = 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(), + recordingChoice: .unconfirmed, + isRejoining: isRejoining, + ) + } +} diff --git a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift new file mode 100644 index 00000000..098b8296 --- /dev/null +++ b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift @@ -0,0 +1,712 @@ +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 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 +{ + 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 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 recordingDeviceRemovalCount: Int + + init(_ summary: BackupCoordinator.ImportSummary) { + sampleCount = summary.sampleCount + evidenceCount = summary.evidenceCount + manualDayCount = summary.manualDayCount + dismissedIssueCount = summary.dismissedIssueCount + trackedRegionCount = summary.trackedRegionCount + recordingDeviceCount = summary.recordingDeviceCount + recordingDeviceRemovalCount = summary.recordingDeviceRemovalCount + } + + var value: BackupCoordinator.ImportSummary { + BackupCoordinator.ImportSummary( + sampleCount: sampleCount, + evidenceCount: evidenceCount, + manualDayCount: manualDayCount, + dismissedIssueCount: dismissedIssueCount, + trackedRegionCount: trackedRegionCount, + recordingDeviceCount: recordingDeviceCount, + recordingDeviceRemovalCount: recordingDeviceRemovalCount, + ) + } + } + + 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 automaticRecordingEnabled: Bool? + let recordingEnabledAt: Date? + let isRejoining: Bool? + let backupImportRecovery: BackupImportRecovery? + let onboardingImportCompletionID: UUID? + + enum CodingKeys: String, CodingKey { + case deviceID + case systemName + case kind + case registeredAt + case automaticRecordingEnabled + case recordingEnabledAt + case isRejoining + 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 + automaticRecordingEnabled = context.automaticRecordingEnabled + recordingEnabledAt = context.recordingEnabledAt + isRejoining = context.isRejoining + self.backupImportRecovery = backupImportRecovery.map(BackupImportRecovery.init) + onboardingImportCompletionID = onboardingImportCompletion?.transactionID + } + + var value: 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, + recordingChoice: recordingChoice, + isRejoining: isRejoining ?? false, + ) + } + } + + 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(), + isRejoining: false, + ) + 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() + if context.automaticRecordingEnabled != nil { return context } + + let confirmed = context.confirmingInitialRecording(isEnabled: isEnabled) + try persist( + confirmed, + backupImportRecovery: backupImportRecovery, + onboardingImportCompletion: onboardingImportCompletion, + ) + resolution = .resolved(confirmed) + return confirmed + } + + public func setAutomaticRecordingEnabled(_ isEnabled: Bool) throws { + let updated = try resolution.get().settingAutomaticRecordingEnabled(isEnabled, at: now()) + 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 { + 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(), + isRejoining: false, + ) + 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, + isRejoining: Bool, + ) -> InstallationRecordingContext { + InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: id), + systemName: systemName, + kind: kind, + ), + registeredAt: registeredAt, + recordingChoice: .unconfirmed, + isRejoining: isRejoining, + ) + } + + 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 66184d3e..49596b60 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -43,14 +43,17 @@ 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, remove old device identities, + /// 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. 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 @@ -194,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 @@ -215,6 +224,10 @@ public protocol WhereScopeAssembling { /// **one** store open. func makeServices() async throws -> WhereServices + /// 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 /// through the in-memory pipeline and must leave no sink attached to the @@ -222,6 +235,12 @@ public protocol WhereScopeAssembling { func makeLogStore() async throws -> PeriscopeStore? } +extension WhereScopeAssembling { + public func discoverRecordingDevices() async throws -> [RecordingDevice] { + [] + } +} + /// Owns the assembly of the user's real world, so `WhereScope` and /// `WhereModel` consume finished pieces rather than wiring up persistence and /// CoreLocation themselves. @@ -236,9 +255,21 @@ 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? + private var preparedStore: SwiftDataStore? - 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,12 +298,16 @@ public final class WhereBootstrap: WhereScopeAssembling { let source = locationSource ?? CoreLocationSource() locationSource = nil do { - let store = try await Task.detached(priority: .userInitiated) { - try SwiftDataStore.make() - }.value + let installationContext = try installationContextStore.resolve() + precondition( + installationContext.automaticRecordingEnabled != nil, + "A real scope cannot open before this installation confirms recording.", + ) + let store = try await prepareStore() let services = try await WhereServices.make( store: store, locationSource: source, + 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 @@ -281,7 +316,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 @@ -293,6 +330,26 @@ public final class WhereBootstrap: WhereScopeAssembling { } } + 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 store.recordingDevices() + } + + 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 @@ -306,9 +363,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 b1a1e689..34e64f09 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift @@ -22,16 +22,18 @@ 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 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. /// /// 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. 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 @@ -39,10 +41,26 @@ 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 && !model.hasOnboarded + return model.activeScope == nil + && (!model.hasOnboarded || !model.hasConfirmedRecordingChoice) + } +} + +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() } } @@ -50,8 +68,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 @@ -63,7 +83,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 } } @@ -189,25 +214,36 @@ struct WidgetSnapshotStep: BudgetedLaunchStep { // MARK: - Reset teardown steps -/// Stop GPS, wipe the store, and log out. Takes the session being erased as +/// 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 — -/// 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() } } @@ -233,17 +269,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..f3c56574 --- /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, + recordingDeviceRemovalCount: 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/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/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index af25b92a..b81c1a28 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,13 +166,148 @@ 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). + /// 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.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 { + let devices = try await activeScope?.services.recording.devices() ?? [] + return RecordingOnboardingRecommendation( + for: context.currentDevice, + devices: devices.map(\.device), + now: now(), + ) + } + 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 + /// 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() + } + } + + /// Persist this installation's explicit onboarding choice, including a changed retry. + @discardableResult + public func confirmInitialRecordingChoice( + isEnabled: Bool, + ) throws -> InstallationRecordingContext { + 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 + /// been committed. Recording confirmation is persisted separately first. public func completeOnboarding() { hasOnboarded = true Self.logger { .onboardingCompleted } } + /// 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 { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current @@ -175,27 +318,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 } @@ -217,6 +366,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, @@ -224,12 +376,17 @@ 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 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 @@ -361,21 +518,31 @@ 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 } /// 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 } } + 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. /// @@ -387,23 +554,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 2893f401..f5619e33 100644 --- a/Where/WhereUI/Sources/Model/WhereScope.swift +++ b/Where/WhereUI/Sources/Model/WhereScope.swift @@ -232,6 +232,7 @@ public final class WhereScope { let services = try await WhereServices.make( store: store, locationSource: locationSource, + 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 @@ -243,18 +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.wantsTracking = true let scope = WhereScope( kind: .demo, diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index e23c6d28..0fa23cc6 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -39,9 +39,27 @@ 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 + } + + 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 { + services.recording.currentDevice.id + } /// The latest known location authorization status, kept live via /// `LocationIngestor.authorizationUpdates()`. @@ -56,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 @@ -76,6 +95,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`. @@ -102,12 +125,19 @@ 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 + /// 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? + + /// 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.localAutomaticRecordingEnabled == true } /// A process-unique session identity. A typed token rather than a raw `Int` @@ -129,11 +159,28 @@ 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 + if let installationContextStore { + self.installationContextStore = installationContextStore + } else { + let registeredAt = now() + self.installationContextStore = InMemoryInstallationRecordingContextStore( + context: InstallationRecordingContext( + currentDevice: scope.services.recording.currentDevice, + registeredAt: registeredAt, + recordingChoice: .on(enabledAt: registeredAt), + isRejoining: false, + ), + ) + } } /// Build a coordinator over a loose service layer, wrapping it in a scope. @@ -166,6 +213,7 @@ public final class WhereSession { /// until the next status change resumes it. deinit { authorizationTask?.cancel() + recordingConfigurationTask?.cancel() regionStyleTask?.cancel() } @@ -180,6 +228,7 @@ public final class WhereSession { public func start() async { await syncAuthorization() observeAuthorizationChanges() + observeRecordingConfigurationChanges() await seedRegionStyles() observeRegionStyleChanges() await reconcileTracking() @@ -287,41 +336,93 @@ 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() } } } + /// Observe Core's focused policy reconciliation output. The controller emits only after + /// 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 } + let updates = services.recording.runtimeUpdates() + recordingConfigurationTask = Task { @MainActor [weak self] in + for await update in updates { + guard let self else { break } + applyRecordingRuntimeUpdate(update) + } + } + } + /// Start or stop GPS ingestion so it matches the user's intent and the /// current authorization. Tracking only runs with Always authorization. A /// launch step (see `WhereLaunch.plan(for:)`). func reconcileTracking() async { + observeRecordingConfigurationChanges() 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 { + await services.recording.startMonitoringChanges() + 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 + } else if case .removed = 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()) } @@ -350,25 +451,78 @@ 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) } 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) + } catch { + Self.logger(attachments: [.error(error, name: "recording-disable-error")]) { + .recordingReconcileFailed(description: error.localizedDescription) + } + } + } + + /// Current synced device list. Registration is an explicit launch operation. + public func recordingDevices() async throws -> [RecordingDeviceConfiguration] { + try await services.recording.devices() + } + + /// 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 { + do { + try await services.ingestor.requestPermission() + } catch { + permissionRequestFailed = true + } + await syncAuthorization() + } + guard sequence == recordingIntentSequence else { return } + let configuration = try await services.recording.setAutomaticRecordingEnabled( + enabled, + authorization: authorizationStatus, + ) + await synchronizeRecordingRuntimeState() + permissionDenied = enabled && permissionRequestFailed + if configuration.localAutomaticRecordingEnabled == true, isTracking { Self.logger { .trackingEnabled } + } else if configuration.localAutomaticRecordingEnabled == false { + Self.logger { .stoppedBackgroundTracking } } } - 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 { + _ = try await services.recording.rename(deviceID, to: nickname) + } + + public func removeRecordingDevice( + _ deviceID: RecordingDeviceID, + ) async throws { + _ = 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 @@ -439,29 +593,44 @@ 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 (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 /// teardown (see `WhereLaunch.resetPlan(for:)`); throws on persistence failure /// 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 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 recording: the + // teardown step must release the scope and App Intents before surfacing the terminal + // partial-success state. + recordingRuntimeState = .unavailable + throw error + } catch { + // 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 } + 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..6bed1b4e --- /dev/null +++ b/Where/WhereUI/Sources/Onboarding/OnboardingRestoreSelection.swift @@ -0,0 +1,124 @@ +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 + } + } + + /// 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 } + } + + 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 c1acc0e0..af08f811 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -3,22 +3,24 @@ 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 -/// 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 +/// 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 -/// `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,6 +28,11 @@ public struct OnboardingView: View { @Environment(WhereModel.self) private var model @Environment(\.stylesheet) private var stylesheet private let gate: LifecycleGateHandle + 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 @@ -40,7 +47,10 @@ 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 deviceDiscovery: DeviceDiscovery = .idle @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() @@ -50,6 +60,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 @@ -58,8 +72,40 @@ public struct OnboardingView: View { private static let logger = WhereLog.session(OnboardingViewLog.self) - public init(gate: LifecycleGateHandle) { + private enum DeviceDiscovery: Equatable { + case idle + case loading + case ready(RecordingOnboardingRecommendation) + case failed(String) + } + + public init( + gate: LifecycleGateHandle, + installationContext: InstallationRecordingContext, + ) { + self.init( + gate: gate, + installationContext: installationContext, + startsAtRecordingChoice: false, + ) + } + + /// 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, + installationContext: InstallationRecordingContext, + startsAtRecordingChoice: Bool, + ) { self.gate = gate + self.installationContext = installationContext + _phase = State(initialValue: startsAtRecordingChoice ? .location : .intro) + _recordingEnabled = State( + initialValue: installationContext.automaticRecordingEnabled + ?? installationContext.recommendedRecordingEnabled, + ) } private let pages = OnboardingPage.all @@ -86,6 +132,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)) @@ -134,6 +181,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, @@ -150,11 +215,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 { @@ -183,6 +244,7 @@ public struct OnboardingView: View { if page < pages.count - 1 { withAnimation { page += 1 } } else { + discardPendingRestore() phase = .pickRegions } } label: { @@ -235,59 +297,132 @@ public struct OnboardingView: View { // MARK: - Location private var location: some View { - VStack(spacing: stylesheet.spacing.xxxLarge) { - Spacer(minLength: 0) - Image(systemName: "location.fill.viewfinder") - .font(stylesheet.typography.onboardingIcon) - .foregroundStyle(Color.accentColor) - .accessibilityHidden(true) - VStack(spacing: stylesheet.spacing.large) { - Text(String(localized: .onboardingLocationTitle)) - .font(.largeTitle.bold()) - .multilineTextAlignment(.center) - Text(String(localized: .onboardingLocationDescription)) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + 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) { + switch deviceDiscovery { + case .idle, .loading: + ProgressView(String(localized: .onboardingRecordingChecking)) + case let .ready(recommendation): + if recommendation.recentRecordingDevice != nil { + Label( + String(localized: .onboardingRecordingRecent), + systemImage: "iphone.radiowaves.left.and.right", + ) + } + case let .failed(description): + Label(description, systemImage: "icloud.slash") + .foregroundStyle(.secondary) + } + 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 || deviceDiscovery == .loading) + } + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.bottom, stylesheet.spacing.xxxLarge) + .frame(maxWidth: .infinity, minHeight: geometry.size.height) } - Spacer(minLength: 0) + .scrollBounceBehavior(.basedOnSize) + } + .task { await discoverRecordingDevices() } + } - VStack(spacing: stylesheet.spacing.large) { - 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) - } label: { - Text(String(localized: .onboardingEnableLocation)) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) + private var recordingTitle: LocalizedStringResource { + switch deviceKind { + case .phone: .onboardingRecordingPhoneTitle + case .tablet: .onboardingRecordingTabletTitle + case .other: .onboardingRecordingOtherTitle + } + } - Button(String(localized: .onboardingNotNow)) { - finish(enableLocation: false) - } - .controlSize(.large) - } - .disabled(isFinishing) + private var recordingRecommendation: LocalizedStringResource { + let recommendsEnabled = if case let .ready(recommendation) = deviceDiscovery { + recommendation.isEnabled + } else { + installationContext.recommendedRecordingEnabled + } + if recommendsEnabled { + return .onboardingRecordingRecommendationOn + } else { + return .onboardingRecordingRecommendationOff } - .padding(.horizontal, stylesheet.spacing.xxxLarge) - .padding(.bottom, stylesheet.spacing.xxxLarge) } - /// 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. + /// 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.automaticRecordingEnabled != 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() @@ -298,6 +433,112 @@ public struct OnboardingView: View { gate.fail(error) return } + + 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 + } + } + + // 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( + 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) } @@ -319,18 +560,34 @@ public struct OnboardingView: View { } } } - model.completeOnboarding() + 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. + private func discoverRecordingDevices() async { + guard deviceDiscovery == .idle else { return } + deviceDiscovery = .loading + do { + let recommendation = try await model.discoverRecordingRecommendation( + for: installationContext, + ) + deviceDiscovery = .ready(recommendation) + if installationContext.automaticRecordingEnabled == nil { + recordingEnabled = recommendation.isEnabled + } + } catch { + deviceDiscovery = .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 + /// gate resolves; they read the granted authorization back and start GPS. private func enableTracking(in scope: WhereScope) async { - scope.preferences.wantsTracking = true do { try await scope.services.ingestor.requestPermission() } catch { @@ -380,34 +637,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() } } @@ -439,9 +694,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 @@ -506,14 +768,63 @@ 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, + ), + installationContext: .testing, + ) + .environment(PreviewSupport.onboardingModel()) + }, + whereSnapshot( + name: "PhoneRecordingChoice", + configurations: SnapshotConfiguration.combinations(devices: [.iPhone]) + [ + SnapshotConfiguration(dynamicType: .accessibility5, device: .iPhone), + ], + ) { + OnboardingView( + gate: LifecycleGateHandle( + id: LaunchStepID.onboarding, + reason: .userForeground, + ), + installationContext: .testing, + startsAtRecordingChoice: true, + ) + .environment(PreviewSupport.onboardingModel()) + }, + whereSnapshot( + name: "TabletRecordingChoice", + configurations: SnapshotConfiguration.combinations(devices: [.iPad]), + ) { + OnboardingView( + gate: LifecycleGateHandle( + id: LaunchStepID.onboarding, + reason: .userForeground, + ), + installationContext: InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID( + rawValue: UUID( + uuidString: "00000000-0000-0000-0000-000000000003", + )!, + ), + systemName: "iPad", + kind: .tablet, + ), + registeredAt: InstallationRecordingContext.testing.registeredAt, + recordingChoice: .unconfirmed, + isRejoining: false, + ), + startsAtRecordingChoice: true, + ) + .environment(PreviewSupport.onboardingModel()) + }, + ] } } diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 0d965182..607f247f 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -115,6 +115,43 @@ WhereSession(services: previewServices(), preferences: previewPreferences()) } + /// Current + left-behind device rows for the Devices screen. + public static func recordingDeviceConfigurations() -> [RecordingDeviceConfiguration] { + let remoteID = RecordingDeviceID( + rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + ) + return [ + RecordingDeviceConfiguration( + device: RecordingDevice( + id: InstallationRecordingContext.testing.currentDevice.id, + systemName: "iPhone", + nickname: "My iPhone", + kind: .phone, + registeredAt: referenceNow.addingTimeInterval(-90 * 24 * 60 * 60), + lastSeenAt: referenceNow, + removedAt: nil, + status: .recording, + ), + isCurrentDevice: true, + localAutomaticRecordingEnabled: true, + ), + 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), + removedAt: nil, + status: .off, + ), + isCurrentDevice: false, + localAutomaticRecordingEnabled: nil, + ), + ] + } + // 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 eaf07531..1974c69b 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", @@ -2167,6 +2215,17 @@ } } }, + "common.retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" + } + } + } + }, "common.save" : { "extractionState" : "manual", "localizations" : { @@ -2465,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" : { @@ -2867,6 +2959,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", @@ -3433,87 +3549,139 @@ } } }, - "onboarding.enableLocation" : { - "comment" : "Button text to prompt the user to enable location services.", + "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" : { + "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.recording.checking" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking your other devices…" } } } }, - "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.recent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Another device was recording recently" + } + } + } + }, + "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?" } } } @@ -3553,6 +3721,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" : { @@ -5305,6 +5485,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" : { @@ -5377,7 +5569,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." } } } @@ -5448,6 +5640,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" : { @@ -5487,7 +5703,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." } } } @@ -5561,6 +5777,249 @@ } } }, + "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" : "Recording choices are local to each device. Remove this device to stop it when it next syncs and hide later automatic locations." + } + } + } + }, + "settings.devices.remove" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove from Where" + } + } + } + }, + "settings.devices.remove.confirm.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "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.remove.confirm.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove this device from Where?" + } + } + } + }, + "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.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" : { + "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" : { @@ -5882,17 +6341,6 @@ } } }, - "settings.keywords.tracking" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "location, gps, tracking, background, permission" - } - } - } - }, "settings.keywords.year" : { "extractionState" : "manual", "localizations" : { @@ -5904,50 +6352,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", @@ -6152,7 +6556,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 20677e3b..16ff009f 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,13 +113,17 @@ 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 // it commits regions with, through the model. GateView(for: OnboardingGate.self) { handle, _ in - OnboardingView(gate: handle) + OnboardingView( + gate: handle, + installationContext: model.installationRecordingContext, + startsAtRecordingChoice: model.hasOnboarded, + ) } }, ) { session in @@ -120,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/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 new file mode 100644 index 00000000..6a3b9aca --- /dev/null +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsRowModel.swift @@ -0,0 +1,174 @@ +import Foundation +import Observation +import WhereCore + +/// Editable presentation state for one synced recording device. +@MainActor +@Observable +final class DeviceSettingsRowModel: Identifiable { + enum Operation: Equatable { + case setRecordingEnabled(Bool) + case rename(String) + case remove + } + + struct OperationFailure: Identifiable, Equatable { + let id = UUID() + let operation: Operation + let message: String + } + + enum OperationState: Equatable { + case idle + case saving(Operation) + case failed(OperationFailure) + } + + let id: RecordingDeviceID + let systemName: String + let kind: RecordingDeviceKind + let isCurrent: Bool + + 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 + + init(configuration: RecordingDeviceConfiguration) { + id = configuration.id + systemName = configuration.device.systemName + kind = configuration.device.kind + isCurrent = configuration.isCurrentDevice + let nickname = configuration.device.nickname ?? "" + self.nickname = nickname + confirmedNickname = nickname + confirmedRecordingEnabled = configuration.localAutomaticRecordingEnabled + recordingEnabled = configuration.localAutomaticRecordingEnabled ?? false + status = configuration.device.status + lastSeenAt = configuration.device.lastSeenAt + } + + var isEnabled: Bool { + get { recordingEnabled } + set { + guard isCurrent, recordingEnabled != newValue else { return } + recordingEnabled = newValue + pendingRecordingIntent = true + clearFailure(matching: .setRecordingEnabled(newValue)) + } + } + + var hasUnsavedNickname: Bool { + normalizedNickname != confirmedNickname + } + + var canSaveNickname: Bool { + hasUnsavedNickname && !isSaving + } + + var isSaving: Bool { + if case .saving = operationState { true } else { false } + } + + var displayName: String { + let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? systemName : trimmed + } + + var systemImage: String { + kind.systemImage + } + + func requestNicknameSave() { + wantsNicknameSave = true + } + + func requestRemoval() { + precondition(!isCurrent, "The current device cannot remove itself.") + wantsRemoval = true + } + + func beginNextOperation() -> Operation? { + guard !isSaving else { return nil } + if wantsRemoval { + wantsRemoval = false + return begin(.remove) + } + if pendingRecordingIntent { + pendingRecordingIntent = false + return begin(.setRecordingEnabled(recordingEnabled)) + } + if wantsNicknameSave, hasUnsavedNickname { + wantsNicknameSave = false + return begin(.rename(normalizedNickname)) + } + wantsNicknameSave = false + return nil + } + + func finish(_ operation: Operation) { + 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 { + let failure = OperationFailure(operation: operation, message: error.localizedDescription) + operationState = .failed(failure) + return failure + } + + func update(from configuration: RecordingDeviceConfiguration) { + 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 + } + + private var normalizedNickname: String { + nickname.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func begin(_ operation: Operation) -> Operation { + operationState = .saving(operation) + return 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 (.remove, _), (.setRecordingEnabled, _), (.rename, _): + break + } + } +} + +extension RecordingDeviceKind { + var systemImage: String { + switch self { + case .phone: "iphone" + case .tablet: "ipad" + case .other: "apple.logo" + } + } +} diff --git a/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift new file mode 100644 index 00000000..49a2c58b --- /dev/null +++ b/Where/WhereUI/Sources/Settings/DeviceSettingsSection.swift @@ -0,0 +1,214 @@ +import SwiftUI +import WhereCore + +/// Form section for one installation's identity, status, local preference, and removal controls. +struct DeviceSettingsSection: View { + let model: DevicesSettingsModel + @Bindable var row: DeviceSettingsRowModel + + @Environment(WhereSession.self) private var session + @Environment(\.openURL) private var openURL + @Environment(\.stylesheet) private var stylesheet + @State private var isConfirmingRemoval = false + + var body: some View { + Section { + 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.isSaving) + .settingsRow(DevicesSettingsView.Item.deviceName, when: row.isCurrent) + + LabeledContent(String(localized: .settingsDevicesStatus)) { + HStack { + Image(systemName: statusSymbol) + .accessibilityHidden(true) + Text(statusTitle) + } + .foregroundStyle(statusStyle) + } + + LabeledContent(String(localized: .settingsDevicesLastActive)) { + Text( + row.lastSeenAt, + format: .dateTime + .month(.abbreviated) + .day() + .year() + .hour() + .minute(), + ) + .foregroundStyle(.secondary) + } + + 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, + ) + + 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(role: .destructive) { + isConfirmingRemoval = true + } label: { + HStack { + Image(systemName: "trash") + Text(String(localized: .settingsDevicesRemove)) + } + .foregroundStyle(.red) + } + .disabled(row.isSaving) + .confirmationDialog( + String(localized: .settingsDevicesRemoveConfirmTitle), + isPresented: $isConfirmingRemoval, + titleVisibility: .visible, + ) { + Button(String(localized: .settingsDevicesRemove), role: .destructive) { + Task { await model.remove(row) } + } + } message: { + Text(String(localized: .settingsDevicesRemoveConfirmMessage)) + } + } + } header: { + Label { + 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: { + Image(systemName: row.systemImage) + } + } footer: { + if row.isCurrent { + Text(String(localized: .settingsDevicesCurrentFooter)) + } else { + Text(String(localized: .settingsDevicesRemoteFooter)) + } + } + } + + private var statusTitle: String { + if row.isCurrent, case .unavailable = session.recordingRuntimeState { + return String(localized: .settingsDevicesStatusUnavailable) + } + switch row.status { + case .unknown: return String(localized: .settingsDevicesStatusPending) + case .recording: return String(localized: .settingsDevicesStatusRecording) + case .off: return String(localized: .settingsDevicesStatusOff) + case .permissionRequired: + return String(localized: .settingsDevicesStatusPermissionRequired) + } + } + + private var statusSymbol: String { + if row.isCurrent, case .unavailable = session.recordingRuntimeState { + return "exclamationmark.triangle" + } + return switch row.status { + case .unknown: "clock.arrow.trianglehead.counterclockwise.rotate.90" + case .recording: "location.fill" + case .off: "location.slash" + case .permissionRequired: "exclamationmark.triangle" + } + } + + private var statusStyle: HierarchicalShapeStyle { + let runtimeIsAvailable = if row.isCurrent { + if case .applied = session.recordingRuntimeState { true } else { false } + } else { + true + } + return row.status == .recording && runtimeIsAvailable + ? .primary + : .secondary + } + + private var showGrantButton: Bool { + guard row.isCurrent, row.isEnabled else { return false } + return switch session.authorizationStatus { + case .notDetermined, .whenInUse: true + case .restricted, .denied, .always: false + } + } + + private var showOpenSettingsButton: Bool { + guard row.isCurrent, 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..3bc1d8f8 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsModel.swift @@ -0,0 +1,169 @@ +import Foundation +import Observation +import WhereCore + +@MainActor +protocol DevicesSettingsSession: AnyObject { + var currentRecordingDeviceID: RecordingDeviceID { get } + func recordingDeviceUpdates() -> AsyncStream + func recordingDevices() async throws -> [RecordingDeviceConfiguration] + func setRecordingEnabled(_ enabled: Bool) async throws + func renameRecordingDevice(_ deviceID: RecordingDeviceID, to nickname: String) async throws + func removeRecordingDevice(_ deviceID: RecordingDeviceID) async throws + func requestPermission() async +} + +extension WhereSession: DevicesSettingsSession { + func recordingDeviceUpdates() -> AsyncStream { + services.dataChangeUpdates() + } +} + +/// 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 { + struct Failure: Identifiable, Equatable { + enum Context: Equatable { + case initialLoad + case operation(deviceID: RecordingDeviceID) + case refresh + } + + let id = UUID() + let context: Context + let message: String + } + + enum LoadState { + case idle + case loading + case empty + case loaded + case failed(Failure) + + var isReadyForSearchFocus: Bool { + if case .loaded = self { true } else { false } + } + } + + private let session: any DevicesSettingsSession + private(set) var state: LoadState = .idle + private(set) var rows: [DeviceSettingsRowModel] = [] + private(set) var presentedFailure: Failure? + @ObservationIgnored private var operationDeviceIDs: Set = [] + + var isShowingError: Bool { + get { presentedFailure != nil } + set { if !newValue { presentedFailure = nil } } + } + + var presentedFailureCanRetry: Bool { + presentedFailure?.context == .refresh + } + + init(session: any DevicesSettingsSession) { + self.session = session + } + + #if DEBUG + init( + session: any DevicesSettingsSession, + configurations: [RecordingDeviceConfiguration], + ) { + self.session = session + apply(configurations) + state = configurations.isEmpty ? .empty : .loaded + } + #endif + + func run() async { + let updates = session.recordingDeviceUpdates() + await load(showLoading: true) + for await _ in updates { + await load(showLoading: false) + } + } + + func retry() async { + await load(showLoading: true) + } + + 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) + } + + func saveNickname(_ row: DeviceSettingsRowModel) async { + row.requestNicknameSave() + await processPendingOperations(for: row) + } + + func remove(_ row: DeviceSettingsRowModel) async { + row.requestRemoval() + await processPendingOperations(for: row) + } + + func requestPermission() async { + await session.requestPermission() + await load(showLoading: false) + } + + private func load(showLoading: Bool) async { + if showLoading, rows.isEmpty { state = .loading } + do { + try await apply(session.recordingDevices()) + state = rows.isEmpty ? .empty : .loaded + if presentedFailure?.context == .refresh { presentedFailure = nil } + } catch { + 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) + case let .rename(nickname): + try await session.renameRecordingDevice(row.id, to: nickname) + 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) + presentedFailure = Failure( + context: .operation(deviceID: row.id), + message: failure.message, + ) + await load(showLoading: false) + return + } + } + } + + 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) + } + } +} diff --git a/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift new file mode 100644 index 00000000..b0de354a --- /dev/null +++ b/Where/WhereUI/Sources/Settings/DevicesSettingsView.swift @@ -0,0 +1,184 @@ +import SnapshotKit +import SwiftUI +import WhereCore + +/// 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? + + @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, + revealWhen: model.state.isReadyForSearchFocus, + ) { + Form { + switch model.state { + case .idle, .loading: + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + 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", + ) + 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.presentedFailure, + ) { _ in + if model.presentedFailureCanRetry { + Button(String(localized: .commonRetry)) { + Task { await model.retry() } + } + } + Button(String(localized: .commonOk), role: .cancel) {} + } message: { failure in + Text(failure.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..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) } @@ -104,13 +122,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/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..003e684b 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 throws {} + 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/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 31520e21..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,9 +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.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. @@ -253,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, ) @@ -303,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() @@ -335,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 new file mode 100644 index 00000000..b8133c8d --- /dev/null +++ b/Where/WhereUI/Tests/DeviceSettingsRowModelTests.swift @@ -0,0 +1,78 @@ +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 date = Date(timeIntervalSinceReferenceDate: 100) + + @Test func presentsRemoteDeviceWithoutAnEditableRecordingChoice() { + let row = DeviceSettingsRowModel( + configuration: configuration(nickname: "Home iPad", status: .recording), + ) + + #expect(row.displayName == "Home iPad") + #expect(row.systemImage == "ipad") + #expect(row.isCurrent == false) + #expect(row.beginNextOperation() == nil) + } + + @Test func refreshAppliesLocalChoiceWithoutCreatingAUserCommand() { + let row = DeviceSettingsRowModel( + configuration: configuration(status: .recording, isCurrent: true, enabled: true), + ) + + row.update(from: configuration(status: .off, isCurrent: true, enabled: false)) + + #expect(row.isEnabled == false) + #expect(row.beginNextOperation() == nil) + } + + @Test func explicitLocalToggleCreatesOneRecordingCommand() { + let row = DeviceSettingsRowModel( + configuration: configuration(status: .recording, isCurrent: true, enabled: true), + ) + + row.isEnabled = false + + #expect(row.beginNextOperation() == .setRecordingEnabled(false)) + } + + @Test func syncedRefreshDoesNotOverwriteAnUnsavedNickname() { + let row = DeviceSettingsRowModel( + configuration: configuration(nickname: "Home", status: .off), + ) + row.nickname = "Home iPad" + + row.update(from: configuration(nickname: "Synced elsewhere", status: .off)) + + #expect(row.nickname == "Home iPad") + #expect(row.hasUnsavedNickname) + } + + private func configuration( + nickname: String? = nil, + status: RecordingDeviceStatus, + isCurrent: Bool = false, + enabled: Bool? = nil, + ) -> RecordingDeviceConfiguration { + RecordingDeviceConfiguration( + device: RecordingDevice( + id: Self.id, + systemName: "iPad", + nickname: nickname, + kind: .tablet, + registeredAt: Self.date, + lastSeenAt: Self.date, + removedAt: nil, + status: status, + ), + isCurrentDevice: isCurrent, + localAutomaticRecordingEnabled: enabled, + ) + } +} diff --git a/Where/WhereUI/Tests/DevicesSettingsModelTests.swift b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift new file mode 100644 index 00000000..475467a3 --- /dev/null +++ b/Where/WhereUI/Tests/DevicesSettingsModelTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct DevicesSettingsModelTests { + 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.run() + + #expect(model.rows.map(\.id) == [Self.currentID, Self.remoteID]) + #expect(model.rows.first?.isCurrent == true) + } + + @Test func localToggleChangesOnlyTheCurrentInstallationsPreference() async throws { + let session = Session(configurations: Self.configurations) + let model = DevicesSettingsModel(session: session) + await model.run() + let current = try #require(model.rows.first) + + current.isEnabled = false + await model.recordingPreferenceChanged(for: current) + + #expect(session.recordingChoices == [false]) + } + + @Test func remoteDeviceCanBeRenamedAndRemovedButNotToggled() async throws { + let session = Session(configurations: Self.configurations) + let model = DevicesSettingsModel(session: session) + await model.run() + let remote = try #require(model.rows.last) + + remote.nickname = "Kitchen iPad" + await model.saveNickname(remote) + await model.remove(remote) + + #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 operationFailureRemainsVisibleAfterRefresh() async throws { + let session = Session(configurations: Self.configurations) + session.nextError = TestFailure() + let model = DevicesSettingsModel(session: session) + await model.run() + let current = try #require(model.rows.first) + + current.isEnabled = false + await model.recordingPreferenceChanged(for: current) + + #expect(model.presentedFailure?.context == .operation(deviceID: Self.currentID)) + } + + private static var configurations: [RecordingDeviceConfiguration] { + [ + configuration( + id: currentID, + name: "iPhone", + kind: .phone, + status: .recording, + isCurrent: true, + enabled: true, + ), + configuration( + id: remoteID, + name: "iPad", + kind: .tablet, + status: .off, + isCurrent: false, + enabled: nil, + ), + ] + } + + private static func configuration( + id: RecordingDeviceID, + name: String, + kind: RecordingDeviceKind, + status: RecordingDeviceStatus, + isCurrent: Bool, + enabled: Bool?, + ) -> RecordingDeviceConfiguration { + RecordingDeviceConfiguration( + device: RecordingDevice( + id: id, + systemName: name, + nickname: nil, + kind: kind, + registeredAt: date, + lastSeenAt: date, + removedAt: nil, + status: status, + ), + isCurrentDevice: isCurrent, + localAutomaticRecordingEnabled: enabled, + ) + } +} + +private struct TestFailure: Error {} + +@MainActor +private final class Session: DevicesSettingsSession { + struct Rename: Equatable { + let id: RecordingDeviceID + let nickname: String + } + + let currentRecordingDeviceID = DevicesSettingsModelTests.currentID + var configurations: [RecordingDeviceConfiguration] + var recordingChoices: [Bool] = [] + var renames: [Rename] = [] + var removals: [RecordingDeviceID] = [] + var nextError: (any Error)? + + init(configurations: [RecordingDeviceConfiguration]) { + self.configurations = configurations + } + + func recordingDeviceUpdates() -> AsyncStream { + AsyncStream { $0.finish() } + } + + func recordingDevices() async throws -> [RecordingDeviceConfiguration] { + configurations + } + + func setRecordingEnabled(_ enabled: Bool) async throws { + try failIfNeeded() + recordingChoices.append(enabled) + } + + func renameRecordingDevice(_ deviceID: RecordingDeviceID, to nickname: String) async throws { + try failIfNeeded() + renames.append(.init(id: deviceID, nickname: nickname)) + } + + func removeRecordingDevice(_ deviceID: RecordingDeviceID) async throws { + try failIfNeeded() + removals.append(deviceID) + } + + func requestPermission() async {} + + 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 new file mode 100644 index 00000000..bac941e8 --- /dev/null +++ b/Where/WhereUI/Tests/InMemoryInstallationRecordingContextStoreTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@_spi(Testing) import WhereCore +@_spi(Testing) import WhereUI + +@MainActor +struct InMemoryInstallationRecordingContextStoreTests { + @Test func confirmationStaysInMemory() throws { + let context = unconfirmedContext() + let store = InMemoryInstallationRecordingContextStore( + context: context, + makeUUID: { Self.replacementDeviceID }, + now: { Self.replacementRegisteredAt }, + ) + + let confirmed = try store.confirmInitialRecording(isEnabled: false) + + #expect(confirmed.automaticRecordingEnabled == false) + #expect(confirmed.registeredAt == Self.registeredAt) + #expect(try store.resolve() == confirmed) + } + + @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.replacementDeviceID) + #expect(store.onboardingContext.registeredAt == Self.replacementRegisteredAt) + #expect(store.onboardingContext.automaticRecordingEnabled == nil) + #expect(store.onboardingContext.isRejoining == false) + } + + @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.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, + recordingChoice: .unconfirmed, + isRejoining: false, + ) + } + + private static let deviceID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + private static let replacementDeviceID = UUID( + uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + )! + private static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) + private static let replacementRegisteredAt = Date(timeIntervalSinceReferenceDate: 300) +} diff --git a/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift new file mode 100644 index 00000000..27490989 --- /dev/null +++ b/Where/WhereUI/Tests/InstallationRecordingContextStoreTests.swift @@ -0,0 +1,422 @@ +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.automaticRecordingEnabled == nil) + #expect(fixture.fileExists == false) + } + + @Test func confirmationPersistsIdentityAndLocalChoiceTogether() 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.automaticRecordingEnabled == false) + #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 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() } + 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 laterConfirmationCannotRewriteTheInitialChoice() 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.automaticRecordingEnabled == 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], + dates: [Self.resetRegisteredAt], + ) + 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.resetDeviceID, + ], dates: [ + Self.registeredAt, + 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.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.resetDeviceID, + ], dates: [ + Self.registeredAt, + 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 resetDeviceID = UUID( + uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + )! + private nonisolated static let registeredAt = Date(timeIntervalSinceReferenceDate: 100) + private nonisolated static let resetRegisteredAt = Date(timeIntervalSinceReferenceDate: 300) + + private func makeFixture( + ids: [UUID] = [Self.deviceID], + dates: [Date] = [Self.registeredAt], + ) 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/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/OnboardingRestoreSelectionTests.swift b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift new file mode 100644 index 00000000..60754674 --- /dev/null +++ b/Where/WhereUI/Tests/OnboardingRestoreSelectionTests.swift @@ -0,0 +1,72 @@ +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) + #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 { + 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, + recordingDeviceRemovalCount: 3, + ) +} diff --git a/Where/WhereUI/Tests/OnboardingTests.swift b/Where/WhereUI/Tests/OnboardingTests.swift index 89bb0fdd..c170740a 100644 --- a/Where/WhereUI/Tests/OnboardingTests.swift +++ b/Where/WhereUI/Tests/OnboardingTests.swift @@ -1,34 +1,89 @@ +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) + + #expect(model.hasOnboarded == false) + #expect(model.hasConfirmedRecordingChoice == false) + #expect(model.installationRecordingContext.recommendedRecordingEnabled) } - @Test func completeOnboardingPersists() { + @Test func confirmationPersistsLocalChoiceOutsidePreferences() 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.automaticRecordingEnabled == false) + + let relaunched = makeModel(preferences: preferences, contextStore: contextStore) + #expect(relaunched.hasOnboarded) + #expect(relaunched.hasConfirmedRecordingChoice) + #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) - // A fresh model over the same preferences sees onboarding as done. - let relaunched = WhereModel( + #expect(retried.automaticRecordingEnabled == false) + #expect(try contextStore.resolve().automaticRecordingEnabled == false) + } + + @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(), ) - #expect(relaunched.hasOnboarded) + } + + 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), + recordingChoice: .unconfirmed, + isRejoining: false, + ), + ) } } 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/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 2eb7ff1f..af5d4e61 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -8,12 +8,19 @@ 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 {} + /// 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. +/// - `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. /// @@ -26,8 +33,14 @@ 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 + private var shouldFailNextRecordingDeviceWrite = false init() throws { backing = try SwiftDataStore.inMemory() @@ -50,6 +63,27 @@ 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 failNextRecordingDeviceWrite() { + shouldFailNextRecordingDeviceWrite = true + } + func failManualDays() { shouldFailManualDay = true } @@ -70,6 +104,45 @@ actor TestStore: WhereStore { backing.changes() } + func dataEpoch() async throws -> WhereDataEpoch { + 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, + 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) } @@ -89,6 +162,60 @@ actor TestStore: WhereStore { try await backing.allSamples() } + func recordingDevices() async throws -> [RecordingDevice] { + 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 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 recordingDeviceRemovals() async throws -> [RecordingDeviceRemoval] { + try await backing.recordingDeviceRemovals() + } + + func addRecordingDeviceRemoval(_ archive: RecordingDeviceRemoval) async throws { + try await backing.addRecordingDeviceRemoval(archive) + } + func write(evidence: Evidence, blob: Data?) async throws { try await backing.write(evidence: evidence, blob: blob) } @@ -129,10 +256,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..6b84b987 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, + recordingDeviceRemovalCount: 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 ee6f1726..c80e37c7 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 throws {} + + 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,6 +525,41 @@ struct WhereLaunchTests { #expect(bootstrap.makeServicesCount == 1) } + @Test func restoredInstallationParksForItsRecordingChoiceBeforeOpening() async throws { + let preferences = makePreferences() + preferences.hasOnboarded = true + let installationContextStore = InMemoryInstallationRecordingContextStore( + context: InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: "iPad", + kind: .tablet, + ), + registeredAt: Date(timeIntervalSinceReferenceDate: 0), + recordingChoice: .unconfirmed, + isRejoining: false, + ), + ) + 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() } + + try await waitUntil { launcher.phase.isAwaitingGate(LaunchStepID.onboarding) } + #expect(model.hasOnboarded) + #expect(model.hasConfirmedRecordingChoice == false) + #expect(bootstrap.makeServicesCount == 0) + + try model.confirmInitialRecordingChoice(isEnabled: false) + 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 @@ -294,7 +568,8 @@ struct WhereLaunchTests { preferences.hasOnboarded = true let model = WhereModel( preferences: preferences, - makeBootstrap: { FailingBootstrap() }, + installationContextStore: makeInstallationRecordingContextStore(), + makeBootstrap: { _ in FailingBootstrap() }, logSystem: .isolated(), ) @@ -337,7 +612,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( @@ -375,7 +651,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( @@ -406,6 +683,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 @@ -413,17 +691,40 @@ 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), + recordingChoice: .unconfirmed, + isRejoining: false, + ) + 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..48801e17 --- /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, + recordingDeviceRemovalCount: 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 7026365f..525314bc 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,13 +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. - #expect(!model.hasOnboarded) + // 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) } @@ -153,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() @@ -173,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 @@ -221,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) @@ -266,13 +276,16 @@ 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 - // 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 @@ -280,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) } @@ -314,16 +327,19 @@ 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) + 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) } @@ -346,6 +362,88 @@ 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) + } + + @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) + try 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) } } @@ -367,6 +465,103 @@ 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 throws { + 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 setAutomaticRecordingEnabled(_ isEnabled: Bool) throws { + onboardingContext = onboardingContext.settingAutomaticRecordingEnabled( + isEnabled, + at: Date(), + ) + } + + func rejoin() throws -> InstallationRecordingContext { + onboardingContext = InstallationRecordingContext( + currentDevice: CurrentRecordingDevice( + id: RecordingDeviceID(rawValue: UUID()), + systemName: onboardingContext.currentDevice.systemName, + kind: onboardingContext.currentDevice.kind, + ), + registeredAt: Date(), + recordingChoice: .unconfirmed, + isRejoining: true, + ) + return 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(), + 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 13e12e94..b530074b 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -3,11 +3,12 @@ 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 + persisted intent, not just the last tap. +/// reflect real authorization plus the installation-local recording choice, +/// not just the last tap. @MainActor struct WhereSessionTrackingTests { private func makeSession( @@ -21,21 +22,43 @@ struct WhereSessionTrackingTests { private func makeSessionAndStore( status: LocationAuthorizationStatus, preferences: WherePreferences, + store: SwiftDataStore? = nil, + installationContext: InstallationRecordingContext = .testing, + installationContextStore: InMemoryInstallationRecordingContextStore? = nil, ) throws -> (WhereSession, ScriptedLocationSource, SwiftDataStore) { - let store = try SwiftDataStore.inMemory() + 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: 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) } + private func installationContext( + initialRecordingEnabled: Bool, + ) -> InstallationRecordingContext { + guard initialRecordingEnabled == false else { return .testing } + return InstallationRecordingContext( + currentDevice: InstallationRecordingContext.testing.currentDevice, + registeredAt: InstallationRecordingContext.testing.registeredAt, + recordingChoice: .off, + isRejoining: false, + ) + } + /// 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,47 +96,63 @@ struct WhereSessionTrackingTests { @Test func stoppingTrackingPersistsAcrossLaunches() async throws { let preferences = makePreferences() - let (session, _) = try makeSession(status: .always, preferences: preferences) + let contextStore = InMemoryInstallationRecordingContextStore(context: .testing) + let (session, _, store) = try makeSessionAndStore( + status: .always, + preferences: preferences, + installationContextStore: contextStore, + ) 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, + installationContextStore: contextStore, + ) await relaunched.start() #expect(!relaunched.isTracking) } - @Test func newerStopWinsOverInFlightStart() async throws { - let preferences = makePreferences() - preferences.wantsTracking = false - let source = GatedStartLocationSource() + @Test func offWinsWhileAnEarlierEnableWaitsForPermission() async throws { + let source = SuspendedPermissionLocationSource() let services = try WhereServices( store: SwiftDataStore.inMemory(), locationSource: source, - reminderScheduler: NoopLoggingReminderScheduler(), - summaryScheduler: NoopDailySummaryScheduler(), - issueAlertScheduler: NoopDataIssueAlertScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), + installationContext: installationContext(initialRecordingEnabled: false), ) - let session = WhereSession(services: services, preferences: preferences) - - let inFlightStart = Task { await session.startTracking() } - await source.waitUntilStartEntered() + let preferences = makePreferences() + let contextStore = InMemoryInstallationRecordingContextStore( + context: installationContext(initialRecordingEnabled: false), + ) + let session = WhereSession( + scope: .fake(services: services, preferences: preferences, logSystem: .shared), + installationContextStore: contextStore, + ) + await session.start() - await session.stopTracking() - #expect(preferences.wantsTracking == false) - #expect(await services.ingestor.isActive == false) + let enabling = Task { + try await session.setRecordingEnabled(true) + } + await waitUntil { source.isAwaitingPermission } - await source.resumeStart() - await inFlightStart.value + try await session.setRecordingEnabled(false) + source.resolvePermission(as: .always) + try await enabling.value - withKnownIssue("The TLA+ pilot reproduces the stale publication after start resumes") { - #expect(session.isTracking == false) - } + let current = try #require( + try await session.recordingDevices() + .first(where: { $0.id == session.currentRecordingDeviceID }), + ) + #expect(current.localAutomaticRecordingEnabled == false) + #expect(current.device.status == .off) + #expect(session.isTracking == false) } @Test func grantingLaterStartsTrackingViaLiveUpdates() async throws { @@ -132,6 +171,64 @@ struct WhereSessionTrackingTests { #expect(session.isTracking) } + @Test func remoteRemovalStopsThisDevice() 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, + installationContext: .testing, + now: { now }, + ) + let preferences = makePreferences() + let session = WhereSession(services: services, preferences: preferences) + await session.start() + + #expect(session.isTracking) + #expect(source.isMonitoring) + + let removalID = try #require( + UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"), + ) + try await store.simulateRemoteRecordingImport( + profiles: [], + metadataChanges: [], + checkIns: [], + removals: [ + RecordingDeviceRemoval( + id: removalID, + deviceID: InstallationRecordingContext.testing.currentDevice.id, + removedAt: now.addingTimeInterval(1), + removedByDeviceID: RecordingDeviceID( + rawValue: #require(UUID( + uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", + )), + ), + ), + ], + ) + + // 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(current.removedAt == now.addingTimeInterval(1)) + } + @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. @@ -152,6 +249,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()) @@ -216,26 +314,57 @@ struct WhereSessionTrackingTests { } } -private actor GatedStartLocationSource: LocationSource { - nonisolated let sampleStream = AsyncStream { _ in } - nonisolated var authorizationUpdates: AsyncStream { - AsyncStream { _ in } +/// 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 } } - private var didEnterStart = false - private var enteredStartContinuation: CheckedContinuation? - private var startContinuation: CheckedContinuation? + var startCount: Int { + lock.withLock { _startCount } + } + + var stopCount: Int { + lock.withLock { _stopCount } + } func start() async { - await withCheckedContinuation { continuation in - startContinuation = continuation - didEnterStart = true - enteredStartContinuation?.resume() - enteredStartContinuation = nil + lock.withLock { + _isMonitoring = true + _startCount += 1 } } - func stop() async {} + func stop() async { + lock.withLock { + _isMonitoring = false + _stopCount += 1 + } + } func requestCurrentLocation() async -> LocationSample? { nil @@ -246,17 +375,48 @@ private actor GatedStartLocationSource: LocationSource { } 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 { + 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 waitUntilStartEntered() async { - guard !didEnterStart else { return } + 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 - enteredStartContinuation = continuation + lock.withLock { permissionContinuation = continuation } } } - func resumeStart() { - precondition(startContinuation != nil) - startContinuation?.resume() - startContinuation = nil + func resolvePermission(as status: LocationAuthorizationStatus) { + let continuation = lock.withLock { + self.status = status + defer { permissionContinuation = nil } + return permissionContinuation + } + continuation?.resume() } } 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"