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..806039b 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1 +1 @@ -github "mxcl/PromiseKit" "6.3.3" +github "dougzilla32/PromiseKit" "ff694600d4d03458121515bdc027ba76df14f7ef" 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)]