From c71e8ddab7bf8fab00418d6a7958d18b37967838 Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 18 Jul 2018 17:44:51 -0700 Subject: [PATCH 1/3] 'Cancel' for PromiseKit --- Cartfile | 2 + Cartfile.resolved | 3 +- Sources/CLGeocoder+Promise.swift | 52 +++++ Sources/CLLocationManager+Promise.swift | 255 ++++++++++++++++++++++++ Tests/CLGeocoderTests.swift | 115 +++++++++++ Tests/CLLocationManagerTests.swift | 58 ++++++ 6 files changed, 484 insertions(+), 1 deletion(-) diff --git a/Cartfile b/Cartfile index 2bfea98..c2d2433 100644 --- a/Cartfile +++ b/Cartfile @@ -1 +1,3 @@ github "mxcl/PromiseKit" ~> 6.0 +#github "PromiseKit/Cancel" ~> 1.0 +github "dougzilla32/Cancel" ~> 1.0 diff --git a/Cartfile.resolved b/Cartfile.resolved index a1be206..6d512a1 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1 +1,2 @@ -github "mxcl/PromiseKit" "6.3.3" +github "dougzilla32/Cancel" "1.0.0" +github "mxcl/PromiseKit" "6.3.4" diff --git a/Sources/CLGeocoder+Promise.swift b/Sources/CLGeocoder+Promise.swift index e80bcee..64e8157 100644 --- a/Sources/CLGeocoder+Promise.swift +++ b/Sources/CLGeocoder+Promise.swift @@ -1,5 +1,6 @@ import CoreLocation.CLGeocoder #if !PMKCocoaPods +import PMKCancel import PromiseKit #endif #if os(iOS) || os(watchOS) || os(OSX) @@ -71,3 +72,54 @@ extension CLGeocoder { // return self == .geocodeCanceled // } //} + +//////////////////////////////////////////////////////////// Cancellation + +extension CLGeocoder { + /// Submits a reverse-geocoding request for the specified location. + public func reverseGeocodeCC(location: CLLocation) -> CancellablePromise<[CLPlacemark]> { + return CancellablePromise { seal in + reverseGeocodeLocation(location, completionHandler: seal.resolve) + } + } + + /// Submits a forward-geocoding request using the specified address dictionary. + @available(iOS, deprecated: 11.0) + public func geocodeCC(_ addressDictionary: [String: String]) -> CancellablePromise<[CLPlacemark]> { + return CancellablePromise { seal in + geocodeAddressDictionary(addressDictionary, completionHandler: seal.resolve) + } + } + + /// Submits a forward-geocoding request using the specified address string. + public func geocodeCC(_ addressString: String) -> CancellablePromise<[CLPlacemark]> { + return CancellablePromise { seal in + geocodeAddressString(addressString, completionHandler: seal.resolve) + } + } + + /// Submits a forward-geocoding request using the specified address string within the specified region. + public func geocodeCC(_ addressString: String, region: CLRegion?) -> CancellablePromise<[CLPlacemark]> { + return CancellablePromise { seal in + geocodeAddressString(addressString, in: region, completionHandler: seal.resolve) + } + } + +#if !os(tvOS) && swift(>=3.2) + /// Submits a forward-geocoding request using the specified postal address. + @available(iOS 11.0, OSX 10.13, watchOS 4.0, *) + public func geocodePostalAddressCC(_ postalAddress: CNPostalAddress) -> CancellablePromise<[CLPlacemark]> { + return CancellablePromise { seal in + geocodePostalAddress(postalAddress, completionHandler: seal.resolve) + } + } + + /// Submits a forward-geocoding requesting using the specified locale and postal address + @available(iOS 11.0, OSX 10.13, watchOS 4.0, *) + public func geocodePostalAddressCC(_ postalAddress: CNPostalAddress, preferredLocale locale: Locale?) -> CancellablePromise<[CLPlacemark]> { + return CancellablePromise { seal in + geocodePostalAddress(postalAddress, preferredLocale: locale, completionHandler: seal.resolve) + } + } +#endif +} diff --git a/Sources/CLLocationManager+Promise.swift b/Sources/CLLocationManager+Promise.swift index 83b4d5b..84c6705 100644 --- a/Sources/CLLocationManager+Promise.swift +++ b/Sources/CLLocationManager+Promise.swift @@ -1,5 +1,6 @@ import CoreLocation.CLLocationManager #if !PMKCocoaPods +import PMKCancel import PromiseKit #endif @@ -305,3 +306,257 @@ private enum PMKCLAuthorizationType { case always case whenInUse } + +//////////////////////////////////////////////////////////// Cancellation + +extension CLLocationManager { + /** + Request the current location, with the ability to cancel the request. + - Note: to obtain a single location use `Promise.lastValue` + - Parameters: + - authorizationType: requestAuthorizationType: We read your Info plist and try to + determine the authorization type we should request automatically. If you + want to force one or the other, change this parameter from its default + value. + - block: A block by which to perform any filtering of the locations that are + returned. In order to only retrieve accurate locations, only return true if the + locations horizontal accuracy < 50 + - Returns: A new promise that fulfills with the most recent CLLocation that satisfies + the provided block if it exists. If the block does not exist, simply return the + last location. + */ + public class func requestLocationCC(authorizationType: RequestAuthorizationType = .automatic, satisfying block: ((CLLocation) -> Bool)? = nil) -> CancellablePromise<[CLLocation]> { + + func std() -> CancellablePromise<[CLLocation]> { + return CancellableLocationManager(satisfying: block).promise + } + + func auth() -> CancellablePromise { + #if os(macOS) + return CancellablePromise { seal in seal.fulfill(()) } + #else + func auth(type: PMKCLAuthorizationType) -> CancellablePromise { + return CancellableAuthorizationCatcher(type: type).promise.done(on: nil) { + switch $0 { + case .restricted, .denied: + throw PMKError.notAuthorized + default: + break + } + } + } + + switch authorizationType { + case .automatic: + switch Bundle.main.permissionType { + case .always, .both: + return auth(type: .always) + case .whenInUse: + return auth(type: .whenInUse) + } + case .whenInUse: + return auth(type: .whenInUse) + case .always: + return auth(type: .always) + } + #endif + } + + switch CLLocationManager.authorizationStatus() { + case .authorizedAlways, .authorizedWhenInUse: + return std() + case .notDetermined: + return auth().then(std) + case .denied, .restricted: + return CancellablePromise(error: PMKError.notAuthorized) + } + } +} + +private class CancellableLocationManager: CLLocationManager, CLLocationManagerDelegate, CancellableTask { + let (promise, seal) = CancellablePromise<[CLLocation]>.pending() + let satisfyingBlock: ((CLLocation) -> Bool)? + + init(satisfying block: ((CLLocation) -> Bool)? = nil) { + satisfyingBlock = block + super.init() + delegate = self + + promise.appendCancellableTask(task: self, reject: seal.reject) + + #if !os(tvOS) + startUpdatingLocation() + #else + requestLocation() + #endif + _ = self.promise.ensure { + self.stopUpdatingLocation() + } + } + + @objc fileprivate func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + if let block = satisfyingBlock { + let satisfiedLocations = locations.filter(block) + if !satisfiedLocations.isEmpty { + seal.fulfill(satisfiedLocations) + } else { + #if os(tvOS) + requestLocation() + #endif + } + } else { + seal.fulfill(locations) + } + } + + @objc func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + let (domain, code) = { ($0.domain, $0.code) }(error as NSError) + if code == CLError.locationUnknown.rawValue && domain == kCLErrorDomain { + // Apple docs say you should just ignore this error + } else { + seal.reject(error) + } + } + + func cancel() { + self.stopUpdatingLocation() + isCancelled = true + } + + var isCancelled = false +} + +#if !os(macOS) + +extension CLLocationManager { + /** + Request CoreLocation authorization from the user + - Note: By default we try to determine the authorization type you want by inspecting your Info.plist + - Note: This method will not perform upgrades from “when-in-use” to “always” unless you specify `.always` for the value of `type`. + */ + @available(iOS 8, tvOS 9, watchOS 2, *) + public class func requestAuthorizationCC(type requestedAuthorizationType: RequestAuthorizationType = .automatic) -> CancellablePromise { + + let currentStatus = CLLocationManager.authorizationStatus() + + func std(type: PMKCLAuthorizationType) -> CancellablePromise { + if currentStatus == .notDetermined { + return CancellableAuthorizationCatcher(type: type).promise + } else { + return .valueCC(currentStatus) + } + } + + switch requestedAuthorizationType { + case .always: + func iOS11Check() -> CancellablePromise { + switch currentStatus { + case .notDetermined, .authorizedWhenInUse: + return CancellableAuthorizationCatcher(type: .always).promise + default: + return .valueCC(currentStatus) + } + } + #if PMKiOS11 + // ^^ define PMKiOS11 if you deploy against the iOS 11 SDK + // otherwise the warning you get below cannot be removed + return iOS11Check() + #else + if #available(iOS 11, *) { + return iOS11Check() + } else { + return std(type: .always) + } + #endif + + case .whenInUse: + return std(type: .whenInUse) + + case .automatic: + if currentStatus == .notDetermined { + switch Bundle.main.permissionType { + case .both, .whenInUse: + return CancellableAuthorizationCatcher(type: .whenInUse).promise + case .always: + return CancellableAuthorizationCatcher(type: .always).promise + } + } else { + return .valueCC(currentStatus) + } + } + } +} + +@available(iOS 8, *) +private class CancellableAuthorizationCatcher: CLLocationManager, CLLocationManagerDelegate, CancellableTask { + let (promise, seal) = CancellablePromise.pending() + var retainCycle: CancellableAuthorizationCatcher? + let initialAuthorizationState = CLLocationManager.authorizationStatus() + + init(type: PMKCLAuthorizationType) { + super.init() + + promise.appendCancellableTask(task: self, reject: seal.reject) + + func ask(type: PMKCLAuthorizationType) { + delegate = self + retainCycle = self + + switch type { + case .always: + #if os(tvOS) + fallthrough + #else + requestAlwaysAuthorization() + #endif + case .whenInUse: + requestWhenInUseAuthorization() + } + + _ = promise.done { _ in + self.retainCycle = nil + } + } + + func iOS11Check() { + switch (initialAuthorizationState, type) { + case (.notDetermined, .always), (.authorizedWhenInUse, .always), (.notDetermined, .whenInUse): + ask(type: type) + default: + seal.fulfill(initialAuthorizationState) + } + } + + #if PMKiOS11 + // ^^ define PMKiOS11 if you deploy against the iOS 11 SDK + // otherwise the warning you get below cannot be removed + iOS11Check() + #else + if #available(iOS 11, *) { + iOS11Check() + } else { + if initialAuthorizationState == .notDetermined { + ask(type: type) + } else { + seal.fulfill(initialAuthorizationState) + } + } + #endif + } + + @objc fileprivate func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + // `didChange` is a lie; it fires this immediately with the current status. + if status != initialAuthorizationState { + seal.fulfill(status) + } + } + + func cancel() { + self.retainCycle = nil + isCancelled = true + } + + var isCancelled = false +} + +#endif diff --git a/Tests/CLGeocoderTests.swift b/Tests/CLGeocoderTests.swift index 479a1fd..29880f3 100644 --- a/Tests/CLGeocoderTests.swift +++ b/Tests/CLGeocoderTests.swift @@ -1,3 +1,4 @@ +import PMKCancel import PMKCoreLocation import CoreLocation import PromiseKit @@ -100,3 +101,117 @@ class CLGeocoderTests: XCTestCase { } private let dummyPlacemark = CLPlacemark() + +//////////////////////////////////////////////////////////// Cancellation + +extension CLGeocoderTests { + func testCancel_reverseGeocodeLocation() { + class MockGeocoder: CLGeocoder { + override func reverseGeocodeLocation(_ location: CLLocation, completionHandler: @escaping CLGeocodeCompletionHandler) { + after(.milliseconds(100)).done { + completionHandler([dummyPlacemark], nil) + } + } + } + + let ex = expectation(description: "") + MockGeocoder().reverseGeocodeCC(location: CLLocation()).done { _ in + XCTFail("not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("error \(error)") + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testCancel_geocodeAddressDictionary() { + class MockGeocoder: CLGeocoder { + override func geocodeAddressDictionary(_ addressDictionary: [AnyHashable: Any], completionHandler: @escaping CLGeocodeCompletionHandler) { + after(.milliseconds(100)).done { + completionHandler([dummyPlacemark], nil) + } + } + } + + let ex = expectation(description: "") + let context = MockGeocoder().geocodeCC([:]).done { _ in + XCTFail("not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("error \(error)") + }.cancelContext + after(.milliseconds(50)).done { + context.cancel() + } + + waitForExpectations(timeout: 1) + } + + func testCancel_geocodeAddressString() { + class MockGeocoder: CLGeocoder { + override func geocodeAddressString(_ addressString: String, completionHandler: @escaping CLGeocodeCompletionHandler) { + after(.milliseconds(100)).done { + completionHandler([dummyPlacemark], nil) + } + } + } + + let ex = expectation(description: "") + let p = MockGeocoder().geocodeCC("").done { _ in + XCTFail("not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("error \(error)") + } + after(.milliseconds(50)).done { + p.cancel() + } + waitForExpectations(timeout: 1) + } + +#if !os(tvOS) && swift(>=3.2) + func testCancel_geocodePostalAddress() { + guard #available(iOS 11.0, OSX 10.13, watchOS 4.0, *) else { return } + + class MockGeocoder: CLGeocoder { + override func geocodePostalAddress(_ postalAddress: CNPostalAddress, completionHandler: @escaping CLGeocodeCompletionHandler) { + after(.milliseconds(100)).done { + completionHandler([dummyPlacemark], nil) + } + } + } + + let ex = expectation(description: "") + let p = MockGeocoder().geocodePostalAddressCC(CNPostalAddress()).done { _ in + XCTFail("not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("error \(error)") + } + after(.milliseconds(50)).done { + p.cancel() + } + waitForExpectations(timeout: 1) + } + + func testCancel_geocodePostalAddressLocale() { + guard #available(iOS 11.0, OSX 10.13, watchOS 4.0, *) else { return } + + class MockGeocoder: CLGeocoder { + override func geocodePostalAddress(_ postalAddress: CNPostalAddress, preferredLocale locale: Locale?, completionHandler: @escaping CLGeocodeCompletionHandler) { + after(.milliseconds(100)).done { + completionHandler([dummyPlacemark], nil) + } + } + } + + let ex = expectation(description: "") + let p = MockGeocoder().geocodePostalAddressCC(CNPostalAddress(), preferredLocale: nil).done { _ in + XCTFail("not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("error \(error)") + } + after(.milliseconds(50)).done { + p.cancel() + } + waitForExpectations(timeout: 1) + } +#endif +} diff --git a/Tests/CLLocationManagerTests.swift b/Tests/CLLocationManagerTests.swift index 914af86..916ba89 100644 --- a/Tests/CLLocationManagerTests.swift +++ b/Tests/CLLocationManagerTests.swift @@ -1,3 +1,4 @@ +import PMKCancel import PMKCoreLocation import CoreLocation import PromiseKit @@ -51,6 +52,63 @@ class Test_CLLocationManager_Swift: XCTestCase { #endif } +//////////////////////////////////////////////////////////// Cancellation + +extension Test_CLLocationManager_Swift { + func testCancel_fulfills_with_multiple_locations() { + swizzle(CLLocationManager.self, #selector(CLLocationManager.startUpdatingLocation)) { + swizzle(CLLocationManager.self, #selector(CLLocationManager.authorizationStatus), isClassMethod: true) { + let ex = expectation(description: "") + + let p = CLLocationManager.requestLocationCC().done { _ in + XCTFail("not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("error \(error)") + } + after(.milliseconds(50)).done { + p.cancel() + } + + waitForExpectations(timeout: 1) + } + } + } + + func testCancel_fufillsWithSatisfyingBlock() { + swizzle(CLLocationManager.self, #selector(CLLocationManager.startUpdatingLocation)) { + swizzle(CLLocationManager.self, #selector(CLLocationManager.authorizationStatus), isClassMethod: true) { + let ex = expectation(description: "") + let block: ((CLLocation) -> Bool) = { location in + return location.coordinate.latitude == dummy.last?.coordinate.latitude + } + let p = CLLocationManager.requestLocationCC(satisfying: block).done { _ in + XCTFail("not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("error \(error)") + } + after(.milliseconds(50)).done { + p.cancel() + } + waitForExpectations(timeout: 1) + } + } + } + +#if os(iOS) + func testCancel_requestAuthorization() { + let ex = expectation(description: "") + + let p = CLLocationManager.requestAuthorizationCC().done { _ in + XCTFail("not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("error \(error)") + } + p.cancel() + + waitForExpectations(timeout: 1, handler: nil) + } +#endif +} /////////////////////////////////////////////////////////////// resources private let dummy = [CLLocation(latitude: 0, longitude: 0), CLLocation(latitude: 10, longitude: 20)] From 4f32637a4fddc04d294559b6043e245c4f5307da Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 18 Jul 2018 23:11:34 -0700 Subject: [PATCH 2/3] 'Cancel' for PromiseKit -- + for PMKCancel, change watchOS deployment target from 3.0 to 2.0 + include PMKCancel in the 'Embed Carthage Frameworks' build phase script for all extensions + include PMKCancel in SPM config for Alamofire and Foundation --- PMKCoreLocation.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/PMKCoreLocation.xcodeproj/project.pbxproj b/PMKCoreLocation.xcodeproj/project.pbxproj index 1f8f467..e7f3d16 100644 --- a/PMKCoreLocation.xcodeproj/project.pbxproj +++ b/PMKCoreLocation.xcodeproj/project.pbxproj @@ -208,6 +208,7 @@ ); inputPaths = ( PromiseKit, + PMKCancel, ); name = "Embed Carthage Frameworks"; outputPaths = ( From dd7ffb45cbf96907624c22f26a870e1b7dc01ea3 Mon Sep 17 00:00:00 2001 From: dougzilla Date: Thu, 19 Jul 2018 15:07:37 -0700 Subject: [PATCH 3/3] 'Cancel' for PromiseKit -- for all Extensions point Cartfile at the forked version of PromiseKit --- Cartfile | 3 ++- Cartfile.resolved | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cartfile b/Cartfile index c2d2433..cdd4145 100644 --- a/Cartfile +++ b/Cartfile @@ -1,3 +1,4 @@ -github "mxcl/PromiseKit" ~> 6.0 +#github "mxcl/PromiseKit" ~> 6.0 +github "dougzilla32/PromiseKit" "PMKCancel" #github "PromiseKit/Cancel" ~> 1.0 github "dougzilla32/Cancel" ~> 1.0 diff --git a/Cartfile.resolved b/Cartfile.resolved index 6d512a1..e1cadd5 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,2 +1,2 @@ github "dougzilla32/Cancel" "1.0.0" -github "mxcl/PromiseKit" "6.3.4" +github "dougzilla32/PromiseKit" "a0217bd7b69af68237dcdeee0197e63259b9d445"