apple_kit

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

RadrootsPermissionLocation.swift (10044B)


      1 import Foundation
      2 
      3 public enum RadrootsPermissionKind: String, Sendable, Equatable, Hashable, CaseIterable {
      4     case notifications
      5     case camera
      6     case photos
      7     case microphone
      8     case location
      9 }
     10 
     11 public enum RadrootsPermissionStatus: String, Sendable, Equatable, Hashable {
     12     case notDetermined
     13     case authorized
     14     case denied
     15     case restricted
     16     case limited
     17     case unavailable
     18     case unsupported
     19 }
     20 
     21 public struct RadrootsPermissionSnapshot: Sendable, Equatable, Hashable {
     22     public let kind: RadrootsPermissionKind
     23     public let status: RadrootsPermissionStatus
     24     public let observedAt: Date
     25 
     26     public init(kind: RadrootsPermissionKind, status: RadrootsPermissionStatus, observedAt: Date) {
     27         self.kind = kind
     28         self.status = status
     29         self.observedAt = observedAt
     30     }
     31 }
     32 
     33 public protocol RadrootsPermissionStatusProvider: Sendable {
     34     func snapshot(for kind: RadrootsPermissionKind) async throws -> RadrootsPermissionSnapshot
     35     func snapshots(for kinds: [RadrootsPermissionKind]) async throws -> [RadrootsPermissionSnapshot]
     36 }
     37 
     38 extension RadrootsPermissionStatusProvider {
     39     public func snapshots(for kinds: [RadrootsPermissionKind]) async throws -> [RadrootsPermissionSnapshot] {
     40         var snapshots: [RadrootsPermissionSnapshot] = []
     41         snapshots.reserveCapacity(kinds.count)
     42         for kind in kinds {
     43             snapshots.append(try await snapshot(for: kind))
     44         }
     45         return snapshots
     46     }
     47 }
     48 
     49 public enum RadrootsLocationAuthorization: String, Sendable, Equatable, Hashable {
     50     case notDetermined
     51     case authorizedWhenInUse
     52     case authorizedAlways
     53     case denied
     54     case restricted
     55     case unavailable
     56     case unsupported
     57 
     58     public var permissionStatus: RadrootsPermissionStatus {
     59         switch self {
     60         case .notDetermined:
     61             .notDetermined
     62         case .authorizedWhenInUse:
     63             .authorized
     64         case .authorizedAlways:
     65             .authorized
     66         case .denied:
     67             .denied
     68         case .restricted:
     69             .restricted
     70         case .unavailable:
     71             .unavailable
     72         case .unsupported:
     73             .unsupported
     74         }
     75     }
     76 }
     77 
     78 public struct RadrootsLocationServicesAvailability: Sendable, Equatable, Hashable {
     79     public let locationServicesEnabled: Bool
     80     public let authorization: RadrootsLocationAuthorization
     81 
     82     public init(locationServicesEnabled: Bool, authorization: RadrootsLocationAuthorization) {
     83         self.locationServicesEnabled = locationServicesEnabled
     84         self.authorization = authorization
     85     }
     86 
     87     public var canRequestWhenInUseAuthorization: Bool {
     88         locationServicesEnabled && authorization == .notDetermined
     89     }
     90 
     91     public var canRequestCurrentLocation: Bool {
     92         locationServicesEnabled && (authorization == .authorizedWhenInUse || authorization == .authorizedAlways)
     93     }
     94 }
     95 
     96 public struct RadrootsLocationCoordinate: Sendable, Equatable, Hashable {
     97     public let latitude: Double
     98     public let longitude: Double
     99 
    100     public init(latitude: Double, longitude: Double) throws {
    101         guard latitude.isFinite, (-90.0...90.0).contains(latitude) else {
    102             throw RadrootsLocationServicesError.invalidRequest("latitude must be between -90 and 90")
    103         }
    104         guard longitude.isFinite, (-180.0...180.0).contains(longitude) else {
    105             throw RadrootsLocationServicesError.invalidRequest("longitude must be between -180 and 180")
    106         }
    107         self.latitude = latitude
    108         self.longitude = longitude
    109     }
    110 }
    111 
    112 public struct RadrootsLocationReading: Sendable, Equatable, Hashable {
    113     public let coordinate: RadrootsLocationCoordinate
    114     public let horizontalAccuracyMeters: Double
    115     public let altitudeMeters: Double?
    116     public let verticalAccuracyMeters: Double?
    117     public let speedMetersPerSecond: Double?
    118     public let courseDegrees: Double?
    119     public let capturedAt: Date
    120 
    121     public init(
    122         coordinate: RadrootsLocationCoordinate,
    123         horizontalAccuracyMeters: Double,
    124         altitudeMeters: Double? = nil,
    125         verticalAccuracyMeters: Double? = nil,
    126         speedMetersPerSecond: Double? = nil,
    127         courseDegrees: Double? = nil,
    128         capturedAt: Date
    129     ) throws {
    130         self.coordinate = coordinate
    131         self.horizontalAccuracyMeters = try Self.normalizedNonNegativeFinite(
    132             horizontalAccuracyMeters,
    133             field: "horizontal accuracy"
    134         )
    135         self.altitudeMeters = try Self.normalizedOptionalFinite(altitudeMeters, field: "altitude")
    136         self.verticalAccuracyMeters = try Self.normalizedOptionalNonNegativeFinite(
    137             verticalAccuracyMeters,
    138             field: "vertical accuracy"
    139         )
    140         self.speedMetersPerSecond = try Self.normalizedOptionalNonNegativeFinite(
    141             speedMetersPerSecond,
    142             field: "speed"
    143         )
    144         self.courseDegrees = try Self.normalizedOptionalCourse(courseDegrees)
    145         guard capturedAt.timeIntervalSinceReferenceDate.isFinite else {
    146             throw RadrootsLocationServicesError.invalidRequest("captured timestamp must be finite")
    147         }
    148         self.capturedAt = capturedAt
    149     }
    150 
    151     public func age(relativeTo now: Date) throws -> TimeInterval {
    152         guard now.timeIntervalSinceReferenceDate.isFinite else {
    153             throw RadrootsLocationServicesError.invalidRequest("comparison timestamp must be finite")
    154         }
    155         return now.timeIntervalSince(capturedAt)
    156     }
    157 
    158     public func isFresh(relativeTo now: Date, maximumAgeSeconds: TimeInterval) throws -> Bool {
    159         let normalizedMaximumAge = try Self.normalizedNonNegativeFinite(maximumAgeSeconds, field: "maximum age")
    160         let currentAge = try age(relativeTo: now)
    161         return currentAge >= 0 && currentAge <= normalizedMaximumAge
    162     }
    163 
    164     public static func normalizedNonNegativeFinite(_ value: Double, field: String) throws -> Double {
    165         guard value.isFinite, value >= 0 else {
    166             throw RadrootsLocationServicesError.invalidRequest("\(field) must be finite and non-negative")
    167         }
    168         return value
    169     }
    170 
    171     public static func normalizedOptionalFinite(_ value: Double?, field: String) throws -> Double? {
    172         guard let value else {
    173             return nil
    174         }
    175         guard value.isFinite else {
    176             throw RadrootsLocationServicesError.invalidRequest("\(field) must be finite")
    177         }
    178         return value
    179     }
    180 
    181     public static func normalizedOptionalNonNegativeFinite(_ value: Double?, field: String) throws -> Double? {
    182         guard let value else {
    183             return nil
    184         }
    185         return try normalizedNonNegativeFinite(value, field: field)
    186     }
    187 
    188     public static func normalizedOptionalCourse(_ value: Double?) throws -> Double? {
    189         guard let value else {
    190             return nil
    191         }
    192         guard value.isFinite, (0.0..<360.0).contains(value) else {
    193             throw RadrootsLocationServicesError.invalidRequest("course must be between 0 and 359.999 degrees")
    194         }
    195         return value
    196     }
    197 }
    198 
    199 public struct RadrootsCurrentLocationRequest: Sendable, Equatable, Hashable {
    200     public let timeoutSeconds: TimeInterval
    201     public let desiredAccuracyMeters: Double?
    202     public let maximumCachedReadingAgeSeconds: TimeInterval?
    203 
    204     public init(
    205         timeoutSeconds: TimeInterval = 12,
    206         desiredAccuracyMeters: Double? = nil,
    207         maximumCachedReadingAgeSeconds: TimeInterval? = nil
    208     ) throws {
    209         guard timeoutSeconds.isFinite, timeoutSeconds > 0 else {
    210             throw RadrootsLocationServicesError.invalidRequest("location timeout must be finite and greater than zero")
    211         }
    212         self.timeoutSeconds = timeoutSeconds
    213         self.desiredAccuracyMeters = try RadrootsLocationReading.normalizedOptionalNonNegativeFinite(
    214             desiredAccuracyMeters,
    215             field: "desired accuracy"
    216         )
    217         self.maximumCachedReadingAgeSeconds = try RadrootsLocationReading.normalizedOptionalNonNegativeFinite(
    218             maximumCachedReadingAgeSeconds,
    219             field: "maximum cached reading age"
    220         )
    221     }
    222 }
    223 
    224 public struct RadrootsCurrentLocationResult: Sendable, Equatable, Hashable {
    225     public let reading: RadrootsLocationReading
    226     public let authorization: RadrootsLocationAuthorization
    227     public let servedFromCache: Bool
    228 
    229     public init(
    230         reading: RadrootsLocationReading,
    231         authorization: RadrootsLocationAuthorization,
    232         servedFromCache: Bool = false
    233     ) throws {
    234         guard authorization == .authorizedWhenInUse || authorization == .authorizedAlways else {
    235             throw RadrootsLocationServicesError.invalidRequest("current location result requires authorized location access")
    236         }
    237         self.reading = reading
    238         self.authorization = authorization
    239         self.servedFromCache = servedFromCache
    240     }
    241 }
    242 
    243 public enum RadrootsLocationServicesError: Error, Equatable, Sendable {
    244     case invalidRequest(String)
    245     case permissionDenied(String)
    246     case unavailable(String)
    247     case timeout(String)
    248     case cancelled(String)
    249     case transientFailure(String)
    250     case permanentFailure(String)
    251 }
    252 
    253 extension RadrootsLocationServicesError: LocalizedError {
    254     public var errorDescription: String? {
    255         switch self {
    256         case .invalidRequest(let message):
    257             message
    258         case .permissionDenied(let message):
    259             message
    260         case .unavailable(let message):
    261             message
    262         case .timeout(let message):
    263             message
    264         case .cancelled(let message):
    265             message
    266         case .transientFailure(let message):
    267             message
    268         case .permanentFailure(let message):
    269             message
    270         }
    271     }
    272 }
    273 
    274 public protocol RadrootsLocationServices: Sendable {
    275     func currentAvailability() async -> RadrootsLocationServicesAvailability
    276     func requestWhenInUseAuthorization() async throws -> RadrootsLocationAuthorization
    277     func currentLocation(_ request: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult
    278 }