2026-01-12 08:48:07 +08:00

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