diff --git a/IONGeolocationLib/IONGLOCPositionModel.swift b/IONGeolocationLib/IONGLOCPositionModel.swift index 4862498..200fd12 100644 --- a/IONGeolocationLib/IONGLOCPositionModel.swift +++ b/IONGeolocationLib/IONGLOCPositionModel.swift @@ -9,8 +9,11 @@ public struct IONGLOCPositionModel: Equatable { private(set) public var speed: Double private(set) public var timestamp: Double private(set) public var verticalAccuracy: Double + private(set) public var magneticHeading: Double? + private(set) public var trueHeading: Double? + private(set) public var headingAccuracy: Double? - private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double) { + private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double, magneticHeading: Double?, trueHeading: Double?, headingAccuracy: Double?) { self.altitude = altitude self.course = course self.horizontalAccuracy = horizontalAccuracy @@ -19,12 +22,25 @@ public struct IONGLOCPositionModel: Equatable { self.speed = speed self.timestamp = timestamp self.verticalAccuracy = verticalAccuracy + self.magneticHeading = magneticHeading + self.trueHeading = trueHeading + self.headingAccuracy = headingAccuracy } } public extension IONGLOCPositionModel { - static func create(from location: CLLocation) -> IONGLOCPositionModel { - .init( + static func create(from location: CLLocation, heading: CLHeading? = nil) -> IONGLOCPositionModel { + var mHeading: Double? = nil + var tHeading: Double? = nil + var hAccuracy: Double? = nil + + if let heading = heading { + if heading.magneticHeading >= 0 { mHeading = heading.magneticHeading } + if heading.trueHeading >= 0 { tHeading = heading.trueHeading } + if heading.headingAccuracy >= 0 { hAccuracy = heading.headingAccuracy } + } + + return .init( altitude: location.altitude, course: location.course, horizontalAccuracy: location.horizontalAccuracy, @@ -32,7 +48,10 @@ public extension IONGLOCPositionModel { longitude: location.coordinate.longitude, speed: location.speed, timestamp: location.timestamp.millisecondsSinceUnixEpoch, - verticalAccuracy: location.verticalAccuracy + verticalAccuracy: location.verticalAccuracy, + magneticHeading: mHeading, + trueHeading: tHeading, + headingAccuracy: hAccuracy ) } } diff --git a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift index e8b85d0..e11a836 100644 --- a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift +++ b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift @@ -39,6 +39,8 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { private let servicesChecker: IONGLOCServicesChecker private var isMonitoringLocation = false + private var lastLocation: CLLocation? + private var lastHeading: CLHeading? // Flag used to indicate that the location request has timed out. // When `true`, the wrapper ignores any location updates received from CLLocationManager. @@ -52,6 +54,7 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { super.init() locationManager.delegate = self + locationManager.headingFilter = 1.0 } public func requestAuthorisation(withType authorisationType: IONGLOCAuthorisationRequestType) { @@ -62,6 +65,7 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { timeoutTriggered = false isMonitoringLocation = true locationManager.startUpdatingLocation() + locationManager.startUpdatingHeading() self.startTimer(timeout: options.timeout) } @@ -72,11 +76,13 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { isMonitoringLocation = true locationManager.startUpdatingLocation() + locationManager.startUpdatingHeading() } public func stopMonitoringLocation() { isMonitoringLocation = false locationManager.stopUpdatingLocation() + locationManager.stopUpdatingHeading() } public func requestSingleLocation(options: IONGLOCRequestOptionsModel) { @@ -137,16 +143,29 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate { timeoutCancellable?.cancel() timeoutCancellable = nil - guard let latestLocation = locations.last else { + guard let lastLocation = locations.last else { currentLocation = nil + self.lastLocation = nil + lastHeading = nil return } - currentLocation = IONGLOCPositionModel.create(from: latestLocation) + + self.lastLocation = lastLocation + let currentHeading = isMonitoringLocation ? lastHeading : nil + currentLocation = IONGLOCPositionModel.create(from: lastLocation, heading: currentHeading) } public func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) { timeoutCancellable?.cancel() timeoutCancellable = nil + currentLocation = nil + lastLocation = nil + lastHeading = nil + } + + public func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + guard self.lastLocation != nil else { return } + lastHeading = newHeading } } diff --git a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift index 1cdee8e..0560488 100644 --- a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift +++ b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift @@ -314,6 +314,117 @@ final class IONGLOCManagerWrapperTests: XCTestCase { // Then waitForExpectations(timeout: 1.0) } + + // MARK: - Heading Tests + + func test_startMonitoringLocation_startsUpdatingHeading() { + // Given + XCTAssertFalse(locationManager.didStartUpdatingHeading) + + // When + sut.startMonitoringLocation() + + // Then + XCTAssertTrue(locationManager.didStartUpdatingHeading) + } + + func test_startMonitoringLocationWithOptions_startsUpdatingHeading() { + // Given + XCTAssertFalse(locationManager.didStartUpdatingHeading) + + // When + let options = IONGLOCRequestOptionsModel(timeout: 1000) + sut.startMonitoringLocation(options: options) + + // Then + XCTAssertTrue(locationManager.didStartUpdatingHeading) + } + + func test_stopMonitoringLocation_stopsUpdatingHeading() { + // Given + sut.startMonitoringLocation() + XCTAssertTrue(locationManager.didStartUpdatingHeading) + + // When + sut.stopMonitoringLocation() + + // Then + XCTAssertFalse(locationManager.didStartUpdatingHeading) + } + + func test_locationUpdateWithHeading_includesHeadingInPositionModel() { + // Given + sut.startMonitoringLocation() + + let expectedLocation = CLLocation(latitude: 37.7749, longitude: -122.4194) + let expectedHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0, headingAccuracy: 1.0) + let expectedPosition = IONGLOCPositionModel.create(from: expectedLocation, heading: expectedHeading) + let expectation = expectation(description: "Location with heading updated.") + + sut.currentLocationPublisher + .sink(receiveCompletion: { _ in }, receiveValue: { position in + if position.magneticHeading == expectedPosition.magneticHeading && position.trueHeading == expectedPosition.trueHeading { + XCTAssertEqual(position, expectedPosition) + expectation.fulfill() + } + }) + .store(in: &cancellables) + + // When + locationManager.updateLocation(to: [CLLocation(latitude: 0, longitude: 0)]) + locationManager.updateHeading(to: expectedHeading) + locationManager.updateLocation(to: [expectedLocation]) + + // Then + waitForExpectations(timeout: 1.0) + } + + func test_headingUpdateWithoutLocation_doesNotUpdatePositionModel() { + // Given + let heading = createMockHeading(magneticHeading: 180.0, trueHeading: 182.0, headingAccuracy: 1.0) + var updateCount = 0 + let expectation = expectation(description: "No update should occur.") + expectation.isInverted = true + + sut.currentLocationPublisher + .sink(receiveCompletion: { _ in }, receiveValue: { _ in + updateCount += 1 + expectation.fulfill() + }) + .store(in: &cancellables) + + // When + locationManager.updateHeading(to: heading) + + // Then + waitForExpectations(timeout: 0.5) + XCTAssertEqual(updateCount, 0) + } + + + + func test_headingFilterIsSetToOneDegree() { + // Then + XCTAssertEqual(locationManager.headingFilter, 1.0) + } + + func test_positionModelWithoutHeading_hasDefaultHeadingValues() { + // Given + let location = CLLocation(latitude: 37.7749, longitude: -122.4194) + let expectedPosition = IONGLOCPositionModel.create(from: location) + let expectation = expectation(description: "Location without heading updated.") + + validateCurrentLocationPublisher(expectation, expectedPosition) + + // When + locationManager.updateLocation(to: [location]) + + // Then + waitForExpectations(timeout: 1.0) + XCTAssertNil(expectedPosition.magneticHeading) + XCTAssertNil(expectedPosition.trueHeading) + XCTAssertNil(expectedPosition.headingAccuracy) + } } private extension IONGLOCManagerWrapperTests { @@ -353,6 +464,14 @@ private extension IONGLOCManagerWrapperTests { } .store(in: &cancellables) } + + func createMockHeading(magneticHeading: Double, trueHeading: Double, headingAccuracy: Double) -> CLHeading { + let heading = MockCLHeading() + heading.mockMagneticHeading = magneticHeading + heading.mockTrueHeading = trueHeading + heading.mockHeadingAccuracy = headingAccuracy + return heading + } } private extension CLLocationManager { @@ -363,3 +482,21 @@ private extension CLLocationManager { private enum MockLocationUpdateError: Error { case locationUpdateFailed } + +private class MockCLHeading: CLHeading { + var mockMagneticHeading: Double = 0.0 + var mockTrueHeading: Double = 0.0 + var mockHeadingAccuracy: Double = 0.0 + + override var magneticHeading: Double { + mockMagneticHeading + } + + override var trueHeading: Double { + mockTrueHeading + } + + override var headingAccuracy: Double { + mockHeadingAccuracy + } +} diff --git a/IONGeolocationLibTests/MockCLLocationManager.swift b/IONGeolocationLibTests/MockCLLocationManager.swift index 7cbf259..b217d36 100644 --- a/IONGeolocationLibTests/MockCLLocationManager.swift +++ b/IONGeolocationLibTests/MockCLLocationManager.swift @@ -5,12 +5,23 @@ class MockCLLocationManager: CLLocationManager { private(set) var didCallRequestLocation = false private(set) var didCallRequestWhenInUseAuthorization = false private(set) var didStartUpdatingLocation = false + private(set) var didStartUpdatingHeading = false private(set) var mockAuthorizationStatus: CLAuthorizationStatus = .notDetermined + private(set) var mockHeadingFilter: CLLocationDegrees = kCLHeadingFilterNone override var authorizationStatus: CLAuthorizationStatus { mockAuthorizationStatus } + override var headingFilter: CLLocationDegrees { + get { + mockHeadingFilter + } + set { + mockHeadingFilter = newValue + } + } + override func startUpdatingLocation() { didStartUpdatingLocation = true } @@ -19,6 +30,14 @@ class MockCLLocationManager: CLLocationManager { didStartUpdatingLocation = false } + override func startUpdatingHeading() { + didStartUpdatingHeading = true + } + + override func stopUpdatingHeading() { + didStartUpdatingHeading = false + } + override func requestLocation() { didCallRequestLocation = true } @@ -40,6 +59,10 @@ class MockCLLocationManager: CLLocationManager { delegate?.locationManager?(self, didUpdateLocations: locations) } + func updateHeading(to heading: CLHeading) { + delegate?.locationManager?(self, didUpdateHeading: heading) + } + func failWhileUpdatingLocation(_ error: Error) { delegate?.locationManager?(self, didFailWithError: error) } diff --git a/README.md b/README.md index c952466..9afed9b 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ It returns the device's latest fetched location position. It can be `nil` if the - `speed`: Instantaneous speed of the device, measured in meters per second (m/s); - `timestamp`: Time at which this location was determined, measured in milliseconds (ms) elapsed since the UNIX epoch (Jan 1, 1970); - `verticalAccuracy`: Validity of the altitude values and their estimated uncertainty, measured in meters (m). +- `magneticHeading`: The heading (measured in degrees) relative to magnetic north. +- `trueHeading`: The heading (measured in degrees) relative to true north. +- `headingAccuracy`: The maximum deviation (measured in degrees) between the reported heading and the true geomagnetic heading. + #### Current Location Publisher @@ -231,7 +235,10 @@ Location updates are delivered as `IONGLOCPositionModel` objects: "verticalAccuracy": 10.0, "course": 180.0, "speed": 0.0, - "timestamp": 1641034800000 + "timestamp": 1641034800000, + "magneticHeading": 5.0, + "trueHeading": 5.0, + "headingAccuracy": 0.0 } ```