Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions IONGeolocationLib/IONGLOCPositionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,20 +22,36 @@ 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,
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
speed: location.speed,
timestamp: location.timestamp.millisecondsSinceUnixEpoch,
verticalAccuracy: location.verticalAccuracy
verticalAccuracy: location.verticalAccuracy,
magneticHeading: mHeading,
trueHeading: tHeading,
headingAccuracy: hAccuracy
)
}
}
Expand Down
23 changes: 21 additions & 2 deletions IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -52,6 +54,7 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {

super.init()
locationManager.delegate = self
locationManager.headingFilter = 1.0
}

public func requestAuthorisation(withType authorisationType: IONGLOCAuthorisationRequestType) {
Expand All @@ -62,6 +65,7 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {
timeoutTriggered = false
isMonitoringLocation = true
locationManager.startUpdatingLocation()
locationManager.startUpdatingHeading()
self.startTimer(timeout: options.timeout)
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
}
137 changes: 137 additions & 0 deletions IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}
23 changes: 23 additions & 0 deletions IONGeolocationLibTests/MockCLLocationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -19,6 +30,14 @@ class MockCLLocationManager: CLLocationManager {
didStartUpdatingLocation = false
}

override func startUpdatingHeading() {
didStartUpdatingHeading = true
}

override func stopUpdatingHeading() {
didStartUpdatingHeading = false
}

override func requestLocation() {
didCallRequestLocation = true
}
Expand All @@ -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)
}
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
```

Expand Down
Loading