replace mock passport flows with live server integration
CI / test (push) Has been cancelled

Switch the app to the real passport enrollment, dashboard, device, alert, and challenge APIs so it can pair with idp.global and act on server-backed state instead of demo data.
This commit is contained in:
2026-04-20 13:21:39 +00:00
parent 271d9657bf
commit a298b5e421
7 changed files with 1136 additions and 48 deletions
@@ -1,4 +1,17 @@
import Foundation
import CryptoKit
#if canImport(UIKit)
import UIKit
#endif
#if canImport(CoreLocation)
import CoreLocation
#endif
#if canImport(Security)
import Security
#endif
protocol IDPServicing {
func bootstrap() async throws -> BootstrapContext
@@ -11,6 +24,399 @@ protocol IDPServicing {
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot
}
enum DefaultIDPService {
static let shared: IDPServicing = LiveIDPService.shared
}
actor LiveIDPService: IDPServicing {
static let shared = LiveIDPService()
private let appStateStore: AppStateStoring
private let keyStore: PassportKeyStoring
init(
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
keyStore: PassportKeyStoring = DefaultPassportKeyStore()
) {
self.appStateStore = appStateStore
self.keyStore = keyStore
}
func bootstrap() async throws -> BootstrapContext {
let suggestedPayload = appStateStore.load()?.session.pairingCode ?? ""
return BootstrapContext(suggestedPairingPayload: suggestedPayload)
}
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
try validateSignedGPSPosition(in: request)
let context = try PairingPayloadParser.parse(request.pairingPayload)
let baseURL = try serverURL(from: context)
let client = LiveTypedRequestClient(baseURL: baseURL)
let keyMaterial = try loadOrCreateKeyMaterial(for: context.originHost)
let signingPayload = try buildEnrollmentSigningPayload(from: context)
let signatureBase64 = try signPayload(signingPayload, with: keyMaterial.privateKeyData)
let enrollmentResponse = try await client.fire(
method: "completePassportEnrollment",
request: CompletePassportEnrollmentRequest(
pairingToken: context.pairingToken,
deviceLabel: DeviceEnvironment.currentDeviceLabel,
platform: DeviceEnvironment.currentPlatform,
publicKeyX963Base64: keyMaterial.publicKeyBase64,
signatureBase64: signatureBase64,
signatureFormat: "der",
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
capabilities: DeviceEnvironment.capabilities
),
responseType: CompletePassportEnrollmentResponse.self
)
let session = AuthSession(
deviceName: enrollmentResponse.device.data.label,
originHost: context.originHost,
serverURL: baseURL.absoluteString,
passportDeviceID: enrollmentResponse.device.id,
pairedAt: .now,
tokenPreview: context.tokenPreview,
pairingCode: request.pairingPayload,
pairingTransport: request.transport,
signedGPSPosition: request.signedGPSPosition
)
let snapshot = try await dashboardSnapshot(for: session)
return SignInResult(session: session, snapshot: snapshot)
}
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
guard let state = appStateStore.load() else {
throw AppError.invalidPairingPayload
}
_ = request
return try await dashboardSnapshot(for: state.session)
}
func refreshDashboard() async throws -> DashboardSnapshot {
guard let state = appStateStore.load() else {
throw AppError.invalidPairingPayload
}
return try await dashboardSnapshot(for: state.session)
}
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
guard let state = appStateStore.load(),
let request = state.requests.first(where: { $0.id == id }) else {
throw AppError.requestNotFound
}
guard let signingPayload = request.signingPayload else {
throw AppError.requestNotFound
}
let client = LiveTypedRequestClient(baseURL: URL(string: state.session.serverURL)!)
let signatureBase64 = try signPayload(signingPayload, with: try privateKeyData(for: state.session))
let locationEvidence = request.requiresLocation
? try await CurrentLocationEvidenceProvider.currentLocationEvidenceIfAvailable()
: nil
_ = try await client.fire(
method: "approvePassportChallenge",
request: ApprovePassportChallengeRequest(
challengeId: request.serverID,
deviceId: state.session.passportDeviceID,
signatureBase64: signatureBase64,
signatureFormat: "der",
location: locationEvidence,
nfc: nil
),
responseType: ApprovePassportChallengeResponse.self
)
return try await dashboardSnapshot(for: state.session)
}
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
guard let state = appStateStore.load(),
let request = state.requests.first(where: { $0.id == id }) else {
throw AppError.requestNotFound
}
let client = LiveTypedRequestClient(baseURL: URL(string: state.session.serverURL)!)
let signedRequest = try signedDeviceRequest(
session: state.session,
action: "rejectPassportChallenge",
signedFields: ["challenge_id=\(request.serverID)"]
)
_ = try await client.fire(
method: "rejectPassportChallenge",
request: RejectPassportChallengeRequest(
deviceId: signedRequest.deviceId,
timestamp: signedRequest.timestamp,
nonce: signedRequest.nonce,
signatureBase64: signedRequest.signatureBase64,
signatureFormat: signedRequest.signatureFormat,
challengeId: request.serverID
),
responseType: RejectPassportChallengeResponse.self
)
return try await dashboardSnapshot(for: state.session)
}
func simulateIncomingRequest() async throws -> DashboardSnapshot {
try await refreshDashboard()
}
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
guard let state = appStateStore.load(),
let notification = state.notifications.first(where: { $0.id == id }) else {
return try await refreshDashboard()
}
let client = LiveTypedRequestClient(baseURL: URL(string: state.session.serverURL)!)
let signedRequest = try signedDeviceRequest(
session: state.session,
action: "markPassportAlertSeen",
signedFields: ["hint_id=\(notification.hintID)"]
)
_ = try await client.fire(
method: "markPassportAlertSeen",
request: MarkPassportAlertSeenRequest(
deviceId: signedRequest.deviceId,
timestamp: signedRequest.timestamp,
nonce: signedRequest.nonce,
signatureBase64: signedRequest.signatureBase64,
signatureFormat: signedRequest.signatureFormat,
hintId: notification.hintID
),
responseType: SimpleSuccessResponse.self
)
return try await dashboardSnapshot(for: state.session)
}
private func dashboardSnapshot(for session: AuthSession) async throws -> DashboardSnapshot {
let client = LiveTypedRequestClient(baseURL: URL(string: session.serverURL)!)
let signedRequest = try signedDeviceRequest(session: session, action: "getPassportDashboard", signedFields: [])
let response = try await client.fire(
method: "getPassportDashboard",
request: signedRequest,
responseType: PassportDashboardResponse.self
)
return DashboardSnapshot(
profile: MemberProfile(
name: response.profile.name,
handle: response.profile.handle,
organization: response.profile.organizations.first?.name ?? "No organization",
deviceCount: response.profile.deviceCount,
recoverySummary: response.profile.recoverySummary
),
requests: response.challenges.map { challengeItem in
ApprovalRequest(
serverID: challengeItem.challenge.id,
hintID: challengeItem.challenge.data.notification?.hintId ?? challengeItem.challenge.id,
title: approvalTitle(for: challengeItem.challenge),
subtitle: approvalSubtitle(for: challengeItem.challenge),
source: challengeItem.challenge.data.metadata.audience ?? challengeItem.challenge.data.metadata.originHost ?? "idp.global",
createdAt: Date(millisecondsSince1970: challengeItem.challenge.data.createdAt),
kind: approvalKind(for: challengeItem.challenge),
risk: approvalRisk(for: challengeItem.challenge),
scopes: approvalScopes(for: challengeItem.challenge),
requiresLocation: challengeItem.challenge.data.metadata.requireLocation,
challenge: challengeItem.challenge.data.challenge,
signingPayload: challengeItem.signingPayload,
deviceSummaryText: challengeItem.challenge.data.metadata.deviceLabel,
locationSummaryText: locationSummary(for: challengeItem.challenge),
networkSummaryText: challengeItem.challenge.data.metadata.originHost,
ipSummaryText: nil,
expiresAtDate: Date(millisecondsSince1970: challengeItem.challenge.data.expiresAt),
status: .pending
)
},
notifications: response.alerts.map { alert in
AppNotification(
serverID: alert.id,
hintID: alert.data.notification.hintId,
title: alert.data.title,
message: alert.data.body,
sentAt: Date(millisecondsSince1970: alert.data.createdAt),
kind: notificationKind(for: alert.data.category),
isUnread: alert.data.seenAt == nil
)
},
devices: response.devices.map { device in
PassportDeviceRecord(
id: device.id,
label: device.data.label,
platform: device.data.platform,
lastSeenAt: device.data.lastSeenAt.map(Date.init(millisecondsSince1970:)),
isCurrent: device.id == session.passportDeviceID
)
}
)
}
private func validateSignedGPSPosition(in request: PairingAuthenticationRequest) throws {
if request.transport == .nfc,
request.signedGPSPosition == nil {
throw AppError.missingSignedGPSPosition
}
if let signedGPSPosition = request.signedGPSPosition,
!signedGPSPosition.verified(for: request.pairingPayload) {
throw AppError.invalidSignedGPSPosition
}
}
private func serverURL(from context: PairingPayloadContext) throws -> URL {
guard let url = URL(string: "https://\(context.originHost)") else {
throw AppError.invalidPairingPayload
}
return url
}
private func buildEnrollmentSigningPayload(from context: PairingPayloadContext) throws -> String {
guard let challenge = context.challenge,
let challengeID = context.challengeID else {
throw AppError.invalidPairingPayload
}
return [
"purpose=passport-enrollment",
"origin=\(context.originHost)",
"token=\(context.pairingToken)",
"challenge=\(challenge)",
"challenge_id=\(challengeID)"
].joined(separator: "\n")
}
private func loadOrCreateKeyMaterial(for originHost: String) throws -> StoredPassportKeyMaterial {
if let existing = try keyStore.load(key: originHost) {
return existing
}
let privateKey = P256.Signing.PrivateKey()
let material = StoredPassportKeyMaterial(
privateKeyBase64: privateKey.rawRepresentation.base64EncodedString(),
publicKeyBase64: privateKey.publicKey.x963Representation.base64EncodedString()
)
try keyStore.save(material, key: originHost)
return material
}
private func privateKeyData(for session: AuthSession) throws -> Data {
guard let stored = try keyStore.load(key: session.originHost),
let data = Data(base64Encoded: stored.privateKeyBase64) else {
throw AppError.invalidPairingPayload
}
return data
}
private func signPayload(_ payload: String, with privateKeyData: Data) throws -> String {
let privateKey = try P256.Signing.PrivateKey(rawRepresentation: privateKeyData)
let signature = try privateKey.signature(for: Data(payload.utf8))
return signature.derRepresentation.base64EncodedString()
}
private func signedDeviceRequest(
session: AuthSession,
action: String,
signedFields: [String]
) throws -> SignedDeviceRequest {
let nonce = UUID().uuidString.lowercased()
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
let payload = ([
"purpose=passport-device-request",
"origin=\(session.originHost)",
"action=\(action)",
"device_id=\(session.passportDeviceID)",
"timestamp=\(timestamp)",
"nonce=\(nonce)"
] + signedFields).joined(separator: "\n")
return SignedDeviceRequest(
deviceId: session.passportDeviceID,
timestamp: timestamp,
nonce: nonce,
signatureBase64: try signPayload(payload, with: try privateKeyData(for: session)),
signatureFormat: "der"
)
}
private func approvalKind(for challenge: ServerPassportChallenge) -> ApprovalRequestKind {
switch challenge.data.type {
case "authentication":
return .signIn
case "physical_access":
return .accessGrant
default:
return .elevatedAction
}
}
private func approvalRisk(for challenge: ServerPassportChallenge) -> ApprovalRisk {
if challenge.data.metadata.requireLocation ||
challenge.data.metadata.requireNfc ||
challenge.data.metadata.locationPolicy != nil ||
challenge.data.type != "authentication" {
return .elevated
}
return .routine
}
private func approvalTitle(for challenge: ServerPassportChallenge) -> String {
challenge.data.metadata.notificationTitle ?? challenge.data.type.replacingOccurrences(of: "_", with: " ").capitalized
}
private func approvalSubtitle(for challenge: ServerPassportChallenge) -> String {
if let audience = challenge.data.metadata.audience {
return "Approve this proof for \(audience)."
}
return "Approve this passport challenge on your trusted device."
}
private func approvalScopes(for challenge: ServerPassportChallenge) -> [String] {
var scopes = ["passport:device-proof"]
if challenge.data.metadata.requireLocation {
scopes.append("context:location")
}
if challenge.data.metadata.requireNfc {
scopes.append("context:nfc")
}
if let label = challenge.data.metadata.locationPolicy?.label {
scopes.append("policy:\(label)")
}
return scopes
}
private func locationSummary(for challenge: ServerPassportChallenge) -> String {
if let locationPolicy = challenge.data.metadata.locationPolicy {
return locationPolicy.label ?? "Location within required area"
}
if challenge.data.metadata.requireLocation {
return "Current location required"
}
return "Location not required"
}
private func notificationKind(for category: String) -> AppNotificationKind {
switch category {
case "security":
return .security
case "admin":
return .approval
default:
return .system
}
}
}
actor MockIDPService: IDPServicing {
static let shared = MockIDPService()
@@ -25,6 +431,7 @@ actor MockIDPService: IDPServicing {
private let appStateStore: AppStateStoring
private var requests: [ApprovalRequest] = []
private var notifications: [AppNotification] = []
private var devices: [PassportDeviceRecord] = []
init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) {
self.appStateStore = appStateStore
@@ -32,9 +439,11 @@ actor MockIDPService: IDPServicing {
if let state = appStateStore.load() {
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
devices = state.devices
} else {
requests = Self.seedRequests()
notifications = Self.seedNotifications()
devices = Self.seedDevices()
}
}
@@ -198,7 +607,8 @@ actor MockIDPService: IDPServicing {
DashboardSnapshot(
profile: profile,
requests: requests,
notifications: notifications
notifications: notifications,
devices: devices
)
}
@@ -220,6 +630,8 @@ actor MockIDPService: IDPServicing {
return AuthSession(
deviceName: context.deviceName,
originHost: context.originHost,
serverURL: "https://\(context.originHost)",
passportDeviceID: UUID().uuidString,
pairedAt: .now,
tokenPreview: context.tokenPreview,
pairingCode: request.pairingPayload,
@@ -260,11 +672,13 @@ actor MockIDPService: IDPServicing {
guard let state = appStateStore.load() else {
requests = Self.seedRequests()
notifications = Self.seedNotifications()
devices = Self.seedDevices()
return
}
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
devices = state.devices
}
private func persistSharedStateIfAvailable() {
@@ -275,7 +689,8 @@ actor MockIDPService: IDPServicing {
session: state.session,
profile: state.profile,
requests: requests,
notifications: notifications
notifications: notifications,
devices: devices
)
)
}
@@ -340,4 +755,477 @@ actor MockIDPService: IDPServicing {
)
]
}
private static func seedDevices() -> [PassportDeviceRecord] {
[
PassportDeviceRecord(id: UUID().uuidString, label: "Phil's iPhone", platform: "ios", lastSeenAt: .now, isCurrent: true),
PassportDeviceRecord(id: UUID().uuidString, label: "Phil's iPad Pro", platform: "ipados", lastSeenAt: .now.addingTimeInterval(-60 * 18), isCurrent: false),
PassportDeviceRecord(id: UUID().uuidString, label: "Berlin MacBook Pro", platform: "macos", lastSeenAt: .now.addingTimeInterval(-60 * 74), isCurrent: false)
]
}
}
private struct StoredPassportKeyMaterial: Codable, Equatable {
let privateKeyBase64: String
let publicKeyBase64: String
}
private protocol PassportKeyStoring {
func load(key: String) throws -> StoredPassportKeyMaterial?
func save(_ value: StoredPassportKeyMaterial, key: String) throws
}
private final class DefaultPassportKeyStore: PassportKeyStoring {
private let service = "global.idp.swiftapp.passport-keys"
private let fallbackDefaults = UserDefaults.standard
func load(key: String) throws -> StoredPassportKeyMaterial? {
#if canImport(Security)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let data = result as? Data {
return try JSONDecoder().decode(StoredPassportKeyMaterial.self, from: data)
}
if status == errSecItemNotFound {
return nil
}
#endif
guard let data = fallbackDefaults.data(forKey: "passport-key-\(key)") else {
return nil
}
return try JSONDecoder().decode(StoredPassportKeyMaterial.self, from: data)
}
func save(_ value: StoredPassportKeyMaterial, key: String) throws {
let data = try JSONEncoder().encode(value)
#if canImport(Security)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemAdd(attributes as CFDictionary, nil)
#endif
fallbackDefaults.set(data, forKey: "passport-key-\(key)")
}
}
private enum DeviceEnvironment {
static var currentPlatform: String {
#if os(iOS)
return "ios"
#elseif os(watchOS)
return "watchos"
#elseif os(macOS)
return "macos"
#else
return "unknown"
#endif
}
static var currentDeviceLabel: String {
#if canImport(UIKit)
return UIDevice.current.name
#elseif os(macOS)
return Host.current().localizedName ?? "Mac Passport"
#elseif os(watchOS)
return "Apple Watch Passport"
#else
return "Swift Passport Device"
#endif
}
static var capabilities: CapabilityRequest {
CapabilityRequest(
gps: true,
nfc: {
#if os(iOS)
return true
#else
return false
#endif
}(),
push: false
)
}
}
private struct LiveTypedRequestClient {
let endpoint: URL
private let session: URLSession = .shared
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(baseURL: URL) {
self.endpoint = baseURL.appending(path: "typedrequest")
}
func fire<Request: Encodable, Response: Decodable>(
method: String,
request: Request,
responseType: Response.Type
) async throws -> Response {
let envelope = TypedRequestEnvelope(method: method, request: request, response: nil, correlation: TypedCorrelation(phase: "request"))
return try await send(envelope: envelope, method: method, responseType: responseType)
}
private func send<Request: Encodable, Response: Decodable>(
envelope: TypedRequestEnvelope<Request>,
method: String,
responseType: Response.Type
) async throws -> Response {
var urlRequest = URLRequest(url: endpoint)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = try encoder.encode(envelope)
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
200 ..< 300 ~= httpResponse.statusCode else {
throw TypedRequestTransportError(message: "Typed request transport failed")
}
let typedResponse = try decoder.decode(TypedResponseEnvelope<Response>.self, from: data)
if let error = typedResponse.error {
throw TypedRequestTransportError(message: error.text)
}
if let retry = typedResponse.retry {
try await Task.sleep(for: .milliseconds(retry.waitForMs))
return try await send(envelope: envelope, method: method, responseType: responseType)
}
guard let responsePayload = typedResponse.response else {
throw TypedRequestTransportError(message: "Typed request returned no response payload")
}
return responsePayload
}
}
private struct TypedCorrelation: Codable {
let id: String
let phase: String
init(id: String = UUID().uuidString, phase: String) {
self.id = id
self.phase = phase
}
}
private struct TypedRequestEnvelope<Request: Encodable>: Encodable {
let method: String
let request: Request
let response: EmptyJSONValue?
let correlation: TypedCorrelation
}
private struct TypedResponseEnvelope<Response: Decodable>: Decodable {
let response: Response?
let error: TypedResponseErrorPayload?
let retry: TypedRetryInstruction?
}
private struct TypedResponseErrorPayload: Decodable {
let text: String
}
private struct TypedRetryInstruction: Decodable {
let waitForMs: Int
}
private struct EmptyJSONValue: Codable {}
private struct TypedRequestTransportError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
private struct SignedDeviceRequest: Encodable {
let deviceId: String
let timestamp: Int
let nonce: String
let signatureBase64: String
let signatureFormat: String
}
private struct CapabilityRequest: Encodable {
let gps: Bool
let nfc: Bool
let push: Bool
}
private struct CompletePassportEnrollmentRequest: Encodable {
let pairingToken: String
let deviceLabel: String
let platform: String
let publicKeyX963Base64: String
let signatureBase64: String
let signatureFormat: String
let appVersion: String?
let capabilities: CapabilityRequest
}
private struct ApprovePassportChallengeRequest: Encodable {
let challengeId: String
let deviceId: String
let signatureBase64: String
let signatureFormat: String
let location: LocationEvidenceRequest?
let nfc: NFCEvidenceRequest?
}
private struct RejectPassportChallengeRequest: Encodable {
let deviceId: String
let timestamp: Int
let nonce: String
let signatureBase64: String
let signatureFormat: String
let challengeId: String
}
private struct MarkPassportAlertSeenRequest: Encodable {
let deviceId: String
let timestamp: Int
let nonce: String
let signatureBase64: String
let signatureFormat: String
let hintId: String
}
private struct LocationEvidenceRequest: Encodable {
let latitude: Double
let longitude: Double
let accuracyMeters: Double
let capturedAt: Int
}
private struct NFCEvidenceRequest: Encodable {
let tagId: String?
let readerId: String?
}
private struct SimpleSuccessResponse: Decodable {
let success: Bool
}
private struct CompletePassportEnrollmentResponse: Decodable {
let device: ServerPassportDevice
}
private struct ApprovePassportChallengeResponse: Decodable {
let success: Bool
}
private struct RejectPassportChallengeResponse: Decodable {
let success: Bool
}
private struct PassportDashboardResponse: Decodable {
struct Profile: Decodable {
let userId: String
let name: String
let handle: String
let organizations: [Organization]
let deviceCount: Int
let recoverySummary: String
}
struct Organization: Decodable {
let id: String
let name: String
}
let profile: Profile
let devices: [ServerPassportDevice]
let challenges: [ServerChallengeItem]
let alerts: [ServerAlert]
}
private struct ServerChallengeItem: Decodable {
let challenge: ServerPassportChallenge
let signingPayload: String
}
private struct ServerPassportDevice: Decodable {
struct DataPayload: Decodable {
let userId: String
let label: String
let platform: String
let status: String
let lastSeenAt: Int?
}
let id: String
let data: DataPayload
}
private struct ServerPassportChallenge: Decodable {
struct DataPayload: Decodable {
struct MetadataPayload: Decodable {
struct LocationPolicyPayload: Decodable {
let mode: String
let label: String?
let latitude: Double
let longitude: Double
let radiusMeters: Double
let maxAccuracyMeters: Double?
}
let originHost: String?
let audience: String?
let notificationTitle: String?
let deviceLabel: String?
let requireLocation: Bool
let requireNfc: Bool
let locationPolicy: LocationPolicyPayload?
}
struct NotificationPayload: Decodable {
let hintId: String
}
let challenge: String
let type: String
let status: String
let metadata: MetadataPayload
let notification: NotificationPayload?
let createdAt: Int
let expiresAt: Int
}
let id: String
let data: DataPayload
}
private struct ServerAlert: Decodable {
struct DataPayload: Decodable {
struct NotificationPayload: Decodable {
let hintId: String
}
let category: String
let title: String
let body: String
let createdAt: Int
let seenAt: Int?
let notification: NotificationPayload
}
let id: String
let data: DataPayload
}
#if canImport(CoreLocation)
@MainActor
private final class CurrentLocationEvidenceProvider: NSObject, @preconcurrency CLLocationManagerDelegate {
private var manager: CLLocationManager?
private var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
static func currentLocationEvidenceIfAvailable() async throws -> LocationEvidenceRequest {
let provider = CurrentLocationEvidenceProvider()
return try await provider.currentLocationEvidence()
}
private func currentLocationEvidence() async throws -> LocationEvidenceRequest {
let manager = CLLocationManager()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
self.manager = manager
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
break
case .notDetermined:
let status = await requestAuthorization(using: manager)
guard status == .authorizedAlways || status == .authorizedWhenInUse else {
throw AppError.locationPermissionDenied
}
case .denied, .restricted:
throw AppError.locationPermissionDenied
@unknown default:
throw AppError.locationUnavailable
}
let location = try await requestLocation(using: manager)
return LocationEvidenceRequest(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
capturedAt: Int(location.timestamp.timeIntervalSince1970 * 1000)
)
}
private func requestAuthorization(using manager: CLLocationManager) async -> CLAuthorizationStatus {
manager.requestWhenInUseAuthorization()
return await withCheckedContinuation { continuation in
authorizationContinuation = continuation
}
}
private func requestLocation(using manager: CLLocationManager) async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
locationContinuation = continuation
manager.requestLocation()
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard let continuation = authorizationContinuation else { return }
let status = manager.authorizationStatus
guard status != .notDetermined else { return }
authorizationContinuation = nil
continuation.resume(returning: status)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let continuation = locationContinuation,
let location = locations.last else {
return
}
authorizationContinuation = nil
locationContinuation = nil
self.manager = nil
continuation.resume(returning: location)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
guard let continuation = locationContinuation else { return }
authorizationContinuation = nil
locationContinuation = nil
self.manager = nil
continuation.resume(throwing: AppError.locationUnavailable)
}
}
#else
private enum CurrentLocationEvidenceProvider {
static func currentLocationEvidenceIfAvailable() async throws -> LocationEvidenceRequest {
throw AppError.locationUnavailable
}
}
#endif
private extension Date {
init(millisecondsSince1970: Int) {
self = Date(timeIntervalSince1970: TimeInterval(millisecondsSince1970) / 1000)
}
}