apple_kit

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

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