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 } } }