419 lines
15 KiB
Swift
419 lines
15 KiB
Swift
import Foundation
|
|
import CoreLocation
|
|
import CoreMotion
|
|
import UIKit
|
|
|
|
@objc public class DumonGeolocation: NSObject, CLLocationManagerDelegate {
|
|
public var onPositionUpdate: (([String: Any]) -> Void)?
|
|
public var onGnssStatus: (([String: Any]) -> Void)?
|
|
public var onAuthorizationChange: ((CLAuthorizationStatus) -> Void)?
|
|
|
|
private let locationManager = CLLocationManager()
|
|
private let motionManager = CMMotionManager()
|
|
|
|
private var latestLocation: CLLocation?
|
|
private var latestTimestampMs: Double = 0
|
|
private var latestAcceleration: Double = 0
|
|
private var latestDirectionRad: Double = 0
|
|
|
|
private var prevLocation: CLLocation?
|
|
private var prevSpeed: Double = 0
|
|
private var prevDirection: Double = 0
|
|
private var lastEmitTimestampMs: Double = 0
|
|
|
|
private var distanceThresholdMeters: Double = 7.0
|
|
private var speedChangeThreshold: Double = 0.5
|
|
private var directionChangeThreshold: Double = 0.17
|
|
private var emitDebounceMs: Double = 1000
|
|
private var drivingEmitIntervalMs: Double = 1600
|
|
|
|
private var enableForwardPrediction = false
|
|
private var maxPredictionSeconds: Double = 1.0
|
|
private var suppressMockedUpdates = false
|
|
private var keepScreenOn = false
|
|
|
|
private var currentMode: String = "normal"
|
|
private var drivingTimer: Timer?
|
|
|
|
private var backgroundTrackingActive = false
|
|
private var foregroundTrackingActive = false
|
|
private var backgroundLatestLocation: CLLocation?
|
|
|
|
private var backgroundPostMinDistanceMeters: Double = 10.0
|
|
private var backgroundPostMinAccuracyMeters: Double = Double.greatestFiniteMagnitude
|
|
private var backgroundMinPostIntervalMs: Double = 10_000
|
|
private var lastPostAttemptMs: Double = 0
|
|
private var lastPostedLocation: CLLocation?
|
|
|
|
private let defaults = UserDefaults.standard
|
|
private let accessTokenKey = "dumon_geo_access_token"
|
|
private let refreshTokenKey = "dumon_geo_refresh_token"
|
|
private let backgroundPostUrlKey = "dumon_geo_post_url"
|
|
|
|
public override init() {
|
|
super.init()
|
|
locationManager.delegate = self
|
|
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
|
locationManager.distanceFilter = distanceThresholdMeters
|
|
locationManager.activityType = .other
|
|
locationManager.pausesLocationUpdatesAutomatically = true
|
|
}
|
|
|
|
public func authorizationStatus() -> CLAuthorizationStatus {
|
|
if #available(iOS 14.0, *) {
|
|
return locationManager.authorizationStatus
|
|
}
|
|
return CLLocationManager.authorizationStatus()
|
|
}
|
|
|
|
public func requestWhenInUsePermission() {
|
|
locationManager.requestWhenInUseAuthorization()
|
|
}
|
|
|
|
public func requestAlwaysPermission() {
|
|
locationManager.requestAlwaysAuthorization()
|
|
}
|
|
|
|
public func startPositioning() {
|
|
foregroundTrackingActive = true
|
|
applyKeepScreenOn(keepScreenOn)
|
|
startMotionUpdates()
|
|
locationManager.startUpdatingLocation()
|
|
}
|
|
|
|
public func stopPositioning() {
|
|
foregroundTrackingActive = false
|
|
locationManager.stopUpdatingLocation()
|
|
stopMotionUpdates()
|
|
stopDrivingEmitLoop()
|
|
applyKeepScreenOn(false)
|
|
}
|
|
|
|
public func setOptions(_ options: [String: Any]) {
|
|
if let v = options["distanceThresholdMeters"] as? Double {
|
|
distanceThresholdMeters = v
|
|
if currentMode == "normal" {
|
|
locationManager.distanceFilter = v
|
|
}
|
|
}
|
|
if let v = options["speedChangeThreshold"] as? Double { speedChangeThreshold = v }
|
|
if let v = options["directionChangeThreshold"] as? Double { directionChangeThreshold = v }
|
|
if let v = options["emitDebounceMs"] as? Double { emitDebounceMs = max(0, v) }
|
|
if let v = options["drivingEmitIntervalMs"] as? Double {
|
|
drivingEmitIntervalMs = max(200, v)
|
|
if currentMode == "driving" {
|
|
stopDrivingEmitLoop()
|
|
startDrivingEmitLoop()
|
|
}
|
|
}
|
|
if let v = options["enableForwardPrediction"] as? Bool { enableForwardPrediction = v }
|
|
if let v = options["maxPredictionSeconds"] as? Double { maxPredictionSeconds = min(max(v, 0.0), 5.0) }
|
|
if let v = options["suppressMockedUpdates"] as? Bool { suppressMockedUpdates = v }
|
|
if let v = options["keepScreenOn"] as? Bool {
|
|
keepScreenOn = v
|
|
applyKeepScreenOn(v)
|
|
}
|
|
if let v = options["backgroundPostMinDistanceMeters"] as? Double {
|
|
backgroundPostMinDistanceMeters = max(0.0, v)
|
|
}
|
|
if let v = options["backgroundPostMinAccuracyMeters"] as? Double {
|
|
backgroundPostMinAccuracyMeters = max(0.0, v)
|
|
}
|
|
if let v = options["backgroundMinPostIntervalMs"] as? Double {
|
|
backgroundMinPostIntervalMs = max(0.0, v)
|
|
}
|
|
}
|
|
|
|
public func setGpsMode(_ mode: String) {
|
|
currentMode = mode == "driving" ? "driving" : "normal"
|
|
if currentMode == "driving" {
|
|
locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
|
|
locationManager.distanceFilter = kCLDistanceFilterNone
|
|
locationManager.activityType = .automotiveNavigation
|
|
startDrivingEmitLoop()
|
|
} else {
|
|
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
|
locationManager.distanceFilter = distanceThresholdMeters
|
|
locationManager.activityType = .other
|
|
stopDrivingEmitLoop()
|
|
}
|
|
}
|
|
|
|
public func getLatestPosition() -> [String: Any] {
|
|
if let location = latestLocation {
|
|
return buildPositionData(from: location)
|
|
}
|
|
return buildDefaultPosition()
|
|
}
|
|
|
|
public func getBackgroundLatestPosition() -> [String: Any]? {
|
|
guard let location = backgroundLatestLocation else { return nil }
|
|
return buildPositionData(from: location)
|
|
}
|
|
|
|
public func getGnssStatus() -> [String: Any]? {
|
|
return nil
|
|
}
|
|
|
|
public func getLocationServicesStatus() -> [String: Any] {
|
|
let enabled = CLLocationManager.locationServicesEnabled()
|
|
return ["gpsEnabled": enabled, "networkEnabled": enabled]
|
|
}
|
|
|
|
public func startBackgroundTracking() {
|
|
// Background tracking disabled on iOS to avoid background location reporting.
|
|
backgroundTrackingActive = false
|
|
}
|
|
|
|
public func stopBackgroundTracking() {
|
|
backgroundTrackingActive = false
|
|
if #available(iOS 9.0, *) {
|
|
locationManager.allowsBackgroundLocationUpdates = false
|
|
}
|
|
if !foregroundTrackingActive {
|
|
locationManager.pausesLocationUpdatesAutomatically = true
|
|
locationManager.stopUpdatingLocation()
|
|
}
|
|
}
|
|
|
|
public func isBackgroundTrackingActive() -> Bool {
|
|
// iOS only tracks in foreground; expose active tracking state for API parity.
|
|
return backgroundTrackingActive || foregroundTrackingActive
|
|
}
|
|
|
|
public func isForegroundTrackingActive() -> Bool {
|
|
return foregroundTrackingActive
|
|
}
|
|
|
|
public func setAuthTokens(accessToken: String, refreshToken: String) {
|
|
defaults.set(accessToken, forKey: accessTokenKey)
|
|
defaults.set(refreshToken, forKey: refreshTokenKey)
|
|
}
|
|
|
|
public func clearAuthTokens() {
|
|
defaults.removeObject(forKey: accessTokenKey)
|
|
defaults.removeObject(forKey: refreshTokenKey)
|
|
}
|
|
|
|
public func getAuthState() -> Bool {
|
|
return defaults.string(forKey: accessTokenKey) != nil
|
|
}
|
|
|
|
public func setBackgroundPostUrl(_ url: String?) {
|
|
if let url = url, !url.isEmpty {
|
|
defaults.set(url, forKey: backgroundPostUrlKey)
|
|
} else {
|
|
defaults.removeObject(forKey: backgroundPostUrlKey)
|
|
}
|
|
}
|
|
|
|
public func getBackgroundPostUrl() -> String? {
|
|
return defaults.string(forKey: backgroundPostUrlKey)
|
|
}
|
|
|
|
// MARK: - CLLocationManagerDelegate
|
|
|
|
public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
|
onAuthorizationChange?(status)
|
|
}
|
|
|
|
public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
guard let location = locations.last else { return }
|
|
latestLocation = location
|
|
latestTimestampMs = location.timestamp.timeIntervalSince1970 * 1000.0
|
|
backgroundLatestLocation = location
|
|
|
|
emitPositionUpdate(from: location, forceEmit: false)
|
|
}
|
|
|
|
public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
// No-op: keep last known fix
|
|
_ = error
|
|
}
|
|
|
|
// MARK: - Internal
|
|
|
|
private func emitPositionUpdate(from location: CLLocation, forceEmit: Bool) {
|
|
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
|
if !forceEmit && (nowMs - lastEmitTimestampMs) < emitDebounceMs {
|
|
return
|
|
}
|
|
|
|
let isMocked = isSimulated(location)
|
|
if suppressMockedUpdates && isMocked {
|
|
return
|
|
}
|
|
|
|
let speedNow = resolvedSpeed(from: location).speed
|
|
let directionNow = resolvedDirection(from: location)
|
|
|
|
let distance = prevLocation.map { location.distance(from: $0) } ?? Double.greatestFiniteMagnitude
|
|
let isSignificantChange = distance >= distanceThresholdMeters
|
|
let speedChanged = abs(speedNow - prevSpeed) > speedChangeThreshold
|
|
let directionChanged = abs(directionNow - prevDirection) > directionChangeThreshold
|
|
|
|
if forceEmit || isSignificantChange || speedChanged || directionChanged {
|
|
prevLocation = location
|
|
prevSpeed = speedNow
|
|
prevDirection = directionNow
|
|
lastEmitTimestampMs = nowMs
|
|
|
|
let data = buildPositionData(from: location)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.onPositionUpdate?(data)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func buildDefaultPosition() -> [String: Any] {
|
|
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
|
return [
|
|
"source": "GNSS",
|
|
"timestamp": nowMs,
|
|
"latitude": 0.0,
|
|
"longitude": 0.0,
|
|
"accuracy": 999.0,
|
|
"speed": 0.0,
|
|
"acceleration": latestAcceleration,
|
|
"directionRad": latestDirectionRad,
|
|
"isMocked": false,
|
|
"mode": currentMode,
|
|
"predicted": false,
|
|
"speedSource": "NONE"
|
|
]
|
|
}
|
|
|
|
private func buildPositionData(from location: CLLocation) -> [String: Any] {
|
|
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
|
let timestampMs = location.timestamp.timeIntervalSince1970 * 1000.0
|
|
let fixAgeMs = max(0.0, nowMs - timestampMs)
|
|
|
|
let speedInfo = resolvedSpeed(from: location)
|
|
let directionRad = resolvedDirection(from: location)
|
|
let accel = latestAcceleration
|
|
|
|
var outLat = location.coordinate.latitude
|
|
var outLon = location.coordinate.longitude
|
|
var predicted = false
|
|
|
|
if enableForwardPrediction && fixAgeMs > 0 {
|
|
let dtSec = min(fixAgeMs / 1000.0, maxPredictionSeconds)
|
|
if dtSec > 0 {
|
|
let speed = speedInfo.speed
|
|
let dNorth = speed * dtSec * cos(directionRad)
|
|
let dEast = speed * dtSec * sin(directionRad)
|
|
let earthRadius = 6371000.0
|
|
let latRad = outLat * Double.pi / 180.0
|
|
let dLat = (dNorth / earthRadius) * (180.0 / Double.pi)
|
|
let dLon = (dEast / (earthRadius * cos(latRad))) * (180.0 / Double.pi)
|
|
outLat += dLat
|
|
outLon += dLon
|
|
predicted = true
|
|
}
|
|
}
|
|
|
|
var data: [String: Any] = [
|
|
"source": isSimulated(location) ? "MOCK" : "GNSS",
|
|
"timestamp": timestampMs,
|
|
"latitude": outLat,
|
|
"longitude": outLon,
|
|
"accuracy": location.horizontalAccuracy,
|
|
"speed": speedInfo.speed,
|
|
"acceleration": accel,
|
|
"directionRad": directionRad,
|
|
"isMocked": isSimulated(location),
|
|
"mode": currentMode,
|
|
"predicted": predicted,
|
|
"speedSource": speedInfo.source
|
|
]
|
|
|
|
if let gnss = speedInfo.speedGnss {
|
|
data["speedGnss"] = gnss
|
|
}
|
|
if let derived = speedInfo.speedDerived {
|
|
data["speedDerived"] = derived
|
|
}
|
|
|
|
latestDirectionRad = directionRad
|
|
return data
|
|
}
|
|
|
|
private func resolvedSpeed(from location: CLLocation) -> (speed: Double, source: String, speedGnss: Double?, speedDerived: Double?) {
|
|
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
|
let timestampMs = location.timestamp.timeIntervalSince1970 * 1000.0
|
|
let fixAgeMs = max(0.0, nowMs - timestampMs)
|
|
|
|
let gnssSpeed = location.speed >= 0 ? location.speed : -1
|
|
let validGnss = gnssSpeed >= 0 && fixAgeMs <= 3000
|
|
|
|
let derivedSpeed: Double? = {
|
|
guard let prev = prevLocation else { return nil }
|
|
let dtMs = timestampMs - prev.timestamp.timeIntervalSince1970 * 1000.0
|
|
let dtSec = dtMs / 1000.0
|
|
if dtSec >= 3.0 && dtSec <= 30.0 {
|
|
let dist = location.distance(from: prev)
|
|
return dist / dtSec
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
if validGnss {
|
|
return (gnssSpeed, "GNSS", gnssSpeed, nil)
|
|
}
|
|
if let derived = derivedSpeed {
|
|
return (derived, "DELTA", nil, derived)
|
|
}
|
|
return (0.0, "NONE", nil, nil)
|
|
}
|
|
|
|
private func resolvedDirection(from location: CLLocation) -> Double {
|
|
if location.course >= 0 {
|
|
return location.course * Double.pi / 180.0
|
|
}
|
|
return latestDirectionRad
|
|
}
|
|
|
|
private func isSimulated(_ location: CLLocation) -> Bool {
|
|
if #available(iOS 15.0, *) {
|
|
return location.sourceInformation?.isSimulatedBySoftware ?? false
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func startMotionUpdates() {
|
|
guard motionManager.isDeviceMotionAvailable else { return }
|
|
motionManager.deviceMotionUpdateInterval = 0.2
|
|
motionManager.startDeviceMotionUpdates(to: OperationQueue()) { [weak self] motion, _ in
|
|
guard let motion = motion else { return }
|
|
let a = motion.userAcceleration
|
|
let magnitude = sqrt(a.x * a.x + a.y * a.y + a.z * a.z)
|
|
self?.latestAcceleration = magnitude
|
|
}
|
|
}
|
|
|
|
private func stopMotionUpdates() {
|
|
motionManager.stopDeviceMotionUpdates()
|
|
}
|
|
|
|
private func startDrivingEmitLoop() {
|
|
if drivingTimer != nil { return }
|
|
let interval = drivingEmitIntervalMs / 1000.0
|
|
drivingTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
guard let self = self, let location = self.latestLocation else { return }
|
|
self.emitPositionUpdate(from: location, forceEmit: true)
|
|
}
|
|
}
|
|
|
|
private func stopDrivingEmitLoop() {
|
|
drivingTimer?.invalidate()
|
|
drivingTimer = nil
|
|
}
|
|
|
|
private func applyKeepScreenOn(_ enabled: Bool) {
|
|
DispatchQueue.main.async {
|
|
UIApplication.shared.isIdleTimerDisabled = enabled
|
|
}
|
|
}
|
|
|
|
}
|