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:
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)
+ }
+}