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