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..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]> { @@ -97,7 +99,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 +122,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 +143,13 @@ private class LocationManager: CLLocationManager, CLLocationManagerDelegate { seal.reject(error) } } + + func cancel() { + self.stopUpdatingLocation() + isCancelled = true + } + + var isCancelled = false } @@ -148,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 { @@ -203,7 +217,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 +225,8 @@ private class AuthorizationCatcher: CLLocationManager, CLLocationManagerDelegate init(type: PMKCLAuthorizationType) { super.init() + promise.setCancellableTask(self) + func ask(type: PMKCLAuthorizationType) { delegate = self retainCycle = self @@ -263,6 +279,13 @@ private class AuthorizationCatcher: CLLocationManager, CLLocationManagerDelegate 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..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..a290f18 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 = cancellable(CLLocationManager.requestLocation(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 = cancellable(CLLocationManager.requestAuthorization()).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)]