commit 8b37d0c0fd38416947781f1b03e7b086da1b98df
parent 1a6a3b207d4891c1bc81f0c96c2f7e318e5e1882
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 03:32:20 -0700
kit: add Apple location services
- add CoreLocation-backed current authorization and location services
- short-circuit denied unavailable and authorized states before requests
- clean up live CoreLocation sessions on timeout cancellation and completion
- cover location service behavior with adapter-backed Swift tests
Diffstat:
3 files changed, 506 insertions(+), 0 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleLocationServices.swift b/Sources/RadrootsKit/RadrootsAppleLocationServices.swift
@@ -0,0 +1,312 @@
+import Foundation
+
+#if canImport(CoreLocation)
+@preconcurrency import CoreLocation
+#endif
+
+public struct RadrootsAppleLocationServicesAdapters: Sendable {
+ public let now: @Sendable () -> Date
+ public let locationServicesEnabled: @Sendable () -> Bool
+ public let authorizationStatus: @Sendable () -> RadrootsLocationAuthorization
+ public let requestWhenInUseAuthorization: @Sendable (TimeInterval) async throws -> RadrootsLocationAuthorization
+ public let requestCurrentLocation: @Sendable (RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading
+
+ public init(
+ now: @escaping @Sendable () -> Date = Date.init,
+ locationServicesEnabled: @escaping @Sendable () -> Bool,
+ authorizationStatus: @escaping @Sendable () -> RadrootsLocationAuthorization,
+ requestWhenInUseAuthorization: @escaping @Sendable (TimeInterval) async throws -> RadrootsLocationAuthorization,
+ requestCurrentLocation: @escaping @Sendable (RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading
+ ) {
+ self.now = now
+ self.locationServicesEnabled = locationServicesEnabled
+ self.authorizationStatus = authorizationStatus
+ self.requestWhenInUseAuthorization = requestWhenInUseAuthorization
+ self.requestCurrentLocation = requestCurrentLocation
+ }
+
+ public static var live: Self {
+ #if canImport(CoreLocation)
+ Self(
+ locationServicesEnabled: {
+ CLLocationManager.locationServicesEnabled()
+ },
+ authorizationStatus: {
+ authorization(
+ for: CLLocationManager().authorizationStatus,
+ locationServicesEnabled: CLLocationManager.locationServicesEnabled()
+ )
+ },
+ requestWhenInUseAuthorization: { timeoutSeconds in
+ try await RadrootsCoreLocationAuthorizationSession().start(timeoutSeconds: timeoutSeconds)
+ },
+ requestCurrentLocation: { request in
+ try await RadrootsCoreLocationReadingSession().start(request: request)
+ }
+ )
+ #else
+ Self(
+ locationServicesEnabled: { false },
+ authorizationStatus: { .unsupported },
+ requestWhenInUseAuthorization: { _ in
+ throw RadrootsLocationServicesError.unavailable("CoreLocation is not available")
+ },
+ requestCurrentLocation: { _ in
+ throw RadrootsLocationServicesError.unavailable("CoreLocation is not available")
+ }
+ )
+ #endif
+ }
+
+ #if canImport(CoreLocation)
+ public static func authorization(
+ for status: CLAuthorizationStatus,
+ locationServicesEnabled: Bool
+ ) -> RadrootsLocationAuthorization {
+ guard locationServicesEnabled else {
+ return .unavailable
+ }
+ switch status {
+ case .notDetermined:
+ return .notDetermined
+ case .restricted:
+ return .restricted
+ case .denied:
+ return .denied
+ case .authorizedAlways:
+ return .authorizedAlways
+ #if os(iOS)
+ case .authorizedWhenInUse:
+ return .authorizedWhenInUse
+ #endif
+ @unknown default:
+ return .unavailable
+ }
+ }
+ #endif
+}
+
+public final class RadrootsAppleLocationServices: RadrootsLocationServices, Sendable {
+ private let adapters: RadrootsAppleLocationServicesAdapters
+
+ public init(adapters: RadrootsAppleLocationServicesAdapters = .live) {
+ self.adapters = adapters
+ }
+
+ public func currentAvailability() async -> RadrootsLocationServicesAvailability {
+ let enabled = adapters.locationServicesEnabled()
+ return RadrootsLocationServicesAvailability(
+ locationServicesEnabled: enabled,
+ authorization: enabled ? adapters.authorizationStatus() : .unavailable
+ )
+ }
+
+ public func requestWhenInUseAuthorization() async throws -> RadrootsLocationAuthorization {
+ let availability = await currentAvailability()
+ guard availability.locationServicesEnabled else {
+ throw RadrootsLocationServicesError.unavailable("location services are disabled")
+ }
+ switch availability.authorization {
+ case .notDetermined:
+ return try await adapters.requestWhenInUseAuthorization(12)
+ case .authorizedWhenInUse:
+ return .authorizedWhenInUse
+ case .authorizedAlways:
+ return .authorizedAlways
+ case .denied:
+ throw RadrootsLocationServicesError.permissionDenied("location permission is denied")
+ case .restricted:
+ throw RadrootsLocationServicesError.permissionDenied("location permission is restricted")
+ case .unavailable:
+ throw RadrootsLocationServicesError.unavailable("location services are unavailable")
+ case .unsupported:
+ throw RadrootsLocationServicesError.unavailable("location services are unsupported")
+ }
+ }
+
+ public func currentLocation(_ request: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult {
+ let availability = await currentAvailability()
+ guard availability.locationServicesEnabled else {
+ throw RadrootsLocationServicesError.unavailable("location services are disabled")
+ }
+ guard availability.canRequestCurrentLocation else {
+ switch availability.authorization {
+ case .denied:
+ throw RadrootsLocationServicesError.permissionDenied("location permission is denied")
+ case .restricted:
+ throw RadrootsLocationServicesError.permissionDenied("location permission is restricted")
+ case .notDetermined:
+ throw RadrootsLocationServicesError.permissionDenied("location permission has not been requested")
+ case .unavailable:
+ throw RadrootsLocationServicesError.unavailable("location services are unavailable")
+ case .unsupported:
+ throw RadrootsLocationServicesError.unavailable("location services are unsupported")
+ case .authorizedWhenInUse, .authorizedAlways:
+ break
+ }
+ throw RadrootsLocationServicesError.unavailable("location services are unavailable")
+ }
+ let reading = try await adapters.requestCurrentLocation(request)
+ if let maximumAgeSeconds = request.maximumCachedReadingAgeSeconds {
+ guard try reading.isFresh(relativeTo: adapters.now(), maximumAgeSeconds: maximumAgeSeconds) else {
+ throw RadrootsLocationServicesError.transientFailure("location reading is older than the requested maximum age")
+ }
+ }
+ return try RadrootsCurrentLocationResult(
+ reading: reading,
+ authorization: availability.authorization
+ )
+ }
+}
+
+#if canImport(CoreLocation)
+@MainActor
+private final class RadrootsCoreLocationAuthorizationSession: NSObject, @preconcurrency CLLocationManagerDelegate {
+ private var continuation: CheckedContinuation<RadrootsLocationAuthorization, any Error>?
+ private var manager: CLLocationManager?
+ private var timeoutTask: Task<Void, Never>?
+
+ func start(timeoutSeconds: TimeInterval) async throws -> RadrootsLocationAuthorization {
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ self.continuation = continuation
+ let manager = CLLocationManager()
+ self.manager = manager
+ manager.delegate = self
+ timeoutTask = Task { [weak self] in
+ let nanoseconds = UInt64(timeoutSeconds * 1_000_000_000)
+ try? await Task.sleep(nanoseconds: nanoseconds)
+ self?.finish(.failure(.timeout("location authorization timed out")))
+ }
+ manager.requestWhenInUseAuthorization()
+ }
+ } onCancel: {
+ Task { @MainActor [weak self] in
+ self?.finish(.failure(.cancelled("location authorization was cancelled")))
+ }
+ }
+ }
+
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
+ let authorization = RadrootsAppleLocationServicesAdapters.authorization(
+ for: manager.authorizationStatus,
+ locationServicesEnabled: CLLocationManager.locationServicesEnabled()
+ )
+ guard authorization != .notDetermined else {
+ return
+ }
+ finish(.success(authorization))
+ }
+
+ private func finish(_ result: Result<RadrootsLocationAuthorization, RadrootsLocationServicesError>) {
+ guard let continuation else {
+ return
+ }
+ self.continuation = nil
+ timeoutTask?.cancel()
+ timeoutTask = nil
+ manager?.delegate = nil
+ manager = nil
+ switch result {
+ case .success(let authorization):
+ continuation.resume(returning: authorization)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+}
+
+@MainActor
+private final class RadrootsCoreLocationReadingSession: NSObject, @preconcurrency CLLocationManagerDelegate {
+ private var continuation: CheckedContinuation<RadrootsLocationReading, any Error>?
+ private var manager: CLLocationManager?
+ private var timeoutTask: Task<Void, Never>?
+
+ func start(request: RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading {
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ self.continuation = continuation
+ let manager = CLLocationManager()
+ self.manager = manager
+ manager.delegate = self
+ if let desiredAccuracyMeters = request.desiredAccuracyMeters {
+ manager.desiredAccuracy = desiredAccuracyMeters
+ }
+ timeoutTask = Task { [weak self] in
+ let nanoseconds = UInt64(request.timeoutSeconds * 1_000_000_000)
+ try? await Task.sleep(nanoseconds: nanoseconds)
+ self?.finish(.failure(.timeout("current location request timed out")))
+ }
+ manager.requestLocation()
+ }
+ } onCancel: {
+ Task { @MainActor [weak self] in
+ self?.finish(.failure(.cancelled("current location request was cancelled")))
+ }
+ }
+ }
+
+ func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
+ guard let location = locations.sorted(by: { $0.timestamp < $1.timestamp }).last else {
+ finish(.failure(.transientFailure("CoreLocation returned no locations")))
+ return
+ }
+ do {
+ finish(.success(try Self.reading(from: location)))
+ } catch let error as RadrootsLocationServicesError {
+ finish(.failure(error))
+ } catch {
+ finish(.failure(.permanentFailure(error.localizedDescription)))
+ }
+ }
+
+ func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
+ if let coreLocationError = error as? CLError {
+ switch coreLocationError.code {
+ case .denied:
+ finish(.failure(.permissionDenied("location permission is denied")))
+ case .locationUnknown:
+ finish(.failure(.transientFailure("current location is temporarily unknown")))
+ case .network:
+ finish(.failure(.transientFailure("location network lookup failed")))
+ default:
+ finish(.failure(.permanentFailure(coreLocationError.localizedDescription)))
+ }
+ } else {
+ finish(.failure(.permanentFailure(error.localizedDescription)))
+ }
+ }
+
+ private func finish(_ result: Result<RadrootsLocationReading, RadrootsLocationServicesError>) {
+ guard let continuation else {
+ return
+ }
+ self.continuation = nil
+ timeoutTask?.cancel()
+ timeoutTask = nil
+ manager?.delegate = nil
+ manager = nil
+ switch result {
+ case .success(let reading):
+ continuation.resume(returning: reading)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+
+ private static func reading(from location: CLLocation) throws -> RadrootsLocationReading {
+ try RadrootsLocationReading(
+ coordinate: RadrootsLocationCoordinate(
+ latitude: location.coordinate.latitude,
+ longitude: location.coordinate.longitude
+ ),
+ horizontalAccuracyMeters: location.horizontalAccuracy,
+ altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
+ verticalAccuracyMeters: location.verticalAccuracy >= 0 ? location.verticalAccuracy : nil,
+ speedMetersPerSecond: location.speed >= 0 ? location.speed : nil,
+ courseDegrees: location.course >= 0 ? location.course : nil,
+ capturedAt: location.timestamp
+ )
+ }
+}
+#endif
diff --git a/Sources/RadrootsKit/RadrootsApplePermissionStatus.swift b/Sources/RadrootsKit/RadrootsApplePermissionStatus.swift
@@ -173,8 +173,10 @@ public struct RadrootsApplePermissionStatusAdapters: Sendable {
.denied
case .authorizedAlways:
.authorized
+ #if os(iOS)
case .authorizedWhenInUse:
.authorized
+ #endif
@unknown default:
.unavailable
}
diff --git a/Tests/RadrootsKitTests/RadrootsAppleLocationServicesTests.swift b/Tests/RadrootsKitTests/RadrootsAppleLocationServicesTests.swift
@@ -0,0 +1,192 @@
+import Foundation
+import Testing
+@testable import RadrootsKit
+
+#if canImport(CoreLocation)
+import CoreLocation
+#endif
+
+@Test func appleLocationServicesReportsCurrentAvailability() async {
+ let service = RadrootsAppleLocationServices(
+ adapters: RadrootsAppleLocationServicesAdapters(
+ locationServicesEnabled: { true },
+ authorizationStatus: { .authorizedWhenInUse },
+ requestWhenInUseAuthorization: { _ in .authorizedWhenInUse },
+ requestCurrentLocation: { _ in
+ throw RadrootsLocationServicesError.permanentFailure("not used")
+ }
+ )
+ )
+
+ let availability = await service.currentAvailability()
+
+ #expect(availability.locationServicesEnabled)
+ #expect(availability.authorization == .authorizedWhenInUse)
+ #expect(availability.canRequestCurrentLocation)
+}
+
+@Test func appleLocationServicesShortCircuitsAuthorizationRequests() async throws {
+ let authorized = RadrootsAppleLocationServices(
+ adapters: RadrootsAppleLocationServicesAdapters(
+ locationServicesEnabled: { true },
+ authorizationStatus: { .authorizedWhenInUse },
+ requestWhenInUseAuthorization: { _ in
+ throw RadrootsLocationServicesError.permanentFailure("should not request")
+ },
+ requestCurrentLocation: { _ in
+ throw RadrootsLocationServicesError.permanentFailure("not used")
+ }
+ )
+ )
+
+ #expect(try await authorized.requestWhenInUseAuthorization() == .authorizedWhenInUse)
+
+ let denied = RadrootsAppleLocationServices(
+ adapters: RadrootsAppleLocationServicesAdapters(
+ locationServicesEnabled: { true },
+ authorizationStatus: { .denied },
+ requestWhenInUseAuthorization: { _ in
+ throw RadrootsLocationServicesError.permanentFailure("should not request")
+ },
+ requestCurrentLocation: { _ in
+ throw RadrootsLocationServicesError.permanentFailure("not used")
+ }
+ )
+ )
+
+ await #expect(throws: RadrootsLocationServicesError.permissionDenied("location permission is denied")) {
+ _ = try await denied.requestWhenInUseAuthorization()
+ }
+}
+
+@Test func appleLocationServicesRequestsAuthorizationWhenUndetermined() async throws {
+ let service = RadrootsAppleLocationServices(
+ adapters: RadrootsAppleLocationServicesAdapters(
+ locationServicesEnabled: { true },
+ authorizationStatus: { .notDetermined },
+ requestWhenInUseAuthorization: { timeoutSeconds in
+ #expect(timeoutSeconds == 12)
+ return .authorizedWhenInUse
+ },
+ requestCurrentLocation: { _ in
+ throw RadrootsLocationServicesError.permanentFailure("not used")
+ }
+ )
+ )
+
+ #expect(try await service.requestWhenInUseAuthorization() == .authorizedWhenInUse)
+}
+
+@Test func appleLocationServicesReturnsCurrentLocationForAuthorizedState() async throws {
+ let capturedAt = Date(timeIntervalSince1970: 100)
+ let reading = try RadrootsLocationReading(
+ coordinate: RadrootsLocationCoordinate(latitude: 49.2827, longitude: -123.1207),
+ horizontalAccuracyMeters: 4,
+ capturedAt: capturedAt
+ )
+ let service = RadrootsAppleLocationServices(
+ adapters: RadrootsAppleLocationServicesAdapters(
+ now: { Date(timeIntervalSince1970: 102) },
+ locationServicesEnabled: { true },
+ authorizationStatus: { .authorizedWhenInUse },
+ requestWhenInUseAuthorization: { _ in .authorizedWhenInUse },
+ requestCurrentLocation: { request in
+ #expect(request.timeoutSeconds == 3)
+ return reading
+ }
+ )
+ )
+
+ let result = try await service.currentLocation(try RadrootsCurrentLocationRequest(
+ timeoutSeconds: 3,
+ maximumCachedReadingAgeSeconds: 5
+ ))
+
+ #expect(result.reading == reading)
+ #expect(result.authorization == .authorizedWhenInUse)
+}
+
+@Test func appleLocationServicesRejectsCurrentLocationForUnauthorizedState() async {
+ let service = RadrootsAppleLocationServices(
+ adapters: RadrootsAppleLocationServicesAdapters(
+ locationServicesEnabled: { true },
+ authorizationStatus: { .notDetermined },
+ requestWhenInUseAuthorization: { _ in .authorizedWhenInUse },
+ requestCurrentLocation: { _ in
+ throw RadrootsLocationServicesError.permanentFailure("should not request")
+ }
+ )
+ )
+
+ await #expect(throws: RadrootsLocationServicesError.permissionDenied("location permission has not been requested")) {
+ _ = try await service.currentLocation(try RadrootsCurrentLocationRequest(timeoutSeconds: 1))
+ }
+}
+
+@Test func appleLocationServicesRejectsStaleCurrentLocationReadings() async throws {
+ let reading = try RadrootsLocationReading(
+ coordinate: RadrootsLocationCoordinate(latitude: 49.2827, longitude: -123.1207),
+ horizontalAccuracyMeters: 4,
+ capturedAt: Date(timeIntervalSince1970: 100)
+ )
+ let service = RadrootsAppleLocationServices(
+ adapters: RadrootsAppleLocationServicesAdapters(
+ now: { Date(timeIntervalSince1970: 200) },
+ locationServicesEnabled: { true },
+ authorizationStatus: { .authorizedWhenInUse },
+ requestWhenInUseAuthorization: { _ in .authorizedWhenInUse },
+ requestCurrentLocation: { _ in reading }
+ )
+ )
+
+ await #expect(throws: RadrootsLocationServicesError.transientFailure("location reading is older than the requested maximum age")) {
+ _ = try await service.currentLocation(try RadrootsCurrentLocationRequest(
+ timeoutSeconds: 1,
+ maximumCachedReadingAgeSeconds: 5
+ ))
+ }
+}
+
+@Test func appleLocationServicesPropagatesTypedLocationFailures() async {
+ let service = RadrootsAppleLocationServices(
+ adapters: RadrootsAppleLocationServicesAdapters(
+ locationServicesEnabled: { true },
+ authorizationStatus: { .authorizedWhenInUse },
+ requestWhenInUseAuthorization: { _ in .authorizedWhenInUse },
+ requestCurrentLocation: { _ in
+ throw RadrootsLocationServicesError.timeout("timed out")
+ }
+ )
+ )
+
+ await #expect(throws: RadrootsLocationServicesError.timeout("timed out")) {
+ _ = try await service.currentLocation(try RadrootsCurrentLocationRequest(timeoutSeconds: 1))
+ }
+}
+
+#if canImport(CoreLocation)
+@Test func appleLocationServicesMapsCoreLocationAuthorization() {
+ #expect(RadrootsAppleLocationServicesAdapters.authorization(
+ for: CLAuthorizationStatus.notDetermined,
+ locationServicesEnabled: true
+ ) == .notDetermined)
+ #expect(RadrootsAppleLocationServicesAdapters.authorization(
+ for: CLAuthorizationStatus.denied,
+ locationServicesEnabled: true
+ ) == .denied)
+ #expect(RadrootsAppleLocationServicesAdapters.authorization(
+ for: CLAuthorizationStatus.authorizedAlways,
+ locationServicesEnabled: true
+ ) == .authorizedAlways)
+ #expect(RadrootsAppleLocationServicesAdapters.authorization(
+ for: CLAuthorizationStatus.authorizedAlways,
+ locationServicesEnabled: false
+ ) == .unavailable)
+ #if os(iOS)
+ #expect(RadrootsAppleLocationServicesAdapters.authorization(
+ for: CLAuthorizationStatus.authorizedWhenInUse,
+ locationServicesEnabled: true
+ ) == .authorizedWhenInUse)
+ #endif
+}
+#endif