745 lines
31 KiB
Swift
745 lines
31 KiB
Swift
//
|
||
// UnlockCountManager.swift
|
||
// QuickLocation
|
||
//
|
||
|
||
import UIKit
|
||
import CoreLocation
|
||
import SwiftyUserDefaults
|
||
|
||
/// 本机解锁次数:protectedData 状态机 + 始终定位保持后台运行窗口(不上报)
|
||
final class UnlockCountManager: NSObject {
|
||
|
||
static let shared = UnlockCountManager()
|
||
|
||
private let locationManager = CLLocationManager()
|
||
private let coarseLocationManager = CLLocationManager()
|
||
private let logPrefix = "[DEBUG-UNLOCK]"
|
||
private let debugLogDefaultsKey = "unlockDebugLogs"
|
||
private let geofenceIdPrefix = "unlock.keepAlive."
|
||
private let geofenceCenterId = "unlock.keepAlive.center"
|
||
private let geofenceRadius: CLLocationDistance = 50
|
||
/// 四向卫星围栏相对中心的偏移(米),用于静止时 GPS 抖动触发
|
||
private let geofenceSatelliteOffset: CLLocationDistance = 40
|
||
/// 相对围栏中心移动超过该距离才刷新围栏网格,避免频繁重注册
|
||
private let geofenceRefreshDistance: CLLocationDistance = 40
|
||
|
||
private var didStart = false
|
||
private var isSessionLocked = false
|
||
private var hasSeenLockInSession = false
|
||
private var protectedDataPollTimer: Timer?
|
||
private var lastGeofenceCenter: CLLocationCoordinate2D?
|
||
private var lastKnownLocation: CLLocation?
|
||
private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
||
/// 当前解锁 session 起点(锁屏结算屏幕时长)
|
||
private var screenSessionStart: Date?
|
||
|
||
private override init() {
|
||
super.init()
|
||
locationManager.delegate = self
|
||
configureLocationManager(
|
||
locationManager,
|
||
accuracy: kCLLocationAccuracyNearestTenMeters,
|
||
distanceFilter: kCLDistanceFilterNone
|
||
)
|
||
coarseLocationManager.delegate = self
|
||
configureLocationManager(
|
||
coarseLocationManager,
|
||
accuracy: 800,
|
||
distanceFilter: 100
|
||
)
|
||
}
|
||
|
||
private func configureLocationManager(
|
||
_ manager: CLLocationManager,
|
||
accuracy: CLLocationAccuracy,
|
||
distanceFilter: CLLocationDistance
|
||
) {
|
||
manager.desiredAccuracy = accuracy
|
||
manager.distanceFilter = distanceFilter
|
||
manager.pausesLocationUpdatesAutomatically = false
|
||
manager.activityType = .otherNavigation
|
||
if #available(iOS 11.0, *) {
|
||
manager.showsBackgroundLocationIndicator = false
|
||
}
|
||
}
|
||
|
||
// MARK: - Public
|
||
|
||
var todayCount: Int {
|
||
rollDayIfNeeded()
|
||
return Defaults[\.unlockCountToday]
|
||
}
|
||
|
||
var totalCount: Int {
|
||
rollDayIfNeeded()
|
||
return Defaults[\.unlockCountTotal]
|
||
}
|
||
|
||
/// 今日屏幕使用时长(秒),含当前解锁 session 的今日部分
|
||
var todayScreenTimeSeconds: Int {
|
||
rollDayIfNeeded()
|
||
let today = todayString()
|
||
var total = Defaults[\.screenTimeByDay][today] ?? 0
|
||
if let start = effectiveScreenSessionStart {
|
||
total += screenTimeSeconds(from: start, to: Date(), onDay: today)
|
||
}
|
||
return total
|
||
}
|
||
|
||
/// 本 App 今日进入前台次数
|
||
var todayAppUsageCount: Int {
|
||
rollDayIfNeeded()
|
||
return Defaults[\.appUsageToday]
|
||
}
|
||
|
||
var hasAlwaysLocationAuthorization: Bool {
|
||
authorizationStatus() == .authorizedAlways
|
||
}
|
||
|
||
func start(launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) {
|
||
let appState = UIApplication.shared.applicationState
|
||
let launchedByLocation = launchOptions?[.location] != nil
|
||
let nonActiveLaunch = appState != .active
|
||
|
||
if nonActiveLaunch {
|
||
beginBootBackgroundTask()
|
||
debugLog("coldBootNonActive appState=\(appState.rawValue) locationKey=\(launchedByLocation) bgMode=\(isLocationBackgroundModeEnabled) auth=\(authorizationStatus().rawValue) bootTask=1")
|
||
}
|
||
|
||
guard !didStart else {
|
||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||
handleNonActiveLaunchIfNeeded(launchOptions)
|
||
return
|
||
}
|
||
didStart = true
|
||
rollDayIfNeeded()
|
||
restoreScreenSessionIfNeeded()
|
||
|
||
let available = UIApplication.shared.isProtectedDataAvailable
|
||
isSessionLocked = !available
|
||
dumpStoredDebugLogs()
|
||
debugLog("start auth=\(authorizationStatus().rawValue) appState=\(appState.rawValue) protected=\(available) bgMode=\(isLocationBackgroundModeEnabled)")
|
||
// 冷启动已解锁:不计数,只同步状态
|
||
if !available {
|
||
hasSeenLockInSession = true
|
||
Defaults[\.unlockWasLocked] = true
|
||
}
|
||
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(protectedDataWillBecomeUnavailable),
|
||
name: UIApplication.protectedDataWillBecomeUnavailableNotification,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(protectedDataDidBecomeAvailable),
|
||
name: UIApplication.protectedDataDidBecomeAvailableNotification,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appDidEnterBackground),
|
||
name: UIApplication.didEnterBackgroundNotification,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appWillTerminate),
|
||
name: UIApplication.willTerminateNotification,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appDidBecomeActive),
|
||
name: UIApplication.didBecomeActiveNotification,
|
||
object: nil
|
||
)
|
||
|
||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||
handleNonActiveLaunchIfNeeded(launchOptions)
|
||
reconcileUnlockIfNeeded(reason: "coldStartAfterLock")
|
||
if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked {
|
||
startScreenSession()
|
||
}
|
||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||
}
|
||
|
||
func refreshAuthorizationAndMonitoring() {
|
||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||
debugLog("refresh auth=\(authorizationStatus().rawValue) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable)")
|
||
notifyChange()
|
||
}
|
||
|
||
// MARK: - Day roll
|
||
|
||
private func todayString() -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.calendar = Calendar.current
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.timeZone = TimeZone.current
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
return formatter.string(from: Date())
|
||
}
|
||
|
||
@discardableResult
|
||
private func rollDayIfNeeded() -> Bool {
|
||
migrateScreenTimeStorageIfNeeded()
|
||
let today = todayString()
|
||
guard Defaults[\.unlockCountDate] != today else { return false }
|
||
|
||
flushScreenSession()
|
||
|
||
Defaults[\.unlockCountDate] = today
|
||
Defaults[\.unlockCountToday] = 0
|
||
Defaults[\.appUsageToday] = 0
|
||
return true
|
||
}
|
||
|
||
private var effectiveScreenSessionStart: Date? {
|
||
screenSessionStart ?? Defaults[\.screenSessionStartAt]
|
||
}
|
||
|
||
private func dayString(for date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.calendar = Calendar.current
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.timeZone = TimeZone.current
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
private func migrateScreenTimeStorageIfNeeded() {
|
||
var byDay = Defaults[\.screenTimeByDay]
|
||
guard byDay.isEmpty else { return }
|
||
|
||
let legacyKey = "screenTimeTodaySeconds"
|
||
let legacyToday = UserDefaults.standard.integer(forKey: legacyKey)
|
||
if legacyToday > 0 {
|
||
let dateKey = Defaults[\.unlockCountDate].isEmpty ? todayString() : Defaults[\.unlockCountDate]
|
||
byDay[dateKey] = legacyToday
|
||
Defaults[\.screenTimeByDay] = byDay
|
||
}
|
||
}
|
||
|
||
private func addScreenTime(from start: Date, to end: Date) {
|
||
guard end > start else { return }
|
||
var cursor = start
|
||
let calendar = Calendar.current
|
||
var byDay = Defaults[\.screenTimeByDay]
|
||
|
||
while cursor < end {
|
||
let dayStart = calendar.startOfDay(for: cursor)
|
||
guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) else { break }
|
||
let segmentEnd = min(end, nextDay)
|
||
let seconds = max(0, Int(segmentEnd.timeIntervalSince(cursor)))
|
||
if seconds > 0 {
|
||
let key = dayString(for: cursor)
|
||
byDay[key, default: 0] += seconds
|
||
}
|
||
cursor = segmentEnd
|
||
}
|
||
|
||
Defaults[\.screenTimeByDay] = byDay
|
||
}
|
||
|
||
private func screenTimeSeconds(from start: Date, to end: Date, onDay dayKey: String) -> Int {
|
||
guard end > start else { return 0 }
|
||
let calendar = Calendar.current
|
||
guard
|
||
let dayStart = date(fromDayKey: dayKey),
|
||
let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart)
|
||
else { return 0 }
|
||
|
||
let segmentStart = max(start, dayStart)
|
||
let segmentEnd = min(end, nextDay)
|
||
guard segmentEnd > segmentStart else { return 0 }
|
||
return max(0, Int(segmentEnd.timeIntervalSince(segmentStart)))
|
||
}
|
||
|
||
private func date(fromDayKey key: String) -> Date? {
|
||
let formatter = DateFormatter()
|
||
formatter.calendar = Calendar.current
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.timeZone = TimeZone.current
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
return formatter.date(from: key)
|
||
}
|
||
|
||
private func restoreScreenSessionIfNeeded() {
|
||
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
||
guard screenSessionStart == nil, let persisted = Defaults[\.screenSessionStartAt] else { return }
|
||
screenSessionStart = persisted
|
||
}
|
||
|
||
private func startScreenSession() {
|
||
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
||
rollDayIfNeeded()
|
||
if screenSessionStart == nil {
|
||
if let persisted = Defaults[\.screenSessionStartAt] {
|
||
screenSessionStart = persisted
|
||
} else {
|
||
screenSessionStart = Date()
|
||
Defaults[\.screenSessionStartAt] = screenSessionStart
|
||
}
|
||
}
|
||
}
|
||
|
||
private func flushScreenSession() {
|
||
guard let start = effectiveScreenSessionStart else { return }
|
||
addScreenTime(from: start, to: Date())
|
||
screenSessionStart = nil
|
||
Defaults[\.screenSessionStartAt] = nil
|
||
notifyChange()
|
||
}
|
||
|
||
private func recordAppUsage() {
|
||
rollDayIfNeeded()
|
||
Defaults[\.appUsageToday] = Defaults[\.appUsageToday] + 1
|
||
notifyChange()
|
||
debugLog("appUsage today=\(Defaults[\.appUsageToday])")
|
||
}
|
||
|
||
// MARK: - Counting
|
||
|
||
private func recordUnlock(reason: String) {
|
||
let now = Date()
|
||
rollDayIfNeeded()
|
||
Defaults[\.unlockCountToday] = Defaults[\.unlockCountToday] + 1
|
||
Defaults[\.unlockCountTotal] = Defaults[\.unlockCountTotal] + 1
|
||
Defaults[\.unlockLastCountedAt] = now
|
||
Defaults[\.unlockWasLocked] = false
|
||
isSessionLocked = false
|
||
Defaults[\.unlockLastHeartbeatAt] = now
|
||
stopProtectedDataPolling()
|
||
startScreenSession()
|
||
notifyChange()
|
||
print("🔓 UnlockCount +\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
||
debugLog("recordUnlock reason=\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
||
}
|
||
|
||
private func markLocked() {
|
||
flushScreenSession()
|
||
isSessionLocked = true
|
||
hasSeenLockInSession = true
|
||
Defaults[\.unlockWasLocked] = true
|
||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||
startProtectedDataPolling()
|
||
}
|
||
|
||
private func persistLifecycleHeartbeat() {
|
||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||
if !UIApplication.shared.isProtectedDataAvailable {
|
||
Defaults[\.unlockWasLocked] = true
|
||
isSessionLocked = true
|
||
hasSeenLockInSession = true
|
||
}
|
||
}
|
||
|
||
private func notifyChange() {
|
||
NotificationCenter.default.post(name: .unlockCountDidChange, object: nil)
|
||
}
|
||
|
||
private func debugLog(_ message: String) {
|
||
let line = "\(Date()) \(logPrefix) \(message)"
|
||
NSLog("%@", line)
|
||
|
||
var logs = UserDefaults.standard.stringArray(forKey: debugLogDefaultsKey) ?? []
|
||
logs.append(line)
|
||
if logs.count > 200 {
|
||
logs.removeFirst(logs.count - 200)
|
||
}
|
||
UserDefaults.standard.set(logs, forKey: debugLogDefaultsKey)
|
||
}
|
||
|
||
private func dumpStoredDebugLogs() {
|
||
let logs = UserDefaults.standard.stringArray(forKey: debugLogDefaultsKey) ?? []
|
||
guard !logs.isEmpty else { return }
|
||
NSLog("%@ storedLogCount=%d", logPrefix, logs.count)
|
||
logs.suffix(50).forEach { NSLog("%@ stored %@", logPrefix, $0) }
|
||
}
|
||
|
||
private func startProtectedDataPolling() {
|
||
guard protectedDataPollTimer == nil else { return }
|
||
guard Defaults[\.unlockWasLocked] || isSessionLocked else { return }
|
||
debugLog("startProtectedDataPolling")
|
||
|
||
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
|
||
guard let self else { return }
|
||
self.pollProtectedDataAvailability()
|
||
}
|
||
protectedDataPollTimer = timer
|
||
RunLoop.main.add(timer, forMode: .common)
|
||
pollProtectedDataAvailability()
|
||
}
|
||
|
||
private func stopProtectedDataPolling() {
|
||
guard protectedDataPollTimer != nil else { return }
|
||
protectedDataPollTimer?.invalidate()
|
||
protectedDataPollTimer = nil
|
||
debugLog("stopProtectedDataPolling")
|
||
}
|
||
|
||
private func pollProtectedDataAvailability() {
|
||
let available = UIApplication.shared.isProtectedDataAvailable
|
||
debugLog("protectedDataPoll protected=\(available) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
||
guard available else { return }
|
||
guard Defaults[\.unlockWasLocked] || isSessionLocked else {
|
||
stopProtectedDataPolling()
|
||
return
|
||
}
|
||
recordUnlock(reason: "protectedDataPoll")
|
||
}
|
||
|
||
/// 对照 protectedData 纠正锁态(定位回调补强,避免挂起丢通知)
|
||
private func syncLockStateFromProtectedData(reason: String) {
|
||
if !UIApplication.shared.isProtectedDataAvailable {
|
||
markLocked()
|
||
return
|
||
}
|
||
if Defaults[\.unlockWasLocked] || isSessionLocked {
|
||
recordUnlock(reason: reason)
|
||
}
|
||
}
|
||
|
||
/// 定位事件短窗口:锁态同步 + 心跳 + FinishTask 断言
|
||
private func performLocationTick(reason: String) {
|
||
let app = UIApplication.shared
|
||
var taskId = UIBackgroundTaskIdentifier.invalid
|
||
taskId = app.beginBackgroundTask(withName: "unlock.locationTick") {
|
||
if taskId != .invalid {
|
||
app.endBackgroundTask(taskId)
|
||
taskId = .invalid
|
||
}
|
||
}
|
||
|
||
syncLockStateFromProtectedData(reason: reason)
|
||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||
|
||
if taskId != .invalid {
|
||
app.endBackgroundTask(taskId)
|
||
taskId = .invalid
|
||
}
|
||
}
|
||
|
||
/// DAS / 定位冷启动短窗口:等 locationd 接管前尽量不被秒挂
|
||
private func beginBootBackgroundTask() {
|
||
endBootBackgroundTask()
|
||
let app = UIApplication.shared
|
||
bootBackgroundTask = app.beginBackgroundTask(withName: "unlock.boot") { [weak self] in
|
||
self?.debugLog("bootTask expired")
|
||
self?.endBootBackgroundTask()
|
||
}
|
||
debugLog("bootTask begin id=\(bootBackgroundTask.rawValue)")
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 25) { [weak self] in
|
||
self?.endBootBackgroundTask()
|
||
}
|
||
}
|
||
|
||
private func endBootBackgroundTask() {
|
||
guard bootBackgroundTask != .invalid else { return }
|
||
let id = bootBackgroundTask
|
||
bootBackgroundTask = .invalid
|
||
UIApplication.shared.endBackgroundTask(id)
|
||
debugLog("bootTask end id=\(id.rawValue)")
|
||
}
|
||
|
||
// MARK: - protectedData / lifecycle
|
||
|
||
@objc private func protectedDataWillBecomeUnavailable() {
|
||
debugLog("protectedDataWillBecomeUnavailable appState=\(UIApplication.shared.applicationState.rawValue)")
|
||
markLocked()
|
||
}
|
||
|
||
@objc private func protectedDataDidBecomeAvailable() {
|
||
debugLog("protectedDataDidBecomeAvailable appState=\(UIApplication.shared.applicationState.rawValue) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
||
guard hasSeenLockInSession || Defaults[\.unlockWasLocked] else { return }
|
||
guard isSessionLocked || Defaults[\.unlockWasLocked] else { return }
|
||
recordUnlock(reason: "protectedData")
|
||
}
|
||
|
||
@objc private func appDidEnterBackground() {
|
||
persistLifecycleHeartbeat()
|
||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||
forceRefreshGeofenceFromLastKnown(reason: "didEnterBackground")
|
||
debugLog("didEnterBackground auth=\(authorizationStatus().rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) regions=\(locationManager.monitoredRegions.count)")
|
||
}
|
||
|
||
@objc private func appWillTerminate() {
|
||
persistLifecycleHeartbeat()
|
||
forceRefreshGeofenceFromLastKnown(reason: "willTerminate")
|
||
debugLog("willTerminate protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked])")
|
||
}
|
||
|
||
@objc private func appDidBecomeActive() {
|
||
debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
||
endBootBackgroundTask()
|
||
reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock")
|
||
recordAppUsage()
|
||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||
}
|
||
|
||
/// 杀进程前已记锁屏,解锁后冷启动/回前台时补计
|
||
private func reconcileUnlockIfNeeded(reason: String) {
|
||
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
||
guard Defaults[\.unlockWasLocked] || isSessionLocked else { return }
|
||
recordUnlock(reason: reason)
|
||
}
|
||
|
||
// MARK: - Location wake
|
||
|
||
private func authorizationStatus() -> CLAuthorizationStatus {
|
||
if #available(iOS 14.0, *) {
|
||
return locationManager.authorizationStatus
|
||
}
|
||
return CLLocationManager.authorizationStatus()
|
||
}
|
||
|
||
/// Info.plist 的 UIBackgroundModes 必须是含 location 的数组,才可安全打开后台持续定位
|
||
private var isLocationBackgroundModeEnabled: Bool {
|
||
guard let modes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String] else {
|
||
return false
|
||
}
|
||
return modes.contains("location")
|
||
}
|
||
|
||
private func restartLocationMonitoringIfPossible(reassertContinuous: Bool = false) {
|
||
guard authorizationStatus() == .authorizedAlways else {
|
||
stopAllLocationMonitoring()
|
||
stopProtectedDataPolling()
|
||
debugLog("stopLocation auth=\(authorizationStatus().rawValue) stopVisits=true stopCoarse=true stopGeofence=true")
|
||
return
|
||
}
|
||
|
||
// 重要位置变化 / Visit / 围栏:杀进程后由系统拉起
|
||
locationManager.startMonitoringSignificantLocationChanges()
|
||
locationManager.startMonitoringVisits()
|
||
debugLog("startVisits auth=\(authorizationStatus().rawValue)")
|
||
|
||
if isLocationBackgroundModeEnabled {
|
||
reassertContinuousLocationUpdates(forceStopFirst: reassertContinuous)
|
||
} else {
|
||
if #available(iOS 9.0, *) {
|
||
locationManager.allowsBackgroundLocationUpdates = false
|
||
coarseLocationManager.allowsBackgroundLocationUpdates = false
|
||
}
|
||
locationManager.stopUpdatingLocation()
|
||
coarseLocationManager.stopUpdatingLocation()
|
||
print("UnlockCountManager: UIBackgroundModes 缺少 location,仅使用重要位置变化/围栏")
|
||
debugLog("startSignificantOnly auth=\(authorizationStatus().rawValue)")
|
||
}
|
||
}
|
||
|
||
private func stopAllLocationMonitoring() {
|
||
locationManager.stopUpdatingLocation()
|
||
locationManager.stopMonitoringSignificantLocationChanges()
|
||
locationManager.stopMonitoringVisits()
|
||
coarseLocationManager.stopUpdatingLocation()
|
||
stopGeofenceMonitoring()
|
||
if #available(iOS 9.0, *) {
|
||
locationManager.allowsBackgroundLocationUpdates = false
|
||
coarseLocationManager.allowsBackgroundLocationUpdates = false
|
||
}
|
||
}
|
||
|
||
/// 停再开持续定位,迫使 locationd 重新挂上 Location subscription(对齐 Lookus 拉起后行为)
|
||
private func reassertContinuousLocationUpdates(forceStopFirst: Bool) {
|
||
guard authorizationStatus() == .authorizedAlways, isLocationBackgroundModeEnabled else { return }
|
||
if forceStopFirst {
|
||
locationManager.stopUpdatingLocation()
|
||
coarseLocationManager.stopUpdatingLocation()
|
||
debugLog("reassertContinuous stopThenStart")
|
||
}
|
||
if #available(iOS 9.0, *) {
|
||
locationManager.allowsBackgroundLocationUpdates = true
|
||
coarseLocationManager.allowsBackgroundLocationUpdates = true
|
||
}
|
||
locationManager.startUpdatingLocation()
|
||
coarseLocationManager.startUpdatingLocation()
|
||
debugLog("startPreciseLocation accuracy=10 distanceFilter=none activity=otherNavigation auth=\(authorizationStatus().rawValue) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable)")
|
||
debugLog("startCoarseLocation accuracy=800 distanceFilter=100 activity=otherNavigation auth=\(authorizationStatus().rawValue) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable)")
|
||
}
|
||
|
||
private func stopGeofenceMonitoring() {
|
||
for region in locationManager.monitoredRegions where region.identifier.hasPrefix(geofenceIdPrefix) {
|
||
locationManager.stopMonitoring(for: region)
|
||
}
|
||
lastGeofenceCenter = nil
|
||
}
|
||
|
||
/// 中心 + 东南西北共 5 个围栏中心点(偏移约 40m)
|
||
private func geofenceGridCenters(around coordinate: CLLocationCoordinate2D) -> [(id: String, coordinate: CLLocationCoordinate2D)] {
|
||
let earthRadius: CLLocationDistance = 6_378_137
|
||
let latRad = coordinate.latitude * .pi / 180
|
||
let dLat = (geofenceSatelliteOffset / earthRadius) * 180 / .pi
|
||
let dLon = (geofenceSatelliteOffset / (earthRadius * cos(latRad))) * 180 / .pi
|
||
return [
|
||
(geofenceCenterId, coordinate),
|
||
("\(geofenceIdPrefix)n", CLLocationCoordinate2D(latitude: coordinate.latitude + dLat, longitude: coordinate.longitude)),
|
||
("\(geofenceIdPrefix)s", CLLocationCoordinate2D(latitude: coordinate.latitude - dLat, longitude: coordinate.longitude)),
|
||
("\(geofenceIdPrefix)e", CLLocationCoordinate2D(latitude: coordinate.latitude, longitude: coordinate.longitude + dLon)),
|
||
("\(geofenceIdPrefix)w", CLLocationCoordinate2D(latitude: coordinate.latitude, longitude: coordinate.longitude - dLon))
|
||
]
|
||
}
|
||
|
||
private func rememberLocation(_ location: CLLocation) {
|
||
lastKnownLocation = location
|
||
Defaults[\.unlockLastLatitude] = location.coordinate.latitude
|
||
Defaults[\.unlockLastLongitude] = location.coordinate.longitude
|
||
}
|
||
|
||
private func resolvedLastKnownLocation() -> CLLocation? {
|
||
if let location = locationManager.location ?? lastKnownLocation {
|
||
return location
|
||
}
|
||
if let lat = Defaults[\.unlockLastLatitude], let lon = Defaults[\.unlockLastLongitude] {
|
||
return CLLocation(latitude: lat, longitude: lon)
|
||
}
|
||
if let lat = Defaults[\.currentLatitude], let lon = Defaults[\.currentLongitude] {
|
||
return CLLocation(latitude: lat, longitude: lon)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func forceRefreshGeofenceFromLastKnown(reason: String) {
|
||
guard let location = resolvedLastKnownLocation() else {
|
||
debugLog("geofenceSkip reason=\(reason) noLastLocation")
|
||
return
|
||
}
|
||
debugLog("geofenceForce reason=\(reason)")
|
||
refreshGeofence(around: location, force: true)
|
||
}
|
||
|
||
private func refreshGeofence(around location: CLLocation, force: Bool = false) {
|
||
guard authorizationStatus() == .authorizedAlways else { return }
|
||
guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else {
|
||
debugLog("geofence unavailable")
|
||
return
|
||
}
|
||
|
||
let coordinate = location.coordinate
|
||
if !force, let last = lastGeofenceCenter {
|
||
let lastLocation = CLLocation(latitude: last.latitude, longitude: last.longitude)
|
||
if lastLocation.distance(from: location) < geofenceRefreshDistance {
|
||
return
|
||
}
|
||
}
|
||
|
||
stopGeofenceMonitoring()
|
||
|
||
let centers = geofenceGridCenters(around: coordinate)
|
||
for item in centers {
|
||
let region = CLCircularRegion(
|
||
center: item.coordinate,
|
||
radius: geofenceRadius,
|
||
identifier: item.id
|
||
)
|
||
region.notifyOnEntry = true
|
||
region.notifyOnExit = true
|
||
locationManager.startMonitoring(for: region)
|
||
}
|
||
lastGeofenceCenter = coordinate
|
||
let ours = locationManager.monitoredRegions.filter { $0.identifier.hasPrefix(geofenceIdPrefix) }.count
|
||
debugLog("geofenceRefresh lat=\(coordinate.latitude) lon=\(coordinate.longitude) radius=\(geofenceRadius) grid=\(centers.count) force=\(force) monitoredOurs=\(ours) monitoredTotal=\(locationManager.monitoredRegions.count)")
|
||
}
|
||
|
||
/// DAS 预热 / 定位拉起 / 任意非前台冷启动:立刻重申持续定位并尝试恢复围栏
|
||
private func handleNonActiveLaunchIfNeeded(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
|
||
let launchedByLocation = launchOptions?[.location] != nil
|
||
let nonActive = UIApplication.shared.applicationState != .active
|
||
guard launchedByLocation || nonActive else { return }
|
||
|
||
let reason: String
|
||
if launchedByLocation {
|
||
reason = "locationLaunch"
|
||
} else if UIApplication.shared.applicationState == .background {
|
||
reason = "backgroundLaunch"
|
||
} else {
|
||
reason = "inactiveLaunch"
|
||
}
|
||
|
||
debugLog("handleLaunch reason=\(reason) locationKey=\(launchedByLocation) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) bgMode=\(isLocationBackgroundModeEnabled)")
|
||
reassertContinuousLocationUpdates(forceStopFirst: true)
|
||
forceRefreshGeofenceFromLastKnown(reason: reason)
|
||
performLocationTick(reason: reason)
|
||
}
|
||
}
|
||
|
||
// MARK: - CLLocationManagerDelegate
|
||
|
||
extension UnlockCountManager: CLLocationManagerDelegate {
|
||
|
||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||
debugLog("authChangedNewAPI auth=\(authorizationStatus().rawValue)")
|
||
notifyChange()
|
||
}
|
||
|
||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||
debugLog("authChangedOldAPI status=\(status.rawValue)")
|
||
notifyChange()
|
||
}
|
||
|
||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||
let source = manager === coarseLocationManager ? "coarse" : "precise"
|
||
debugLog("didUpdateLocations source=\(source) count=\(locations.count) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
||
performLocationTick(reason: "locationUpdate")
|
||
if let latest = locations.last {
|
||
rememberLocation(latest)
|
||
refreshGeofence(around: latest)
|
||
}
|
||
// locationd 已开始送点,可结束 DAS 预热 boot task
|
||
endBootBackgroundTask()
|
||
}
|
||
|
||
func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
|
||
debugLog("didVisit arrival=\(visit.arrivalDate) departure=\(visit.departureDate) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
||
beginBootBackgroundTask()
|
||
reassertContinuousLocationUpdates(forceStopFirst: true)
|
||
performLocationTick(reason: "visit")
|
||
if visit.coordinate.latitude != 0 || visit.coordinate.longitude != 0 {
|
||
let location = CLLocation(latitude: visit.coordinate.latitude, longitude: visit.coordinate.longitude)
|
||
rememberLocation(location)
|
||
refreshGeofence(around: location, force: true)
|
||
}
|
||
}
|
||
|
||
func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
|
||
guard region.identifier.hasPrefix(geofenceIdPrefix) else { return }
|
||
debugLog("didEnterRegion id=\(region.identifier) appState=\(UIApplication.shared.applicationState.rawValue)")
|
||
beginBootBackgroundTask()
|
||
reassertContinuousLocationUpdates(forceStopFirst: true)
|
||
performLocationTick(reason: "geofenceEnter")
|
||
}
|
||
|
||
func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
|
||
guard region.identifier.hasPrefix(geofenceIdPrefix) else { return }
|
||
debugLog("didExitRegion id=\(region.identifier) appState=\(UIApplication.shared.applicationState.rawValue)")
|
||
beginBootBackgroundTask()
|
||
reassertContinuousLocationUpdates(forceStopFirst: true)
|
||
performLocationTick(reason: "geofenceExit")
|
||
if let location = manager.location {
|
||
rememberLocation(location)
|
||
refreshGeofence(around: location, force: true)
|
||
} else {
|
||
forceRefreshGeofenceFromLastKnown(reason: "geofenceExit")
|
||
}
|
||
}
|
||
|
||
func locationManager(_ manager: CLLocationManager, didStartMonitoringFor region: CLRegion) {
|
||
debugLog("geofenceStarted id=\(region.identifier)")
|
||
}
|
||
|
||
func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) {
|
||
debugLog("geofenceFail id=\(region?.identifier ?? "nil") error=\(error.localizedDescription)")
|
||
}
|
||
|
||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||
print("UnlockCountManager location error: \(error.localizedDescription)")
|
||
debugLog("locationError=\(error.localizedDescription)")
|
||
}
|
||
}
|