RadrootsAppleLocationServices.swift (13338B)
1 import Foundation 2 3 #if canImport(CoreLocation) 4 @preconcurrency import CoreLocation 5 #endif 6 7 public struct RadrootsAppleLocationServicesAdapters: Sendable { 8 public let now: @Sendable () -> Date 9 public let locationServicesEnabled: @Sendable () -> Bool 10 public let authorizationStatus: @Sendable () -> RadrootsLocationAuthorization 11 public let requestWhenInUseAuthorization: @Sendable (TimeInterval) async throws -> RadrootsLocationAuthorization 12 public let requestCurrentLocation: @Sendable (RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading 13 14 public init( 15 now: @escaping @Sendable () -> Date = Date.init, 16 locationServicesEnabled: @escaping @Sendable () -> Bool, 17 authorizationStatus: @escaping @Sendable () -> RadrootsLocationAuthorization, 18 requestWhenInUseAuthorization: @escaping @Sendable (TimeInterval) async throws -> RadrootsLocationAuthorization, 19 requestCurrentLocation: @escaping @Sendable (RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading 20 ) { 21 self.now = now 22 self.locationServicesEnabled = locationServicesEnabled 23 self.authorizationStatus = authorizationStatus 24 self.requestWhenInUseAuthorization = requestWhenInUseAuthorization 25 self.requestCurrentLocation = requestCurrentLocation 26 } 27 28 public static var live: Self { 29 #if canImport(CoreLocation) 30 Self( 31 locationServicesEnabled: { 32 CLLocationManager.locationServicesEnabled() 33 }, 34 authorizationStatus: { 35 authorization( 36 for: CLLocationManager().authorizationStatus, 37 locationServicesEnabled: CLLocationManager.locationServicesEnabled() 38 ) 39 }, 40 requestWhenInUseAuthorization: { timeoutSeconds in 41 try await RadrootsCoreLocationAuthorizationSession().start(timeoutSeconds: timeoutSeconds) 42 }, 43 requestCurrentLocation: { request in 44 try await RadrootsCoreLocationReadingSession().start(request: request) 45 } 46 ) 47 #else 48 Self( 49 locationServicesEnabled: { false }, 50 authorizationStatus: { .unsupported }, 51 requestWhenInUseAuthorization: { _ in 52 throw RadrootsLocationServicesError.unavailable("CoreLocation is not available") 53 }, 54 requestCurrentLocation: { _ in 55 throw RadrootsLocationServicesError.unavailable("CoreLocation is not available") 56 } 57 ) 58 #endif 59 } 60 61 #if canImport(CoreLocation) 62 public static func authorization( 63 for status: CLAuthorizationStatus, 64 locationServicesEnabled: Bool 65 ) -> RadrootsLocationAuthorization { 66 guard locationServicesEnabled else { 67 return .unavailable 68 } 69 switch status { 70 case .notDetermined: 71 return .notDetermined 72 case .restricted: 73 return .restricted 74 case .denied: 75 return .denied 76 case .authorizedAlways: 77 return .authorizedAlways 78 #if os(iOS) 79 case .authorizedWhenInUse: 80 return .authorizedWhenInUse 81 #endif 82 @unknown default: 83 return .unavailable 84 } 85 } 86 #endif 87 } 88 89 public final class RadrootsAppleLocationServices: RadrootsLocationServices, Sendable { 90 private let adapters: RadrootsAppleLocationServicesAdapters 91 92 public init(adapters: RadrootsAppleLocationServicesAdapters = .live) { 93 self.adapters = adapters 94 } 95 96 public func currentAvailability() async -> RadrootsLocationServicesAvailability { 97 let enabled = adapters.locationServicesEnabled() 98 return RadrootsLocationServicesAvailability( 99 locationServicesEnabled: enabled, 100 authorization: enabled ? adapters.authorizationStatus() : .unavailable 101 ) 102 } 103 104 public func requestWhenInUseAuthorization() async throws -> RadrootsLocationAuthorization { 105 let availability = await currentAvailability() 106 guard availability.locationServicesEnabled else { 107 throw RadrootsLocationServicesError.unavailable("location services are disabled") 108 } 109 switch availability.authorization { 110 case .notDetermined: 111 return try await adapters.requestWhenInUseAuthorization(12) 112 case .authorizedWhenInUse: 113 return .authorizedWhenInUse 114 case .authorizedAlways: 115 return .authorizedAlways 116 case .denied: 117 throw RadrootsLocationServicesError.permissionDenied("location permission is denied") 118 case .restricted: 119 throw RadrootsLocationServicesError.permissionDenied("location permission is restricted") 120 case .unavailable: 121 throw RadrootsLocationServicesError.unavailable("location services are unavailable") 122 case .unsupported: 123 throw RadrootsLocationServicesError.unavailable("location services are unsupported") 124 } 125 } 126 127 public func currentLocation(_ request: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult { 128 let availability = await currentAvailability() 129 guard availability.locationServicesEnabled else { 130 throw RadrootsLocationServicesError.unavailable("location services are disabled") 131 } 132 guard availability.canRequestCurrentLocation else { 133 switch availability.authorization { 134 case .denied: 135 throw RadrootsLocationServicesError.permissionDenied("location permission is denied") 136 case .restricted: 137 throw RadrootsLocationServicesError.permissionDenied("location permission is restricted") 138 case .notDetermined: 139 throw RadrootsLocationServicesError.permissionDenied("location permission has not been requested") 140 case .unavailable: 141 throw RadrootsLocationServicesError.unavailable("location services are unavailable") 142 case .unsupported: 143 throw RadrootsLocationServicesError.unavailable("location services are unsupported") 144 case .authorizedWhenInUse, .authorizedAlways: 145 break 146 } 147 throw RadrootsLocationServicesError.unavailable("location services are unavailable") 148 } 149 let reading = try await adapters.requestCurrentLocation(request) 150 if let maximumAgeSeconds = request.maximumCachedReadingAgeSeconds { 151 guard try reading.isFresh(relativeTo: adapters.now(), maximumAgeSeconds: maximumAgeSeconds) else { 152 throw RadrootsLocationServicesError.transientFailure("location reading is older than the requested maximum age") 153 } 154 } 155 return try RadrootsCurrentLocationResult( 156 reading: reading, 157 authorization: availability.authorization 158 ) 159 } 160 } 161 162 #if canImport(CoreLocation) 163 @MainActor 164 private final class RadrootsCoreLocationAuthorizationSession: NSObject, @preconcurrency CLLocationManagerDelegate { 165 private var continuation: CheckedContinuation<RadrootsLocationAuthorization, any Error>? 166 private var manager: CLLocationManager? 167 private var timeoutTask: Task<Void, Never>? 168 169 func start(timeoutSeconds: TimeInterval) async throws -> RadrootsLocationAuthorization { 170 try await withTaskCancellationHandler { 171 try await withCheckedThrowingContinuation { continuation in 172 self.continuation = continuation 173 let manager = CLLocationManager() 174 self.manager = manager 175 manager.delegate = self 176 timeoutTask = Task { [weak self] in 177 let nanoseconds = UInt64(timeoutSeconds * 1_000_000_000) 178 try? await Task.sleep(nanoseconds: nanoseconds) 179 self?.finish(.failure(.timeout("location authorization timed out"))) 180 } 181 manager.requestWhenInUseAuthorization() 182 } 183 } onCancel: { 184 Task { @MainActor [weak self] in 185 self?.finish(.failure(.cancelled("location authorization was cancelled"))) 186 } 187 } 188 } 189 190 func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { 191 let authorization = RadrootsAppleLocationServicesAdapters.authorization( 192 for: manager.authorizationStatus, 193 locationServicesEnabled: CLLocationManager.locationServicesEnabled() 194 ) 195 guard authorization != .notDetermined else { 196 return 197 } 198 finish(.success(authorization)) 199 } 200 201 private func finish(_ result: Result<RadrootsLocationAuthorization, RadrootsLocationServicesError>) { 202 guard let continuation else { 203 return 204 } 205 self.continuation = nil 206 timeoutTask?.cancel() 207 timeoutTask = nil 208 manager?.delegate = nil 209 manager = nil 210 switch result { 211 case .success(let authorization): 212 continuation.resume(returning: authorization) 213 case .failure(let error): 214 continuation.resume(throwing: error) 215 } 216 } 217 } 218 219 @MainActor 220 private final class RadrootsCoreLocationReadingSession: NSObject, @preconcurrency CLLocationManagerDelegate { 221 private var continuation: CheckedContinuation<RadrootsLocationReading, any Error>? 222 private var manager: CLLocationManager? 223 private var timeoutTask: Task<Void, Never>? 224 225 func start(request: RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading { 226 try await withTaskCancellationHandler { 227 try await withCheckedThrowingContinuation { continuation in 228 self.continuation = continuation 229 let manager = CLLocationManager() 230 self.manager = manager 231 manager.delegate = self 232 if let desiredAccuracyMeters = request.desiredAccuracyMeters { 233 manager.desiredAccuracy = desiredAccuracyMeters 234 } 235 timeoutTask = Task { [weak self] in 236 let nanoseconds = UInt64(request.timeoutSeconds * 1_000_000_000) 237 try? await Task.sleep(nanoseconds: nanoseconds) 238 self?.finish(.failure(.timeout("current location request timed out"))) 239 } 240 manager.requestLocation() 241 } 242 } onCancel: { 243 Task { @MainActor [weak self] in 244 self?.finish(.failure(.cancelled("current location request was cancelled"))) 245 } 246 } 247 } 248 249 func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 250 guard let location = locations.sorted(by: { $0.timestamp < $1.timestamp }).last else { 251 finish(.failure(.transientFailure("CoreLocation returned no locations"))) 252 return 253 } 254 do { 255 finish(.success(try Self.reading(from: location))) 256 } catch let error as RadrootsLocationServicesError { 257 finish(.failure(error)) 258 } catch { 259 finish(.failure(.permanentFailure(error.localizedDescription))) 260 } 261 } 262 263 func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) { 264 if let coreLocationError = error as? CLError { 265 switch coreLocationError.code { 266 case .denied: 267 finish(.failure(.permissionDenied("location permission is denied"))) 268 case .locationUnknown: 269 finish(.failure(.transientFailure("current location is temporarily unknown"))) 270 case .network: 271 finish(.failure(.transientFailure("location network lookup failed"))) 272 default: 273 finish(.failure(.permanentFailure(coreLocationError.localizedDescription))) 274 } 275 } else { 276 finish(.failure(.permanentFailure(error.localizedDescription))) 277 } 278 } 279 280 private func finish(_ result: Result<RadrootsLocationReading, RadrootsLocationServicesError>) { 281 guard let continuation else { 282 return 283 } 284 self.continuation = nil 285 timeoutTask?.cancel() 286 timeoutTask = nil 287 manager?.delegate = nil 288 manager = nil 289 switch result { 290 case .success(let reading): 291 continuation.resume(returning: reading) 292 case .failure(let error): 293 continuation.resume(throwing: error) 294 } 295 } 296 297 private static func reading(from location: CLLocation) throws -> RadrootsLocationReading { 298 try RadrootsLocationReading( 299 coordinate: RadrootsLocationCoordinate( 300 latitude: location.coordinate.latitude, 301 longitude: location.coordinate.longitude 302 ), 303 horizontalAccuracyMeters: location.horizontalAccuracy, 304 altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil, 305 verticalAccuracyMeters: location.verticalAccuracy >= 0 ? location.verticalAccuracy : nil, 306 speedMetersPerSecond: location.speed >= 0 ? location.speed : nil, 307 courseDegrees: location.course >= 0 ? location.course : nil, 308 capturedAt: location.timestamp 309 ) 310 } 311 } 312 #endif