Files
traccar-client-macos/TraccarClient/Location/CoreLocationProvider.swift
T
jayanezj 3f351296b8 feat: add native local location capture (#4)
Add explicit permission handling, independent continuous and single capture,
location status and deterministic CI coverage.

Final decisions and validation:
https://github.com/jayanezj/traccar-client-macos/issues/4#issuecomment-6399189792
2026-10-25 15:58:05 +01:00

159 lines
6.1 KiB
Swift

import CoreLocation
import Foundation
@MainActor
final class CoreLocationProvider: LocationProviding {
private(set) var availability: LocationAvailability
var onEvent: ((LocationEvent) -> Void)?
private let permissionManager: CLLocationManager
private var permissionDelegate: NativeLocationDelegate?
private var continuousManager: CLLocationManager?
private var continuousDelegate: NativeLocationDelegate?
private var singleManager: CLLocationManager?
private var singleDelegate: NativeLocationDelegate?
private var availabilityRevision = 0
private var continuousRequest: Int?
private var singleRequest: Int?
init() {
permissionManager = CLLocationManager()
availability = LocationAvailability(
permission: LocationPermission(permissionManager.authorizationStatus), services: .checking
)
permissionDelegate = NativeLocationDelegate { [weak self] _ in self?.refreshAvailability() }
permissionManager.delegate = permissionDelegate
refreshAvailability()
}
func refreshAvailability() {
availability.permission = LocationPermission(permissionManager.authorizationStatus)
onEvent?(.availability(availability))
availabilityRevision += 1
let revision = availabilityRevision
Task { [weak self] in
// The synchronous service-status query may block; do not perform it on the UI thread.
let enabled = await Task.detached { CLLocationManager.locationServicesEnabled() }.value
guard let self, revision == availabilityRevision else { return }
availability.services = enabled ? .enabled : .disabled
onEvent?(.availability(availability))
}
}
func requestAuthorization() { permissionManager.requestWhenInUseAuthorization() }
func startContinuous(accuracy: LocationAccuracy, request: Int) {
stopContinuous()
let manager = CLLocationManager()
continuousManager = manager
continuousRequest = request
continuousDelegate = makeDelegate(kind: .continuous, request: request)
manager.delegate = continuousDelegate
manager.desiredAccuracy = Self.desiredAccuracy(accuracy)
manager.distanceFilter = kCLDistanceFilterNone
manager.startUpdatingLocation()
}
func stopContinuous() {
continuousRequest = nil
continuousManager?.stopUpdatingLocation()
continuousManager?.delegate = nil
continuousManager = nil
continuousDelegate = nil
}
func requestSingle(accuracy: LocationAccuracy, request: Int) {
cancelSingle()
let manager = CLLocationManager()
singleManager = manager
singleRequest = request
singleDelegate = makeDelegate(kind: .single, request: request)
manager.delegate = singleDelegate
manager.desiredAccuracy = Self.desiredAccuracy(accuracy)
manager.requestLocation()
}
func cancelSingle() {
singleRequest = nil
singleManager?.stopUpdatingLocation()
singleManager?.delegate = nil
singleManager = nil
singleDelegate = nil
}
static func desiredAccuracy(_ accuracy: LocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .low: kCLLocationAccuracyKilometer
case .medium: kCLLocationAccuracyHundredMeters
case .high: kCLLocationAccuracyBest
}
}
private func makeDelegate(kind: LocationRequestKind, request: Int) -> NativeLocationDelegate {
NativeLocationDelegate { [weak self] event in
guard let self else { return }
switch event {
case .authorization:
refreshAvailability()
case .locations(let locations):
deliver(locations, kind: kind, request: request)
case .failure(let error):
deliver(error, kind: kind, request: request)
}
}
}
private func deliver(_ locations: [CLLocation], kind: LocationRequestKind, request: Int) {
guard isCurrent(kind, request: request) else { return }
let fixes = locations.compactMap(LocationFix.init)
if fixes.isEmpty {
onEvent?(.failure(kind, request: request, reason: .unavailable, fallback: nil))
} else {
onEvent?(.locations(kind, request: request, fixes: fixes))
}
if kind == .single && singleRequest == request { cancelSingle() }
}
private func deliver(_ error: any Error, kind: LocationRequestKind, request: Int) {
guard isCurrent(kind, request: request) else { return }
let reason: LocationFailure
switch (error as? CLError)?.code {
case .denied: reason = .permissionDenied
case .locationUnknown: reason = .unavailable
default: reason = .other((error as NSError).code)
}
let fallback = singleManager?.location.flatMap(LocationFix.init)
onEvent?(.failure(kind, request: request, reason: reason, fallback: fallback))
if kind == .single && singleRequest == request { cancelSingle() }
if reason == .permissionDenied { refreshAvailability() }
}
private func isCurrent(_ kind: LocationRequestKind, request: Int) -> Bool {
kind == .single ? singleRequest == request : continuousRequest == request
}
}
private enum NativeLocationEvent {
case authorization
case locations([CLLocation])
case failure(any Error)
}
// CLLocationManager is created on MainActor and delivers delegates on that thread's run loop.
@MainActor
private final class NativeLocationDelegate: NSObject, @preconcurrency CLLocationManagerDelegate {
private let receive: (NativeLocationEvent) -> Void
init(receive: @escaping (NativeLocationEvent) -> Void) { self.receive = receive }
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { receive(.authorization) }
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
receive(.locations(locations))
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
receive(.failure(error))
}
}