apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

commit 522bbb42c89ad6f9347b8f5300c4d0f6f4920bb1
parent 841700cf1121eb6ede62b5677755d54247ddc53e
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 03:26:27 -0700

kit: add permission and location contracts

- add reusable permission snapshot and location request models
- validate coordinate accuracy timeout and reading freshness inputs
- add deterministic permission and location testing fakes
- cover the new contracts with Swift package tests

Diffstat:
ASources/RadrootsKit/RadrootsPermissionLocation.swift | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKitTesting/RadrootsPermissionLocationTesting.swift | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsPermissionLocationTests.swift | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 576 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsPermissionLocation.swift b/Sources/RadrootsKit/RadrootsPermissionLocation.swift @@ -0,0 +1,278 @@ +import Foundation + +public enum RadrootsPermissionKind: String, Sendable, Equatable, Hashable, CaseIterable { + case notifications + case camera + case photos + case microphone + case location +} + +public enum RadrootsPermissionStatus: String, Sendable, Equatable, Hashable { + case notDetermined + case authorized + case denied + case restricted + case limited + case unavailable + case unsupported +} + +public struct RadrootsPermissionSnapshot: Sendable, Equatable, Hashable { + public let kind: RadrootsPermissionKind + public let status: RadrootsPermissionStatus + public let observedAt: Date + + public init(kind: RadrootsPermissionKind, status: RadrootsPermissionStatus, observedAt: Date) { + self.kind = kind + self.status = status + self.observedAt = observedAt + } +} + +public protocol RadrootsPermissionStatusProvider: Sendable { + func snapshot(for kind: RadrootsPermissionKind) async throws -> RadrootsPermissionSnapshot + func snapshots(for kinds: [RadrootsPermissionKind]) async throws -> [RadrootsPermissionSnapshot] +} + +extension RadrootsPermissionStatusProvider { + public func snapshots(for kinds: [RadrootsPermissionKind]) async throws -> [RadrootsPermissionSnapshot] { + var snapshots: [RadrootsPermissionSnapshot] = [] + snapshots.reserveCapacity(kinds.count) + for kind in kinds { + snapshots.append(try await snapshot(for: kind)) + } + return snapshots + } +} + +public enum RadrootsLocationAuthorization: String, Sendable, Equatable, Hashable { + case notDetermined + case authorizedWhenInUse + case authorizedAlways + case denied + case restricted + case unavailable + case unsupported + + public var permissionStatus: RadrootsPermissionStatus { + switch self { + case .notDetermined: + .notDetermined + case .authorizedWhenInUse: + .authorized + case .authorizedAlways: + .authorized + case .denied: + .denied + case .restricted: + .restricted + case .unavailable: + .unavailable + case .unsupported: + .unsupported + } + } +} + +public struct RadrootsLocationServicesAvailability: Sendable, Equatable, Hashable { + public let locationServicesEnabled: Bool + public let authorization: RadrootsLocationAuthorization + + public init(locationServicesEnabled: Bool, authorization: RadrootsLocationAuthorization) { + self.locationServicesEnabled = locationServicesEnabled + self.authorization = authorization + } + + public var canRequestWhenInUseAuthorization: Bool { + locationServicesEnabled && authorization == .notDetermined + } + + public var canRequestCurrentLocation: Bool { + locationServicesEnabled && (authorization == .authorizedWhenInUse || authorization == .authorizedAlways) + } +} + +public struct RadrootsLocationCoordinate: Sendable, Equatable, Hashable { + public let latitude: Double + public let longitude: Double + + public init(latitude: Double, longitude: Double) throws { + guard latitude.isFinite, (-90.0...90.0).contains(latitude) else { + throw RadrootsLocationServicesError.invalidRequest("latitude must be between -90 and 90") + } + guard longitude.isFinite, (-180.0...180.0).contains(longitude) else { + throw RadrootsLocationServicesError.invalidRequest("longitude must be between -180 and 180") + } + self.latitude = latitude + self.longitude = longitude + } +} + +public struct RadrootsLocationReading: Sendable, Equatable, Hashable { + public let coordinate: RadrootsLocationCoordinate + public let horizontalAccuracyMeters: Double + public let altitudeMeters: Double? + public let verticalAccuracyMeters: Double? + public let speedMetersPerSecond: Double? + public let courseDegrees: Double? + public let capturedAt: Date + + public init( + coordinate: RadrootsLocationCoordinate, + horizontalAccuracyMeters: Double, + altitudeMeters: Double? = nil, + verticalAccuracyMeters: Double? = nil, + speedMetersPerSecond: Double? = nil, + courseDegrees: Double? = nil, + capturedAt: Date + ) throws { + self.coordinate = coordinate + self.horizontalAccuracyMeters = try Self.normalizedNonNegativeFinite( + horizontalAccuracyMeters, + field: "horizontal accuracy" + ) + self.altitudeMeters = try Self.normalizedOptionalFinite(altitudeMeters, field: "altitude") + self.verticalAccuracyMeters = try Self.normalizedOptionalNonNegativeFinite( + verticalAccuracyMeters, + field: "vertical accuracy" + ) + self.speedMetersPerSecond = try Self.normalizedOptionalNonNegativeFinite( + speedMetersPerSecond, + field: "speed" + ) + self.courseDegrees = try Self.normalizedOptionalCourse(courseDegrees) + guard capturedAt.timeIntervalSinceReferenceDate.isFinite else { + throw RadrootsLocationServicesError.invalidRequest("captured timestamp must be finite") + } + self.capturedAt = capturedAt + } + + public func age(relativeTo now: Date) throws -> TimeInterval { + guard now.timeIntervalSinceReferenceDate.isFinite else { + throw RadrootsLocationServicesError.invalidRequest("comparison timestamp must be finite") + } + return now.timeIntervalSince(capturedAt) + } + + public func isFresh(relativeTo now: Date, maximumAgeSeconds: TimeInterval) throws -> Bool { + let normalizedMaximumAge = try Self.normalizedNonNegativeFinite(maximumAgeSeconds, field: "maximum age") + let currentAge = try age(relativeTo: now) + return currentAge >= 0 && currentAge <= normalizedMaximumAge + } + + public static func normalizedNonNegativeFinite(_ value: Double, field: String) throws -> Double { + guard value.isFinite, value >= 0 else { + throw RadrootsLocationServicesError.invalidRequest("\(field) must be finite and non-negative") + } + return value + } + + public static func normalizedOptionalFinite(_ value: Double?, field: String) throws -> Double? { + guard let value else { + return nil + } + guard value.isFinite else { + throw RadrootsLocationServicesError.invalidRequest("\(field) must be finite") + } + return value + } + + public static func normalizedOptionalNonNegativeFinite(_ value: Double?, field: String) throws -> Double? { + guard let value else { + return nil + } + return try normalizedNonNegativeFinite(value, field: field) + } + + public static func normalizedOptionalCourse(_ value: Double?) throws -> Double? { + guard let value else { + return nil + } + guard value.isFinite, (0.0..<360.0).contains(value) else { + throw RadrootsLocationServicesError.invalidRequest("course must be between 0 and 359.999 degrees") + } + return value + } +} + +public struct RadrootsCurrentLocationRequest: Sendable, Equatable, Hashable { + public let timeoutSeconds: TimeInterval + public let desiredAccuracyMeters: Double? + public let maximumCachedReadingAgeSeconds: TimeInterval? + + public init( + timeoutSeconds: TimeInterval = 12, + desiredAccuracyMeters: Double? = nil, + maximumCachedReadingAgeSeconds: TimeInterval? = nil + ) throws { + guard timeoutSeconds.isFinite, timeoutSeconds > 0 else { + throw RadrootsLocationServicesError.invalidRequest("location timeout must be finite and greater than zero") + } + self.timeoutSeconds = timeoutSeconds + self.desiredAccuracyMeters = try RadrootsLocationReading.normalizedOptionalNonNegativeFinite( + desiredAccuracyMeters, + field: "desired accuracy" + ) + self.maximumCachedReadingAgeSeconds = try RadrootsLocationReading.normalizedOptionalNonNegativeFinite( + maximumCachedReadingAgeSeconds, + field: "maximum cached reading age" + ) + } +} + +public struct RadrootsCurrentLocationResult: Sendable, Equatable, Hashable { + public let reading: RadrootsLocationReading + public let authorization: RadrootsLocationAuthorization + public let servedFromCache: Bool + + public init( + reading: RadrootsLocationReading, + authorization: RadrootsLocationAuthorization, + servedFromCache: Bool = false + ) throws { + guard authorization == .authorizedWhenInUse || authorization == .authorizedAlways else { + throw RadrootsLocationServicesError.invalidRequest("current location result requires authorized location access") + } + self.reading = reading + self.authorization = authorization + self.servedFromCache = servedFromCache + } +} + +public enum RadrootsLocationServicesError: Error, Equatable, Sendable { + case invalidRequest(String) + case permissionDenied(String) + case unavailable(String) + case timeout(String) + case cancelled(String) + case transientFailure(String) + case permanentFailure(String) +} + +extension RadrootsLocationServicesError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + case .permissionDenied(let message): + message + case .unavailable(let message): + message + case .timeout(let message): + message + case .cancelled(let message): + message + case .transientFailure(let message): + message + case .permanentFailure(let message): + message + } + } +} + +public protocol RadrootsLocationServices: Sendable { + func currentAvailability() async -> RadrootsLocationServicesAvailability + func requestWhenInUseAuthorization() async throws -> RadrootsLocationAuthorization + func currentLocation(_ request: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult +} diff --git a/Sources/RadrootsKitTesting/RadrootsPermissionLocationTesting.swift b/Sources/RadrootsKitTesting/RadrootsPermissionLocationTesting.swift @@ -0,0 +1,102 @@ +import Foundation +import RadrootsKit + +public actor RadrootsInMemoryPermissionStatusProvider: RadrootsPermissionStatusProvider { + private var statuses: [RadrootsPermissionKind: RadrootsPermissionStatus] + private var observedAt: Date + private let defaultStatus: RadrootsPermissionStatus + + public init( + statuses: [RadrootsPermissionKind: RadrootsPermissionStatus] = [:], + defaultStatus: RadrootsPermissionStatus = .notDetermined, + observedAt: Date = Date(timeIntervalSince1970: 0) + ) { + self.statuses = statuses + self.defaultStatus = defaultStatus + self.observedAt = observedAt + } + + public func setStatus(_ status: RadrootsPermissionStatus, for kind: RadrootsPermissionKind, observedAt: Date? = nil) { + statuses[kind] = status + if let observedAt { + self.observedAt = observedAt + } + } + + public func snapshot(for kind: RadrootsPermissionKind) async throws -> RadrootsPermissionSnapshot { + RadrootsPermissionSnapshot( + kind: kind, + status: statuses[kind] ?? defaultStatus, + observedAt: observedAt + ) + } +} + +public actor RadrootsFakeLocationServices: RadrootsLocationServices { + private var availability: RadrootsLocationServicesAvailability + private var authorizationAfterRequest: RadrootsLocationAuthorization + private var currentLocationOutcome: Result<RadrootsLocationReading, RadrootsLocationServicesError> + private var requestAuthorizationCountValue: Int + private var currentLocationRequestCountValue: Int + + public init( + availability: RadrootsLocationServicesAvailability = RadrootsLocationServicesAvailability( + locationServicesEnabled: true, + authorization: .notDetermined + ), + authorizationAfterRequest: RadrootsLocationAuthorization = .authorizedWhenInUse, + currentLocationOutcome: Result<RadrootsLocationReading, RadrootsLocationServicesError> + ) { + self.availability = availability + self.authorizationAfterRequest = authorizationAfterRequest + self.currentLocationOutcome = currentLocationOutcome + self.requestAuthorizationCountValue = 0 + self.currentLocationRequestCountValue = 0 + } + + public func setAvailability(_ availability: RadrootsLocationServicesAvailability) { + self.availability = availability + } + + public func setAuthorizationAfterRequest(_ authorization: RadrootsLocationAuthorization) { + self.authorizationAfterRequest = authorization + } + + public func setCurrentLocationOutcome(_ outcome: Result<RadrootsLocationReading, RadrootsLocationServicesError>) { + currentLocationOutcome = outcome + } + + public func currentAvailability() async -> RadrootsLocationServicesAvailability { + availability + } + + public func requestWhenInUseAuthorization() async throws -> RadrootsLocationAuthorization { + requestAuthorizationCountValue += 1 + availability = RadrootsLocationServicesAvailability( + locationServicesEnabled: availability.locationServicesEnabled, + authorization: authorizationAfterRequest + ) + return authorizationAfterRequest + } + + public func currentLocation(_ request: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult { + currentLocationRequestCountValue += 1 + switch currentLocationOutcome { + case .success(let reading): + return try RadrootsCurrentLocationResult( + reading: reading, + authorization: availability.authorization + ) + case .failure(let error): + throw error + } + } + + public var requestAuthorizationCount: Int { + requestAuthorizationCountValue + } + + public var currentLocationRequestCount: Int { + currentLocationRequestCountValue + } +} diff --git a/Tests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift @@ -69,3 +69,66 @@ import RadrootsKitTesting #expect(try store.get(selected) == nil) #expect(try store.get(relay) == Data("relay".utf8)) } + +@Test func inMemoryPermissionStatusProviderReturnsDefaultsAndOverrides() async throws { + let provider = RadrootsInMemoryPermissionStatusProvider( + statuses: [.camera: .denied], + defaultStatus: .notDetermined, + observedAt: Date(timeIntervalSince1970: 1) + ) + + #expect(try await provider.snapshot(for: .camera).status == .denied) + #expect(try await provider.snapshot(for: .location).status == .notDetermined) + + await provider.setStatus(.authorized, for: .location, observedAt: Date(timeIntervalSince1970: 2)) + + let location = try await provider.snapshot(for: .location) + #expect(location.status == .authorized) + #expect(location.observedAt == Date(timeIntervalSince1970: 2)) +} + +@Test func fakeLocationServicesTracksRequestsAndResults() async throws { + let reading = try RadrootsLocationReading( + coordinate: RadrootsLocationCoordinate(latitude: 49.2827, longitude: -123.1207), + horizontalAccuracyMeters: 6, + capturedAt: Date(timeIntervalSince1970: 10) + ) + let service = RadrootsFakeLocationServices( + availability: RadrootsLocationServicesAvailability( + locationServicesEnabled: true, + authorization: .notDetermined + ), + authorizationAfterRequest: .authorizedWhenInUse, + currentLocationOutcome: .success(reading) + ) + + #expect(await service.currentAvailability().authorization == .notDetermined) + #expect(try await service.requestWhenInUseAuthorization() == .authorizedWhenInUse) + #expect(await service.currentAvailability().authorization == .authorizedWhenInUse) + + let result = try await service.currentLocation(try RadrootsCurrentLocationRequest(timeoutSeconds: 2)) + #expect(result.reading == reading) + #expect(result.authorization == .authorizedWhenInUse) + #expect(await service.requestAuthorizationCount == 1) + #expect(await service.currentLocationRequestCount == 1) +} + +@Test func fakeLocationServicesCanReturnTypedFailures() async throws { + let reading = try RadrootsLocationReading( + coordinate: RadrootsLocationCoordinate(latitude: 49.2827, longitude: -123.1207), + horizontalAccuracyMeters: 6, + capturedAt: Date(timeIntervalSince1970: 10) + ) + let service = RadrootsFakeLocationServices( + availability: RadrootsLocationServicesAvailability( + locationServicesEnabled: true, + authorization: .authorizedWhenInUse + ), + currentLocationOutcome: .success(reading) + ) + await service.setCurrentLocationOutcome(.failure(.timeout("timed out"))) + + await #expect(throws: RadrootsLocationServicesError.timeout("timed out")) { + _ = try await service.currentLocation(try RadrootsCurrentLocationRequest(timeoutSeconds: 2)) + } +} diff --git a/Tests/RadrootsKitTests/RadrootsPermissionLocationTests.swift b/Tests/RadrootsKitTests/RadrootsPermissionLocationTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func permissionSnapshotsPreserveKindStatusAndObservationTime() { + let observedAt = Date(timeIntervalSince1970: 20) + let snapshot = RadrootsPermissionSnapshot( + kind: .location, + status: .authorized, + observedAt: observedAt + ) + + #expect(snapshot.kind == .location) + #expect(snapshot.status == .authorized) + #expect(snapshot.observedAt == observedAt) +} + +@Test func locationAvailabilityMapsUsableAuthorizationStates() { + #expect(RadrootsLocationServicesAvailability( + locationServicesEnabled: true, + authorization: .notDetermined + ).canRequestWhenInUseAuthorization) + #expect(RadrootsLocationServicesAvailability( + locationServicesEnabled: true, + authorization: .authorizedWhenInUse + ).canRequestCurrentLocation) + #expect(RadrootsLocationServicesAvailability( + locationServicesEnabled: true, + authorization: .authorizedAlways + ).canRequestCurrentLocation) + #expect(!RadrootsLocationServicesAvailability( + locationServicesEnabled: false, + authorization: .authorizedWhenInUse + ).canRequestCurrentLocation) + #expect(RadrootsLocationAuthorization.authorizedWhenInUse.permissionStatus == .authorized) + #expect(RadrootsLocationAuthorization.restricted.permissionStatus == .restricted) +} + +@Test func locationCoordinateValidatesLatitudeAndLongitude() throws { + let coordinate = try RadrootsLocationCoordinate(latitude: 49.2827, longitude: -123.1207) + + #expect(coordinate.latitude == 49.2827) + #expect(coordinate.longitude == -123.1207) + + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsLocationCoordinate(latitude: 91, longitude: 0) + } + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsLocationCoordinate(latitude: 0, longitude: -181) + } + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsLocationCoordinate(latitude: .nan, longitude: 0) + } +} + +@Test func locationReadingValidatesAccuracyCourseSpeedAndFreshness() throws { + let capturedAt = Date(timeIntervalSince1970: 100) + let reading = try RadrootsLocationReading( + coordinate: RadrootsLocationCoordinate(latitude: 10, longitude: 20), + horizontalAccuracyMeters: 5, + altitudeMeters: 120, + verticalAccuracyMeters: 8, + speedMetersPerSecond: 2, + courseDegrees: 359.9, + capturedAt: capturedAt + ) + + #expect(reading.horizontalAccuracyMeters == 5) + #expect(try reading.age(relativeTo: Date(timeIntervalSince1970: 106)) == 6) + #expect(try reading.isFresh(relativeTo: Date(timeIntervalSince1970: 106), maximumAgeSeconds: 10)) + #expect(!(try reading.isFresh(relativeTo: Date(timeIntervalSince1970: 120), maximumAgeSeconds: 10))) + + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsLocationReading( + coordinate: RadrootsLocationCoordinate(latitude: 10, longitude: 20), + horizontalAccuracyMeters: -1, + capturedAt: capturedAt + ) + } + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsLocationReading( + coordinate: RadrootsLocationCoordinate(latitude: 10, longitude: 20), + horizontalAccuracyMeters: 1, + speedMetersPerSecond: -1, + capturedAt: capturedAt + ) + } + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsLocationReading( + coordinate: RadrootsLocationCoordinate(latitude: 10, longitude: 20), + horizontalAccuracyMeters: 1, + courseDegrees: 360, + capturedAt: capturedAt + ) + } +} + +@Test func currentLocationRequestAndResultValidateOperationalBounds() throws { + let request = try RadrootsCurrentLocationRequest( + timeoutSeconds: 4, + desiredAccuracyMeters: 10, + maximumCachedReadingAgeSeconds: 15 + ) + let reading = try RadrootsLocationReading( + coordinate: RadrootsLocationCoordinate(latitude: 10, longitude: 20), + horizontalAccuracyMeters: 5, + capturedAt: Date(timeIntervalSince1970: 100) + ) + let result = try RadrootsCurrentLocationResult( + reading: reading, + authorization: .authorizedWhenInUse, + servedFromCache: true + ) + + #expect(request.timeoutSeconds == 4) + #expect(request.desiredAccuracyMeters == 10) + #expect(request.maximumCachedReadingAgeSeconds == 15) + #expect(result.reading == reading) + #expect(result.servedFromCache) + + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsCurrentLocationRequest(timeoutSeconds: 0) + } + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsCurrentLocationRequest(timeoutSeconds: .infinity) + } + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsCurrentLocationRequest(timeoutSeconds: 1, desiredAccuracyMeters: -.leastNonzeroMagnitude) + } + #expect(throws: RadrootsLocationServicesError.self) { + _ = try RadrootsCurrentLocationResult(reading: reading, authorization: .denied) + } +}