Skip to content

Commit a1d9bb7

Browse files
27pchrislItsChaceD
andauthored
feat: add support for heading
* Heading support * Doc, heading accuracy * fix getCurrentPosition and integrate CLHeading into location publishers * remove heading from getCurrentPosition * optional heading values * dont send heading to getCurrentPosition * update heading tests and cache last heading * fix unit tests on iphone 17 sim --------- Co-authored-by: Chace Daniels <chace.daniels@outsystems.com>
1 parent 2f3b3e7 commit a1d9bb7

File tree

5 files changed

+212
-7
lines changed

5 files changed

+212
-7
lines changed

IONGeolocationLib/IONGLOCPositionModel.swift

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ public struct IONGLOCPositionModel: Equatable {
99
private(set) public var speed: Double
1010
private(set) public var timestamp: Double
1111
private(set) public var verticalAccuracy: Double
12+
private(set) public var magneticHeading: Double?
13+
private(set) public var trueHeading: Double?
14+
private(set) public var headingAccuracy: Double?
1215

13-
private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double) {
16+
private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double, magneticHeading: Double?, trueHeading: Double?, headingAccuracy: Double?) {
1417
self.altitude = altitude
1518
self.course = course
1619
self.horizontalAccuracy = horizontalAccuracy
@@ -19,20 +22,36 @@ public struct IONGLOCPositionModel: Equatable {
1922
self.speed = speed
2023
self.timestamp = timestamp
2124
self.verticalAccuracy = verticalAccuracy
25+
self.magneticHeading = magneticHeading
26+
self.trueHeading = trueHeading
27+
self.headingAccuracy = headingAccuracy
2228
}
2329
}
2430

2531
public extension IONGLOCPositionModel {
26-
static func create(from location: CLLocation) -> IONGLOCPositionModel {
27-
.init(
32+
static func create(from location: CLLocation, heading: CLHeading? = nil) -> IONGLOCPositionModel {
33+
var mHeading: Double? = nil
34+
var tHeading: Double? = nil
35+
var hAccuracy: Double? = nil
36+
37+
if let heading = heading {
38+
if heading.magneticHeading >= 0 { mHeading = heading.magneticHeading }
39+
if heading.trueHeading >= 0 { tHeading = heading.trueHeading }
40+
if heading.headingAccuracy >= 0 { hAccuracy = heading.headingAccuracy }
41+
}
42+
43+
return .init(
2844
altitude: location.altitude,
2945
course: location.course,
3046
horizontalAccuracy: location.horizontalAccuracy,
3147
latitude: location.coordinate.latitude,
3248
longitude: location.coordinate.longitude,
3349
speed: location.speed,
3450
timestamp: location.timestamp.millisecondsSinceUnixEpoch,
35-
verticalAccuracy: location.verticalAccuracy
51+
verticalAccuracy: location.verticalAccuracy,
52+
magneticHeading: mHeading,
53+
trueHeading: tHeading,
54+
headingAccuracy: hAccuracy
3655
)
3756
}
3857
}

IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {
3939
private let servicesChecker: IONGLOCServicesChecker
4040

4141
private var isMonitoringLocation = false
42+
private var lastLocation: CLLocation?
43+
private var lastHeading: CLHeading?
4244

4345
// Flag used to indicate that the location request has timed out.
4446
// When `true`, the wrapper ignores any location updates received from CLLocationManager.
@@ -52,6 +54,7 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {
5254

5355
super.init()
5456
locationManager.delegate = self
57+
locationManager.headingFilter = 1.0
5558
}
5659

5760
public func requestAuthorisation(withType authorisationType: IONGLOCAuthorisationRequestType) {
@@ -62,6 +65,7 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {
6265
timeoutTriggered = false
6366
isMonitoringLocation = true
6467
locationManager.startUpdatingLocation()
68+
locationManager.startUpdatingHeading()
6569
self.startTimer(timeout: options.timeout)
6670
}
6771

@@ -72,11 +76,13 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {
7276

7377
isMonitoringLocation = true
7478
locationManager.startUpdatingLocation()
79+
locationManager.startUpdatingHeading()
7580
}
7681

7782
public func stopMonitoringLocation() {
7883
isMonitoringLocation = false
7984
locationManager.stopUpdatingLocation()
85+
locationManager.stopUpdatingHeading()
8086
}
8187

8288
public func requestSingleLocation(options: IONGLOCRequestOptionsModel) {
@@ -137,16 +143,29 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate {
137143

138144
timeoutCancellable?.cancel()
139145
timeoutCancellable = nil
140-
guard let latestLocation = locations.last else {
146+
guard let lastLocation = locations.last else {
141147
currentLocation = nil
148+
self.lastLocation = nil
149+
lastHeading = nil
142150
return
143151
}
144-
currentLocation = IONGLOCPositionModel.create(from: latestLocation)
152+
153+
self.lastLocation = lastLocation
154+
let currentHeading = isMonitoringLocation ? lastHeading : nil
155+
currentLocation = IONGLOCPositionModel.create(from: lastLocation, heading: currentHeading)
145156
}
146157

147158
public func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
148159
timeoutCancellable?.cancel()
149160
timeoutCancellable = nil
161+
150162
currentLocation = nil
163+
lastLocation = nil
164+
lastHeading = nil
165+
}
166+
167+
public func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
168+
guard self.lastLocation != nil else { return }
169+
lastHeading = newHeading
151170
}
152171
}

IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,117 @@ final class IONGLOCManagerWrapperTests: XCTestCase {
314314
// Then
315315
waitForExpectations(timeout: 1.0)
316316
}
317+
318+
// MARK: - Heading Tests
319+
320+
func test_startMonitoringLocation_startsUpdatingHeading() {
321+
// Given
322+
XCTAssertFalse(locationManager.didStartUpdatingHeading)
323+
324+
// When
325+
sut.startMonitoringLocation()
326+
327+
// Then
328+
XCTAssertTrue(locationManager.didStartUpdatingHeading)
329+
}
330+
331+
func test_startMonitoringLocationWithOptions_startsUpdatingHeading() {
332+
// Given
333+
XCTAssertFalse(locationManager.didStartUpdatingHeading)
334+
335+
// When
336+
let options = IONGLOCRequestOptionsModel(timeout: 1000)
337+
sut.startMonitoringLocation(options: options)
338+
339+
// Then
340+
XCTAssertTrue(locationManager.didStartUpdatingHeading)
341+
}
342+
343+
func test_stopMonitoringLocation_stopsUpdatingHeading() {
344+
// Given
345+
sut.startMonitoringLocation()
346+
XCTAssertTrue(locationManager.didStartUpdatingHeading)
347+
348+
// When
349+
sut.stopMonitoringLocation()
350+
351+
// Then
352+
XCTAssertFalse(locationManager.didStartUpdatingHeading)
353+
}
354+
355+
func test_locationUpdateWithHeading_includesHeadingInPositionModel() {
356+
// Given
357+
sut.startMonitoringLocation()
358+
359+
let expectedLocation = CLLocation(latitude: 37.7749, longitude: -122.4194)
360+
let expectedHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0, headingAccuracy: 1.0)
361+
let expectedPosition = IONGLOCPositionModel.create(from: expectedLocation, heading: expectedHeading)
362+
let expectation = expectation(description: "Location with heading updated.")
363+
364+
sut.currentLocationPublisher
365+
.sink(receiveCompletion: { _ in }, receiveValue: { position in
366+
if position.magneticHeading == expectedPosition.magneticHeading && position.trueHeading == expectedPosition.trueHeading {
367+
XCTAssertEqual(position, expectedPosition)
368+
expectation.fulfill()
369+
}
370+
})
371+
.store(in: &cancellables)
372+
373+
// When
374+
locationManager.updateLocation(to: [CLLocation(latitude: 0, longitude: 0)])
375+
locationManager.updateHeading(to: expectedHeading)
376+
locationManager.updateLocation(to: [expectedLocation])
377+
378+
// Then
379+
waitForExpectations(timeout: 1.0)
380+
}
381+
382+
func test_headingUpdateWithoutLocation_doesNotUpdatePositionModel() {
383+
// Given
384+
let heading = createMockHeading(magneticHeading: 180.0, trueHeading: 182.0, headingAccuracy: 1.0)
385+
var updateCount = 0
386+
let expectation = expectation(description: "No update should occur.")
387+
expectation.isInverted = true
388+
389+
sut.currentLocationPublisher
390+
.sink(receiveCompletion: { _ in }, receiveValue: { _ in
391+
updateCount += 1
392+
expectation.fulfill()
393+
})
394+
.store(in: &cancellables)
395+
396+
// When
397+
locationManager.updateHeading(to: heading)
398+
399+
// Then
400+
waitForExpectations(timeout: 0.5)
401+
XCTAssertEqual(updateCount, 0)
402+
}
403+
404+
405+
406+
func test_headingFilterIsSetToOneDegree() {
407+
// Then
408+
XCTAssertEqual(locationManager.headingFilter, 1.0)
409+
}
410+
411+
func test_positionModelWithoutHeading_hasDefaultHeadingValues() {
412+
// Given
413+
let location = CLLocation(latitude: 37.7749, longitude: -122.4194)
414+
let expectedPosition = IONGLOCPositionModel.create(from: location)
415+
let expectation = expectation(description: "Location without heading updated.")
416+
417+
validateCurrentLocationPublisher(expectation, expectedPosition)
418+
419+
// When
420+
locationManager.updateLocation(to: [location])
421+
422+
// Then
423+
waitForExpectations(timeout: 1.0)
424+
XCTAssertNil(expectedPosition.magneticHeading)
425+
XCTAssertNil(expectedPosition.trueHeading)
426+
XCTAssertNil(expectedPosition.headingAccuracy)
427+
}
317428
}
318429

319430
private extension IONGLOCManagerWrapperTests {
@@ -353,6 +464,14 @@ private extension IONGLOCManagerWrapperTests {
353464
}
354465
.store(in: &cancellables)
355466
}
467+
468+
func createMockHeading(magneticHeading: Double, trueHeading: Double, headingAccuracy: Double) -> CLHeading {
469+
let heading = MockCLHeading()
470+
heading.mockMagneticHeading = magneticHeading
471+
heading.mockTrueHeading = trueHeading
472+
heading.mockHeadingAccuracy = headingAccuracy
473+
return heading
474+
}
356475
}
357476

358477
private extension CLLocationManager {
@@ -363,3 +482,21 @@ private extension CLLocationManager {
363482
private enum MockLocationUpdateError: Error {
364483
case locationUpdateFailed
365484
}
485+
486+
private class MockCLHeading: CLHeading {
487+
var mockMagneticHeading: Double = 0.0
488+
var mockTrueHeading: Double = 0.0
489+
var mockHeadingAccuracy: Double = 0.0
490+
491+
override var magneticHeading: Double {
492+
mockMagneticHeading
493+
}
494+
495+
override var trueHeading: Double {
496+
mockTrueHeading
497+
}
498+
499+
override var headingAccuracy: Double {
500+
mockHeadingAccuracy
501+
}
502+
}

IONGeolocationLibTests/MockCLLocationManager.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,23 @@ class MockCLLocationManager: CLLocationManager {
55
private(set) var didCallRequestLocation = false
66
private(set) var didCallRequestWhenInUseAuthorization = false
77
private(set) var didStartUpdatingLocation = false
8+
private(set) var didStartUpdatingHeading = false
89
private(set) var mockAuthorizationStatus: CLAuthorizationStatus = .notDetermined
10+
private(set) var mockHeadingFilter: CLLocationDegrees = kCLHeadingFilterNone
911

1012
override var authorizationStatus: CLAuthorizationStatus {
1113
mockAuthorizationStatus
1214
}
1315

16+
override var headingFilter: CLLocationDegrees {
17+
get {
18+
mockHeadingFilter
19+
}
20+
set {
21+
mockHeadingFilter = newValue
22+
}
23+
}
24+
1425
override func startUpdatingLocation() {
1526
didStartUpdatingLocation = true
1627
}
@@ -19,6 +30,14 @@ class MockCLLocationManager: CLLocationManager {
1930
didStartUpdatingLocation = false
2031
}
2132

33+
override func startUpdatingHeading() {
34+
didStartUpdatingHeading = true
35+
}
36+
37+
override func stopUpdatingHeading() {
38+
didStartUpdatingHeading = false
39+
}
40+
2241
override func requestLocation() {
2342
didCallRequestLocation = true
2443
}
@@ -40,6 +59,10 @@ class MockCLLocationManager: CLLocationManager {
4059
delegate?.locationManager?(self, didUpdateLocations: locations)
4160
}
4261

62+
func updateHeading(to heading: CLHeading) {
63+
delegate?.locationManager?(self, didUpdateHeading: heading)
64+
}
65+
4366
func failWhileUpdatingLocation(_ error: Error) {
4467
delegate?.locationManager?(self, didFailWithError: error)
4568
}

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ It returns the device's latest fetched location position. It can be `nil` if the
117117
- `speed`: Instantaneous speed of the device, measured in meters per second (m/s);
118118
- `timestamp`: Time at which this location was determined, measured in milliseconds (ms) elapsed since the UNIX epoch (Jan 1, 1970);
119119
- `verticalAccuracy`: Validity of the altitude values and their estimated uncertainty, measured in meters (m).
120+
- `magneticHeading`: The heading (measured in degrees) relative to magnetic north.
121+
- `trueHeading`: The heading (measured in degrees) relative to true north.
122+
- `headingAccuracy`: The maximum deviation (measured in degrees) between the reported heading and the true geomagnetic heading.
123+
120124

121125
#### Current Location Publisher
122126

@@ -231,7 +235,10 @@ Location updates are delivered as `IONGLOCPositionModel` objects:
231235
"verticalAccuracy": 10.0,
232236
"course": 180.0,
233237
"speed": 0.0,
234-
"timestamp": 1641034800000
238+
"timestamp": 1641034800000,
239+
"magneticHeading": 5.0,
240+
"trueHeading": 5.0,
241+
"headingAccuracy": 0.0
235242
}
236243
```
237244

0 commit comments

Comments
 (0)