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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,5 @@ fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output

build/
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 2.0.0

### Breaking Changes
- The method `requestSingleLocation()` was replaced with `requestSingleLocation(options: IONGLOCRequestOptionsModel)`.
This change allows adding new configuration parameters in the future without breaking changes.

### Additions
- Added `IONGLOCRequestOptionsModel` to configure timeout (and future parameters).
- Added overload `startMonitoringLocation(options: IONGLOCRequestOptionsModel)`.

### Fixes
- Introduced timeout handling for both `requestSingleLocation` and `startMonitoringLocation`.

## 1.0.2

### Fixes
Expand Down
4 changes: 4 additions & 0 deletions IONGeolocationLib.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
1DE511302EABBBFC0096C679 /* IONGLOCRequestOptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE5112F2EABBBF70096C679 /* IONGLOCRequestOptionsModel.swift */; };
752B49212D11B262002EA65D /* IONGLOCManagerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752B49202D11B262002EA65D /* IONGLOCManagerWrapper.swift */; };
752B49232D11D421002EA65D /* IONGLOCAuthorisationRequestType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752B49222D11D421002EA65D /* IONGLOCAuthorisationRequestType.swift */; };
752B49262D11D440002EA65D /* IONGLOCAuthorisation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752B49252D11D440002EA65D /* IONGLOCAuthorisation.swift */; };
Expand All @@ -29,6 +30,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
1DE5112F2EABBBF70096C679 /* IONGLOCRequestOptionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IONGLOCRequestOptionsModel.swift; sourceTree = "<group>"; };
752B49202D11B262002EA65D /* IONGLOCManagerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IONGLOCManagerWrapper.swift; sourceTree = "<group>"; };
752B49222D11D421002EA65D /* IONGLOCAuthorisationRequestType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IONGLOCAuthorisationRequestType.swift; sourceTree = "<group>"; };
752B49252D11D440002EA65D /* IONGLOCAuthorisation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IONGLOCAuthorisation.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -94,6 +96,7 @@
752B49252D11D440002EA65D /* IONGLOCAuthorisation.swift */,
752B49222D11D421002EA65D /* IONGLOCAuthorisationRequestType.swift */,
752B49272D11D46D002EA65D /* IONGLOCPositionModel.swift */,
1DE5112F2EABBBF70096C679 /* IONGLOCRequestOptionsModel.swift */,
);
path = IONGeolocationLib;
sourceTree = "<group>";
Expand Down Expand Up @@ -243,6 +246,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1DE511302EABBBFC0096C679 /* IONGLOCRequestOptionsModel.swift in Sources */,
752B49262D11D440002EA65D /* IONGLOCAuthorisation.swift in Sources */,
752B49282D11D46D002EA65D /* IONGLOCPositionModel.swift in Sources */,
752B49232D11D421002EA65D /* IONGLOCAuthorisationRequestType.swift in Sources */,
Expand Down
Binary file removed IONGeolocationLib.zip
Binary file not shown.
9 changes: 9 additions & 0 deletions IONGeolocationLib/IONGLOCRequestOptionsModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

public struct IONGLOCRequestOptionsModel {
let timeout: Int

public init(timeout: Int? = nil) {
self.timeout = timeout ?? 5000
}
}
6 changes: 4 additions & 2 deletions IONGeolocationLib/Publishers/IONGLOCManagerProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,23 @@ public protocol IONGLOCAuthorisationHandler {

public enum IONGLOCLocationError: Error {
case locationUnavailable
case timeout
case other(_ error: Error)
}

public protocol IONGLOCLocationHandler {
var currentLocation: IONGLOCPositionModel? { get }
var currentLocationPublisher: AnyPublisher<IONGLOCPositionModel, IONGLOCLocationError> { get }

var locationTimeoutPublisher: AnyPublisher<IONGLOCLocationError, Never> { get }
func updateConfiguration(_ configuration: IONGLOCConfigurationModel)
}

public protocol IONGLOCSingleLocationHandler: IONGLOCLocationHandler {
func requestSingleLocation()
func requestSingleLocation(options: IONGLOCRequestOptionsModel)
}

public protocol IONGLOCMonitorLocationHandler: IONGLOCLocationHandler {
func startMonitoringLocation(options: IONGLOCRequestOptionsModel)
func startMonitoringLocation()
func stopMonitoringLocation()
}
Expand Down
69 changes: 61 additions & 8 deletions IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ public typealias IONGLOCService = IONGLOCServicesChecker & IONGLOCAuthorisationH

public struct IONGLOCServicesValidator: IONGLOCServicesChecker {
public init() {}

public func areLocationServicesEnabled() -> Bool {
CLLocationManager.locationServicesEnabled()
}
Expand All @@ -16,6 +16,7 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {
public var authorisationStatusPublisher: Published<IONGLOCAuthorisation>.Publisher { $authorisationStatus }

@Published public var currentLocation: IONGLOCPositionModel?
private var timeoutCancellable: AnyCancellable?
public var currentLocationPublisher: AnyPublisher<IONGLOCPositionModel, IONGLOCLocationError> {
Publishers.Merge($currentLocation, currentLocationForceSubject)
.dropFirst() // ignore the first value as it's the one set on the constructor.
Expand All @@ -27,13 +28,23 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {
.eraseToAnyPublisher()
}

public var locationTimeoutPublisher: AnyPublisher<IONGLOCLocationError, Never> {
locationTimeoutSubject.eraseToAnyPublisher()
}

private let currentLocationForceSubject = PassthroughSubject<IONGLOCPositionModel?, Never>()

private let locationTimeoutSubject = PassthroughSubject<IONGLOCLocationError, Never>()

private let locationManager: CLLocationManager
private let servicesChecker: IONGLOCServicesChecker

private var isMonitoringLocation = false

// Flag used to indicate that the location request has timed out.
// When `true`, the wrapper ignores any location updates received from CLLocationManager.
// This prevents "stale" or "ghost" events from being sent to subscribers after the timeout has occurred.
private var timeoutTriggered = false

public init(locationManager: CLLocationManager = .init(), servicesChecker: IONGLOCServicesChecker = IONGLOCServicesValidator()) {
self.locationManager = locationManager
self.servicesChecker = servicesChecker
Expand All @@ -46,8 +57,19 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {
public func requestAuthorisation(withType authorisationType: IONGLOCAuthorisationRequestType) {
authorisationType.requestAuthorization(using: locationManager)
}


public func startMonitoringLocation(options: IONGLOCRequestOptionsModel) {
timeoutTriggered = false
isMonitoringLocation = true
locationManager.startUpdatingLocation()
self.startTimer(timeout: options.timeout)
}

public func startMonitoringLocation() {
guard !timeoutTriggered else {
return
}

isMonitoringLocation = true
locationManager.startUpdatingLocation()
}
Expand All @@ -57,17 +79,40 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService {
locationManager.stopUpdatingLocation()
}

public func requestSingleLocation() {
public func requestSingleLocation(options: IONGLOCRequestOptionsModel) {
timeoutTriggered = false
// If monitoring is active meaning the location service is already running
// and calling .requestLocation() will not trigger a new location update,
// we can just return the current location.
if isMonitoringLocation, let location = currentLocation {
currentLocationForceSubject.send(location)
return
}
locationManager.requestLocation()

self.locationManager.requestLocation()
self.startTimer(timeout: options.timeout)
}


private func startTimer(timeout: Int) {
timeoutCancellable?.cancel()
timeoutCancellable = nil
timeoutCancellable = Just(())
.delay(for: .milliseconds(timeout), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.timeoutTriggered = true
self.locationTimeoutSubject.send(.timeout)

if self.isMonitoringLocation {
self.isMonitoringLocation = false
self.stopMonitoringLocation()
}

self.timeoutCancellable?.cancel()
self.timeoutCancellable = nil
}
}

public func updateConfiguration(_ configuration: IONGLOCConfigurationModel) {
locationManager.desiredAccuracy = configuration.enableHighAccuracy ? kCLLocationAccuracyBest : kCLLocationAccuracyThreeKilometers
configuration.minimumUpdateDistanceInMeters.map {
Expand All @@ -84,16 +129,24 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate {
public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
authorisationStatus = manager.currentAuthorisationValue
}

public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard !timeoutTriggered else {
return
}

timeoutCancellable?.cancel()
timeoutCancellable = nil
guard let latestLocation = locations.last else {
currentLocation = nil
return
}
currentLocation = IONGLOCPositionModel.create(from: latestLocation)
}

public func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
timeoutCancellable?.cancel()
timeoutCancellable = nil
currentLocation = nil
}
}
44 changes: 43 additions & 1 deletion IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ final class IONGLOCManagerWrapperTests: XCTestCase {
// Then
XCTAssertFalse(locationManager.didStartUpdatingLocation)
}

func test_startMonitoringLocation_timeoutFires() {
// Given
let expectation = self.expectation(description: "Timeout should fire for monitoring location")

// When
let options = IONGLOCRequestOptionsModel(timeout: 1)
sut.startMonitoringLocation(options: options)

// Then
validateLocationTimeoutPublisher(expectation)

waitForExpectations(timeout: 1.0)
}

// MARK: - 'requestSingleLocation' tests

Expand All @@ -131,11 +145,25 @@ final class IONGLOCManagerWrapperTests: XCTestCase {
XCTAssertFalse(locationManager.didCallRequestLocation)

// When
sut.requestSingleLocation()
sut.requestSingleLocation(options: IONGLOCRequestOptionsModel())

// Then
XCTAssertTrue(locationManager.didCallRequestLocation)
}

func test_requestSingleLocation_timeoutFires() {
// Given
let expectation = self.expectation(description: "Timeout should fire for single location request")

// When
let options = IONGLOCRequestOptionsModel(timeout: 1)
sut.requestSingleLocation(options: options)

// Then
validateLocationTimeoutPublisher(expectation)

waitForExpectations(timeout: 1.0)
}

// MARK: - 'updateConfiguration' tests

Expand Down Expand Up @@ -311,6 +339,20 @@ private extension IONGLOCManagerWrapperTests {
}
.store(in: &cancellables)
}

func validateLocationTimeoutPublisher(_ expectation: XCTestExpectation) {
sut.locationTimeoutPublisher
.sink { error in
switch error {
case .timeout:
expectation.fulfill()
break
default:
XCTFail("Expected timeout error, got \(error)")
}
}
.store(in: &cancellables)
}
}

private extension CLLocationManager {
Expand Down
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ All the library's features are split in 4 different protocols. Each are detailed
- `IONGLOCAuthorisationHandler`
- `IONGLOCSingleLocationHandler`
- `IONGLOCMonitorLocationHandler`
- `IONGLOCRequestOptionsModel`

There's also the typealias `IONGLOCService` that merges all protocols together. Its concrete implementation is achieved by the `IONGLOCManagerWrapper` class.

Expand Down Expand Up @@ -125,6 +126,15 @@ var currentLocationPublisher: AnyPublisher<IONGLOCPositionModel, IONGLOCLocation

It returns a publisher that delivers all location updates to whoever subscribes to it. The `currentLocation` values are the elements that can be emitted by `currentLocationPublisher`.

#### Location Timeout Publisher

```swift
var locationTimeoutPublisher: AnyPublisher<IONGLOCLocationError, Never>
```

It returns a publisher that emits a `.timeout` event when a request exceeds the specified timeout in `IONGLOCRequestOptionsModel`.


#### Update the Location Manager's Configuration

```swift
Expand All @@ -135,6 +145,22 @@ Updates two properties that condition how location update events are generated:
- `enableHighAccuracy`: Boolean value that indicates if the app wants location data accuracy to be at its best or not. It needs to be explicitly mentioned by the method callers
- `minimumUpdateDistanceInMeters`: Minimum distance the device must move horizontally before an update event is generated, measured in meters (m). As it's optional, it can be omitted by the method callers.

### `IONGLOCRequestOptionsModel`

Used to configure options for location requests.

- `timeout`: Maximum duration (ms) to wait for a location update. Default is `5000`.

```swift
let options = IONGLOCRequestOptionsModel(timeout: 10000)

// Single location
locationService.requestSingleLocation(options: options)

// Continuous monitoring
locationService.startMonitoringLocation(options: options)
```

### `IONGLOCSingleLocationHandler`

It's responsible to trigger one-time deliveries of the device's current location. It's composed by the following:
Expand All @@ -143,11 +169,14 @@ It's responsible to trigger one-time deliveries of the device's current location
#### Request Device's Current Location

```swift
func requestSingleLocation()
func requestSingleLocation(options: IONGLOCRequestOptionsModel)
```

The method returns immediately. By calling it, it triggers an update to `currentLocation` and a new element delivery by `currentLocationPublisher`.

**Note:** The signature of `requestSingleLocation` has changed.
You now need to pass an `IONGLOCRequestOptionsModel` to configure options such as `timeout`.


### `IONGLOCMonitorLocationHandler`

Expand All @@ -157,19 +186,25 @@ It's responsible for the continuous generation of updates that report the device

#### Start Monitoring the Device's Position

```swift
func startMonitoringLocation(options: IONGLOCRequestOptionsModel)
```
- uses the provided options, e.g., a timeout.

```swift
func startMonitoringLocation()
```
- uses the legacy behavior without any options.

The method returns immediately. By calling it, it triggers an update to `currentLocation` and signals `currentLocationPublisher` to continuously emit relevant location updates.
Both methods return immediately. By calling them, they trigger an update to `currentLocation` and signal `currentLocationPublisher` to continuously emit relevant location updates.

#### Stop Monitoring the Device's Position

```swift
func stopMonitoringLocation()
```

The method should be called whenever you no longer need to received location-related events.
The method should be called whenever you no longer need to receive location-related events.

## Error Handling

Expand All @@ -178,6 +213,7 @@ The library uses `IONGLOCLocationError` for error handling regarding location po
```swift
enum IONGLOCLocationError: Error {
case locationUnavailable
case timeout
case other(_ error: Error)
}
```
Expand Down
Loading
Loading