diff --git a/Sources/SwiftLocation/Async Tasks/AnyTask.swift b/Sources/SwiftLocation/Async Tasks/AnyTask.swift index a00a792..50af226 100644 --- a/Sources/SwiftLocation/Async Tasks/AnyTask.swift +++ b/Sources/SwiftLocation/Async Tasks/AnyTask.swift @@ -53,6 +53,6 @@ public extension AnyTask { public protocol CancellableTask: AnyObject { - func cancel(task: any AnyTask) + func cancel(task: any AnyTask, completion: (([AnyTask]) -> Void)?) } diff --git a/Sources/SwiftLocation/Async Tasks/LocatePermission.swift b/Sources/SwiftLocation/Async Tasks/LocatePermission.swift index dcd0657..abc177e 100644 --- a/Sources/SwiftLocation/Async Tasks/LocatePermission.swift +++ b/Sources/SwiftLocation/Async Tasks/LocatePermission.swift @@ -56,13 +56,13 @@ extension Tasks { switch event { case .didChangeAuthorization(let authorization): guard let continuation = continuation else { - cancellable?.cancel(task: self) + cancellable?.cancel(task: self, completion: nil) return } continuation.resume(returning: authorization) self.continuation = nil - cancellable?.cancel(task: self) + cancellable?.cancel(task: self, completion: nil) default: break } diff --git a/Sources/SwiftLocation/Async Tasks/SingleUpdateLocation.swift b/Sources/SwiftLocation/Async Tasks/SingleUpdateLocation.swift index c9ef34b..34274df 100644 --- a/Sources/SwiftLocation/Async Tasks/SingleUpdateLocation.swift +++ b/Sources/SwiftLocation/Async Tasks/SingleUpdateLocation.swift @@ -76,11 +76,11 @@ extension Tasks { continuation?.resume(returning: .didUpdateLocations(filteredLocations)) continuation = nil - cancellable?.cancel(task: self) + cancellable?.cancel(task: self, completion: nil) case let .didFailWithError(error): continuation?.resume(returning: .didFailed(error)) continuation = nil - cancellable?.cancel(task: self) + cancellable?.cancel(task: self, completion: nil) default: break } diff --git a/Sources/SwiftLocation/Location Managers/LocationAsyncBridge.swift b/Sources/SwiftLocation/Location Managers/LocationAsyncBridge.swift index f5c3332..6f8d090 100644 --- a/Sources/SwiftLocation/Location Managers/LocationAsyncBridge.swift +++ b/Sources/SwiftLocation/Location Managers/LocationAsyncBridge.swift @@ -32,7 +32,7 @@ final class LocationAsyncBridge: CancellableTask { // MARK: - Private Properties - private var tasks = [AnyTask]() + private var tasks = SynchronizedArray() weak var location: Location? // MARK: - Internal function @@ -49,21 +49,23 @@ final class LocationAsyncBridge: CancellableTask { /// Cancel the execution of a task. /// /// - Parameter task: task to cancel. - func cancel(task: AnyTask) { - cancel(taskUUID: task.uuid) + func cancel(task: AnyTask, completion: (([AnyTask]) -> Void)? = nil) { + cancel(taskUUID: task.uuid, completion: completion) } /// Cancel the execution of a task with a given unique identifier. /// /// - Parameter uuid: unique identifier of the task to remove - private func cancel(taskUUID uuid: UUID) { - tasks.removeAll { task in + private func cancel(taskUUID uuid: UUID, completion: (([AnyTask]) -> Void)? = nil) { + tasks.removeAll(where: { task in if task.uuid == uuid { - task.didCancelled() return true } else { return false } + }) { removedTasks in + completion?(removedTasks) + removedTasks.forEach { $0.didCancelled() } } } @@ -90,7 +92,7 @@ final class LocationAsyncBridge: CancellableTask { /// /// - Parameter event: event to dispatch. func dispatchEvent(_ event: LocationManagerBridgeEvent) { - for task in tasks { + tasks.forEach { task in task.receivedLocationManagerEvent(event) } @@ -100,4 +102,12 @@ final class LocationAsyncBridge: CancellableTask { } } + /// Count the task of the given class + /// + /// - Parameters: + /// - type: type of `AnyTask` conform task to remove. + func count(tasksTypes type: AnyTask.Type) -> Int { + let typeToCount = ObjectIdentifier(type) + return tasks.filter({ $0.taskType == typeToCount }).count + } } diff --git a/Sources/SwiftLocation/Location Managers/LocationManagerProtocol.swift b/Sources/SwiftLocation/Location Managers/LocationManagerProtocol.swift index 450cc53..e42d341 100644 --- a/Sources/SwiftLocation/Location Managers/LocationManagerProtocol.swift +++ b/Sources/SwiftLocation/Location Managers/LocationManagerProtocol.swift @@ -43,9 +43,25 @@ public protocol LocationManagerProtocol { var activityType: CLActivityType { get set } #endif + #if !os(tvOS) + var pausesLocationUpdatesAutomatically: Bool { get set } + #endif + + #if !os(tvOS) + var showsBackgroundLocationIndicator: Bool { get set } + #endif + var distanceFilter: CLLocationDistance { get set } var desiredAccuracy: CLLocationAccuracy { get set } + #if !os(tvOS) + var headingFilter: CLLocationDegrees { get set } + #endif + + #if !os(tvOS) + var headingOrientation: CLDeviceOrientation { get set } + #endif + #if !os(tvOS) var allowsBackgroundLocationUpdates: Bool { get set } #endif diff --git a/Sources/SwiftLocation/Location.swift b/Sources/SwiftLocation/Location.swift index f722b41..b5a4495 100644 --- a/Sources/SwiftLocation/Location.swift +++ b/Sources/SwiftLocation/Location.swift @@ -97,6 +97,24 @@ public final class Location { } #endif + #if !os(tvOS) + /// A Boolean value that indicates whether the location-manager object may pause location updates. + /// By defualt is `true`. + public var pausesLocationUpdatesAutomatically: Bool { + get { locationManager.pausesLocationUpdatesAutomatically } + set { locationManager.pausesLocationUpdatesAutomatically = newValue } + } + #endif + + #if !os(tvOS) + /// A Boolean value that indicates whether the status bar changes its appearance when an app uses location services in the background. + /// By defualt is `false`. + public var showsBackgroundLocationIndicator: Bool { + get { locationManager.showsBackgroundLocationIndicator } + set { locationManager.showsBackgroundLocationIndicator = newValue } + } + #endif + /// The minimum distance in meters the device must move horizontally before an update event is generated. /// By defualt is set to `kCLDistanceFilterNone`. /// @@ -107,6 +125,24 @@ public final class Location { set { locationManager.distanceFilter = newValue } } + #if !os(tvOS) + /// The minimum angular change in degrees required to generate new heading events. + /// By defualt is `1` degree. + public var headingFilter: CLLocationDegrees { + get { locationManager.headingFilter } + set { locationManager.headingFilter = newValue } + } + #endif + + #if !os(tvOS) + /// The device orientation to use when computing heading values. + /// By defualt is `CLDeviceOrientation.portrait`. + public var headingOrientation: CLDeviceOrientation { + get { locationManager.headingOrientation } + set { locationManager.headingOrientation = newValue } + } + #endif + /// Indicates whether the app receives location updates when running in the background. /// By default is `false`. /// @@ -272,7 +308,11 @@ public final class Location { locationManager.startUpdatingLocation() stream.onTermination = { @Sendable _ in - self.asyncBridge.cancel(task: task) + self.asyncBridge.cancel(task: task) { _ in + if self.asyncBridge.count(tasksTypes: Tasks.ContinuousUpdateLocation.self) <= 0 { + self.stopUpdatingLocation() + } + } } } } diff --git a/Sources/SwiftLocation/Support/Extensions.swift b/Sources/SwiftLocation/Support/Extensions.swift index 692c23d..7ed5098 100644 --- a/Sources/SwiftLocation/Support/Extensions.swift +++ b/Sources/SwiftLocation/Support/Extensions.swift @@ -139,7 +139,7 @@ extension UserDefaults { } func location(forKey key: String) -> CLLocation? { - guard let locationData = UserDefaults.standard.data(forKey: key) else { + guard let locationData = data(forKey: key) else { return nil } diff --git a/Sources/SwiftLocation/Support/SynchronizedArray.swift b/Sources/SwiftLocation/Support/SynchronizedArray.swift new file mode 100644 index 0000000..5de5e6f --- /dev/null +++ b/Sources/SwiftLocation/Support/SynchronizedArray.swift @@ -0,0 +1,371 @@ +// +// SynchronizedArray.swift +// Hikingbook +// +// Created by Kf on 2020/5/14. +// Copyright © 2020 Zheng-Xiang Ke. All rights reserved. +// + +import Foundation + +// Reference: https://gist.github.com/basememara/afaae5310a6a6b97bdcdbe4c2fdcd0c6 +class SynchronizedArray { + private let queue = DispatchQueue(label: "com.github.malcommac.SwiftLocation", attributes: .concurrent) + private var array = [Element]() + + public convenience init(_ array: [Element]) { + self.init() + self.array = array + } +} + +// MARK: - Properties +extension SynchronizedArray { + + /// The first element of the collection. + var first: Element? { + var result: Element? + queue.sync { result = self.array.first } + return result + } + + /// The last element of the collection. + var last: Element? { + var result: Element? + queue.sync { result = self.array.last } + return result + } + + /// The number of elements in the array. + var count: Int { + var result = 0 + queue.sync { result = self.array.count } + return result + } + + /// A Boolean value indicating whether the collection is empty. + var isEmpty: Bool { + var result = false + queue.sync { result = self.array.isEmpty } + return result + } + + /// A textual representation of the array and its elements. + var description: String { + var result = "" + queue.sync { result = self.array.description } + return result + } + + var originalArray: [Element] { + var result = [Element]() + queue.sync { result = self.array } + return result + } +} + +// MARK: - Immutable +extension SynchronizedArray { + + /// Returns the first element of the sequence that satisfies the given predicate. + /// + /// - Parameter predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element is a match. + /// - Returns: The first element of the sequence that satisfies predicate, or nil if there is no element that satisfies predicate. + func first(where predicate: (Element) -> Bool) -> Element? { + var result: Element? + queue.sync { result = self.array.first(where: predicate) } + return result + } + + /// Returns the last element of the sequence that satisfies the given predicate. + /// + /// - Parameter predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element is a match. + /// - Returns: The last element of the sequence that satisfies predicate, or nil if there is no element that satisfies predicate. + func last(where predicate: (Element) -> Bool) -> Element? { + var result: Element? + queue.sync { result = self.array.last(where: predicate) } + return result + } + + /// Returns an array containing, in order, the elements of the sequence that satisfy the given predicate. + /// + /// - Parameter isIncluded: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element should be included in the returned array. + /// - Returns: An array of the elements that includeElement allowed. + func filter(_ isIncluded: @escaping (Element) -> Bool) -> SynchronizedArray { + var result: SynchronizedArray? + queue.sync { result = SynchronizedArray(self.array.filter(isIncluded)) } + return result! + } + + /// Returns the first index in which an element of the collection satisfies the given predicate. + /// + /// - Parameter predicate: A closure that takes an element as its argument and returns a Boolean value that indicates whether the passed element represents a match. + /// - Returns: The index of the first element for which predicate returns true. If no elements in the collection satisfy the given predicate, returns nil. + func firstIndex(where predicate: (Element) -> Bool) -> Int? { + var result: Int? + queue.sync { result = self.array.firstIndex(where: predicate) } + return result + } + + /// Returns the elements of the collection, sorted using the given predicate as the comparison between elements. + /// + /// - Parameter areInIncreasingOrder: A predicate that returns true if its first argument should be ordered before its second argument; otherwise, false. + /// - Returns: A sorted array of the collection’s elements. + func sorted(by areInIncreasingOrder: (Element, Element) -> Bool) -> SynchronizedArray { + var result: SynchronizedArray? + queue.sync { result = SynchronizedArray(self.array.sorted(by: areInIncreasingOrder)) } + return result! + } + + /// Returns an array containing the results of mapping the given closure over the sequence’s elements. + /// + /// - Parameter transform: A closure that accepts an element of this sequence as its argument and returns an optional value. + /// - Returns: An array of the non-nil results of calling transform with each element of the sequence. + func map(_ transform: @escaping (Element) -> ElementOfResult) -> [ElementOfResult] { + var result = [ElementOfResult]() + queue.sync { result = self.array.map(transform) } + return result + } + + /// Returns an array containing the non-nil results of calling the given transformation with each element of this sequence. + /// + /// - Parameter transform: A closure that accepts an element of this sequence as its argument and returns an optional value. + /// - Returns: An array of the non-nil results of calling transform with each element of the sequence. + func compactMap(_ transform: (Element) -> ElementOfResult?) -> [ElementOfResult] { + var result = [ElementOfResult]() + queue.sync { result = self.array.compactMap(transform) } + return result + } + + /// Returns the result of combining the elements of the sequence using the given closure. + /// + /// - Parameters: + /// - initialResult: The value to use as the initial accumulating value. initialResult is passed to nextPartialResult the first time the closure is executed. + /// - nextPartialResult: A closure that combines an accumulating value and an element of the sequence into a new accumulating value, to be used in the next call of the nextPartialResult closure or returned to the caller. + /// - Returns: The final accumulated value. If the sequence has no elements, the result is initialResult. + func reduce(_ initialResult: ElementOfResult, _ nextPartialResult: @escaping (ElementOfResult, Element) -> ElementOfResult) -> ElementOfResult { + var result: ElementOfResult? + queue.sync { result = self.array.reduce(initialResult, nextPartialResult) } + return result ?? initialResult + } + + /// Returns the result of combining the elements of the sequence using the given closure. + /// + /// - Parameters: + /// - initialResult: The value to use as the initial accumulating value. + /// - updateAccumulatingResult: A closure that updates the accumulating value with an element of the sequence. + /// - Returns: The final accumulated value. If the sequence has no elements, the result is initialResult. + func reduce(into initialResult: ElementOfResult, _ updateAccumulatingResult: @escaping (inout ElementOfResult, Element) -> ()) -> ElementOfResult { + var result: ElementOfResult? + queue.sync { result = self.array.reduce(into: initialResult, updateAccumulatingResult) } + return result ?? initialResult + } + + /// Calls the given closure on each element in the sequence in the same order as a for-in loop. + /// + /// - Parameter body: A closure that takes an element of the sequence as a parameter. + func forEach(_ body: (Element) -> Void) { + queue.sync { self.array.forEach(body) } + } + + /// Returns a Boolean value indicating whether the sequence contains an element that satisfies the given predicate. + /// + /// - Parameter predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value that indicates whether the passed element represents a match. + /// - Returns: true if the sequence contains an element that satisfies predicate; otherwise, false. + func contains(where predicate: (Element) -> Bool) -> Bool { + var result = false + queue.sync { result = self.array.contains(where: predicate) } + return result + } + + /// Returns a Boolean value indicating whether every element of a sequence satisfies a given predicate. + /// + /// - Parameter predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value that indicates whether the passed element satisfies a condition. + /// - Returns: true if the sequence contains only elements that satisfy predicate; otherwise, false. + func allSatisfy(_ predicate: (Element) -> Bool) -> Bool { + var result = false + queue.sync { result = self.array.allSatisfy(predicate) } + return result + } + + func suffix(_ maxLength: Int) -> [Element] { + var result = [Element]() + queue.sync { result = self.array.suffix(maxLength) } + return result + } +} + +// MARK: - Mutable +extension SynchronizedArray { + + /// Adds a new element at the end of the array. + /// + /// - Parameter element: The element to append to the array. + func append(_ element: Element) { + queue.async(flags: .barrier) { + self.array.append(element) + } + } + + /// Adds new elements at the end of the array. + /// + /// - Parameter element: The elements to append to the array. + func append(_ elements: [Element]) { + queue.async(flags: .barrier) { + self.array += elements + } + } + + /// Inserts a new element at the specified position. + /// + /// - Parameters: + /// - element: The new element to insert into the array. + /// - index: The position at which to insert the new element. + func insert(_ element: Element, at index: Int) { + queue.async(flags: .barrier) { + self.array.insert(element, at: index) + } + } + + /// Removes and returns the element at the specified position. + /// + /// - Parameters: + /// - index: The position of the element to remove. + /// - completion: The handler with the removed element. + func remove(at index: Int, completion: ((Element) -> Void)? = nil) { + queue.async(flags: .barrier) { + let element = self.array.remove(at: index) + DispatchQueue.main.async { completion?(element) } + } + } + + /// Removes and returns the elements that meet the criteria. + /// + /// - Parameters: + /// - predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element is a match. + /// - completion: The handler with the removed elements. + func remove(where predicate: @escaping (Element) -> Bool, completion: (([Element]) -> Void)? = nil) { + queue.async(flags: .barrier) { + var elements = [Element]() + + while let index = self.array.firstIndex(where: predicate) { + elements.append(self.array.remove(at: index)) + } + + DispatchQueue.main.async { completion?(elements) } + } + } + + /// Removes all elements from the array. + /// + /// - Parameter completion: The handler with the removed elements. + func removeAll(completion: (([Element]) -> Void)? = nil) { + queue.async(flags: .barrier) { + let elements = self.array + self.array.removeAll() + DispatchQueue.main.async { completion?(elements) } + } + } + + func removeAll(where shouldBeRemoved: @escaping (Element) throws -> Bool, completion: (([Element]) -> Void)? = nil) { + queue.async(flags: .barrier) { + var elements: [Element] = [] + self.array.removeAll(where: { + let removed = (try? shouldBeRemoved($0)) ?? false + if removed { + elements.append($0) + } + return removed + }) + DispatchQueue.main.async { completion?(elements) } + } + } + + func removeSubrange(_ bounds: Range, completion: ((ArraySlice) -> Void)? = nil) { + queue.async(flags: .barrier) { + let elements = self.array[bounds] + self.array.removeSubrange(bounds) + DispatchQueue.main.async { completion?(elements) } + } + } +} + +extension SynchronizedArray { + + /// Accesses the element at the specified position if it exists. + /// + /// - Parameter index: The position of the element to access. + /// - Returns: optional element if it exists. + subscript(index: Int) -> Element? { + get { + var result: Element? + + queue.sync { + guard self.array.startIndex..) -> ArraySlice { + get { + var result = ArraySlice() + + queue.sync { + result = self.array[bounds] + } + + return result + } + set { + queue.async(flags: .barrier) { + self.array[bounds] = newValue + } + } + } +} + +// MARK: - Equatable +extension SynchronizedArray where Element: Equatable { + + /// Returns a Boolean value indicating whether the sequence contains the given element. + /// + /// - Parameter element: The element to find in the sequence. + /// - Returns: true if the element was found in the sequence; otherwise, false. + func contains(_ element: Element) -> Bool { + var result = false + queue.sync { result = self.array.contains(element) } + return result + } +} + +// MARK: - Infix operators +extension SynchronizedArray { + + /// Adds a new element at the end of the array. + /// + /// - Parameters: + /// - left: The collection to append to. + /// - right: The element to append to the array. + static func +=(left: inout SynchronizedArray, right: Element) { + left.append(right) + } + + /// Adds new elements at the end of the array. + /// + /// - Parameters: + /// - left: The collection to append to. + /// - right: The elements to append to the array. + static func +=(left: inout SynchronizedArray, right: [Element]) { + left.append(right) + } +} diff --git a/Tests/SwiftLocationTests/MockedLocationManager.swift b/Tests/SwiftLocationTests/MockedLocationManager.swift index 3e6fba3..6290160 100644 --- a/Tests/SwiftLocationTests/MockedLocationManager.swift +++ b/Tests/SwiftLocationTests/MockedLocationManager.swift @@ -33,6 +33,8 @@ public class MockedLocationManager: LocationManagerProtocol { public weak var delegate: CLLocationManagerDelegate? public var allowsBackgroundLocationUpdates: Bool = false + public var pausesLocationUpdatesAutomatically: Bool = true + public var showsBackgroundLocationIndicator: Bool = false public var isLocationServicesEnabled: Bool = true { didSet { @@ -57,6 +59,8 @@ public class MockedLocationManager: LocationManagerProtocol { public var desiredAccuracy: CLLocationAccuracy = 100.0 public var activityType: CLActivityType = .other public var distanceFilter: CLLocationDistance = kCLDistanceFilterNone + public var headingFilter: CLLocationDegrees = 1 + public var headingOrientation: CLDeviceOrientation = CLDeviceOrientation.portrait public var onValidatePlistConfiguration: ((_ permission: LocationPermission) -> Error?) = { _ in return nil