apple_kit

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

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