From 1c9d79fa74732569cf7317b2e11c2dbe77d33d65 Mon Sep 17 00:00:00 2001 From: dougzilla32 Date: Sun, 7 Oct 2018 15:00:26 +0900 Subject: [PATCH 1/2] 'Cancel' for PromiseKit -- provides the ability to cancel promises and promise chains --- Cartfile | 3 +- Cartfile.resolved | 2 +- Sources/CLLocationManager+Promise.swift | 63 ++++++++++++- Tests/CLGeocoderTests.swift | 114 ++++++++++++++++++++++++ Tests/CLLocationManagerTests.swift | 57 ++++++++++++ 5 files changed, 235 insertions(+), 4 deletions(-) diff --git a/Cartfile b/Cartfile index 2bfea98..c517d21 100644 --- a/Cartfile +++ b/Cartfile @@ -1 +1,2 @@ -github "mxcl/PromiseKit" ~> 6.0 +#github "mxcl/PromiseKit" ~> 6.0 +github "dougzilla32/PromiseKit" "CoreCancel" diff --git a/Cartfile.resolved b/Cartfile.resolved index a1be206..80a4000 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1 +1 @@ -github "mxcl/PromiseKit" "6.3.3" +github "dougzilla32/PromiseKit" "087b3cf470890ff9ea841212e2f3e285fecf3988" diff --git a/Sources/CLLocationManager+Promise.swift b/Sources/CLLocationManager+Promise.swift index 83b4d5b..2237fed 100644 --- a/Sources/CLLocationManager+Promise.swift +++ b/Sources/CLLocationManager+Promise.swift @@ -97,7 +97,7 @@ extension CLLocationManager { } } -private class LocationManager: CLLocationManager, CLLocationManagerDelegate { +private class LocationManager: CLLocationManager, CLLocationManagerDelegate, CancellableTask { let (promise, seal) = Promise<[CLLocation]>.pending() let satisfyingBlock: ((CLLocation) -> Bool)? @@ -120,6 +120,9 @@ private class LocationManager: CLLocationManager, CLLocationManagerDelegate { satisfyingBlock = block super.init() delegate = self + + promise.setCancellableTask(self, reject: seal.reject) + #if !os(tvOS) startUpdatingLocation() #else @@ -138,6 +141,13 @@ private class LocationManager: CLLocationManager, CLLocationManagerDelegate { seal.reject(error) } } + + func cancel() { + self.stopUpdatingLocation() + isCancelled = true + } + + var isCancelled = false } @@ -203,7 +213,7 @@ extension CLLocationManager { } @available(iOS 8, *) -private class AuthorizationCatcher: CLLocationManager, CLLocationManagerDelegate { +private class AuthorizationCatcher: CLLocationManager, CLLocationManagerDelegate, CancellableTask { let (promise, fulfill) = Guarantee.pending() var retainCycle: AuthorizationCatcher? let initialAuthorizationState = CLLocationManager.authorizationStatus() @@ -211,6 +221,8 @@ private class AuthorizationCatcher: CLLocationManager, CLLocationManagerDelegate init(type: PMKCLAuthorizationType) { super.init() + promise.setCancellableTask(self) + func ask(type: PMKCLAuthorizationType) { delegate = self retainCycle = self @@ -263,6 +275,13 @@ private class AuthorizationCatcher: CLLocationManager, CLLocationManagerDelegate fulfill(status) } } + + func cancel() { + self.retainCycle = nil + isCancelled = true + } + + var isCancelled = false } #endif @@ -305,3 +324,43 @@ private enum PMKCLAuthorizationType { case always case whenInUse } + +//////////////////////////////////////////////////////////// Cancellable wrappers + +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 cancellableRequestLocation(authorizationType: RequestAuthorizationType = .automatic, satisfying block: ((CLLocation) -> Bool)? = nil) -> CancellablePromise<[CLLocation]> { + return cancellable(requestLocation(authorizationType: authorizationType, satisfying: block)) + } +} + + +#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 cancellableRequestAuthorization(type requestedAuthorizationType: RequestAuthorizationType = .automatic) -> CancellablePromise { + return cancellable(requestAuthorization(type: requestedAuthorizationType)) + } +} + +#endif diff --git a/Tests/CLGeocoderTests.swift b/Tests/CLGeocoderTests.swift index 479a1fd..6cea49c 100644 --- a/Tests/CLGeocoderTests.swift +++ b/Tests/CLGeocoderTests.swift @@ -100,3 +100,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: "") + cancellable(MockGeocoder().reverseGeocode(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 = cancellable(MockGeocoder().geocode([:])).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 = cancellable(MockGeocoder().geocode("")).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 = cancellable(MockGeocoder().geocodePostalAddress(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 = cancellable(MockGeocoder().geocodePostalAddress(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..03df273 100644 --- a/Tests/CLLocationManagerTests.swift +++ b/Tests/CLLocationManagerTests.swift @@ -51,6 +51,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 = cancellable(CLLocationManager.requestLocation()).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.cancellableRequestLocation(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.cancellableRequestAuthorization().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 57a265d0362e6a1f2d4e007e42d2e904ace8cae9 Mon Sep 17 00:00:00 2001 From: dougzilla32 Date: Tue, 16 Oct 2018 11:53:02 +0900 Subject: [PATCH 2/2] 'Cancel' for PromiseKit -- remove cancellable wrappers (they are unnecessary) --- Sources/CLLocationManager+Promise.swift | 44 +++---------------------- Tests/CLLocationManagerTests.swift | 4 +-- 2 files changed, 6 insertions(+), 42 deletions(-) diff --git a/Sources/CLLocationManager+Promise.swift b/Sources/CLLocationManager+Promise.swift index 2237fed..96119a0 100644 --- a/Sources/CLLocationManager+Promise.swift +++ b/Sources/CLLocationManager+Promise.swift @@ -43,6 +43,8 @@ extension CLLocationManager { - 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. + - Note: cancelling this promise will cancel the underlying task + - SeeAlso: [Cancellation](http://promisekit.org/docs/) */ public class func requestLocation(authorizationType: RequestAuthorizationType = .automatic, satisfying block: ((CLLocation) -> Bool)? = nil) -> Promise<[CLLocation]> { @@ -158,6 +160,8 @@ 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`. + - Note: cancelling this promise will cancel the underlying task + - SeeAlso: [Cancellation](http://promisekit.org/docs/) */ @available(iOS 8, tvOS 9, watchOS 2, *) public class func requestAuthorization(type requestedAuthorizationType: RequestAuthorizationType = .automatic) -> Guarantee { @@ -324,43 +328,3 @@ private enum PMKCLAuthorizationType { case always case whenInUse } - -//////////////////////////////////////////////////////////// Cancellable wrappers - -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 cancellableRequestLocation(authorizationType: RequestAuthorizationType = .automatic, satisfying block: ((CLLocation) -> Bool)? = nil) -> CancellablePromise<[CLLocation]> { - return cancellable(requestLocation(authorizationType: authorizationType, satisfying: block)) - } -} - - -#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 cancellableRequestAuthorization(type requestedAuthorizationType: RequestAuthorizationType = .automatic) -> CancellablePromise { - return cancellable(requestAuthorization(type: requestedAuthorizationType)) - } -} - -#endif diff --git a/Tests/CLLocationManagerTests.swift b/Tests/CLLocationManagerTests.swift index 03df273..a290f18 100644 --- a/Tests/CLLocationManagerTests.swift +++ b/Tests/CLLocationManagerTests.swift @@ -80,7 +80,7 @@ extension Test_CLLocationManager_Swift { let block: ((CLLocation) -> Bool) = { location in return location.coordinate.latitude == dummy.last?.coordinate.latitude } - let p = CLLocationManager.cancellableRequestLocation(satisfying: block).done { _ in + let p = cancellable(CLLocationManager.requestLocation(satisfying: block)).done { _ in XCTFail("not cancelled") }.catch(policy: .allErrors) { error in error.isCancelled ? ex.fulfill() : XCTFail("error \(error)") @@ -97,7 +97,7 @@ extension Test_CLLocationManager_Swift { func testCancel_requestAuthorization() { let ex = expectation(description: "") - let p = CLLocationManager.cancellableRequestAuthorization().done { _ in + let p = cancellable(CLLocationManager.requestAuthorization()).done { _ in XCTFail("not cancelled") }.catch(policy: .allErrors) { error in error.isCancelled ? ex.fulfill() : XCTFail("error \(error)")