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
This commit is contained in:
jayanezj committed 2026-10-25 15:58:05 +01:00
1 parent db90ad9a0e
commit 3f351296b8
18 files changed
+1030 -38

No files matched your search

@@ -0,0 +1,158 @@
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))
}
}
+199
View File
@@ -0,0 +1,199 @@
import Foundation
import Observation
@MainActor
@Observable
final class LocationModel {
private(set) var availability: LocationAvailability
private(set) var continuousRequested = false
private(set) var singleStatus: SingleCaptureStatus = .idle
private(set) var lastFix: LocationFix?
private(set) var lastFixOrigin: LocationFixOrigin?
private(set) var errorMessage: String?
private(set) var continuousError: String?
@ObservationIgnored private let provider: any LocationProviding
@ObservationIgnored private let readAccuracy: () throws -> LocationAccuracy
@ObservationIgnored private var permissionRequested = false
@ObservationIgnored private var continuousRequest: Int?
@ObservationIgnored private var singleRequest: Int?
@ObservationIgnored private var nextRequest = 0
@ObservationIgnored private var continuousBlocked = false
@ObservationIgnored private var continuousAccuracy: LocationAccuracy = .medium
@ObservationIgnored private var singleAccuracy: LocationAccuracy = .medium
init(provider: any LocationProviding, readAccuracy: @escaping () throws -> LocationAccuracy = { .medium }) {
self.provider = provider
self.readAccuracy = readAccuracy
availability = provider.availability
provider.onEvent = { [weak self] in self?.receive($0) }
}
var status: String {
if availability.services == .disabled { return "Location Services disabled" }
if availability.permission == .denied { return "Location permission denied" }
if availability.permission == .restricted { return "Location permission restricted" }
if continuousError != nil { return "Location unavailable" }
if continuousRequested || singleStatus == .waiting {
if !availability.canCapture { return "Waiting for location authorization" }
return lastFix == nil ? "Waiting for location" : "Location capture active"
}
return "Paused"
}
func startContinuous(accuracy: LocationAccuracy? = nil) {
guard !continuousRequested else { return }
guard let selected = selectedAccuracy(accuracy) else { return }
preparePermissionRequest()
continuousRequested = true
continuousAccuracy = selected
continuousBlocked = false
continuousError = nil
errorMessage = nil
provider.refreshAvailability()
reconcile()
}
func pause() {
continuousRequested = false
stopContinuous()
continuousError = nil
continuousBlocked = false
}
func captureOnce(accuracy: LocationAccuracy? = nil) {
guard singleStatus != .waiting else { return }
guard let selected = selectedAccuracy(accuracy) else {
singleStatus = .failed
return
}
preparePermissionRequest()
singleStatus = .waiting
singleAccuracy = selected
errorMessage = nil
provider.refreshAvailability()
reconcile()
}
func cancelSingle() {
guard singleStatus == .waiting else { return }
singleRequest = nil
singleStatus = .cancelled
provider.cancelSingle()
}
func shutdown() {
pause()
cancelSingle()
}
func refreshAvailability() { provider.refreshAvailability() }
private func preparePermissionRequest() {
// A new explicit attempt may retry an undetermined request; status refreshes never do so on their own.
if !continuousRequested && singleStatus != .waiting { permissionRequested = false }
}
private func selectedAccuracy(_ requested: LocationAccuracy?) -> LocationAccuracy? {
do {
return try requested ?? readAccuracy()
} catch {
errorMessage = "Saved tracking preferences could not be read. Review Settings before capture."
return nil
}
}
private func reconcile() {
guard continuousRequested || singleStatus == .waiting else { return }
if availability.services == .disabled || [.denied, .restricted].contains(availability.permission) {
stopContinuous()
errorMessage = availability.services == .disabled
? "Enable Location Services in System Settings to capture locations."
: "Location access is denied or restricted. Review Location Services in System Settings."
if singleStatus == .waiting {
singleRequest = nil
singleStatus = .failed
provider.cancelSingle()
}
return
}
guard availability.services == .enabled else { return }
if availability.permission == .notDetermined {
guard !permissionRequested else { return }
permissionRequested = true
provider.requestAuthorization()
return
}
guard availability.canCapture else { return }
if continuousRequested && continuousRequest == nil && !continuousBlocked {
nextRequest += 1
continuousRequest = nextRequest
provider.startContinuous(accuracy: continuousAccuracy, request: nextRequest)
}
if singleStatus == .waiting && singleRequest == nil {
nextRequest += 1
singleRequest = nextRequest
provider.requestSingle(accuracy: singleAccuracy, request: nextRequest)
}
}
private func stopContinuous() {
continuousRequest = nil
provider.stopContinuous()
}
private func receive(_ event: LocationEvent) {
switch event {
case .availability(let value):
if availability != value {
continuousBlocked = false
continuousError = nil
errorMessage = nil
}
availability = value
if !value.canCapture { stopContinuous() }
reconcile()
case .locations(let kind, let request, let fixes):
guard accepts(kind, request: request), let fix = fixes.max(by: { $0.timestamp < $1.timestamp }) else {
return
}
lastFix = fix
lastFixOrigin = kind == .single ? .single : .continuous
if kind == .single {
singleStatus = .received
singleRequest = nil
} else {
continuousError = nil
}
errorMessage = nil
case .failure(let kind, let request, let reason, let fallback):
guard accepts(kind, request: request) else { return }
handleFailure(kind, reason: reason, fallback: fallback)
}
}
private func accepts(_ kind: LocationRequestKind, request: Int) -> Bool {
kind == .single ? singleStatus == .waiting && singleRequest == request
: continuousRequested && continuousRequest == request
}
private func handleFailure(_ kind: LocationRequestKind, reason: LocationFailure, fallback: LocationFix?) {
if kind == .continuous {
continuousError = reason.message
if reason == .permissionDenied {
continuousBlocked = true
stopContinuous()
}
return
}
singleRequest = nil
if reason != .permissionDenied, let known = fallback ?? lastFix {
lastFix = known
lastFixOrigin = .lastKnown
singleStatus = .lastKnown
} else {
singleStatus = .failed
}
errorMessage = reason.message
}
}
+102
View File
@@ -0,0 +1,102 @@
import CoreLocation
import Foundation
enum LocationPermission: String {
case notDetermined = "Not requested"
case authorized = "Authorized"
case denied = "Denied"
case restricted = "Restricted"
init(_ status: CLAuthorizationStatus) {
switch status {
case .authorizedAlways: self = .authorized
case .notDetermined: self = .notDetermined
case .denied: self = .denied
default: self = .restricted
}
}
}
enum LocationServices: String {
case checking = "Checking"
case enabled = "Enabled"
case disabled = "Disabled"
}
struct LocationAvailability: Equatable {
var permission: LocationPermission
var services: LocationServices
var canCapture: Bool { permission == .authorized && services == .enabled }
}
struct LocationFix: Equatable {
let latitude: Double
let longitude: Double
let timestamp: Date
let horizontalAccuracy: Double
let altitude: Double?
let speed: Double?
let course: Double?
init?(_ location: CLLocation) {
guard CLLocationCoordinate2DIsValid(location.coordinate),
location.horizontalAccuracy.isFinite, location.horizontalAccuracy >= 0,
location.timestamp.timeIntervalSinceReferenceDate.isFinite else { return nil }
latitude = location.coordinate.latitude
longitude = location.coordinate.longitude
timestamp = location.timestamp
horizontalAccuracy = location.horizontalAccuracy
altitude = location.verticalAccuracy >= 0 && location.altitude.isFinite ? location.altitude : nil
speed = location.speed >= 0 && location.speed.isFinite ? location.speed : nil
course = (0..<360).contains(location.course) && location.course.isFinite ? location.course : nil
}
}
enum LocationFailure: Equatable {
case permissionDenied
case unavailable
case other(Int)
var message: String {
switch self {
case .permissionDenied: "Location access is denied. Review Location Services in System Settings."
case .unavailable: "No location is available. Try again when Location Services can obtain a fix."
case .other(let code): "Location could not be obtained (error \(code))."
}
}
}
enum LocationRequestKind { case continuous, single }
enum LocationFixOrigin: String {
case continuous = "Continuous capture"
case single = "Single capture"
case lastKnown = "Last-known location; no new fix"
}
enum SingleCaptureStatus: String {
case idle = "Idle"
case waiting = "Waiting for location"
case received = "Location received"
case lastKnown = "Last-known location used; no new fix"
case failed = "No location obtained"
case cancelled = "Cancelled"
}
enum LocationEvent {
case availability(LocationAvailability)
case locations(LocationRequestKind, request: Int, fixes: [LocationFix])
case failure(LocationRequestKind, request: Int, reason: LocationFailure, fallback: LocationFix?)
}
@MainActor
protocol LocationProviding: AnyObject {
var availability: LocationAvailability { get }
var onEvent: ((LocationEvent) -> Void)? { get set }
func refreshAvailability()
func requestAuthorization()
func startContinuous(accuracy: LocationAccuracy, request: Int)
func stopContinuous()
func requestSingle(accuracy: LocationAccuracy, request: Int)
func cancelSingle()
}