diff --git a/Project.swift b/Project.swift index 7b0324b8b..71d23275f 100644 --- a/Project.swift +++ b/Project.swift @@ -201,6 +201,9 @@ let project = Project( "NSLocationAlwaysAndWhenInUseUsageDescription": .string( "Where checks your location in the background so it can log which region you're in each day.", ), + "NSPhotoLibraryUsageDescription": .string( + "Where reads photo locations and dates to build a history you review before importing.", + ), ]), sources: ["Where/Where/Sources/**"], resources: ["Where/Where/Resources/**"], diff --git a/Where/TODOs.md b/Where/TODOs.md index 332686eaa..2af35a46e 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -24,7 +24,6 @@ The item format and the placement rule live in the root ## P1s (Should do) - fix(WhereCore) [needs-design]: Scope initial CloudKit-import readiness to Where's expected store/container. `CloudKitImportReadiness.start()` observes `NSPersistentCloudKitContainer.eventChangedNotification` with `object: nil`, and `eventChanged(_:)` accepts any successful completed import (`WhereCore/Sources/Persistence/CloudKitImportReadiness.swift:19-49`), while discovery starts that observer before `WhereLaunch.prepareStore()` creates the intended store (`WhereUI/Sources/Launch/WhereLaunch.swift:326-333`). An unrelated CloudKit-backed store in the process could therefore release onboarding against an incomplete device list. Bind readiness to the container/store created for this launch (or return its initial-import completion directly from store preparation), ignore unrelated notifications, and cover that filtering with tests. (pr#160 review) - refactor(WhereUI) [quick-win]: Remove `StoredContext.CodingKeys`; it lists every property under the identical synthesized key and the installation-context sidecar has no shipped compatibility shape to preserve (`WhereUI/Sources/Launch/InstallationRecordingContextStore.swift:138-158`). Let the compiler synthesize the keys and retain the existing persistence round-trip coverage as the wire-shape guard. (pr#160 review) -- feat(Where) [needs-design]: Add an optional onboarding step that backfills the current year from the GPS metadata of photos in the user's library. `OnboardingView.Phase` currently moves from region selection/customization directly to location permission (`WhereUI/Sources/Onboarding/OnboardingView.swift:30`), while `DayJournal.ingest(_:)` is the existing bulk sample path (`WhereCore/Sources/Journal/DayJournal.swift:82`). Design a PhotoKit-backed importer that requests access only after an explicit opt-in, reads location and capture time locally without uploading photo contents, previews what will be added, records photo-derived provenance rather than treating it as live GPS, deduplicates repeat imports, and makes skipping the screen frictionless. (human 2026-08-03) - refactor(WhereCore) [needs-design]: Scope diagnostic emission so Flyover's unactivated sibling demo world cannot write its activity through the process-global `WhereLog` / `Periscope.shared` facade into the active real scope's durable diagnostic store. `WhereFlyoverWorld.build()` correctly gives the sibling a private `Periscope` and never starts its sink, but static `WhereLog` channels still bypass that injection; carry the scope's logging system through services/models or add a task-/environment-scoped routing context before treating Flyover's diagnostic activity as isolated. Domain data, preferences, widgets, notifications, and location remain in memory/no-op already. (`WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift`, `WhereCore/Sources/Logging/WhereLog.swift`; agent 2026-07-29) - fix(WhereUI) [quick-win]: `CalendarDay.displayDate` resolves through `Calendar.current` (`DateRangeFormatting.swift:33`), so every day label that flows through it — relabel, logged days, resolution details, the region drill-in — renders a wrong date on a non-Gregorian device: `startOfDay(in:)` interprets the day's Gregorian Y-M-D as *that* calendar's components, so a Buddhist-era device resolves 2026-07-26 to a date ~543 years off. `DateRangeFormatting.abbreviated` (`:6`, `:19`) and `PresenceTimeline.stints` (`PresenceTimeline.swift:37`) also *default* to `.current`, and `PresenceTimelineList` (`:12`) doesn't pass `report.calendar`. Take an explicit calendar (Gregorian + current time zone) in the helper and thread the report's calendar from the call sites. The `where.gregorian_calendar` Bumper rule that should catch this is blind to the implicit-member form — filed in the root [`TODOs.md`](../TODOs.md). (audit 2026-07-26) - fix(WhereCore) [needs-design]: `WhereServices.setPrimaryRegions(_:)` (`:285`) commits atomically but skips `DayJournal.reconcileAfterDayDataChange()` — widgets/reminders/summary don't refresh until foreground/configure. Route picker commits through the unified fan-out, or document the intentional deferral. Out of scope for [`PostWriteReconcile`](Specifications/PostWriteReconcile/README.md) until routed. (audit 2026-07-26) @@ -102,6 +101,8 @@ re-recording: # Completed issues +- feat(Where): Add an optional onboarding step that backfills the current year from photo GPS metadata. (Resolved 2026-08-07: the explicit opt-in reads only local Photos dates and coordinates, applies a best-effort device-capture filter, previews an editable timeline, records deterministic `.photo` samples, and commits only the approved draft; skipping writes nothing.) + - fix(WhereCore): Push data-generation membership into SwiftData fetch predicates. (Resolved 2026-08-08: `GenerationScopedFetch` now builds generation-aware descriptors for all seven scoped entity types, composes membership with identity/range filters before materialization, and preserves legacy `nil == .initial` rows. `SwiftDataStoreTests` covers mixed-generation reads, updates, and rotation deletes.) - 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.) diff --git a/Where/Tools/Tests/upgrade_backup_test.rb b/Where/Tools/Tests/upgrade_backup_test.rb index bd8192867..e453e2198 100644 --- a/Where/Tools/Tests/upgrade_backup_test.rb +++ b/Where/Tools/Tests/upgrade_backup_test.rb @@ -7,7 +7,7 @@ class UpgradeBackupTest < Minitest::Test def test_v1_adds_current_tables_without_inventing_recording_consent upgraded = upgrade_manifest(base_manifest(1)) - assert_equal 4, upgraded.fetch("formatVersion") + assert_equal 5, upgraded.fetch("formatVersion") assert_equal [], upgraded.fetch("recordingDeviceProfiles") assert_equal [], upgraded.fetch("recordingDeviceMetadataChanges") assert_equal [], upgraded.fetch("recordingDeviceRemovals") @@ -53,7 +53,7 @@ def test_v3_reshapes_recording_device_data upgraded = upgrade_manifest(manifest) - assert_equal 4, upgraded.fetch("formatVersion") + assert_equal 5, upgraded.fetch("formatVersion") assert_equal({ "kind" => { "other" => {} }, "registrationGenerationID" => "generation-id", @@ -66,8 +66,8 @@ def test_v3_reshapes_recording_device_data }, upgraded.fetch("recordingDeviceMetadataChanges").last) end - def test_v4_is_idempotent - once = upgrade_manifest(base_manifest(4)) + def test_v5_is_idempotent + once = upgrade_manifest(base_manifest(5)) assert_equal once, upgrade_manifest(Marshal.load(Marshal.dump(once))) end @@ -83,7 +83,7 @@ def test_normalizes_legacy_iso8601_dates_to_current_unix_timestamps end def test_rejects_branch_only_or_future_formats - error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(5)) } + error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(6)) } assert_equal 1, error.status end diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 04c920992..f9b23c3bb 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -1,9 +1,10 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Reshapes a legacy Where backup into the current v4 manifest. The automatic-recording feature +# Reshapes a legacy Where backup into the current v5 manifest. The automatic-recording feature # was not shipped in v1 or v2, so upgrading adds the recording tables empty; it never invents an -# installation or recording consent. v4 expands device kinds and groups metadata edit payloads. +# installation or recording consent. v4 expands device kinds and groups metadata edit payloads; +# v5 adds `photo` to the valid sample-source vocabulary without reshaping older samples. require "json" require "tmpdir" @@ -12,7 +13,7 @@ require "set" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 4 +CURRENT_FORMAT_VERSION = 5 SUPPORTED_SOURCE_FORMAT_VERSIONS = (1..CURRENT_FORMAT_VERSION).freeze REGION_MAP = { @@ -192,6 +193,8 @@ def upgrade_manifest(manifest) manifest.delete("recordingDevices") manifest.delete("recordingDeviceCheckIns") manifest.delete("recordingPolicyChanges") + # v5 adds `photo` as a valid sample-source case. Existing source values need + # no reshape, so the final version declaration is sufficient. manifest["formatVersion"] = CURRENT_FORMAT_VERSION warnings.uniq.each { |message| warn "warning: #{message}" } manifest diff --git a/Where/Where/Sources/PhotoKitLocationLibrary.swift b/Where/Where/Sources/PhotoKitLocationLibrary.swift new file mode 100644 index 000000000..206824816 --- /dev/null +++ b/Where/Where/Sources/PhotoKitLocationLibrary.swift @@ -0,0 +1,74 @@ +import Foundation +import Photos +import RegionKit +import WhereCore + +/// Production, metadata-only Photos adapter for onboarding history import. +/// It never asks PhotoKit for image data or thumbnails and never performs a +/// photo-library change request. +struct PhotoKitLocationLibrary: PhotoLocationLibrary { + func authorizationStatus() async -> PhotoLibraryAuthorization { + Self.map(PHPhotoLibrary.authorizationStatus(for: .readWrite)) + } + + func requestAuthorization() async -> PhotoLibraryAuthorization { + await withCheckedContinuation { continuation in + PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in + continuation.resume(returning: Self.map(status)) + } + } + } + + @concurrent + func assets(in interval: DateInterval) async throws -> [PhotoLocationAsset] { + let options = PHFetchOptions() + options.includeAssetSourceTypes = [.typeUserLibrary] + options.includeHiddenAssets = false + options.predicate = NSPredicate( + format: "mediaType == %d AND creationDate >= %@ AND creationDate < %@", + PHAssetMediaType.image.rawValue, + interval.start as NSDate, + interval.end as NSDate, + ) + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] + + let fetched = PHAsset.fetchAssets(with: options) + var values: [PhotoLocationAsset] = [] + values.reserveCapacity(fetched.count) + for index in 0 ..< fetched.count { + try Task.checkCancellation() + let asset = fetched.object(at: index) + values.append(PhotoLocationAsset( + capturedAt: asset.creationDate, + addedAt: asset.addedDate, + coordinate: asset.location.map { + Coordinate( + latitude: $0.coordinate.latitude, + longitude: $0.coordinate.longitude, + ) + }, + horizontalAccuracy: asset.location?.horizontalAccuracy, + source: Self.map(asset.sourceType), + isHidden: asset.isHidden, + )) + } + return values + } + + private static func map(_ status: PHAuthorizationStatus) -> PhotoLibraryAuthorization { + switch status { + case .notDetermined: .notDetermined + case .restricted: .restricted + case .denied: .denied + case .authorized: .authorized + case .limited: .limited + @unknown default: .denied + } + } + + private static func map(_ source: PHAssetSourceType) -> PhotoAssetLibrarySource { + if source.contains(.typeCloudShared) { return .cloudShared } + if source.contains(.typeiTunesSynced) { return .synced } + return .userLibrary + } +} diff --git a/Where/Where/Sources/RegularApplicationRuntime.swift b/Where/Where/Sources/RegularApplicationRuntime.swift index f418c3df4..0fcc76e36 100644 --- a/Where/Where/Sources/RegularApplicationRuntime.swift +++ b/Where/Where/Sources/RegularApplicationRuntime.swift @@ -64,6 +64,7 @@ final class RegularApplicationRuntime: WhereApplicationRuntime { ) }, logSystem: .shared, + photoLibrary: PhotoKitLocationLibrary(), ) } diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 8df952cbe..db7f254f8 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -47,8 +47,9 @@ internal shape. - **Primary regions *are* the tracked-region set.** `primaryRegions()` / `setPrimaryRegions(_:)` read/write the same `SDTrackedRegion` rows as `trackedRegions()` — picking scopes GPS attribution *and* carries each - region's `RegionAppearance` + pick order. `RegionAppearance` is data - (WhereCore); the token→`Color` mapping is presentation (WhereUI). + region's `RegionAppearance` + pick order. Await the live-attributor reconcile + before `setPrimaryRegions(_:)` returns. `RegionAppearance` is data (WhereCore); + the token→`Color` mapping is presentation (WhereUI). - **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 @@ -108,6 +109,11 @@ internal shape. `ScriptedLocationSource` in tests/previews; `requestCurrentLocation()` returns `nil`, never throws, and backs `LocationIngestor.captureTodayIfNeeded(now:)`. +- **Photo import is metadata-only and provisional.** `PhotoLocationLibrary` + may expose only capture/added dates, coordinate/accuracy, asset source, and + hidden state; `PhotoHistoryPlanner` keeps its draft in memory and + `DayJournal.importPhotoHistory(_:)` commits approved `.photo` samples plus + audited corrections in one transaction. - **`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 diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 6f63d8c4f..63ffff860 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -44,6 +44,9 @@ one it belongs to rather than to a god-object: which surface and persist each region's picked `RegionAppearance` — color token, emoji, SF Symbol — and pick order alongside the synced rows) — one row per region, defaulting to the four until the user chooses in the onboarding / + Settings region picker. `setPrimaryRegions(_:)` also waits for the live + attributor to rebuild before returning, so a following write cannot publish + against the previous region set. 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. @@ -63,8 +66,9 @@ one it belongs to rather than to a god-object: - **`DayJournal`** — the user-sourced writes: manual-day overlays (`addManualDay` / `overrideDay` / `addManualDays`), clears (`clearManualDay` / `clearYear` / `eraseAllData`), evidence, and issue - dismissals. Each write commits, then awaits its reminder reconcile + widget - publish so the next reader sees a fully-applied change. + dismissals, and approved photo-history imports. Each write commits, then + awaits its reminder reconcile + widget publish so the next reader sees a + fully-applied change. - **`DemoDataBuilder`** — writes the dataset the app's demo mode runs on into a given `WhereServices`: a plausible current year of living in New York with @@ -110,6 +114,14 @@ one it belongs to rather than to a god-object: the current installation's `RecordingDeviceID`. Every durable retry entry also carries the data generation that authorized it, so a pre-reset fix can be discarded but never written into the replacement generation. +- **`PhotoLocationLibrary` / `PhotoHistoryPlanner`** — the metadata-only seam + and pure planner for onboarding's optional current-year Photos backfill. The + app adapter supplies capture date, added date, coordinate, accuracy, source, + and hidden state only; the planner applies the best-effort device-capture + policy, tags accepted samples as `.photo`, gives them deterministic IDs for + repeat-import deduplication, and keeps corrections provisional until one + `DayJournal.importPhotoHistory(_:)` transaction. Approved corrections carry + a `ManualEntryAudit` stamped when the user imports the draft. - **`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 diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 719121b0c..b173b19df 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -19,11 +19,12 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// /// v3 adds sample provenance, immutable installation profiles, nickname changes, and archive /// tombstones. v4 expands device kinds, groups metadata edit payloads, and renames the - /// profile's registration-generation key. There's no in-app decode fallback for an older - /// archive — it is reshaped out of band by + /// profile's registration-generation key. v5 adds `.photo` to the persisted + /// `SampleSource` vocabulary. 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 = 4 + public static let currentFormatVersion = 5 public let formatVersion: Int public let exportedAt: Date diff --git a/Where/WhereCore/Sources/DataResolution/DaySamples.swift b/Where/WhereCore/Sources/DataResolution/DaySamples.swift index 2c40d5558..261b7e46e 100644 --- a/Where/WhereCore/Sources/DataResolution/DaySamples.swift +++ b/Where/WhereCore/Sources/DataResolution/DaySamples.swift @@ -53,7 +53,7 @@ public final class DaySamples: Sendable { case .gpsVisit, .gpsSignificantChange: byDay[CalendarDay(from: sample.timestamp, in: calendar), default: []] .append(sample) - case .manual, .evidenceImplied: + case .photo, .manual, .evidenceImplied: continue } } diff --git a/Where/WhereCore/Sources/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index 9dff51334..c9a11dd3e 100644 --- a/Where/WhereCore/Sources/Journal/DayJournal.swift +++ b/Where/WhereCore/Sources/Journal/DayJournal.swift @@ -108,6 +108,24 @@ public actor DayJournal { await reconcileAfterDayDataChange() } + /// Persist an approved photo-history draft as one transaction, including + /// any authoritative day corrections made in the preview. Re-importing is + /// idempotent because the planner gives every sample a deterministic id. + public func importPhotoHistory(_ history: PhotoHistoryImport) async throws { + guard !history.samples.isEmpty || !history.corrections.isEmpty else { return } + try await Self.logger.measure(.importPhotoHistory, budget: .seconds(5)) { + try await store.performInCurrentGeneration { + for sample in history.samples { + try await store.add(sample: sample) + } + for correction in history.corrections { + try await store.setManualDay(correction) + } + } + } + await reconcileAfterDayDataChange() + } + // MARK: - Retroactive entry public func addManualSample(_ sample: LocationSample) async throws { diff --git a/Where/WhereCore/Sources/Location/LocationSample.swift b/Where/WhereCore/Sources/Location/LocationSample.swift index b0e2b7d4e..4e182f852 100644 --- a/Where/WhereCore/Sources/Location/LocationSample.swift +++ b/Where/WhereCore/Sources/Location/LocationSample.swift @@ -12,6 +12,8 @@ public enum SampleSource: Sendable, Hashable, Codable { case gpsVisit /// A `CLLocationManager` significant-change callback. case gpsSignificantChange + /// Read from the location metadata of a photo the user approved importing. + case photo /// User typed in a coordinate or picked a place after the fact. case manual /// Derived from an attached piece of evidence (e.g. a boarding pass). @@ -26,6 +28,7 @@ public enum SampleSource: Sendable, Hashable, Codable { switch self { case .gpsVisit: "gpsVisit" case .gpsSignificantChange: "gpsSignificantChange" + case .photo: "photo" case .manual: "manual" case .evidenceImplied: "evidenceImplied" } @@ -37,7 +40,7 @@ public enum SampleSource: Sendable, Hashable, Codable { public var isGPS: Bool { switch self { case .gpsVisit, .gpsSignificantChange: true - case .manual, .evidenceImplied: false + case .photo, .manual, .evidenceImplied: false } } @@ -68,6 +71,7 @@ public enum SampleSource: Sendable, Hashable, Codable { switch candidate { case .gpsVisit: return .gpsVisit case .gpsSignificantChange: return .gpsSignificantChange + case .photo: return .photo case .manual: return .manual case .evidenceImplied: guard let evidenceId, @@ -87,6 +91,7 @@ public enum SampleSource: Sendable, Hashable, Codable { private static let knownCases: [SampleSource] = [ .gpsVisit, .gpsSignificantChange, + .photo, .manual, .evidenceImplied( id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, diff --git a/Where/WhereCore/Sources/Logging/DayJournalLog.swift b/Where/WhereCore/Sources/Logging/DayJournalLog.swift index f13aeb29f..8171e2f7e 100644 --- a/Where/WhereCore/Sources/Logging/DayJournalLog.swift +++ b/Where/WhereCore/Sources/Logging/DayJournalLog.swift @@ -14,6 +14,8 @@ enum DayJournalLog: LogEvent { enum SpanName: Hashable { /// A bulk sample load in one transaction (fixtures, future imports). case ingestBatch + /// A photo-history batch plus its approved day corrections. + case importPhotoHistory /// A date-range manual-day backfill in one transaction. case backfillDays /// A multi-day overlay clear in one transaction. diff --git a/Where/WhereCore/Sources/PhotoImport/PhotoHistoryDraft.swift b/Where/WhereCore/Sources/PhotoImport/PhotoHistoryDraft.swift new file mode 100644 index 000000000..156dc94ed --- /dev/null +++ b/Where/WhereCore/Sources/PhotoImport/PhotoHistoryDraft.swift @@ -0,0 +1,205 @@ +import CryptoKit +import Foundation +import RegionKit + +/// A provisional current-year history derived from photo locations. It stays +/// entirely in memory until the user approves it during onboarding. +public struct PhotoHistoryDraft: Sendable { + /// How an affected day should be committed. + public enum DayDecision: Sendable, Equatable { + /// Keep the photo samples and use their attributed regions. + case included + /// Do not import any photo samples from this day. + case excluded + /// Keep the photo samples, but authoritatively use these regions in the + /// year report. The raw samples remain available for an audit/reset. + case corrected(Set) + } + + public let year: Int + public let calendar: Calendar + public let samples: [LocationSample] + + private let attributor: RegionAttributor + private var decisions: [CalendarDay: DayDecision] = [:] + + public init( + year: Int, + calendar: Calendar, + samples: [LocationSample], + regions: [Region], + ) { + self.year = year + self.calendar = calendar + self.samples = samples.sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp } + return $0.id.uuidString < $1.id.uuidString + } + attributor = RegionAttributor(for: regions) + } + + /// Calendar days represented by at least one candidate sample. + public var affectedDays: [CalendarDay] { + Array(Set(samples.map { CalendarDay(from: $0.timestamp, in: calendar) })).sorted() + } + + public func decision(for day: CalendarDay) -> DayDecision { + decisions[day] ?? .included + } + + public var hasExcludedDays: Bool { + decisions.values.contains(.excluded) + } + + /// Restore every excluded day to its photo-derived attribution without + /// disturbing corrections made to other days. + public mutating func restoreExcludedDays() { + decisions = decisions.filter { $0.value != .excluded } + } + + /// Apply one decision to every candidate day in an inclusive range. + public mutating func setDecision( + _ decision: DayDecision, + from start: CalendarDay, + through end: CalendarDay, + ) { + guard start <= end else { return } + for day in affectedDays where day >= start && day <= end { + if decision == .included { + decisions.removeValue(forKey: day) + } else { + decisions[day] = decision + } + } + } + + /// The report rendered by the onboarding preview after applying all draft + /// exclusions and authoritative corrections. + public var report: YearReport { + let included = samples.filter { sample in + decision(for: CalendarDay(from: sample.timestamp, in: calendar)) != .excluded + } + let corrections = decisions.compactMap { day, decision -> DayPresence? in + guard case let .corrected(regions) = decision else { return nil } + return DayPresence(day: day, regions: regions, isAuthoritative: true, audit: nil) + } + return DayAggregator(calendar: calendar, timeZone: calendar.timeZone).report( + for: year, + samples: included, + manualDays: corrections, + attributor: attributor, + ) + } + + /// Build the atomic payload persisted after the user approves the preview. + /// Every authoritative correction carries the audit of that approval. + public func approvedImport(audit: ManualEntryAudit) -> PhotoHistoryImport { + let approvedSamples = samples.filter { sample in + decision(for: CalendarDay(from: sample.timestamp, in: calendar)) != .excluded + } + let corrections = decisions.compactMap { day, decision -> DayPresence? in + guard case let .corrected(regions) = decision else { return nil } + return DayPresence(day: day, regions: regions, isAuthoritative: true, audit: audit) + } + return PhotoHistoryImport(samples: approvedSamples, corrections: corrections) + } +} + +/// Samples and authoritative day corrections saved as one photo-history import. +public struct PhotoHistoryImport: Sendable, Equatable { + public let samples: [LocationSample] + public let corrections: [DayPresence] + + public init(samples: [LocationSample], corrections: [DayPresence]) { + self.samples = samples + self.corrections = corrections + } +} + +/// Filters photo metadata using the onboarding's best-effort device-capture +/// policy and builds a provisional, editable history. +public struct PhotoHistoryPlanner: Sendable { + public static let captureAddedTolerance: TimeInterval = 5 * 60 + + public init() {} + + @concurrent + public func makeDraft( + assets: [PhotoLocationAsset], + year: Int, + regions: [Region], + calendar: Calendar, + now: Date, + ) async throws -> PhotoHistoryDraft { + let aggregator = DayAggregator(calendar: calendar, timeZone: calendar.timeZone) + let yearInterval = aggregator.yearInterval(year: year) + let end = min(yearInterval.end, now) + let interval = DateInterval(start: yearInterval.start, end: end) + var samplesByID: [UUID: LocationSample] = [:] + samplesByID.reserveCapacity(assets.count) + + for asset in assets { + try Task.checkCancellation() + guard asset.source == .userLibrary, + asset.isHidden == false, + let capturedAt = asset.capturedAt, + let addedAt = asset.addedAt, + interval.contains(capturedAt), + abs(addedAt.timeIntervalSince(capturedAt)) <= Self.captureAddedTolerance, + let coordinate = asset.coordinate, + (-90.0 ... 90.0).contains(coordinate.latitude), + (-180.0 ... 180.0).contains(coordinate.longitude), + let accuracy = asset.horizontalAccuracy, + accuracy >= 0 + else { continue } + + let id = Self.sampleID(timestamp: capturedAt, coordinate: coordinate) + samplesByID[id] = LocationSample( + id: id, + timestamp: capturedAt, + coordinate: coordinate, + horizontalAccuracy: accuracy, + source: .photo, + ) + } + + return PhotoHistoryDraft( + year: year, + calendar: calendar, + samples: Array(samplesByID.values), + regions: regions, + ) + } + + /// Stable, privacy-preserving identity derived only from metadata Where + /// persists. Multiple images at the same millisecond and coordinate collapse + /// to one useful location sample, and Photos identifiers are never stored. + private static func sampleID(timestamp: Date, coordinate: Coordinate) -> UUID { + let milliseconds = Int64((timestamp.timeIntervalSince1970 * 1000).rounded()) + let latitude = Int64((coordinate.latitude * 10_000_000).rounded()) + let longitude = Int64((coordinate.longitude * 10_000_000).rounded()) + let input = Data("photo|\(milliseconds)|\(latitude)|\(longitude)".utf8) + var bytes = Array(SHA256.hash(data: input).prefix(16)) + // RFC 9562 UUIDv8: application-defined payload plus the standard variant. + bytes[6] = (bytes[6] & 0x0F) | 0x80 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return 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], + )) + } +} diff --git a/Where/WhereCore/Sources/PhotoImport/PhotoLocationLibrary.swift b/Where/WhereCore/Sources/PhotoImport/PhotoLocationLibrary.swift new file mode 100644 index 000000000..18159613c --- /dev/null +++ b/Where/WhereCore/Sources/PhotoImport/PhotoLocationLibrary.swift @@ -0,0 +1,75 @@ +import Foundation +import RegionKit + +/// The user's current Photos read authorization, expressed without leaking +/// PhotoKit types into the domain and presentation layers. +public enum PhotoLibraryAuthorization: Sendable, Equatable { + case notDetermined + case limited + case authorized + case denied + case restricted +} + +/// The broad library bucket PhotoKit reports for an asset. PhotoKit does not +/// expose the identity of the device that originally captured an image. +public enum PhotoAssetLibrarySource: Sendable, Equatable { + case userLibrary + case cloudShared + case synced +} + +/// Location-related metadata for one image in the user's Photos library. +/// +/// Optional fields preserve malformed/incomplete assets until the pure planner +/// decides whether they are importable. No image data, filename, caption, or +/// Photos identifier crosses this boundary. +public struct PhotoLocationAsset: Sendable, Equatable { + public let capturedAt: Date? + public let addedAt: Date? + public let coordinate: Coordinate? + public let horizontalAccuracy: Double? + public let source: PhotoAssetLibrarySource + public let isHidden: Bool + + public init( + capturedAt: Date?, + addedAt: Date?, + coordinate: Coordinate?, + horizontalAccuracy: Double?, + source: PhotoAssetLibrarySource, + isHidden: Bool, + ) { + self.capturedAt = capturedAt + self.addedAt = addedAt + self.coordinate = coordinate + self.horizontalAccuracy = horizontalAccuracy + self.source = source + self.isHidden = isHidden + } +} + +/// Read-only access to photo location metadata. Production is backed by +/// PhotoKit; tests and previews inject a scripted implementation. +public protocol PhotoLocationLibrary: Sendable { + func authorizationStatus() async -> PhotoLibraryAuthorization + func requestAuthorization() async -> PhotoLibraryAuthorization + func assets(in interval: DateInterval) async throws -> [PhotoLocationAsset] +} + +/// Preview/test default that never prompts or returns Photos data. +public struct UnavailablePhotoLocationLibrary: PhotoLocationLibrary { + public init() {} + + public func authorizationStatus() async -> PhotoLibraryAuthorization { + .denied + } + + public func requestAuthorization() async -> PhotoLibraryAuthorization { + .denied + } + + public func assets(in _: DateInterval) async throws -> [PhotoLocationAsset] { + [] + } +} diff --git a/Where/WhereCore/Sources/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift index 01f929093..51abf2c5b 100644 --- a/Where/WhereCore/Sources/RegionAttribution.swift +++ b/Where/WhereCore/Sources/RegionAttribution.swift @@ -3,6 +3,12 @@ import os import PeriscopeCore import RegionKit +/// A live attributor that can deterministically catch up with its backing +/// store before a dependent operation continues. +protocol RegionAttributionReconciling: RegionAttributing { + func reconcile() async +} + /// A live, swappable `RegionAttributing` derived from the store's tracked /// regions. A reference type so every `WhereServices` collaborator that holds it /// sees a rebuild the moment the tracked set changes — a local edit or a remote @@ -12,7 +18,7 @@ import RegionKit /// snapshot. Rebuilding (re-parsing the per-region GeoJSON) happens only when /// 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 { +final class RegionAttribution: RegionAttributionReconciling { /// 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. diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index aff11e36c..6f847f1c1 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -383,6 +383,9 @@ public struct WhereServices: Sendable { try await store.performInCurrentGeneration { try await store.setPrimaryRegions(regions) } + if let liveAttributor = attributor as? any RegionAttributionReconciling { + await liveAttributor.reconcile() + } } /// Return the services to a clean slate for the app's "erase all data & reset" teardown: diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index ea5c54ff1..8ccee5b51 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -78,6 +78,13 @@ struct BackupServiceTests { horizontalAccuracy: 10, source: .evidenceImplied(id: evidenceWithBlobId, kind: .boardingPass), ), + LocationSample( + id: UUID(uuidString: "33333333-3333-3333-3333-333333333333")!, + timestamp: Date(timeIntervalSince1970: 1_700_200_000), + coordinate: Coordinate(latitude: 41.8781, longitude: -87.6298), + horizontalAccuracy: 8, + source: .photo, + ), ] } @@ -239,12 +246,12 @@ struct BackupServiceTests { } @Test func unsupportedFormatIsRejectedBeforeItsMissingCurrentFieldsAreDecoded() { - let legacyManifest = Data(#"{"formatVersion":5}"#.utf8) + let futureManifest = Data(#"{"formatVersion":6}"#.utf8) do { - _ = try BackupService.decodeManifest(legacyManifest) - Issue.record("Expected the legacy backup format to be rejected.") - } catch BackupService.BackupError.unsupportedFormatVersion(5) { + _ = try BackupService.decodeManifest(futureManifest) + Issue.record("Expected the future backup format to be rejected.") + } catch BackupService.BackupError.unsupportedFormatVersion(6) { // Expected: the version envelope was decoded before the strict current shape. } catch { Issue.record("Unexpected error: \(error)") diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift index fe35fc0b1..c3f1e3eec 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -140,6 +140,34 @@ struct DayJournalTests { #expect(await h.widgets.publishCount == 1) } + @Test func photoHistoryImportPersistsSamplesAndCorrectionsWithOneFanOut() async throws { + let h = try Self.makeHarness() + let sample = sample(at: "2026-01-10T12:00:00-08:00", source: .photo) + let day = CalendarDay(from: sample.timestamp, in: WhereCoreTestSupport.calendar()) + let audit = ManualEntryAudit( + recordedAt: WhereCoreTestSupport.iso("2026-08-07T12:00:00-07:00"), + note: nil, + location: nil, + ) + + try await h.journal.importPhotoHistory(PhotoHistoryImport( + samples: [sample], + corrections: [DayPresence( + day: day, + regions: [.newYork], + isAuthoritative: true, + audit: audit, + )], + )) + + let report = try await h.reader.yearReport(for: 2026) + #expect(report.days.first?.regions == [.newYork]) + #expect(try await h.store.allSamples().first?.source == .photo) + #expect(try await h.store.allManualDays().first?.audit == audit) + #expect(await h.reminders.reconcileCount == 1) + #expect(await h.widgets.publishCount == 1) + } + @Test func emptyBulkIngestIsANoOp() async throws { let h = try Self.makeHarness() try await h.journal.ingest([LocationSample]()) @@ -341,13 +369,16 @@ struct DayJournalTests { ), ) - private func sample(at isoString: String) -> LocationSample { + private func sample( + at isoString: String, + source: SampleSource = .manual, + ) -> LocationSample { LocationSample( id: UUID(), timestamp: WhereCoreTestSupport.iso(isoString), coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), horizontalAccuracy: 5, - source: .manual, + source: source, ) } } diff --git a/Where/WhereCore/Tests/PhotoHistoryDraftTests.swift b/Where/WhereCore/Tests/PhotoHistoryDraftTests.swift new file mode 100644 index 000000000..b6da45c65 --- /dev/null +++ b/Where/WhereCore/Tests/PhotoHistoryDraftTests.swift @@ -0,0 +1,180 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct PhotoHistoryPlannerTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + @Test func acceptsOnlyLikelyCurrentYearDeviceCaptures() async throws { + let capture = try #require(ISO8601DateFormatter().date(from: "2026-03-10T12:00:00Z")) + let valid = asset(capturedAt: capture, addedAt: capture.addingTimeInterval(300)) + let tooLate = asset(capturedAt: capture, addedAt: capture.addingTimeInterval(301)) + let shared = asset(capturedAt: capture, addedAt: capture, source: .cloudShared) + let hidden = asset(capturedAt: capture, addedAt: capture, isHidden: true) + let invalidAccuracy = asset(capturedAt: capture, addedAt: capture, accuracy: -1) + let old = try asset( + capturedAt: #require(ISO8601DateFormatter().date(from: "2025-12-31T23:59:59Z")), + addedAt: capture, + ) + + let draft = try await PhotoHistoryPlanner().makeDraft( + assets: [valid, tooLate, shared, hidden, invalidAccuracy, old], + year: 2026, + regions: [.california], + calendar: calendar, + now: #require(ISO8601DateFormatter().date(from: "2026-08-07T12:00:00Z")), + ) + + #expect(draft.samples.count == 1) + #expect(draft.samples.first?.source == .photo) + } + + @Test func duplicateMetadataProducesOneStableSample() async throws { + let capture = try #require(ISO8601DateFormatter().date(from: "2026-03-10T12:00:00Z")) + let input = asset(capturedAt: capture, addedAt: capture) + let planner = PhotoHistoryPlanner() + let now = try #require(ISO8601DateFormatter().date(from: "2026-08-07T12:00:00Z")) + let first = try await planner.makeDraft( + assets: [input, input], + year: 2026, + regions: [.california], + calendar: calendar, + now: now, + ) + let second = try await planner.makeDraft( + assets: [input], + year: 2026, + regions: [.california], + calendar: calendar, + now: now, + ) + + #expect(first.samples.count == 1) + #expect(first.samples.first?.id == second.samples.first?.id) + } + + private func asset( + capturedAt: Date?, + addedAt: Date?, + source: PhotoAssetLibrarySource = .userLibrary, + isHidden: Bool = false, + accuracy: Double = 12, + ) -> PhotoLocationAsset { + PhotoLocationAsset( + capturedAt: capturedAt, + addedAt: addedAt, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: accuracy, + source: source, + isHidden: isHidden, + ) + } +} + +struct PhotoHistoryDraftTests { + private static let audit = ManualEntryAudit( + recordedAt: Date(timeIntervalSince1970: 1_800_000_000), + note: nil, + location: nil, + ) + + @Test func exclusionsAndCorrectionsRebuildPreviewAndImport() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let firstDate = try #require(ISO8601DateFormatter().date(from: "2026-01-01T12:00:00Z")) + let secondDate = try #require(ISO8601DateFormatter().date(from: "2026-01-02T12:00:00Z")) + var draft = PhotoHistoryDraft( + year: 2026, + calendar: calendar, + samples: [ + LocationSample( + timestamp: firstDate, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 10, + source: .photo, + ), + LocationSample( + timestamp: secondDate, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 10, + source: .photo, + ), + ], + regions: [.california, .newYork], + ) + let first = CalendarDay(from: firstDate, in: calendar) + let second = CalendarDay(from: secondDate, in: calendar) + + draft.setDecision(.corrected([.newYork]), from: first, through: second) + draft.setDecision(.excluded, from: first, through: first) + + #expect(draft.report.days == [ + DayPresence(day: second, regions: [.newYork], isAuthoritative: false, audit: nil), + ]) + let approved = draft.approvedImport(audit: Self.audit) + #expect(approved.samples.map(\.timestamp) == [secondDate]) + #expect(approved.corrections.map(\.day) == [second]) + #expect(approved.corrections.first?.audit == Self.audit) + #expect(draft.decision(for: first) == .excluded) + #expect(draft.decision(for: second) == .corrected([.newYork])) + } + + @Test func includedDecisionResetsAnEdit() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let date = try #require(ISO8601DateFormatter().date(from: "2026-01-01T12:00:00Z")) + let day = CalendarDay(from: date, in: calendar) + var draft = PhotoHistoryDraft( + year: 2026, + calendar: calendar, + samples: [LocationSample( + timestamp: date, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 10, + source: .photo, + )], + regions: [.california], + ) + + draft.setDecision(.excluded, from: day, through: day) + draft.setDecision(.included, from: day, through: day) + + #expect(draft.decision(for: day) == .included) + #expect(draft.approvedImport(audit: Self.audit).samples.count == 1) + } + + @Test func restoringExclusionsPreservesOtherCorrections() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let firstDate = try #require(ISO8601DateFormatter().date(from: "2026-01-01T12:00:00Z")) + let secondDate = try #require(ISO8601DateFormatter().date(from: "2026-01-02T12:00:00Z")) + let first = CalendarDay(from: firstDate, in: calendar) + let second = CalendarDay(from: secondDate, in: calendar) + var draft = PhotoHistoryDraft( + year: 2026, + calendar: calendar, + samples: [firstDate, secondDate].map { + LocationSample( + timestamp: $0, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 10, + source: .photo, + ) + }, + regions: [.california, .newYork], + ) + draft.setDecision(.excluded, from: first, through: first) + draft.setDecision(.corrected([.newYork]), from: second, through: second) + + draft.restoreExcludedDays() + + #expect(draft.hasExcludedDays == false) + #expect(draft.decision(for: first) == .included) + #expect(draft.decision(for: second) == .corrected([.newYork])) + } +} diff --git a/Where/WhereCore/Tests/PhotoLocationLibraryTests.swift b/Where/WhereCore/Tests/PhotoLocationLibraryTests.swift new file mode 100644 index 000000000..1024c2b87 --- /dev/null +++ b/Where/WhereCore/Tests/PhotoLocationLibraryTests.swift @@ -0,0 +1,12 @@ +import Foundation +import Testing +@testable import WhereCore + +struct PhotoLocationLibraryTests { + @Test func unavailableLibraryNeverPromptsOrReturnsAssets() async throws { + let library = UnavailablePhotoLocationLibrary() + #expect(await library.authorizationStatus() == .denied) + #expect(await library.requestAuthorization() == .denied) + #expect(try await library.assets(in: DateInterval(start: .distantPast, end: .now)) == []) + } +} diff --git a/Where/WhereCore/Tests/WhereCoreTests.swift b/Where/WhereCore/Tests/WhereCoreTests.swift index c298ccac0..656ab378f 100644 --- a/Where/WhereCore/Tests/WhereCoreTests.swift +++ b/Where/WhereCore/Tests/WhereCoreTests.swift @@ -124,6 +124,11 @@ struct EvidenceKindTests { } struct SampleSourceTests { + @Test func photo_roundTripsThroughCodable() throws { + let data = try JSONEncoder().encode(SampleSource.photo) + #expect(try JSONDecoder().decode(SampleSource.self, from: data) == .photo) + } + @Test func evidenceImplied_roundTripsThroughCodable() throws { let evidenceId = UUID() let original = SampleSource.evidenceImplied(id: evidenceId, kind: .boardingPass) diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index a1ad9d8be..362d2f1de 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -87,6 +87,23 @@ struct WhereServicesTests { #expect(report.totals[.other] == 1) } + @Test func settingPrimaryRegionsWaitsForLiveAttribution() async throws { + let store = try SwiftDataStore.inMemory() + let recorder = ReconcileRecorder() + let attributor = RecordingLiveAttributor(recorder: recorder) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + attributor: attributor, + ) + + try await services.setPrimaryRegions([ + PrimaryRegion(region: .california, appearance: nil, order: 0), + ]) + + #expect(await recorder.count == 1) + } + private static var pacificCalendar: Calendar { WhereCoreTestSupport.calendar() } @@ -1633,6 +1650,34 @@ struct WhereServicesTests { } } +private actor ReconcileRecorder { + private(set) var count = 0 + + func record() { + count += 1 + } +} + +private struct RecordingLiveAttributor: RegionAttributionReconciling { + let recorder: ReconcileRecorder + + func reconcile() async { + await recorder.record() + } + + func region(at coordinate: Coordinate) -> Region { + RegionAttributor.shared.region(at: coordinate) + } + + func distanceToBoundary(of region: Region, from coordinate: Coordinate) -> Double? { + RegionAttributor.shared.distanceToBoundary(of: region, from: coordinate) + } + + var loadedRegions: [Region] { + RegionAttributor.shared.loadedRegions + } +} + @Sendable private func waitUntil( timeout: Duration = .seconds(2), diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 7965f0d81..8afc67985 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -18,6 +18,10 @@ Layering, localization, preview, and testing conventions live in the feature - 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). +- Onboarding's Photos step receives an injected `PhotoLocationLibrary`, keeps + the derived timeline provisional through review, and defers its approved + import until the final recording choice creates the real scope; skipping + must remain side-effect-free. - 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`. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index a0e1bf9ac..df7e20a46 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -115,7 +115,8 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's `OnboardingGate` and handed its `LifecycleGateHandle`. The gate roots the trunk, so there is no session behind it: a paged intro, then picking up to five primary US regions (map or searchable list) and - giving each a look, then verifying this installation's automatic-recording + giving each a look, an optional metadata-only Photos scan that previews and + edits the current year's inferred timeline, 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 @@ -123,7 +124,9 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's 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 + appearances and the approved photo history before resolving the gate. The + Photos scan reads no image data, thumbnails, captions, or filenames and + saves nothing until final confirmation. 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 diff --git a/Where/WhereUI/SnapshotTests/OnboardingPhotoImportViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/OnboardingPhotoImportViewSnapshotTests.swift new file mode 100644 index 000000000..1ea2e4e05 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/OnboardingPhotoImportViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct OnboardingPhotoImportViewSnapshotTests { + @Test func photoImport() async { + await assertSnapshots(of: OnboardingPhotoImportView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad.png new file mode 100644 index 000000000..2f57ec81e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2de6de65832d04b6eeca00a2e3da676ba9d0401220cf10b7e5e178ce272ce4c +size 273833 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_accessibility.png new file mode 100644 index 000000000..bb3f3f69c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84a0b50c49ce5aecd09c51f61db63c4a3f65b89ec5755bdf68a61bc42e9c64b0 +size 473312 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_ax5.png new file mode 100644 index 000000000..54dfe24fe --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe7a7077c86b025907099893f3790d93f42b53e1c22ad592fe874acd54cef01f +size 491757 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_contrast.png new file mode 100644 index 000000000..e5a1472a8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6766af35e8ad44b9bbef6fb2e1c184d57a94e972f3cfb1a6b28c7cfd70d75705 +size 275653 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_dark.png new file mode 100644 index 000000000..4a31bb473 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d226f398888557a1019b9628403e1bfb7027bc0b1c2c48c030b6844197969192 +size 296128 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone.png new file mode 100644 index 000000000..bfbf4f02a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:502da7b901213f5a1c5b9c98396ab41685bb469882bd1a89a4343525b1e8b101 +size 178335 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_accessibility.png new file mode 100644 index 000000000..08b8b997a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f5a1b9799269f76d45bc64127e0f7d62990b0375428b82ae45f6bbe4fa0be69 +size 351225 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_ax5.png new file mode 100644 index 000000000..08be5d314 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9588b40c847e16b27646db9b9f6dd68afbf90bc476947abf0aad3b3eb78170c2 +size 233796 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_contrast.png new file mode 100644 index 000000000..ceaf86c9a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9e0cc13df69a3e96e2bc6714a1a3026218156e5fd587e14262c65d8bfa3caed +size 178665 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_dark.png new file mode 100644 index 000000000..178db1106 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Offer_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a05d2b44c700d5cd0d571b6db3883a0748716e391151d8d7537ab5efbcb94f3d +size 188420 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad.png new file mode 100644 index 000000000..38798b01e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:593a7d31276fddc5683f476a1f37d63ccecddc0973f38bef35bc9efcef646b14 +size 297012 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_accessibility.png new file mode 100644 index 000000000..0f2f73782 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad5b4d098b6578a0b7198d385a5b5e9929ec4f7871a233cc79f4b122254572d2 +size 514230 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_ax5.png new file mode 100644 index 000000000..65420c9d9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96528c4670cf0b3fec3714e251458f408f904edee8bfb00331bfa41fd9e958aa +size 534293 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_contrast.png new file mode 100644 index 000000000..04f0cc1f5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a5b3c9cf0ca7b513c7cbeb900e32c160319615bcbc9e1251404aa0836e25b0f +size 293580 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_dark.png new file mode 100644 index 000000000..3a788a3ba --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63452e357f817332f709d66e78bc2bb9d9ad77115add18d74937a1c012e74c99 +size 295449 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone.png new file mode 100644 index 000000000..032ef7e43 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b4ce7f33dcda9009eeb259470899a378b3260e7b6b6466603e060431fee2fe6 +size 185174 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_accessibility.png new file mode 100644 index 000000000..4373507c4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a25f6c1603b8a88dce9c7a426e75f22c56fb2e12f0c597241d4dcda62197687 +size 370910 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_ax5.png new file mode 100644 index 000000000..de69dbabd --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96516749ddb718362c668b5c271a3ebd2ddeb2e2786332e27432633faedadeb0 +size 317358 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_contrast.png new file mode 100644 index 000000000..a60b5674f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9f38521a61a6f7e96ba6b5e34a36fb541223eb48b54b3a7caea21b7e0abc82c +size 182128 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_dark.png new file mode 100644 index 000000000..95149f9f5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/OnboardingPhotoImportViewSnapshotTests/photoImport.Review_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:644d6a99e2fe2c568346b63d95814789085a05d835869c3ebf10945a7de0fad7 +size 184047 diff --git a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift index 1325fb8ad..522c94a9e 100644 --- a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift +++ b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift @@ -4,8 +4,14 @@ import PeriscopeCore /// flow continues and the user isn't stranded — so they log at `.warning`; /// only a scope that can't be created is fatal to the launch. enum OnboardingViewLog: LogEvent { + enum SpanName: Hashable { + case photoScan + } + case regionCommitFailed(description: String) case backupRestoreFailed(description: String) + case photoScanFailed(description: String) + case photoImportFailed(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 @@ -35,7 +41,8 @@ enum OnboardingViewLog: LogEvent { var level: LogLevel { switch self { - case .regionCommitFailed, .backupRestoreFailed, .demoBuildFailed, + case .regionCommitFailed, .backupRestoreFailed, .photoScanFailed, + .photoImportFailed, .demoBuildFailed, .discardedCorruptInstallationContextPending: .warning case .locationPermissionDenied: .info case .installationContextWriteFailed, .installationContextSecurityCleanupFailed, @@ -50,6 +57,10 @@ enum OnboardingViewLog: LogEvent { "Failed to commit onboarding region picks: \(description)" case let .backupRestoreFailed(description): "Onboarding backup restore failed: \(description)" + case let .photoScanFailed(description): + "Onboarding photo metadata scan failed: \(description)" + case let .photoImportFailed(description): + "Onboarding photo history import failed: \(description)" case let .backupRestoreCleanupFailed(description): "Onboarding backup restore committed but recording cleanup failed: \(description)" case .locationPermissionDenied: diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index be6cdda34..f7010c455 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -147,6 +147,11 @@ public final class WhereModel { private let now: @Sendable () -> Date + /// Metadata-only Photos access for the optional onboarding backfill. The + /// production adapter is composed by the app; tests/previews use the + /// unavailable default and never prompt. + let photoLibrary: any PhotoLocationLibrary + /// The year the scene's `YearReportModel` opens on. Always the current year in /// the app; a preview/test can pin it via the services init. let initialSelectedYear: Int @@ -298,6 +303,7 @@ public final class WhereModel { any InstallationRecordingContextStoring, ) -> any WhereScopeAssembling, logSystem: Periscope, + photoLibrary: any PhotoLocationLibrary = UnavailablePhotoLocationLibrary(), now: @escaping @Sendable () -> Date = { Date() }, ) { self.preferences = preferences @@ -307,6 +313,7 @@ public final class WhereModel { ) self.makeBootstrap = makeBootstrap self.logSystem = logSystem + self.photoLibrary = photoLibrary self.now = now scopeState = .loggedOut(bootstrap: makeBootstrap(installationContextStore)) initialSelectedYear = WhereModel.currentYear @@ -328,6 +335,7 @@ public final class WhereModel { selectedYear: Int = WhereModel.currentYear, preferences: WherePreferences, logSystem: Periscope, + photoLibrary: any PhotoLocationLibrary = UnavailablePhotoLocationLibrary(), now: @escaping @Sendable () -> Date = { Date() }, ) { let installationContextStore = InMemoryInstallationRecordingContextStore( @@ -346,6 +354,7 @@ public final class WhereModel { ) makeBootstrap = { _ in InjectedServicesAssembler(services: services) } self.logSystem = logSystem + self.photoLibrary = photoLibrary self.now = now initialSelectedYear = selectedYear initialYearDetails = details diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift b/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift index cadcef5fc..15c8ff3f0 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift @@ -12,6 +12,7 @@ final class OnboardingFlowModel { case intro case pickRegions case customize + case photos case location } @@ -27,6 +28,7 @@ final class OnboardingFlowModel { var phase: Phase var page = 0 var selection = PrimaryRegionSelectionModel() + var photoImport = OnboardingPhotoImportModel() var recordingEnabled: Bool var deviceDiscovery = DeviceDiscovery.idle var isFinishing = false @@ -35,6 +37,8 @@ final class OnboardingFlowModel { var showImporter = false var showRestoreStrategyDialog = false + private var pendingPhotoDraft: PhotoHistoryDraft? + private static let demoBuildDisplayTime = Duration.seconds(2) private static let logger = WhereLog.session(OnboardingViewLog.self) @@ -79,6 +83,32 @@ final class OnboardingFlowModel { } } + func scanPhotos(using model: WhereModel) { + photoImport.beginScan() + Task { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + await photoImport.scan( + library: model.photoLibrary, + year: WhereModel.currentYear, + regions: selection.selectedRegions, + calendar: calendar, + now: Date(), + ) + } + } + + func approvePhotoHistory() { + guard let draft = photoImport.beginImport() else { return } + pendingPhotoDraft = draft + phase = .location + } + + func skipPhotoHistory() { + pendingPhotoDraft = nil + phase = .location + } + func finish(using model: WhereModel) { guard !isFinishing else { return } let readyImport = restoreSelection.readyImport @@ -123,6 +153,26 @@ final class OnboardingFlowModel { guard await importBackup(readyImport, into: scope, using: model) else { return } } + if selection.hasSelection { + do { + try await selection.commit(using: scope) + } catch { + Self.logger(attachments: [.error(error, name: "commit-error")]) { + .regionCommitFailed(description: error.localizedDescription) + } + if pendingPhotoDraft != nil { + photoImport.importFailed(error) + isFinishing = false + phase = .photos + return + } + } + } + + if let pendingPhotoDraft { + guard await importPhotoHistory(pendingPhotoDraft, into: scope) else { return } + } + do { try await configureRecording(in: scope) } catch { @@ -139,16 +189,6 @@ final class OnboardingFlowModel { } return } - - if selection.hasSelection { - do { - try await selection.commit(using: scope) - } catch { - Self.logger(attachments: [.error(error, name: "commit-error")]) { - .regionCommitFailed(description: error.localizedDescription) - } - } - } if !model.hasOnboarded { model.completeOnboarding() } @@ -287,4 +327,38 @@ final class OnboardingFlowModel { Self.logger { .locationPermissionDenied } } } + + private func importPhotoHistory( + _ draft: PhotoHistoryDraft, + into scope: WhereScope, + ) async -> Bool { + do { + let sample = await scope.services.ingestor.currentLocation() + let location = sample.map { + CapturedLocation( + coordinate: $0.coordinate, + horizontalAccuracy: $0.horizontalAccuracy, + timestamp: $0.timestamp, + ) + } + let history = draft.approvedImport(audit: ManualEntryAudit( + recordedAt: Date(), + note: nil, + location: location, + )) + try await scope.services.journal.importPhotoHistory(history) + pendingPhotoDraft = nil + return true + } catch is CancellationError { + photoImport.importCancelled() + } catch { + photoImport.importFailed(error) + Self.logger(attachments: [.error(error, name: "photo-import-error")]) { + .photoImportFailed(description: error.localizedDescription) + } + } + isFinishing = false + phase = .photos + return false + } } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingPhotoImportModel.swift b/Where/WhereUI/Sources/Onboarding/OnboardingPhotoImportModel.swift new file mode 100644 index 000000000..b67b95dd6 --- /dev/null +++ b/Where/WhereUI/Sources/Onboarding/OnboardingPhotoImportModel.swift @@ -0,0 +1,140 @@ +import Foundation +import Observation +import RegionKit +import WhereCore + +/// Observable state for onboarding's optional, metadata-only Photos scan. +/// The draft is provisional until `OnboardingView` commits its approved import. +@MainActor +@Observable +final class OnboardingPhotoImportModel { + private static let logger = WhereLog.session(OnboardingViewLog.self) + + enum Activity { + case offer + case scanning + case blocked(PhotoLibraryAuthorization) + case empty(isLimited: Bool) + case ready(PhotoHistoryDraft, isLimited: Bool) + case importing(PhotoHistoryDraft, isLimited: Bool) + } + + var activity: Activity = .offer + var errorMessage: String? + + var isShowingError: Bool { + get { errorMessage != nil } + set { if !newValue { errorMessage = nil } } + } + + var draft: PhotoHistoryDraft? { + switch activity { + case let .ready(draft, _), let .importing(draft, _): draft + case .offer, .scanning, .blocked, .empty: nil + } + } + + var isLimited: Bool { + switch activity { + case let .empty(isLimited), let .ready(_, isLimited), let .importing(_, isLimited): + isLimited + case .offer, .scanning, .blocked: false + } + } + + func scan( + library: any PhotoLocationLibrary, + year: Int, + regions: [Region], + calendar: Calendar, + now: Date, + ) async { + guard case .scanning = activity else { return } + do { + var authorization = await library.authorizationStatus() + if authorization == .notDetermined { + authorization = await library.requestAuthorization() + } + guard authorization == .authorized || authorization == .limited else { + activity = .blocked(authorization) + return + } + + let yearInterval = DayAggregator( + calendar: calendar, + timeZone: calendar.timeZone, + ).yearInterval(year: year) + let draft = try await Self.logger.measure(.photoScan, budget: .seconds(15)) { + let assets = try await library.assets(in: DateInterval( + start: yearInterval.start, + end: min(yearInterval.end, now), + )) + return try await PhotoHistoryPlanner().makeDraft( + assets: assets, + year: year, + regions: regions, + calendar: calendar, + now: now, + ) + } + guard Task.isCancelled == false else { return } + let limited = authorization == .limited + activity = draft.samples.isEmpty ? .empty(isLimited: limited) : .ready( + draft, + isLimited: limited, + ) + } catch is CancellationError { + activity = .offer + } catch { + activity = .offer + errorMessage = error.localizedDescription + Self.logger(attachments: [.error(error, name: "photo-scan-error")]) { + .photoScanFailed(description: error.localizedDescription) + } + } + } + + func beginScan() { + errorMessage = nil + activity = .scanning + } + + func apply( + _ decision: PhotoHistoryDraft.DayDecision, + from start: Date, + through end: Date, + ) { + guard case let .ready(current, limited) = activity else { return } + var updated = current + updated.setDecision( + decision, + from: CalendarDay(from: start, in: current.calendar), + through: CalendarDay(from: end, in: current.calendar), + ) + activity = .ready(updated, isLimited: limited) + } + + func restoreExcludedDays() { + guard case let .ready(current, limited) = activity else { return } + var updated = current + updated.restoreExcludedDays() + activity = .ready(updated, isLimited: limited) + } + + func beginImport() -> PhotoHistoryDraft? { + guard case let .ready(draft, limited) = activity else { return nil } + activity = .importing(draft, isLimited: limited) + return draft + } + + func importFailed(_ error: any Error) { + guard case let .importing(draft, limited) = activity else { return } + activity = .ready(draft, isLimited: limited) + errorMessage = error.localizedDescription + } + + func importCancelled() { + guard case let .importing(draft, limited) = activity else { return } + activity = .ready(draft, isLimited: limited) + } +} diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingPhotoImportView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingPhotoImportView.swift new file mode 100644 index 000000000..fb89e16a5 --- /dev/null +++ b/Where/WhereUI/Sources/Onboarding/OnboardingPhotoImportView.swift @@ -0,0 +1,399 @@ +import RegionKit +import SnapshotKit +import SwiftUI +import UIKit +import WhereCore + +/// Optional onboarding step that scans only Photos metadata, previews a draft +/// timeline, and lets the user correct or exclude date ranges before import. +struct OnboardingPhotoImportView: View { + @Bindable var model: OnboardingPhotoImportModel + let onScan: () -> Void + let onImport: () -> Void + let onSkip: () -> Void + + @Environment(\.stylesheet) private var stylesheet + @State private var editingStint: RegionStint? + + var body: some View { + NavigationStack { + content + .navigationTitle(String(localized: .onboardingPhotoNavigationTitle)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: .onboardingPhotoSkip), action: onSkip) + .disabled(isBusy) + } + } + } + .sheet(item: $editingStint) { stint in + PhotoHistoryStintEditor( + stint: stint, + onApply: model.apply, + ) + } + .alert( + String(localized: .onboardingPhotoErrorTitle), + isPresented: $model.isShowingError, + ) {} message: { + if let message = model.errorMessage { Text(message) } + } + } + + @ViewBuilder + private var content: some View { + switch model.activity { + case .offer: + offer + case .scanning: + AppIconLoadingView(caption: String(localized: .onboardingPhotoScanning)) + case .blocked: + blocked + case let .empty(limited): + empty(isLimited: limited) + case let .ready(draft, limited): + review(draft: draft, isLimited: limited, isImporting: false) + case let .importing(draft, limited): + review(draft: draft, isLimited: limited, isImporting: true) + } + } + + private var offer: some View { + VStack(spacing: stylesheet.spacing.xxxLarge) { + Spacer(minLength: 0) + Image(systemName: "photo.badge.location") + .font(stylesheet.typography.onboardingIcon) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + VStack(spacing: stylesheet.spacing.large) { + Text(String(localized: .onboardingPhotoTitle)) + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + Text(String(localized: .onboardingPhotoDescription)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Label( + String(localized: .onboardingPhotoPrivacy), + systemImage: "lock.shield.fill", + ) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + Spacer(minLength: 0) + Button(action: onScan) { + Text(String(localized: .onboardingPhotoScan)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.bottom, stylesheet.spacing.xxxLarge) + } + + private var blocked: some View { + ContentUnavailableView { + Label( + String(localized: .onboardingPhotoAccessTitle), + systemImage: "photo.badge.exclamationmark", + ) + } description: { + Text(String(localized: .onboardingPhotoAccessDescription)) + } actions: { + Button(String(localized: .onboardingPhotoScan), action: onScan) + Link( + String(localized: .onboardingPhotoOpenSettings), + destination: URL(string: UIApplication.openSettingsURLString)!, + ) + .buttonStyle(.borderedProminent) + } + } + + private func empty(isLimited: Bool) -> some View { + ContentUnavailableView { + Label( + String(localized: .onboardingPhotoEmptyTitle), + systemImage: "calendar.badge.minus", + ) + } description: { + Text(String(localized: isLimited + ? .onboardingPhotoEmptyLimitedDescription + : .onboardingPhotoEmptyDescription)) + } actions: { + if isLimited { + Button(String(localized: .onboardingPhotoScan), action: onScan) + Link( + String(localized: .onboardingPhotoOpenSettings), + destination: URL(string: UIApplication.openSettingsURLString)!, + ) + .buttonStyle(.borderedProminent) + } + } + } + + private func review( + draft: PhotoHistoryDraft, + isLimited: Bool, + isImporting: Bool, + ) -> some View { + let stints = PresenceTimeline.stints(from: draft.report, calendar: draft.calendar) + return VStack(spacing: 0) { + if isLimited { + Label( + String(localized: .onboardingPhotoLimitedNotice), + systemImage: "photo.on.rectangle.angled", + ) + .font(.subheadline) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.yellow.opacity(0.15)) + } + List { + Section { + ForEach(stints) { stint in + Button { editingStint = stint } label: { + PhotoHistoryStintRow(stint: stint, calendar: draft.calendar) + } + .buttonStyle(.plain) + } + } header: { + Text(String(localized: .onboardingPhotoReviewTitle)) + } footer: { + Text(String(localized: .onboardingPhotoReviewFooter)) + } + + if draft.hasExcludedDays { + Section { + Button( + String(localized: .onboardingPhotoRestoreExcluded), + action: model.restoreExcludedDays, + ) + } footer: { + Text(String(localized: .onboardingPhotoRestoreExcludedFooter)) + } + } + + Section { + Button(action: onImport) { + if isImporting { + SavingStatusRow(text: String(localized: .onboardingPhotoImporting)) + } else { + Text(String(localized: .onboardingPhotoImport)) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isImporting || stints.isEmpty) + } + .listRowBackground(Color.clear) + } + } + } + + private var isBusy: Bool { + switch model.activity { + case .scanning, .importing: true + case .offer, .blocked, .empty, .ready: false + } + } +} + +#if DEBUG + extension OnboardingPhotoImportView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + [ + whereSnapshot(name: "Offer", configurations: .screenDefaults) { + OnboardingPhotoImportView( + model: OnboardingPhotoImportModel(), + onScan: {}, + onImport: {}, + onSkip: {}, + ) + }, + whereSnapshot(name: "Review", configurations: .screenDefaults) { + let model = OnboardingPhotoImportModel() + model.activity = .ready(Self.previewDraft, isLimited: true) + return OnboardingPhotoImportView( + model: model, + onScan: {}, + onImport: {}, + onSkip: {}, + ) + }, + ] + } + + private static var previewDraft: PhotoHistoryDraft { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return PhotoHistoryDraft( + year: 2026, + calendar: calendar, + samples: [ + LocationSample( + timestamp: Date(timeIntervalSince1970: 1_768_500_000), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 12, + source: .photo, + ), + LocationSample( + timestamp: Date(timeIntervalSince1970: 1_768_586_400), + coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), + horizontalAccuracy: 15, + source: .photo, + ), + ], + regions: [.california, .newYork], + ) + } + } +#endif + +/// Compact, tappable preview row for one provisional stay. +private struct PhotoHistoryStintRow: View { + let stint: RegionStint + let calendar: Calendar + + @Environment(\.regionStyles) private var regionStyles + + var body: some View { + Label { + VStack(alignment: .leading) { + Text(stint.region.localizedName) + .foregroundStyle(.primary) + Text(dateRange) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } icon: { + Text(regionStyles.style(for: stint.region).emoji) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .accessibilityHint(String(localized: .onboardingPhotoEditHint)) + } + + private var dateRange: String { + DateRangeFormatting.abbreviated( + start: stint.start, + end: stint.end, + calendar: calendar, + ) + } +} + +/// Edits all or part of one provisional stay without touching persistence. +private struct PhotoHistoryStintEditor: View { + let stint: RegionStint + let onApply: (PhotoHistoryDraft.DayDecision, Date, Date) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var start: Date + @State private var end: Date + @State private var regions: RegionSelectionState + + init( + stint: RegionStint, + onApply: @escaping (PhotoHistoryDraft.DayDecision, Date, Date) -> Void, + ) { + self.stint = stint + self.onApply = onApply + _start = State(initialValue: stint.start) + _end = State(initialValue: stint.end) + _regions = State(initialValue: RegionSelectionState(selectedRegions: [stint.region])) + } + + var body: some View { + NavigationStack { + Form { + Section { + WhereDatePicker( + String(localized: .onboardingPhotoFrom), + selection: $start, + earliest: stint.start, + latest: end, + displayedComponents: .date, + ) + WhereDatePicker( + String(localized: .onboardingPhotoThrough), + selection: $end, + earliest: start, + latest: stint.end, + displayedComponents: .date, + ) + } footer: { + Text(String(localized: .onboardingPhotoRangeFooter)) + } + + Section { + ForEach(regions.items) { RegionToggleRow(item: $0) } + } header: { + Text(String(localized: .relabelRegionsHeader)) + } + + Section { + Button(String(localized: .onboardingPhotoUseLocations)) { + onApply(.included, start, end) + dismiss() + } + + Button(String(localized: .onboardingPhotoExclude), role: .destructive) { + onApply(.excluded, start, end) + dismiss() + } + } footer: { + Text(String(localized: .onboardingPhotoExcludeFooter)) + } + } + .navigationTitle(String(localized: .onboardingPhotoEditTitle)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: .commonCancel)) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: .manualSave)) { + onApply(.corrected(regions.selectedRegions), start, end) + dismiss() + } + .disabled(regions.selectedRegions.isEmpty) + } + } + } + } +} + +#if DEBUG + #Preview("Photo history") { + OnboardingPhotoImportView.snapshotPreviews + } + + #Preview("Photo history stint row") { + List { + PhotoHistoryStintRow( + stint: RegionStint( + region: .california, + start: Date(timeIntervalSince1970: 1_768_500_000), + end: Date(timeIntervalSince1970: 1_768_586_400), + dayCount: 2, + ), + calendar: Calendar(identifier: .gregorian), + ) + } + } + + #Preview("Photo history stint editor") { + PhotoHistoryStintEditor( + stint: RegionStint( + region: .california, + start: Date(timeIntervalSince1970: 1_768_500_000), + end: Date(timeIntervalSince1970: 1_768_586_400), + dayCount: 2, + ), + onApply: { _, _, _ in }, + ) + } +#endif diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 74c809ee9..7e40c9553 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -68,6 +68,7 @@ public struct OnboardingView: View { case .intro: introScreen case .pickRegions: pickRegions case .customize: customize + case .photos: photos case .location: location } } @@ -230,11 +231,22 @@ public struct OnboardingView: View { RegionCustomizeView( model: flow.selection, onBack: { flow.phase = .pickRegions }, - onFinish: { flow.phase = .location }, + onFinish: { flow.phase = .photos }, ) } } + // MARK: - Photo history + + private var photos: some View { + OnboardingPhotoImportView( + model: flow.photoImport, + onScan: { flow.scanPhotos(using: model) }, + onImport: flow.approvePhotoHistory, + onSkip: flow.skipPhotoHistory, + ) + } + // MARK: - Location private var location: some View { diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 0e303dabb..b0865e357 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3572,6 +3572,338 @@ } } }, + "onboarding.notNow" : { + "comment" : "Button title for skipping the onboarding flow.", + "extractionState" : "manual", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Not Now" + } + } + } + }, + "onboarding.photo.accessDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow Photos access in Settings to scan location metadata, or skip this step and continue onboarding." + } + } + } + }, + "onboarding.photo.accessTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Photos access is off" + } + } + } + }, + "onboarding.photo.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where can use likely device-taken photos with locations to build a draft of this year. You'll review everything before it is saved." + } + } + } + }, + "onboarding.photo.editHint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Adjust or exclude dates in this stay" + } + } + } + }, + "onboarding.photo.editTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Adjust Stay" + } + } + } + }, + "onboarding.photo.emptyDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No likely device-taken photos with usable locations were found for this year." + } + } + } + }, + "onboarding.photo.emptyLimitedDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No usable locations were found in the photos Where can access. You can allow more photos in Settings or continue." + } + } + } + }, + "onboarding.photo.emptyTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No photo locations found" + } + } + } + }, + "onboarding.photo.errorTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't import photo history" + } + } + } + }, + "onboarding.photo.exclude" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Exclude These Dates" + } + } + } + }, + "onboarding.photo.excludeFooter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Excluded photo locations are not saved." + } + } + } + }, + "onboarding.photo.from" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "From" + } + } + } + }, + "onboarding.photo.import" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import This History" + } + } + } + }, + "onboarding.photo.importing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Saving photo history…" + } + } + } + }, + "onboarding.photo.limitedNotice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This draft uses only the photos you allowed Where to access." + } + } + } + }, + "onboarding.photo.navigationTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Photo History" + } + } + } + }, + "onboarding.photo.openSettings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Settings" + } + } + } + }, + "onboarding.photo.privacy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Only location, capture date, and library-added date are read. Photo contents, thumbnails, captions, and filenames are never loaded or uploaded." + } + } + } + }, + "onboarding.photo.rangeFooter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Narrow the dates to change only part of this stay." + } + } + } + }, + "onboarding.photo.restoreExcluded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restore Excluded Days" + } + } + } + }, + "onboarding.photo.restoreExcludedFooter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Put every excluded day back into the photo-derived timeline. Other corrections stay unchanged." + } + } + } + }, + "onboarding.photo.reviewFooter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tap a stay to change its regions, narrow its dates, or exclude it. Nothing is saved until you finish onboarding." + } + } + } + }, + "onboarding.photo.reviewTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Review your timeline" + } + } + } + }, + "onboarding.photo.scan" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Scan Photo Locations" + } + } + } + }, + "onboarding.photo.scanning" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Scanning photo locations…" + } + } + } + }, + "onboarding.photo.skip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Skip" + } + } + } + }, + "onboarding.photo.through" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Through" + } + } + } + }, + "onboarding.photo.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Build this year's history" + } + } + } + }, + "onboarding.photo.useLocations" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Use Photo Locations" + } + } + } + }, "onboarding.privacy.description" : { "comment" : "Description of the privacy policy of the app.", "extractionState" : "manual", diff --git a/Where/WhereUI/Tests/OnboardingFlowModelTests.swift b/Where/WhereUI/Tests/OnboardingFlowModelTests.swift index 95925e2e1..228e0ec94 100644 --- a/Where/WhereUI/Tests/OnboardingFlowModelTests.swift +++ b/Where/WhereUI/Tests/OnboardingFlowModelTests.swift @@ -1,5 +1,6 @@ import Foundation import LifecycleKit +import RegionKit import Testing @_spi(Testing) import WhereCore @testable import WhereUI @@ -22,6 +23,32 @@ struct OnboardingFlowModelTests { #expect(model.phase == .pickRegions) } + @Test func approvedPhotoDraftWaitsForTheFinalRecordingChoice() throws { + let model = makeModel(startsAtRecordingChoice: false) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let draft = PhotoHistoryDraft( + year: 2026, + calendar: calendar, + samples: [LocationSample( + timestamp: Date(timeIntervalSince1970: 1_768_500_000), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 12, + source: .photo, + )], + regions: [.california], + ) + model.photoImport.activity = .ready(draft, isLimited: false) + + model.approvePhotoHistory() + + #expect(model.phase == .location) + guard case .importing = model.photoImport.activity else { + Issue.record("The approved draft should remain pending until final confirmation") + return + } + } + private func makeModel(startsAtRecordingChoice: Bool) -> OnboardingFlowModel { OnboardingFlowModel( gate: LifecycleGateHandle( diff --git a/Where/WhereUI/Tests/OnboardingPhotoImportModelTests.swift b/Where/WhereUI/Tests/OnboardingPhotoImportModelTests.swift new file mode 100644 index 000000000..6df487392 --- /dev/null +++ b/Where/WhereUI/Tests/OnboardingPhotoImportModelTests.swift @@ -0,0 +1,145 @@ +import Foundation +import RegionKit +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct OnboardingPhotoImportModelTests { + private struct ScriptedLibrary: PhotoLocationLibrary { + let authorization: PhotoLibraryAuthorization + let requestedAuthorization: PhotoLibraryAuthorization + let values: [PhotoLocationAsset] + var fails = false + + func authorizationStatus() async -> PhotoLibraryAuthorization { + authorization + } + + func requestAuthorization() async -> PhotoLibraryAuthorization { + requestedAuthorization + } + + func assets(in _: DateInterval) async throws -> [PhotoLocationAsset] { + if fails { throw Failure.read } + return values + } + } + + private enum Failure: Error { + case read + } + + @Test func scanBuildsAProvisionalDraft() async throws { + let capturedAt = try #require(ISO8601DateFormatter().date(from: "2026-02-10T12:00:00Z")) + let model = OnboardingPhotoImportModel() + model.beginScan() + + try await model.scan( + library: ScriptedLibrary( + authorization: .authorized, + requestedAuthorization: .authorized, + values: [asset(capturedAt: capturedAt)], + ), + year: 2026, + regions: [.california], + calendar: calendar, + now: #require(ISO8601DateFormatter().date(from: "2026-08-07T12:00:00Z")), + ) + + let draft = try #require(model.draft) + #expect(draft.samples.count == 1) + #expect(model.isLimited == false) + } + + @Test func limitedEmptyScanStaysHonest() async throws { + let model = OnboardingPhotoImportModel() + model.beginScan() + + try await model.scan( + library: ScriptedLibrary( + authorization: .notDetermined, + requestedAuthorization: .limited, + values: [], + ), + year: 2026, + regions: [.california], + calendar: calendar, + now: #require(ISO8601DateFormatter().date(from: "2026-08-07T12:00:00Z")), + ) + + guard case let .empty(isLimited) = model.activity else { + Issue.record("Expected an empty result") + return + } + #expect(isLimited) + } + + @Test func deniedScanBecomesBlockedWithoutReading() async throws { + let model = OnboardingPhotoImportModel() + model.beginScan() + + try await model.scan( + library: ScriptedLibrary( + authorization: .denied, + requestedAuthorization: .denied, + values: [asset(capturedAt: .now)], + fails: true, + ), + year: 2026, + regions: [.california], + calendar: calendar, + now: #require(ISO8601DateFormatter().date(from: "2026-08-07T12:00:00Z")), + ) + + guard case .blocked(.denied) = model.activity else { + Issue.record("Expected denied authorization to block the scan") + return + } + #expect(model.errorMessage == nil) + } + + @Test func cancelledImportReturnsToTheApprovedDraftWithoutAnError() throws { + let capturedAt = try #require(ISO8601DateFormatter().date(from: "2026-02-10T12:00:00Z")) + let model = OnboardingPhotoImportModel() + let draft = PhotoHistoryDraft( + year: 2026, + calendar: calendar, + samples: [LocationSample( + timestamp: capturedAt, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 12, + source: .photo, + )], + regions: [.california], + ) + model.activity = .ready(draft, isLimited: false) + + _ = model.beginImport() + model.importCancelled() + + #expect(model.draft?.samples.count == 1) + #expect(model.errorMessage == nil) + guard case .ready = model.activity else { + Issue.record("Expected cancellation to restore the ready state") + return + } + } + + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private func asset(capturedAt: Date) -> PhotoLocationAsset { + PhotoLocationAsset( + capturedAt: capturedAt, + addedAt: capturedAt, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 12, + source: .userLibrary, + isHidden: false, + ) + } +}