diff --git a/Package.swift b/Package.swift index 866e841..09ce218 100644 --- a/Package.swift +++ b/Package.swift @@ -9,13 +9,13 @@ let package = Package( products: [ .library( name: "SwiftLocation", - targets: ["SwiftLocation"]), + targets: ["SwiftLocation"]) ], targets: [ .target( name: "SwiftLocation"), .testTarget( name: "SwiftLocationTests", - dependencies: ["SwiftLocation"]), + dependencies: ["SwiftLocation"]) ] ) diff --git a/Sources/SwiftLocation/Async Tasks/AccuracyAuthorization.swift b/Sources/SwiftLocation/Async Tasks/AccuracyAuthorization.swift index e9fbe2f..c49c408 100644 --- a/Sources/SwiftLocation/Async Tasks/AccuracyAuthorization.swift +++ b/Sources/SwiftLocation/Async Tasks/AccuracyAuthorization.swift @@ -27,9 +27,9 @@ import Foundation import CoreLocation extension Tasks { - + public final class AccuracyAuthorization: AnyTask { - + // MARK: - Support Structures /// Stream produced by the task. @@ -37,10 +37,10 @@ extension Tasks { /// The event produced by the stream. public enum StreamEvent: CustomStringConvertible, Equatable { - + /// A new change in accuracy level authorization has been captured. case didUpdateAccuracyAuthorization(_ accuracyAuthorization: CLAccuracyAuthorization) - + /// Return the accuracy authorization of the event var accuracyAuthorization: CLAccuracyAuthorization { switch self { @@ -48,22 +48,22 @@ extension Tasks { return accuracyAuthorization } } - + public var description: String { switch self { case .didUpdateAccuracyAuthorization: return "didUpdateAccuracyAuthorization" } } - + } - + // MARK: - Public Properties public let uuid = UUID() public var stream: Stream.Continuation? public var cancellable: CancellableTask? - + // MARK: - Functions public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { @@ -75,5 +75,5 @@ extension Tasks { } } } - + } diff --git a/Sources/SwiftLocation/Async Tasks/AccuracyPermission.swift b/Sources/SwiftLocation/Async Tasks/AccuracyPermission.swift index 7befc27..8135320 100644 --- a/Sources/SwiftLocation/Async Tasks/AccuracyPermission.swift +++ b/Sources/SwiftLocation/Async Tasks/AccuracyPermission.swift @@ -27,31 +27,31 @@ import Foundation import CoreLocation extension Tasks { - + public final class AccuracyPermission: AnyTask { - + // MARK: - Support Structures public typealias Continuation = CheckedContinuation - + // MARK: - Public Properties public let uuid = UUID() public var cancellable: CancellableTask? var continuation: Continuation? - + // MARK: - Private Properties private weak var instance: Location? - + // MARK: - Initialization init(instance: Location) { self.instance = instance } - + // MARK: - Functions - + public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { switch event { case .didChangeAccuracyAuthorization(let auth): @@ -60,28 +60,29 @@ extension Tasks { break } } - + + @MainActor func requestTemporaryPermission(purposeKey: String) async throws -> CLAccuracyAuthorization { try await withCheckedThrowingContinuation { continuation in guard let instance = self.instance else { return } - + guard instance.locationManager.locationServicesEnabled() else { continuation.resume(throwing: LocationErrors.locationServicesDisabled) return } - + let authorizationStatus = instance.authorizationStatus guard authorizationStatus != .notDetermined else { continuation.resume(throwing: LocationErrors.authorizationRequired) return } - + let accuracyAuthorization = instance.accuracyAuthorization guard accuracyAuthorization != .fullAccuracy else { continuation.resume(with: .success(accuracyAuthorization)) return } - + self.continuation = continuation instance.asyncBridge.add(task: self) instance.locationManager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: purposeKey) { error in @@ -89,7 +90,7 @@ extension Tasks { continuation.resume(throwing: error) return } - + // If the user chooses reduced accuracy, the didChangeAuthorization delegate method will not called. if instance.locationManager.accuracyAuthorization == .reducedAccuracy { let accuracyAuthorization = instance.accuracyAuthorization @@ -98,7 +99,7 @@ extension Tasks { } } } - + } - + } diff --git a/Sources/SwiftLocation/Async Tasks/AnyTask.swift b/Sources/SwiftLocation/Async Tasks/AnyTask.swift index a00a792..f175b15 100644 --- a/Sources/SwiftLocation/Async Tasks/AnyTask.swift +++ b/Sources/SwiftLocation/Async Tasks/AnyTask.swift @@ -27,32 +27,33 @@ import Foundation public enum Tasks { } +@MainActor public protocol AnyTask: AnyObject { - + var cancellable: CancellableTask? { get set } var uuid: UUID { get } var taskType: ObjectIdentifier { get } - + func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) func didCancelled() func willStart() - + } public extension AnyTask { - + var taskType: ObjectIdentifier { ObjectIdentifier(Self.self) } - + func didCancelled() { } func willStart() { } - -} +} +@MainActor public protocol CancellableTask: AnyObject { - + func cancel(task: any AnyTask) - + } diff --git a/Sources/SwiftLocation/Async Tasks/Authorization.swift b/Sources/SwiftLocation/Async Tasks/Authorization.swift index 49dc4d2..62eda12 100644 --- a/Sources/SwiftLocation/Async Tasks/Authorization.swift +++ b/Sources/SwiftLocation/Async Tasks/Authorization.swift @@ -27,9 +27,9 @@ import Foundation import CoreLocation extension Tasks { - + public final class Authorization: AnyTask { - + // MARK: - Support Structures /// Stream produced by the task. @@ -37,10 +37,10 @@ extension Tasks { /// The event produced by the stream. public enum StreamEvent { - + /// Authorization did change with a new value case didChangeAuthorization(_ status: CLAuthorizationStatus) - + /// The current status of the authorization. public var authorizationStatus: CLAuthorizationStatus { switch self { @@ -48,15 +48,15 @@ extension Tasks { return status } } - + } - + // MARK: - Public Properties public let uuid = UUID() public var stream: Stream.Continuation? public var cancellable: CancellableTask? - + // MARK: - Functions public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { @@ -68,5 +68,5 @@ extension Tasks { } } } - + } diff --git a/Sources/SwiftLocation/Async Tasks/BeaconMonitoring.swift b/Sources/SwiftLocation/Async Tasks/BeaconMonitoring.swift index 43f2a78..800f613 100644 --- a/Sources/SwiftLocation/Async Tasks/BeaconMonitoring.swift +++ b/Sources/SwiftLocation/Async Tasks/BeaconMonitoring.swift @@ -28,33 +28,33 @@ import CoreLocation #if !os(watchOS) && !os(tvOS) extension Tasks { - + public final class BeaconMonitoring: AnyTask { - + // MARK: - Support Structures /// The event produced by the stream. public typealias Stream = AsyncStream - + /// The event produced by the stream. public enum StreamEvent: CustomStringConvertible, Equatable { case didRange(beacons: [CLBeacon], constraint: CLBeaconIdentityConstraint) case didFailRanginFor(constraint: CLBeaconIdentityConstraint, error: Error) - + public static func == (lhs: Tasks.BeaconMonitoring.StreamEvent, rhs: Tasks.BeaconMonitoring.StreamEvent) -> Bool { switch (lhs, rhs) { case (let .didRange(b1, _), let .didRange(b2, _)): return b1 == b2 - + case (let .didFailRanginFor(c1, _), let .didFailRanginFor(c2, _)): return c1 == c2 - + default: return false - + } } - + public var description: String { switch self { case .didFailRanginFor: @@ -64,20 +64,20 @@ extension Tasks { } } } - + // MARK: - Public Properties public let uuid = UUID() public var stream: Stream.Continuation? public var cancellable: CancellableTask? public private(set) var satisfying: CLBeaconIdentityConstraint - + // MARK: - Initialization init(satisfying: CLBeaconIdentityConstraint) { self.satisfying = satisfying } - + // MARK: - Functions public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { @@ -90,8 +90,8 @@ extension Tasks { break } } - + } - + } #endif diff --git a/Sources/SwiftLocation/Async Tasks/ContinuousUpdateLocation.swift b/Sources/SwiftLocation/Async Tasks/ContinuousUpdateLocation.swift index 5fcc840..4353442 100644 --- a/Sources/SwiftLocation/Async Tasks/ContinuousUpdateLocation.swift +++ b/Sources/SwiftLocation/Async Tasks/ContinuousUpdateLocation.swift @@ -27,37 +27,37 @@ import Foundation import CoreLocation extension Tasks { - + public final class ContinuousUpdateLocation: AnyTask { - + // MARK: - Support Structures /// Stream produced by the task. public typealias Stream = AsyncStream - + /// The event produced by the stream. public enum StreamEvent: CustomStringConvertible, Equatable { - + /// A new array of locations has been received. case didUpdateLocations(_ locations: [CLLocation]) - + /// Something went wrong while reading new locations. case didFailed(_ error: Error) - + #if os(iOS) /// Location updates did resume. case didResume - + /// Location updates did pause. case didPaused #endif - + /// Return the location received by the event if it's a location event. /// In case of multiple events it will return the most recent one. public var location: CLLocation? { locations?.max(by: { $0.timestamp < $1.timestamp }) } - + /// Return the list of locations received if the event is a location update. public var locations: [CLLocation]? { guard case .didUpdateLocations(let locations) = self else { @@ -65,7 +65,7 @@ extension Tasks { } return locations } - + /// Error received if any. public var error: Error? { guard case .didFailed(let e) = self else { @@ -73,22 +73,22 @@ extension Tasks { } return e } - + public var description: String { switch self { #if os(iOS) - case .didPaused: + case .didPaused: return "paused" case .didResume: return "resume" #endif - case let .didFailed(e): + case let .didFailed(e): return "error \(e.localizedDescription)" case let .didUpdateLocations(l): return "\(l.count) locations" } } - + public static func == (lhs: Tasks.ContinuousUpdateLocation.StreamEvent, rhs: Tasks.ContinuousUpdateLocation.StreamEvent) -> Bool { switch (lhs, rhs) { case (.didFailed(let e1), .didFailed(let e2)): @@ -106,23 +106,23 @@ extension Tasks { } } } - + // MARK: - Public Properties - + public let uuid = UUID() public var stream: Stream.Continuation? public var cancellable: CancellableTask? - + // MARK: - Private Properties private weak var instance: Location? - + // MARK: - Initialization init(instance: Location) { self.instance = instance } - + // MARK: - Functions public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { @@ -130,32 +130,31 @@ extension Tasks { #if os(iOS) case .locationUpdatesPaused: stream?.yield(.didPaused) - + case .locationUpdatesResumed: stream?.yield(.didResume) #endif - + case let .didFailWithError(error): stream?.yield(.didFailed(error)) - + case let .receiveNewLocations(locations): stream?.yield(.didUpdateLocations(locations)) - + default: break } } - + public func didCancelled() { guard let stream = stream else { return } - + stream.finish() self.stream = nil } - + } - - + } diff --git a/Sources/SwiftLocation/Async Tasks/HeadingMonitoring.swift b/Sources/SwiftLocation/Async Tasks/HeadingMonitoring.swift index 6720b7d..23d3d00 100644 --- a/Sources/SwiftLocation/Async Tasks/HeadingMonitoring.swift +++ b/Sources/SwiftLocation/Async Tasks/HeadingMonitoring.swift @@ -28,55 +28,55 @@ import CoreLocation #if os(iOS) extension Tasks { - + public final class HeadingMonitoring: AnyTask { - + // MARK: - Support Structures /// The event produced by the stream. public typealias Stream = AsyncStream - + /// The event produced by the stream. public enum StreamEvent: CustomStringConvertible, Equatable { - + /// A new heading value has been received. case didUpdateHeading(_ heading: CLHeading) - + /// An error has occurred. case didFailWithError(_ error: Error) - + public static func == (lhs: Tasks.HeadingMonitoring.StreamEvent, rhs: Tasks.HeadingMonitoring.StreamEvent) -> Bool { switch (lhs, rhs) { case (let .didUpdateHeading(h1), let .didUpdateHeading(h2)): return h1 == h2 - + case (let .didFailWithError(e1), let .didFailWithError(e2)): return e1.localizedDescription == e2.localizedDescription - + default: return false - + } } - + public var description: String { switch self { case .didFailWithError: return "didFailWithError" - + case .didUpdateHeading: return "didUpdateHeading" - + } } } - + // MARK: - Public Properties - + public let uuid = UUID() public var stream: Stream.Continuation? public var cancellable: CancellableTask? - + // MARK: - Functions public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { @@ -89,8 +89,8 @@ extension Tasks { break } } - + } - + } #endif diff --git a/Sources/SwiftLocation/Async Tasks/LocatePermission.swift b/Sources/SwiftLocation/Async Tasks/LocatePermission.swift index dcd0657..8af5758 100644 --- a/Sources/SwiftLocation/Async Tasks/LocatePermission.swift +++ b/Sources/SwiftLocation/Async Tasks/LocatePermission.swift @@ -27,31 +27,31 @@ import Foundation import CoreLocation extension Tasks { - + public final class LocatePermission: AnyTask { - + // MARK: - Support Structures public typealias Continuation = CheckedContinuation - + // MARK: - Public Properties public let uuid = UUID() public var cancellable: CancellableTask? var continuation: Continuation? - + // MARK: - Private Properties private weak var instance: Location? - + // MARK: - Initialization init(instance: Location) { self.instance = instance } - + // MARK: - Functions - + public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { switch event { case .didChangeAuthorization(let authorization): @@ -59,7 +59,15 @@ extension Tasks { cancellable?.cancel(task: self) return } - + + guard authorization != .notDetermined else { + // The location manager can return .notDetermined before a user hits the location popup. + // This causes the await to return before a user has tapped a button on the popup, so we + // ignore it here. Once the user hits a button on the popup, receivedLocationManagerEvent will + // be called again with a better authorization. + return + } + continuation.resume(returning: authorization) self.continuation = nil cancellable?.cancel(task: self) @@ -67,28 +75,29 @@ extension Tasks { break } } - + @MainActor func requestWhenInUsePermission() async throws -> CLAuthorizationStatus { try await withCheckedThrowingContinuation { continuation in guard let instance = self.instance else { return } - + let isAuthorized = instance.authorizationStatus != .notDetermined guard !isAuthorized else { continuation.resume(returning: instance.authorizationStatus) return } - + self.continuation = continuation instance.asyncBridge.add(task: self) instance.locationManager.requestWhenInUseAuthorization() } } - + #if !os(tvOS) + @MainActor func requestAlwaysPermission() async throws -> CLAuthorizationStatus { try await withCheckedThrowingContinuation { continuation in guard let instance = self.instance else { return } - + #if os(macOS) let isAuthorized = instance.authorizationStatus != .notDetermined #else @@ -98,14 +107,14 @@ extension Tasks { continuation.resume(with: .success(instance.authorizationStatus)) return } - + self.continuation = continuation instance.asyncBridge.add(task: self) instance.locationManager.requestAlwaysAuthorization() } } #endif - + } - + } diff --git a/Sources/SwiftLocation/Async Tasks/LocationServicesEnabled.swift b/Sources/SwiftLocation/Async Tasks/LocationServicesEnabled.swift index c5e5689..72a9210 100644 --- a/Sources/SwiftLocation/Async Tasks/LocationServicesEnabled.swift +++ b/Sources/SwiftLocation/Async Tasks/LocationServicesEnabled.swift @@ -27,20 +27,21 @@ import Foundation import CoreLocation extension Tasks { - + + @MainActor public final class LocationServicesEnabled: AnyTask { - + // MARK: - Support Structures - + /// Stream produced by the task. public typealias Stream = AsyncStream - + /// The event produced by the stream. public enum StreamEvent: CustomStringConvertible, Equatable { - + /// A new change in the location services status has been detected. case didChangeLocationEnabled(_ enabled: Bool) - + /// Return `true` if location service is enabled. var isLocationEnabled: Bool { switch self { @@ -48,25 +49,25 @@ extension Tasks { return enabled } } - + public var description: String { switch self { case .didChangeLocationEnabled: return "didChangeLocationEnabled" - + } } - + } - + // MARK: - Public Properties - + public let uuid = UUID() public var stream: Stream.Continuation? public var cancellable: CancellableTask? - + // MARK: - Functions - + public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { switch event { case .didChangeLocationEnabled(let enabled): @@ -75,7 +76,7 @@ extension Tasks { break } } - + } - + } diff --git a/Sources/SwiftLocation/Async Tasks/RegionMonitoring.swift b/Sources/SwiftLocation/Async Tasks/RegionMonitoring.swift index 2a32806..0615f1e 100644 --- a/Sources/SwiftLocation/Async Tasks/RegionMonitoring.swift +++ b/Sources/SwiftLocation/Async Tasks/RegionMonitoring.swift @@ -27,29 +27,29 @@ import Foundation import CoreLocation extension Tasks { - + public final class RegionMonitoring: AnyTask { - + // MARK: - Support Structures /// The event produced by the stream. public typealias Stream = AsyncStream - + /// The event produced by the stream. public enum StreamEvent: CustomStringConvertible, Equatable { - + /// User entered the specified region. case didEnterTo(region: CLRegion) - + /// User exited from the specified region. case didExitTo(region: CLRegion) - + /// A new region is being monitored. case didStartMonitoringFor(region: CLRegion) - + /// Specified region monitoring error occurred. case monitoringDidFailFor(region: CLRegion?, error: Error) - + public var description: String { switch self { case .didEnterTo: @@ -62,7 +62,7 @@ extension Tasks { return "monitoringDidFail: \(error.localizedDescription)" } } - + public static func == (lhs: Tasks.RegionMonitoring.StreamEvent, rhs: Tasks.RegionMonitoring.StreamEvent) -> Bool { switch (lhs, rhs) { case (let .didEnterTo(r1), let .didEnterTo(r2)): @@ -77,47 +77,47 @@ extension Tasks { return false } } - + } - + // MARK: - Public Properties - + public let uuid = UUID() public var stream: Stream.Continuation? public var cancellable: CancellableTask? - + private weak var instance: Location? private(set) var region: CLRegion - + // MARK: - Initialization init(instance: Location, region: CLRegion) { self.instance = instance self.region = region } - + // MARK: - Functions public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { switch event { case let .didStartMonitoringFor(region): stream?.yield(.didStartMonitoringFor(region: region)) - + case let .didEnterRegion(region): stream?.yield(.didEnterTo(region: region)) - + case let .didExitRegion(region): stream?.yield(.didExitTo(region: region)) - + case let .monitoringDidFailFor(region, error): stream?.yield(.monitoringDidFailFor(region: region, error: error)) - + default: break - + } } - + } - + } diff --git a/Sources/SwiftLocation/Async Tasks/SignificantLocationMonitoring.swift b/Sources/SwiftLocation/Async Tasks/SignificantLocationMonitoring.swift index c9dba69..aa522af 100644 --- a/Sources/SwiftLocation/Async Tasks/SignificantLocationMonitoring.swift +++ b/Sources/SwiftLocation/Async Tasks/SignificantLocationMonitoring.swift @@ -27,9 +27,9 @@ import Foundation import CoreLocation extension Tasks { - + public final class SignificantLocationMonitoring: AnyTask { - + // MARK: - Support Structures /// The event produced by the stream. @@ -37,19 +37,19 @@ extension Tasks { /// The event produced by the stream. public enum StreamEvent: CustomStringConvertible, Equatable { - + /// Location changes stream paused. case didPaused - + /// Location changes stream resumed. case didResume - + /// New locations received. case didUpdateLocations(_ locations: [CLLocation]) - + /// An error has occurred. case didFailWithError(_ error: Error) - + public var description: String { switch self { case let .didFailWithError(error): @@ -65,32 +65,32 @@ extension Tasks { } } - + public static func == (lhs: Tasks.SignificantLocationMonitoring.StreamEvent, rhs: Tasks.SignificantLocationMonitoring.StreamEvent) -> Bool { switch (lhs, rhs) { case (let .didFailWithError(e1), let .didFailWithError(e2)): return e1.localizedDescription == e2.localizedDescription - + case (let .didUpdateLocations(l1), let .didUpdateLocations(l2)): return l1 == l2 - + case (.didPaused, .didPaused): return true - + case (.didResume, .didResume): return true - + default: return false - + } } } - + public let uuid = UUID() public var stream: Stream.Continuation? public var cancellable: CancellableTask? - + public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { switch event { case let .receiveNewLocations(locations): @@ -105,7 +105,7 @@ extension Tasks { break } } - + } - + } diff --git a/Sources/SwiftLocation/Async Tasks/SingleUpdateLocation.swift b/Sources/SwiftLocation/Async Tasks/SingleUpdateLocation.swift index c9ef34b..96cec6b 100644 --- a/Sources/SwiftLocation/Async Tasks/SingleUpdateLocation.swift +++ b/Sources/SwiftLocation/Async Tasks/SingleUpdateLocation.swift @@ -27,25 +27,25 @@ import Foundation import CoreLocation extension Tasks { - + public final class SingleUpdateLocation: AnyTask { - + // MARK: - Support Structures public typealias Continuation = CheckedContinuation - + // MARK: - Public Properties public let uuid = UUID() public var cancellable: CancellableTask? var continuation: Continuation? - + // MARK: - Private Properties private var accuracyFilters: AccuracyFilters? private var timeout: TimeInterval? private weak var instance: Location? - + // MARK: - Initialization init(instance: Location, accuracy: AccuracyFilters?, timeout: TimeInterval?) { @@ -53,9 +53,10 @@ extension Tasks { self.accuracyFilters = accuracy self.timeout = timeout } - + // MARK: - Functions + @MainActor func run() async throws -> ContinuousUpdateLocation.StreamEvent { try await withCheckedThrowingContinuation { continuation in guard let instance = self.instance else { return } @@ -65,7 +66,7 @@ extension Tasks { instance.locationManager.requestLocation() } } - + public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { switch event { case let .receiveNewLocations(locations): @@ -73,7 +74,7 @@ extension Tasks { guard filteredLocations.isEmpty == false else { return // none of the locations respect passed filters } - + continuation?.resume(returning: .didUpdateLocations(filteredLocations)) continuation = nil cancellable?.cancel(task: self) @@ -85,20 +86,21 @@ extension Tasks { break } } - + public func didCancelled() { + continuation?.resume(throwing: LocationErrors.cancelled) continuation = nil } - + public func willStart() { guard let timeout else { return } - + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in self?.continuation?.resume(throwing: LocationErrors.timeout) } } } - + } diff --git a/Sources/SwiftLocation/Async Tasks/VisitsMonitoring.swift b/Sources/SwiftLocation/Async Tasks/VisitsMonitoring.swift index a65ac45..a8495c5 100644 --- a/Sources/SwiftLocation/Async Tasks/VisitsMonitoring.swift +++ b/Sources/SwiftLocation/Async Tasks/VisitsMonitoring.swift @@ -28,11 +28,11 @@ import CoreLocation #if !os(watchOS) && !os(tvOS) extension Tasks { - + public final class VisitsMonitoring: AnyTask { - + // MARK: - Support Structures - + /// The event produced by the stream. public typealias Stream = AsyncStream @@ -41,10 +41,10 @@ extension Tasks { /// A new visit-related event was received. case didVisit(_ visit: CLVisit) - + /// Receive an error. case didFailWithError(_ error: Error) - + public var description: String { switch self { case .didVisit: @@ -53,7 +53,7 @@ extension Tasks { return "didFailWithError: \(error.localizedDescription)" } } - + public static func == (lhs: Tasks.VisitsMonitoring.StreamEvent, rhs: Tasks.VisitsMonitoring.StreamEvent) -> Bool { switch (lhs, rhs) { case (let .didVisit(v1), let .didVisit(v2)): @@ -65,13 +65,13 @@ extension Tasks { } } } - + // MARK: - Public Properties public let uuid = UUID() public var stream: Stream.Continuation? public var cancellable: CancellableTask? - + // MARK: - Functions public func receivedLocationManagerEvent(_ event: LocationManagerBridgeEvent) { @@ -85,6 +85,6 @@ extension Tasks { } } } - + } #endif diff --git a/Sources/SwiftLocation/Location Managers/LocationAsyncBridge.swift b/Sources/SwiftLocation/Location Managers/LocationAsyncBridge.swift index f5c3332..7d47e80 100644 --- a/Sources/SwiftLocation/Location Managers/LocationAsyncBridge.swift +++ b/Sources/SwiftLocation/Location Managers/LocationAsyncBridge.swift @@ -28,15 +28,16 @@ import CoreLocation /// This bridge is used to link the object which manage the underlying events /// from `CLLocationManagerDelegate`. +@MainActor final class LocationAsyncBridge: CancellableTask { - + // MARK: - Private Properties - + private var tasks = [AnyTask]() weak var location: Location? // MARK: - Internal function - + /// Add a new task to the queued operations to bridge. /// /// - Parameter task: task to add. @@ -45,14 +46,14 @@ final class LocationAsyncBridge: CancellableTask { tasks.append(task) task.willStart() } - + /// Cancel the execution of a task. /// /// - Parameter task: task to cancel. func cancel(task: AnyTask) { cancel(taskUUID: task.uuid) } - + /// Cancel the execution of a task with a given unique identifier. /// /// - Parameter uuid: unique identifier of the task to remove @@ -66,7 +67,7 @@ final class LocationAsyncBridge: CancellableTask { } } } - + /// Cancel the task of the given class and optional validated condition. /// /// - Parameters: @@ -78,14 +79,14 @@ final class LocationAsyncBridge: CancellableTask { let isCorrectType = ($0.taskType == typeToRemove) let isConditionValid = (condition == nil ? true : condition!($0)) let shouldRemove = (isCorrectType && isConditionValid) - + if shouldRemove { $0.didCancelled() } return shouldRemove }) } - + /// Dispatch the event to the tasks. /// /// - Parameter event: event to dispatch. @@ -93,11 +94,14 @@ final class LocationAsyncBridge: CancellableTask { for task in tasks { task.receivedLocationManagerEvent(event) } - + // store cached location if case .receiveNewLocations(let locations) = event { - location?.lastLocation = locations.last + Task { @MainActor in + location?.lastLocation = locations.last + } + } } - + } diff --git a/Sources/SwiftLocation/Location Managers/LocationManagerBridgeEvent.swift b/Sources/SwiftLocation/Location Managers/LocationManagerBridgeEvent.swift index dd4d968..bb4dfcb 100644 --- a/Sources/SwiftLocation/Location Managers/LocationManagerBridgeEvent.swift +++ b/Sources/SwiftLocation/Location Managers/LocationManagerBridgeEvent.swift @@ -28,27 +28,27 @@ import CoreLocation /// This is the list of events who can be received by any task. public enum LocationManagerBridgeEvent { - + // MARK: - Authorization - + case didChangeLocationEnabled(_ enabled: Bool) case didChangeAuthorization(_ status: CLAuthorizationStatus) case didChangeAccuracyAuthorization(_ authorization: CLAccuracyAuthorization) // MARK: - Location Monitoring - + case locationUpdatesPaused case locationUpdatesResumed case receiveNewLocations(locations: [CLLocation]) - + // MARK: - Region Monitoring - + case didEnterRegion(_ region: CLRegion) case didExitRegion(_ region: CLRegion) case didStartMonitoringFor(_ region: CLRegion) // MARK: - Failures - + case didFailWithError(_ error: Error) case monitoringDidFailFor(region: CLRegion?, error: Error) @@ -57,15 +57,15 @@ public enum LocationManagerBridgeEvent { #if !os(watchOS) && !os(tvOS) case didVisit(visit: CLVisit) #endif - + // MARK: - Headings - + #if os(iOS) case didUpdateHeading(_ heading: CLHeading) #endif - + // MARK: - Beacons - + #if !os(watchOS) && !os(tvOS) case didRange(beacons: [CLBeacon], constraint: CLBeaconIdentityConstraint) case didFailRanginFor(constraint: CLBeaconIdentityConstraint, error: Error) diff --git a/Sources/SwiftLocation/Location Managers/LocationManagerProtocol.swift b/Sources/SwiftLocation/Location Managers/LocationManagerProtocol.swift index 450cc53..0ca5352 100644 --- a/Sources/SwiftLocation/Location Managers/LocationManagerProtocol.swift +++ b/Sources/SwiftLocation/Location Managers/LocationManagerProtocol.swift @@ -29,42 +29,42 @@ import CoreLocation /// The `CLLocationManager` implementation used to provide a mocked version /// of the system location manager used to write tests. public protocol LocationManagerProtocol { - + // MARK: - Delegate - + var delegate: CLLocationManagerDelegate? { get set } - + // MARK: - Authorization - + var authorizationStatus: CLAuthorizationStatus { get } var accuracyAuthorization: CLAccuracyAuthorization { get } - + #if !os(tvOS) var activityType: CLActivityType { get set } #endif - + var distanceFilter: CLLocationDistance { get set } var desiredAccuracy: CLLocationAccuracy { get set } - + #if !os(tvOS) var allowsBackgroundLocationUpdates: Bool { get set } #endif - + func locationServicesEnabled() -> Bool - + // MARK: - Location Permissions - + func validatePlistConfigurationOrThrow(permission: LocationPermission) throws func validatePlistConfigurationForTemporaryAccuracy(purposeKey: String) throws func requestWhenInUseAuthorization() - + #if !os(tvOS) func requestAlwaysAuthorization() #endif func requestTemporaryFullAccuracyAuthorization(withPurposeKey purposeKey: String, completion: ((Error?) -> Void)?) // MARK: - Getting Locations - + #if !os(tvOS) func startUpdatingLocation() func stopUpdatingLocation() @@ -73,13 +73,13 @@ public protocol LocationManagerProtocol { #if !os(watchOS) && !os(tvOS) // MARK: - Monitoring Regions - + func startMonitoring(for region: CLRegion) func stopMonitoring(for region: CLRegion) #endif - + // MARK: - Monitoring Visits - + #if !os(watchOS) && !os(tvOS) func startMonitoringVisits() func stopMonitoringVisits() @@ -87,20 +87,20 @@ public protocol LocationManagerProtocol { #if !os(watchOS) && !os(tvOS) // MARK: - Monitoring Significant Location Changes - + func startMonitoringSignificantLocationChanges() func stopMonitoringSignificantLocationChanges() #endif - + #if os(iOS) // MARK: - Getting Heading func startUpdatingHeading() func stopUpdatingHeading() #endif - + // MARK: - Beacon Ranging - + #if !os(watchOS) && !os(tvOS) func startRangingBeacons(satisfying constraint: CLBeaconIdentityConstraint) func stopRangingBeacons(satisfying constraint: CLBeaconIdentityConstraint) diff --git a/Sources/SwiftLocation/Location.swift b/Sources/SwiftLocation/Location.swift index f722b41..3168023 100644 --- a/Sources/SwiftLocation/Location.swift +++ b/Sources/SwiftLocation/Location.swift @@ -28,26 +28,27 @@ import CoreLocation /// Instantiate this class to query and setup the Location Services and all the function /// of the library itself. +@MainActor public final class Location { - + // MARK: - Private Properties - + /// Underlying location manager implementation. private(set) var locationManager: LocationManagerProtocol - + /// Bridge for async/await communication via tasks. private(set) var asyncBridge = LocationAsyncBridge() /// The delegate which receive events from the underlying `locationManager` implementation /// and dispatch them to the `asyncBridge` through the final output function. private(set) var locationDelegate: LocationDelegate - + /// Cache used to store some bits of the data retrived by the underlying core location service. private let cache = UserDefaults(suiteName: "com.swiftlocation.cache") private let locationCacheKey = "lastLocation" // MARK: - Public Properties - + /// The last received location from underlying Location Manager service. /// This is persistent between sesssions and store the latest result with no /// filters or logic behind. @@ -59,7 +60,7 @@ public final class Location { cache?.set(location: newValue, forKey: locationCacheKey) } } - + /// Indicate whether location services are enabled on the device. /// /// NOTE: @@ -67,27 +68,27 @@ public final class Location { public var locationServicesEnabled: Bool { get async { await Task.detached { - self.locationManager.locationServicesEnabled() + await self.locationManager.locationServicesEnabled() }.value } } - + /// The status of your app’s authorization to provide parental controls. public var authorizationStatus: CLAuthorizationStatus { locationManager.authorizationStatus } - + /// Indicates the level of location accuracy the app has permission to use. public var accuracyAuthorization: CLAccuracyAuthorization { locationManager.accuracyAuthorization } - + /// Indicates the accuracy of the location data that your app wants to receive. public var accuracy: LocationAccuracy { get { .init(level: locationManager.desiredAccuracy) } set { locationManager.desiredAccuracy = newValue.level } } - + #if !os(tvOS) /// The type of activity the app expects the user to typically perform while in the app’s location session. /// By default is set to `CLActivityType.other`. @@ -96,7 +97,7 @@ public final class Location { set { locationManager.activityType = newValue } } #endif - + /// The minimum distance in meters the device must move horizontally before an update event is generated. /// By defualt is set to `kCLDistanceFilterNone`. /// @@ -106,7 +107,7 @@ public final class Location { get { locationManager.distanceFilter } set { locationManager.distanceFilter = newValue } } - + /// Indicates whether the app receives location updates when running in the background. /// By default is `false`. /// @@ -123,9 +124,9 @@ public final class Location { set { locationManager.allowsBackgroundLocationUpdates = newValue } } #endif - + // MARK: - Initialization - + #if !os(tvOS) /// Initialize a new SwiftLocation instance to work with the Core Location service. /// @@ -153,29 +154,33 @@ public final class Location { self.asyncBridge.location = self } #endif - + // MARK: - Monitor Location Services Enabled - + /// Initiate a new async stream to monitor the status of the location services. /// - Returns: observable async stream. + @MainActor public func startMonitoringLocationServices() async -> Tasks.LocationServicesEnabled.Stream { let task = Tasks.LocationServicesEnabled() return Tasks.LocationServicesEnabled.Stream { stream in task.stream = stream asyncBridge.add(task: task) stream.onTermination = { @Sendable _ in - self.stopMonitoringLocationServices() + Task { @MainActor in + self.stopMonitoringLocationServices() + } + } } } - + /// Stop observing the location services status updates. public func stopMonitoringLocationServices() { asyncBridge.cancel(tasksTypes: Tasks.LocationServicesEnabled.self) } - + // MARK: - Monitor Authorization Status - + /// Monitor updates about the authorization status. /// /// - Returns: stream of authorization statuses. @@ -185,18 +190,21 @@ public final class Location { task.stream = stream asyncBridge.add(task: task) stream.onTermination = { @Sendable _ in - self.stopMonitoringAuthorization() + Task { @MainActor in + self.stopMonitoringAuthorization() + } + } } } - + /// Stop monitoring changes of authorization status by stopping all running streams. public func stopMonitoringAuthorization() { asyncBridge.cancel(tasksTypes: Tasks.Authorization.self) } - + // MARK: - Monitor Accuracy Authorization - + /// Monitor accuracy authorization level. /// /// - Returns: a stream of statuses. @@ -206,18 +214,21 @@ public final class Location { task.stream = stream asyncBridge.add(task: task) stream.onTermination = { @Sendable _ in - self.stopMonitoringAccuracyAuthorization() + Task { @MainActor in + self.stopMonitoringAccuracyAuthorization() + } + } } } - + /// Stop monitoring accuracy authorization status by stopping all running streams. public func stopMonitoringAccuracyAuthorization() { asyncBridge.cancel(tasksTypes: Tasks.AccuracyAuthorization.self) } - + // MARK: - Request Permission for Location - + /// Request to monitor location changes. /// /// - Parameter permission: type of permission you would to require. @@ -225,7 +236,7 @@ public final class Location { @discardableResult public func requestPermission(_ permission: LocationPermission) async throws -> CLAuthorizationStatus { try locationManager.validatePlistConfigurationOrThrow(permission: permission) - + switch permission { case .whenInUse: return try await requestWhenInUsePermission() @@ -239,7 +250,7 @@ public final class Location { #endif } } - + /// Temporary request the authorization to get precise location of the user. /// /// - Parameter key: purpose key used to prompt the user. Must be defined into the `Info.plist`. @@ -249,9 +260,9 @@ public final class Location { try locationManager.validatePlistConfigurationForTemporaryAccuracy(purposeKey: key) return try await requestTemporaryPrecisionPermission(purposeKey: key) } - + // MARK: - Monitor Location Updates - + #if !os(tvOS) /// Start receiving changes of the locations with a stream. /// @@ -260,32 +271,35 @@ public final class Location { guard locationManager.authorizationStatus != .notDetermined else { throw LocationErrors.authorizationRequired } - + guard locationManager.authorizationStatus.canMonitorLocation else { throw LocationErrors.notAuthorized } - + let task = Tasks.ContinuousUpdateLocation(instance: self) return Tasks.ContinuousUpdateLocation.Stream { stream in task.stream = stream asyncBridge.add(task: task) - + locationManager.startUpdatingLocation() stream.onTermination = { @Sendable _ in - self.asyncBridge.cancel(task: task) + Task { @MainActor in + self.asyncBridge.cancel(task: task) + } + } } } - + /// Stop updating location updates streams. public func stopUpdatingLocation() { locationManager.stopUpdatingLocation() asyncBridge.cancel(tasksTypes: Tasks.ContinuousUpdateLocation.self) } #endif - + // MARK: - Get Location - + /// Request a one-shot location from the underlying core location service. /// /// - Parameters: @@ -294,20 +308,23 @@ public final class Location { /// - Returns: event received. public func requestLocation(accuracy filters: AccuracyFilters? = nil, timeout: TimeInterval? = nil) async throws -> Tasks.ContinuousUpdateLocation.StreamEvent { - + // Setup the desidered accuracy based upon the highest resolution. locationManager.desiredAccuracy = AccuracyFilters.highestAccuracyLevel(currentLevel: locationManager.desiredAccuracy, filters: filters) let task = Tasks.SingleUpdateLocation(instance: self, accuracy: filters, timeout: timeout) return try await withTaskCancellationHandler { try await task.run() } onCancel: { - asyncBridge.cancel(task: task) + Task { @MainActor in + asyncBridge.cancel(task: task) + } + } } - + #if !os(watchOS) && !os(tvOS) // MARK: - Monitor Regions - + /// Starts the monitoring a region and receive stream of events from it. /// /// - Parameter region: region to monitor. @@ -319,11 +336,14 @@ public final class Location { asyncBridge.add(task: task) locationManager.startMonitoring(for: region) stream.onTermination = { @Sendable _ in - self.asyncBridge.cancel(task: task) + Task { @MainActor in + self.asyncBridge.cancel(task: task) + } + } } } - + /// Stop monitoring a region. /// /// - Parameter region: region. @@ -333,9 +353,9 @@ public final class Location { } } #endif - + // MARK: - Monitor Visits Updates - + #if !os(watchOS) && !os(tvOS) /// Starts monitoring visits to locations. /// @@ -347,21 +367,24 @@ public final class Location { asyncBridge.add(task: task) locationManager.startMonitoringVisits() stream.onTermination = { @Sendable _ in - self.stopMonitoringVisits() + Task { @MainActor in + self.stopMonitoringVisits() + } + } } } - + /// Stop monitoring visits updates. public func stopMonitoringVisits() { asyncBridge.cancel(tasksTypes: Tasks.VisitsMonitoring.self) locationManager.stopMonitoringVisits() } #endif - + #if !os(watchOS) && !os(tvOS) // MARK: - Monitor Significant Locations - + /// Starts monitoring significant location changes. /// /// - Returns: stream of events of location changes. @@ -372,21 +395,24 @@ public final class Location { asyncBridge.add(task: task) locationManager.startMonitoringSignificantLocationChanges() stream.onTermination = { @Sendable _ in - self.stopMonitoringSignificantLocationChanges() + Task { @MainActor in + self.stopMonitoringSignificantLocationChanges() + } + } } } - + /// Stops monitoring location changes. public func stopMonitoringSignificantLocationChanges() { locationManager.stopMonitoringSignificantLocationChanges() asyncBridge.cancel(tasksTypes: Tasks.SignificantLocationMonitoring.self) } #endif - + #if os(iOS) // MARK: - Monitor Device Heading Updates - + /// Starts monitoring heading changes. /// /// - Returns: stream of events for heading @@ -397,20 +423,23 @@ public final class Location { asyncBridge.add(task: task) locationManager.startUpdatingHeading() stream.onTermination = { @Sendable _ in - self.stopUpdatingHeading() + Task { @MainActor in + self.stopUpdatingHeading() + } + } } } - + /// Stops monitoring device heading changes. public func stopUpdatingHeading() { locationManager.stopUpdatingHeading() asyncBridge.cancel(tasksTypes: Tasks.HeadingMonitoring.self) - } + } #endif - + // MARK: - Monitor Beacons Ranging - + /// Starts the delivery of notifications for the specified beacon region. /// /// - Parameter satisfying: A `CLBeaconIdentityConstraint` constraint. @@ -423,11 +452,14 @@ public final class Location { asyncBridge.add(task: task) locationManager.startRangingBeacons(satisfying: satisfying) stream.onTermination = { @Sendable _ in - self.stopRangingBeacons(satisfying: satisfying) + Task { @MainActor in + self.stopRangingBeacons(satisfying: satisfying) + } + } } } - + /// Stops monitoring beacon ranging for passed constraint. /// /// - Parameter satisfying: A `CLBeaconIdentityConstraint` constraint. @@ -438,9 +470,9 @@ public final class Location { locationManager.stopRangingBeacons(satisfying: satisfying) } #endif - + // MARK: - Private Functions - + /// Request temporary increase of the precision. /// /// - Parameter purposeKey: purpose key. @@ -450,10 +482,13 @@ public final class Location { return try await withTaskCancellationHandler { try await task.requestTemporaryPermission(purposeKey: purposeKey) } onCancel: { - asyncBridge.cancel(task: task) + Task { @MainActor in + asyncBridge.cancel(task: task) + } + } } - + /// Request authorization to get location when app is in use. /// /// - Returns: authorization obtained. @@ -462,10 +497,13 @@ public final class Location { return try await withTaskCancellationHandler { try await task.requestWhenInUsePermission() } onCancel: { - asyncBridge.cancel(task: task) + Task { @MainActor in + asyncBridge.cancel(task: task) + } + } } - + #if !os(tvOS) /// Request authorization to get location both in foreground and background. /// @@ -475,9 +513,12 @@ public final class Location { return try await withTaskCancellationHandler { try await task.requestAlwaysPermission() } onCancel: { - asyncBridge.cancel(task: task) + Task { @MainActor in + asyncBridge.cancel(task: task) + } + } } #endif - + } diff --git a/Sources/SwiftLocation/Support/Extensions.swift b/Sources/SwiftLocation/Support/Extensions.swift index 692c23d..9ff777b 100644 --- a/Sources/SwiftLocation/Support/Extensions.swift +++ b/Sources/SwiftLocation/Support/Extensions.swift @@ -29,11 +29,11 @@ import CoreLocation // MARK: - CoreLocation Extensions extension CLLocationManager: LocationManagerProtocol { - + public func locationServicesEnabled() -> Bool { CLLocationManager.locationServicesEnabled() } - + /// Evaluate the `Info.plist` file data and throw exceptions in case of misconfiguration. /// /// - Parameter permission: permission you would to obtain. @@ -51,17 +51,17 @@ extension CLLocationManager: LocationManagerProtocol { } } } - + public func validatePlistConfigurationForTemporaryAccuracy(purposeKey: String) throws { guard Bundle.hasTemporaryPermission(purposeKey: purposeKey) else { throw LocationErrors.plistNotConfigured } } - + } extension CLAccuracyAuthorization: CustomStringConvertible { - + public var description: String { switch self { case .fullAccuracy: @@ -72,11 +72,11 @@ extension CLAccuracyAuthorization: CustomStringConvertible { return "Unknown (\(rawValue))" } } - + } extension CLAuthorizationStatus: CustomStringConvertible { - + public var description: String { switch self { case .notDetermined: return "notDetermined" @@ -87,7 +87,7 @@ extension CLAuthorizationStatus: CustomStringConvertible { @unknown default: return "unknown" } } - + var canMonitorLocation: Bool { switch self { case .authorizedAlways, .authorizedWhenInUse: @@ -96,17 +96,17 @@ extension CLAuthorizationStatus: CustomStringConvertible { return false } } - + } // MARK: - Foundation Extensions extension Bundle { - + private static let alwaysAndWhenInUse = "NSLocationAlwaysAndWhenInUseUsageDescription" private static let whenInUse = "NSLocationWhenInUseUsageDescription" private static let temporary = "NSLocationTemporaryUsageDescriptionDictionary" - + static func hasTemporaryPermission(purposeKey: String) -> Bool { guard let node = Bundle.main.object(forInfoDictionaryKey: temporary) as? NSDictionary, let value = node.object(forKey: purposeKey) as? String, @@ -115,29 +115,29 @@ extension Bundle { } return true } - + static func hasWhenInUsePermission() -> Bool { !(Bundle.main.object(forInfoDictionaryKey: whenInUse) as? String ?? "").isEmpty } - + static func hasAlwaysAndWhenInUsePermission() -> Bool { !(Bundle.main.object(forInfoDictionaryKey: alwaysAndWhenInUse) as? String ?? "").isEmpty } - + } extension UserDefaults { - - func set(location:CLLocation?, forKey key: String) { + + func set(location: CLLocation?, forKey key: String) { guard let location else { removeObject(forKey: key) return } - + let locationData = try? NSKeyedArchiver.archivedData(withRootObject: location, requiringSecureCoding: false) set(locationData, forKey: key) } - + func location(forKey key: String) -> CLLocation? { guard let locationData = UserDefaults.standard.data(forKey: key) else { return nil @@ -149,6 +149,5 @@ extension UserDefaults { return nil } } - -} +} diff --git a/Sources/SwiftLocation/Support/LocationDelegate.swift b/Sources/SwiftLocation/Support/LocationDelegate.swift index c61e1dd..55b5f17 100644 --- a/Sources/SwiftLocation/Support/LocationDelegate.swift +++ b/Sources/SwiftLocation/Support/LocationDelegate.swift @@ -28,93 +28,116 @@ import CoreLocation /// This is the class which receive events from the `LocationManagerProtocol` implementation /// and dispatch to the bridged tasks. -final class LocationDelegate: NSObject, CLLocationManagerDelegate { - +@MainActor +final class LocationDelegate: NSObject, @preconcurrency CLLocationManagerDelegate { + private weak var asyncBridge: LocationAsyncBridge? - + private var locationManager: LocationManagerProtocol { asyncBridge!.location!.locationManager } - + init(asyncBridge: LocationAsyncBridge) { self.asyncBridge = asyncBridge super.init() } - + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - asyncBridge?.dispatchEvent(.didChangeAuthorization(locationManager.authorizationStatus)) - asyncBridge?.dispatchEvent(.didChangeAccuracyAuthorization(locationManager.accuracyAuthorization)) - asyncBridge?.dispatchEvent(.didChangeLocationEnabled(locationManager.locationServicesEnabled())) + + asyncBridge?.dispatchEvent(.didChangeAuthorization(locationManager.authorizationStatus)) + asyncBridge?.dispatchEvent(.didChangeAccuracyAuthorization(locationManager.accuracyAuthorization)) + asyncBridge?.dispatchEvent(.didChangeLocationEnabled(locationManager.locationServicesEnabled())) + } - + // MARK: - Location Updates - - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + asyncBridge?.dispatchEvent(.receiveNewLocations(locations: locations)) + } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + asyncBridge?.dispatchEvent(.didFailWithError(error)) + } - + // MARK: - Heading Updates - + #if os(iOS) func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + asyncBridge?.dispatchEvent(.didUpdateHeading(newHeading)) + } #endif - + #if os(iOS) // MARK: - Pause/Resume func locationManagerDidPauseLocationUpdates(_ manager: CLLocationManager) { asyncBridge?.dispatchEvent(.locationUpdatesPaused) } - + func locationManagerDidResumeLocationUpdates(_ manager: CLLocationManager) { asyncBridge?.dispatchEvent(.locationUpdatesResumed) + } #endif - + // MARK: - Region Monitoring - + #if os(iOS) func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) { asyncBridge?.dispatchEvent(.monitoringDidFailFor(region: region, error: error)) + } - + func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { + asyncBridge?.dispatchEvent(.didEnterRegion(region)) + } - + func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { + asyncBridge?.dispatchEvent(.didExitRegion(region)) + } - + func locationManager(_ manager: CLLocationManager, didStartMonitoringFor region: CLRegion) { + asyncBridge?.dispatchEvent(.didStartMonitoringFor(region)) + } #endif - + // MARK: - Visits Monitoring - + #if os(iOS) func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) { + asyncBridge?.dispatchEvent(.didVisit(visit: visit)) + } #endif - + #if os(iOS) // MARK: - Beacons Ranging - + func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying beaconConstraint: CLBeaconIdentityConstraint) { + asyncBridge?.dispatchEvent(.didRange(beacons: beacons, constraint: beaconConstraint)) + } - + func locationManager(_ manager: CLLocationManager, didFailRangingFor beaconConstraint: CLBeaconIdentityConstraint, error: Error) { + asyncBridge?.dispatchEvent(.didFailRanginFor(constraint: beaconConstraint, error: error)) + } #endif - + } diff --git a/Sources/SwiftLocation/Support/LocationErrors.swift b/Sources/SwiftLocation/Support/LocationErrors.swift index 9fa2cc9..078b0c7 100644 --- a/Sources/SwiftLocation/Support/LocationErrors.swift +++ b/Sources/SwiftLocation/Support/LocationErrors.swift @@ -27,22 +27,25 @@ import Foundation /// Throwable errors enum LocationErrors: LocalizedError { - + /// Info.plist authorization are not correctly defined. case plistNotConfigured - + /// System location services are disabled by the user or not available. case locationServicesDisabled - + /// You must require location authorization from the user before executing the operation. case authorizationRequired - + /// Not authorized by the user. case notAuthorized - + /// Operation timeout. case timeout - + + /// Cancelled before operation could complete. + case cancelled + var errorDescription: String? { switch self { case .plistNotConfigured: @@ -55,7 +58,9 @@ enum LocationErrors: LocalizedError { return "Not Authorized" case .timeout: return "Timeout" + case .cancelled: + return "Cancelled" } } - + } diff --git a/Sources/SwiftLocation/Support/SupportModels.swift b/Sources/SwiftLocation/Support/SupportModels.swift index e6a2b72..289b640 100644 --- a/Sources/SwiftLocation/Support/SupportModels.swift +++ b/Sources/SwiftLocation/Support/SupportModels.swift @@ -32,12 +32,12 @@ import CoreLocation public typealias AccuracyFilters = [AccuracyFilter] extension AccuracyFilters { - + /// Return the highest value of the accuracy level used as filter /// in both horizontal and vertical direction. static func highestAccuracyLevel(currentLevel: CLLocationAccuracy = kCLLocationAccuracyReduced, filters: AccuracyFilters?) -> CLLocationAccuracy { guard let filters else { return currentLevel } - + var value: Double = currentLevel for filter in filters { switch filter { @@ -51,7 +51,7 @@ extension AccuracyFilters { } return value } - + } /// Single Accuracy filter. @@ -66,9 +66,9 @@ public enum AccuracyFilter { case course(CLLocationDirectionAccuracy) /// Filter using a custom function. case custom(_ isIncluded: ((CLLocation) -> Bool)) - + // MARK: - Internal Functions - + /// Return a filtered array of the location which match passed filters. /// /// - Parameters: @@ -79,7 +79,7 @@ public enum AccuracyFilter { guard let filters else { return locations } return locations.filter { AccuracyFilter.isLocation($0, validForFilters: filters) } } - + /// Return if location is valid for a given set of accuracy filters. /// /// - Parameters: @@ -91,7 +91,7 @@ public enum AccuracyFilter { let isValid = (firstInvalidFilter == nil) return isValid } - + /// Return if location match `self` filter. /// /// - Parameter location: location to check. @@ -110,7 +110,7 @@ public enum AccuracyFilter { return isIncluded(location) } } - + } // MARK: - Location Accuracy @@ -137,7 +137,7 @@ public enum LocationAccuracy { case bestForNavigation /// Custom precision, may require precise location authorization. case custom(Double) - + init(level: CLLocationAccuracy) { switch level { case kCLLocationAccuracyBest: self = .best @@ -149,7 +149,7 @@ public enum LocationAccuracy { default: self = .custom(level) } } - + internal var level: CLLocationAccuracy { switch self { case .best: return kCLLocationAccuracyBest diff --git a/Sources/SwiftLocation/SwiftLocation.swift b/Sources/SwiftLocation/SwiftLocation.swift index 6125ec8..97c0630 100644 --- a/Sources/SwiftLocation/SwiftLocation.swift +++ b/Sources/SwiftLocation/SwiftLocation.swift @@ -26,8 +26,8 @@ import Foundation public class SwiftLocationVersion { - + /// Version of the SDK. public static let version = "6.0.1" - + } diff --git a/Tests/SwiftLocationTests/MockedLocationManager.swift b/Tests/SwiftLocationTests/MockedLocationManager.swift index 3e6fba3..bb8f7d0 100644 --- a/Tests/SwiftLocationTests/MockedLocationManager.swift +++ b/Tests/SwiftLocationTests/MockedLocationManager.swift @@ -28,12 +28,12 @@ import CoreLocation @testable import SwiftLocation public class MockedLocationManager: LocationManagerProtocol { - + let fakeInstance = CLLocationManager() public weak var delegate: CLLocationManagerDelegate? public var allowsBackgroundLocationUpdates: Bool = false - + public var isLocationServicesEnabled: Bool = true { didSet { guard isLocationServicesEnabled != oldValue else { return } @@ -43,10 +43,11 @@ public class MockedLocationManager: LocationManagerProtocol { public var authorizationStatus: CLAuthorizationStatus = .notDetermined { didSet { guard authorizationStatus != oldValue else { return } - delegate?.locationManagerDidChangeAuthorization?(fakeInstance) + self.delegate?.locationManagerDidChangeAuthorization?(fakeInstance) + } } - + public var accuracyAuthorization: CLAccuracyAuthorization = .reducedAccuracy { didSet { guard accuracyAuthorization != oldValue else { return } @@ -57,16 +58,15 @@ public class MockedLocationManager: LocationManagerProtocol { public var desiredAccuracy: CLLocationAccuracy = 100.0 public var activityType: CLActivityType = .other public var distanceFilter: CLLocationDistance = kCLDistanceFilterNone - + public var onValidatePlistConfiguration: ((_ permission: LocationPermission) -> Error?) = { _ in return nil } - + public var onRequestWhenInUseAuthorization: (() -> CLAuthorizationStatus) = { .notDetermined } public var onRequestAlwaysAuthorization: (() -> CLAuthorizationStatus) = { .notDetermined } public var onRequestValidationForTemporaryAccuracy: ((String) -> Error?) = { _ in return nil } - public func updateLocations(event: Tasks.ContinuousUpdateLocation.StreamEvent) { switch event { case let .didUpdateLocations(locations): @@ -96,7 +96,7 @@ public class MockedLocationManager: LocationManagerProtocol { } } #endif - + #if !os(watchOS) && !os(tvOS) public func updateVisits(event: Tasks.VisitsMonitoring.StreamEvent) { switch event { @@ -106,109 +106,109 @@ public class MockedLocationManager: LocationManagerProtocol { delegate?.locationManager?(fakeInstance, didFailWithError: error) } } - + public func updateRegionMonitoring(event: Tasks.RegionMonitoring.StreamEvent) { switch event { case let .didEnterTo(region): delegate?.locationManager?(fakeInstance, didEnterRegion: region) - + case let .didExitTo(region): delegate?.locationManager?(fakeInstance, didExitRegion: region) - + case let .didStartMonitoringFor(region): delegate?.locationManager?(fakeInstance, didStartMonitoringFor: region) - + case let .monitoringDidFailFor(region, error): delegate?.locationManager?(fakeInstance, monitoringDidFailFor: region, withError: error) - + } } #endif - + public func validatePlistConfigurationForTemporaryAccuracy(purposeKey: String) throws { if let error = onRequestValidationForTemporaryAccuracy(purposeKey) { throw error } } - + public func validatePlistConfigurationOrThrow(permission: LocationPermission) throws { if let error = onValidatePlistConfiguration(permission) { throw error } } - + public func locationServicesEnabled() -> Bool { isLocationServicesEnabled } - + public func requestAlwaysAuthorization() { self.authorizationStatus = onRequestAlwaysAuthorization() } - + public func requestWhenInUseAuthorization() { self.authorizationStatus = onRequestWhenInUseAuthorization() } - + public func requestTemporaryFullAccuracyAuthorization(withPurposeKey purposeKey: String, completion: ((Error?) -> Void)? = nil) { self.accuracyAuthorization = .fullAccuracy completion?(nil) } - + public func startUpdatingLocation() { - + } - + public func stopUpdatingLocation() { - + } - + public func requestLocation() { - + } - + public func startMonitoring(for region: CLRegion) { - + } - + public func stopMonitoring(for region: CLRegion) { - + } - + public func startMonitoringVisits() { } - + public func stopMonitoringVisits() { - + } - + public func startMonitoringSignificantLocationChanges() { - + } - + public func stopMonitoringSignificantLocationChanges() { - + } - + public func startUpdatingHeading() { - + } - + public func stopUpdatingHeading() { - + } - + #if !os(watchOS) && !os(tvOS) public func startRangingBeacons(satisfying constraint: CLBeaconIdentityConstraint) { - + } - + public func stopRangingBeacons(satisfying constraint: CLBeaconIdentityConstraint) { - + } #endif - + public init() { - + } } diff --git a/Tests/SwiftLocationTests/SwiftLocationTests.swift b/Tests/SwiftLocationTests/SwiftLocationTests.swift index 4dc1150..165d7e6 100644 --- a/Tests/SwiftLocationTests/SwiftLocationTests.swift +++ b/Tests/SwiftLocationTests/SwiftLocationTests.swift @@ -27,20 +27,21 @@ import XCTest import CoreLocation @testable import SwiftLocation +@MainActor final class SwiftLocationTests: XCTestCase { - + private var mockLocationManager: MockedLocationManager! private var location: Location! - + override func setUp() { super.setUp() - + self.mockLocationManager = MockedLocationManager() self.location = Location(locationManager: mockLocationManager) } - + // MARK: - Tests - + /// Tests the location services enabled changes. func testMonitoringLocationServicesEnabled() async throws { let expectedValues = simulateLocationServicesChanges() @@ -54,7 +55,7 @@ final class SwiftLocationTests: XCTestCase { } } } - + /// Test authorization status changes. func testMonitoringAuthorizationStatus() async throws { let expectedValues = simulateAuthorizationStatusChanges() @@ -68,7 +69,7 @@ final class SwiftLocationTests: XCTestCase { } } } - + /// Test accuracy authorization changes. func testMonitoringAccuracyAuthorization() async throws { let expectedValues = simulateAccuracyAuthorizationChanges() @@ -82,7 +83,7 @@ final class SwiftLocationTests: XCTestCase { } } } - + #if !os(tvOS) /// Test request for permission with failure in plist configuration func testRequestPermissionsFailureWithPlistConfiguration() async throws { @@ -94,14 +95,14 @@ final class SwiftLocationTests: XCTestCase { return nil } } - + do { let newStatus = try await location.requestPermission(.always) XCTFail("Permission should fail due to missing plist while it returned \(newStatus)") } catch { } } #endif - + func testRequestPermissionWhenInUseSuccess() async throws { do { let expectedStatus = CLAuthorizationStatus.restricted @@ -114,7 +115,7 @@ final class SwiftLocationTests: XCTestCase { XCTFail("Request should not fail: \(error.localizedDescription)") } } - + #if !os(tvOS) func testRequestAlwaysSuccess() async throws { do { @@ -122,7 +123,7 @@ final class SwiftLocationTests: XCTestCase { mockLocationManager.onRequestAlwaysAuthorization = { return expectedStatus } - + let newStatus = try await location.requestPermission(.always) XCTAssertEqual(expectedStatus, newStatus) } catch { @@ -130,14 +131,12 @@ final class SwiftLocationTests: XCTestCase { } } #endif - + /// Test the request location permission while observing authorization status change. func testMonitorAuthorizationWithPermissionRequest() async throws { - mockLocationManager.authorizationStatus = .notDetermined - XCTAssertEqual(location.authorizationStatus, .notDetermined) - + let exp = XCTestExpectation() - + let initialStatus = mockLocationManager.authorizationStatus Task.detached { for await event in await self.location.startMonitoringAuthorization() { @@ -146,25 +145,29 @@ final class SwiftLocationTests: XCTestCase { } } - sleep(1) - #if os(macOS) - mockLocationManager.onRequestAlwaysAuthorization = { return .authorizedAlways } - let newStatus = try await location.requestPermission(.always) - XCTAssertEqual(newStatus, .authorizedAlways) - #else - mockLocationManager.onRequestWhenInUseAuthorization = { return .authorizedWhenInUse } - let newStatus = try await location.requestPermission(.whenInUse) - XCTAssertEqual(newStatus, .authorizedWhenInUse) - #endif - - await fulfillment(of: [exp]) + Task { + try await Task.sleep(nanoseconds: 100_000_000) + mockLocationManager.authorizationStatus = .notDetermined + XCTAssertEqual(location.authorizationStatus, .notDetermined) +#if os(macOS) + mockLocationManager.onRequestAlwaysAuthorization = { return .authorizedAlways } + let newStatus = try await location.requestPermission(.always) + XCTAssertEqual(newStatus, .authorizedAlways) +#else + mockLocationManager.onRequestWhenInUseAuthorization = { return .authorizedWhenInUse } + let newStatus = try await location.requestPermission(.whenInUse) + XCTAssertEqual(newStatus, .authorizedWhenInUse) +#endif + } + + await fulfillment(of: [exp], timeout: 500) } - + /// Test increment of precision and monitoring. func testRequestPrecisionPosition() async throws { mockLocationManager.authorizationStatus = .notDetermined XCTAssertEqual(mockLocationManager.accuracyAuthorization, .reducedAccuracy) - + #if os(macOS) mockLocationManager.onRequestAlwaysAuthorization = { return .authorizedAlways } let newStatus = try await location.requestPermission(.always) @@ -174,21 +177,21 @@ final class SwiftLocationTests: XCTestCase { let newStatus = try await location.requestPermission(.whenInUse) XCTAssertEqual(newStatus, .authorizedWhenInUse) #endif - + // Test misconfigured Info.plist file do { - mockLocationManager.onRequestValidationForTemporaryAccuracy = { purposeKey in + mockLocationManager.onRequestValidationForTemporaryAccuracy = { _ in return LocationErrors.plistNotConfigured } - let _ = try await location.requestTemporaryPrecisionAuthorization(purpose: "test") + _ = try await location.requestTemporaryPrecisionAuthorization(purpose: "test") XCTFail("This should fail") } catch { XCTAssertEqual(error as? LocationErrors, LocationErrors.plistNotConfigured) } - + // Test correct configuration do { - mockLocationManager.onRequestValidationForTemporaryAccuracy = { purposeKey in + mockLocationManager.onRequestValidationForTemporaryAccuracy = { _ in return nil } let newStatus = try await location.requestTemporaryPrecisionAuthorization(purpose: "test") @@ -197,7 +200,7 @@ final class SwiftLocationTests: XCTestCase { XCTFail("This should not fail: \(error.localizedDescription)") } } - + #if !os(tvOS) /// Test stream of updates for locations. func testUpdatingLocations() async throws { @@ -221,7 +224,7 @@ final class SwiftLocationTests: XCTestCase { } } #endif - + /// Test one shot request method. func testRequestLocation() async throws { // Request authorization @@ -236,7 +239,7 @@ final class SwiftLocationTests: XCTestCase { simulateRequestLocationDelayedResponse(event: .didFailed(LocationErrors.notAuthorized)) let e1 = try await self.location.requestLocation() XCTAssertEqual(e1.error as? LocationErrors, LocationErrors.notAuthorized) - + // Check the return of several location let now = Date() let l1 = CLLocation( @@ -255,8 +258,7 @@ final class SwiftLocationTests: XCTestCase { let e2 = try await self.location.requestLocation() XCTAssertEqual(e2.location, l3) XCTAssertEqual(e2.locations?.count, 3) - - + // Check the timeout with a filtered location do { let nonValidLocation = CLLocation( @@ -264,14 +266,14 @@ final class SwiftLocationTests: XCTestCase { altitude: 100, horizontalAccuracy: 50, verticalAccuracy: 20, timestamp: Date() ) simulateRequestLocationDelayedResponse(event: .didUpdateLocations([nonValidLocation])) - - let _ = try await self.location.requestLocation(accuracy: [ + + _ = try await self.location.requestLocation(accuracy: [ .horizontal(100) ], timeout: 2) } catch { XCTAssertEqual(error as? LocationErrors, LocationErrors.timeout) } - + // Check the return of some non filtered locations do { let now = Date() @@ -291,14 +293,14 @@ final class SwiftLocationTests: XCTestCase { coordinate: CLLocationCoordinate2D(latitude: 40, longitude: 10), altitude: 100, horizontalAccuracy: 1, verticalAccuracy: 1, timestamp: now.addingTimeInterval(-6) ) - + simulateRequestLocationDelayedResponse(event: .didUpdateLocations([ nonValidLocationByVerticalAccuracy, validLocation1, nonValidLocationByHorizontalAccuracy, validLocation2 ])) - + let event = try await self.location.requestLocation(accuracy: [ .horizontal(200), .vertical(100) @@ -310,7 +312,7 @@ final class SwiftLocationTests: XCTestCase { } } - + #if !os(watchOS) && !os(tvOS) func testMonitorCLRegion() async throws { let (expectedValues, region) = simulateRegions() @@ -325,7 +327,7 @@ final class SwiftLocationTests: XCTestCase { } } #endif - + #if !os(watchOS) && !os(tvOS) func testMonitoringVisits() async throws { let expectedValues = simulateVisits() @@ -340,7 +342,7 @@ final class SwiftLocationTests: XCTestCase { } } #endif - + #if !os(watchOS) && !os(tvOS) func testMonitoringSignificantLocationChanges() async throws { let expectedValues = simulateSignificantLocations() @@ -355,16 +357,16 @@ final class SwiftLocationTests: XCTestCase { } } #endif - + #if !os(tvOS) func testAllowsBackgroundLocationUpdates() async throws { location.allowsBackgroundLocationUpdates = true XCTAssertEqual(location.allowsBackgroundLocationUpdates, location.locationManager.allowsBackgroundLocationUpdates) } #endif - + // MARK: - Private Functions - + #if !os(watchOS) && !os(tvOS) private func simulateSignificantLocations() -> [Tasks.SignificantLocationMonitoring.StreamEvent] { let sequence: [Tasks.SignificantLocationMonitoring.StreamEvent] = [ @@ -390,7 +392,7 @@ final class SwiftLocationTests: XCTestCase { return sequence } #endif - + #if !os(watchOS) && !os(tvOS) private func simulateVisits() -> [Tasks.VisitsMonitoring.StreamEvent] { let sequence: [Tasks.VisitsMonitoring.StreamEvent] = [ @@ -406,7 +408,7 @@ final class SwiftLocationTests: XCTestCase { return sequence } #endif - + #if !os(watchOS) && !os(tvOS) private func simulateRegions() -> (sequence: [Tasks.RegionMonitoring.StreamEvent], region: CLRegion) { let region = CLBeaconRegion(uuid: UUID(), identifier: "beacon_1") @@ -424,13 +426,13 @@ final class SwiftLocationTests: XCTestCase { return (sequence, region) } #endif - + private func simulateRequestLocationDelayedResponse(event: Tasks.ContinuousUpdateLocation.StreamEvent) { DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { self.mockLocationManager.updateLocations(event: event) }) } - + private func simulateLocationUpdates() -> [Tasks.ContinuousUpdateLocation.StreamEvent] { #if os(iOS) let sequence: [Tasks.ContinuousUpdateLocation.StreamEvent] = [ @@ -495,20 +497,19 @@ final class SwiftLocationTests: XCTestCase { ]) ] #endif - + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { for event in sequence { self.mockLocationManager.updateLocations(event: event) usleep(10) // 0.1s } }) - + return sequence } - - + private func simulateAccuracyAuthorizationChanges() -> [CLAccuracyAuthorization] { - let sequence : [CLAccuracyAuthorization] = [.fullAccuracy, .fullAccuracy, .fullAccuracy, .reducedAccuracy] + let sequence: [CLAccuracyAuthorization] = [.fullAccuracy, .fullAccuracy, .fullAccuracy, .reducedAccuracy] DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { for value in sequence { self.mockLocationManager.accuracyAuthorization = value @@ -517,12 +518,12 @@ final class SwiftLocationTests: XCTestCase { }) return [.fullAccuracy, .reducedAccuracy] } - + private func simulateAuthorizationStatusChanges() -> [CLAuthorizationStatus] { #if os(macOS) - let sequence : [CLAuthorizationStatus] = [.notDetermined, .restricted, .denied, .denied, .authorizedAlways] + let sequence: [CLAuthorizationStatus] = [.notDetermined, .restricted, .denied, .denied, .authorizedAlways] #else - let sequence : [CLAuthorizationStatus] = [.notDetermined, .restricted, .denied, .denied, .authorizedWhenInUse, .authorizedAlways] + let sequence: [CLAuthorizationStatus] = [.notDetermined, .restricted, .denied, .denied, .authorizedWhenInUse, .authorizedAlways] #endif DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { for value in sequence { @@ -530,14 +531,14 @@ final class SwiftLocationTests: XCTestCase { usleep(10) // 0.1s } }) - + #if os(macOS) return [.restricted, .denied, .authorizedAlways] #else return [.restricted, .denied, .authorizedWhenInUse, .authorizedAlways] #endif } - + private func simulateLocationServicesChanges() -> [Bool] { let sequence = [false, true, false, true, true, true, false] // only real changes are detected DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { @@ -548,5 +549,5 @@ final class SwiftLocationTests: XCTestCase { }) return [false, true, false, true, false] } - + }