// // MQTTService.swift // QuickLocation // // Created by 八条 on 2026/6/12. // import Foundation import CocoaMQTT import UIKit import CoreLocation import Network import CoreTelephony import AVFoundation import RxSwift // MARK: - MQTT 模型 /// 位置上报类型 enum MqttType: String, Codable { case track = "track" // 追踪(获取)和更新用户状态 case disconnect = "disconnect" case message = "message" case join = "join" // 加入群 case leave = "leave" // 离开群 case dismiss = "dismiss" // 解散群 case kick = "kick" // 踢人 case signIn = "signIn" // 签到 case sos = "sos" // 求助 case needTrack = "needtrack" // 追踪上报 case emote = "emote" // 接收表情 case phone = "phone" // 手机信息上报 case phoneUsage = "phoneUsage" // 当前 App 使用次数上报 case lockApp = "lockApp" // 远程锁 App case appUnlock = "appUnlock" // 远程解锁 App } /// 单点位置 struct Points: Codable { let lat: Double let lon: Double let addr: String let time: Int64 // 毫秒时间戳 let speed: Double let bearing: Double let altitude: Double let accuracy: Double } /// 位置上报数据(发送) struct MqttLocation: Codable { let battery: String let points: [Points] } /// 签到上报数据 struct MqttSignIn: Codable { let topic: String? let latitude: Double let longitude: Double let address: String } /// MQTT 收到的位置消息(接收解析用) struct MqttIncomingMessage: Decodable { let type: String? let data: MqttIncomingData? let extra: String? } struct MqttIncomingData: Decodable { let points: [Points]? let battery: String? let index: Int? let group_key: String? let user_id: String? let lock_app: MqttLockAppBody? let app_unlock: MqttLockAppBody? let sender: String? } struct MqttLockAppBody: Decodable { let os: String? let group_key: String? let group_name: String? let user_id: String? let icon_index: Int? let message: String? let lock_time: Int64? let app_icon: String? let token: MqttFlexibleStringArray? } enum MqttFlexibleStringArray: Decodable { case one(String) case many([String]) var values: [String] { switch self { case .one(let value): return value.isEmpty ? [] : [value] case .many(let values): return values.filter { !$0.isEmpty } } } init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let values = try? container.decode([String].self) { self = .many(values) return } if let value = try? container.decode(String.self) { self = .one(value) return } if let value = try? container.decode(Int.self) { self = .one(String(value)) return } self = .many([]) } } extension PhoneLockRecord { init(mqtt body: MqttLockAppBody) { self.init() groupKey = body.group_key ?? "" groupName = body.group_name ?? "" userId = body.user_id ?? "" tokens = body.token?.values ?? [] os = body.os ?? "" iconIndex = body.icon_index ?? 0 message = body.message ?? "" lockTime = body.lock_time ?? 0 appIcon = body.app_icon ?? "" } } private struct MqttAppUnlockReceiptBody: Codable { let os: String let group_key: String let group_name: String let user_id: String let token: [String] } private struct MqttAppUnlockReceiptData: Codable { let app_unlock: MqttAppUnlockReceiptBody } private struct MqttAppUnlockReceiptPayload: Codable { let type: String let data: MqttAppUnlockReceiptData let extra: String } private struct PendingAppUnlockReceipt: Codable, Equatable { let identifier: String let userId: String let payload: String init(userId: String, payload: String) { identifier = UUID().uuidString self.userId = userId self.payload = payload } private enum CodingKeys: String, CodingKey { case identifier case userId case payload } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) identifier = try container.decodeIfPresent(String.self, forKey: .identifier) ?? UUID().uuidString userId = try container.decode(String.self, forKey: .userId) payload = try container.decode(String.self, forKey: .payload) } } private struct ExpectedAppUnlockEcho { var count: Int var expiresAt: Date } final class AppUnlockCoordinator { static let shared = AppUnlockCoordinator() private let pendingReceiptsKey = "AppUnlockPendingReceipts" private var pendingReceipts: [PendingAppUnlockReceipt] private var inFlightReceipts: [UInt16: PendingAppUnlockReceipt] = [:] private var expectedOutgoingEchoes: [String: ExpectedAppUnlockEcho] = [:] private var allowRequestUserId = "" private var completedAllowUserIds: Set = [] private var observedUserId = "" private var lockStateRevision: UInt64 = 0 private var locallyUnlockedTokensByUserId: [String: Set] = [:] private var locallyUnlockedAllUserIds: Set = [] private var localUnlockSuppressionExpirations: [String: Date] = [:] private var allowDisposable: Disposable? private var observers: [NSObjectProtocol] = [] private var isStarted = false private init() { if let data = UserDefaults.standard.data(forKey: pendingReceiptsKey), let receipts = try? JSONDecoder().decode([PendingAppUnlockReceipt].self, from: data) { pendingReceipts = receipts } else { pendingReceipts = [] } for receipt in pendingReceipts { restoreLocalUnlockSuppression(from: receipt) } } func start() { performOnMain { [weak self] in guard let self, !self.isStarted else { return } self.isStarted = true self.observers.append( NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in self?.checkOfflineUnlockForCurrentUser() } ) self.observers.append( NotificationCenter.default.addObserver( forName: .RefreshUserConfigNotification, object: nil, queue: .main ) { [weak self] _ in self?.checkOfflineUnlockForCurrentUser() } ) self.checkOfflineUnlockForCurrentUser() self.flushPendingReceipts() } } func checkOfflineUnlockForCurrentUser() { performOnMain { [weak self] in guard let self else { return } let userId = AppContextManager.shared.userId.trimmed guard !userId.isEmpty else { return } if self.observedUserId != userId { self.observedUserId = userId self.completedAllowUserIds.remove(userId) } guard !self.completedAllowUserIds.contains(userId) else { return } if !self.allowRequestUserId.isEmpty, self.allowRequestUserId != userId { self.allowDisposable?.dispose() self.allowDisposable = nil self.allowRequestUserId = "" } guard self.allowRequestUserId.isEmpty else { return } self.allowRequestUserId = userId self.allowDisposable = UserService.phoneUnlockAllow() .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] response in guard let self else { return } self.finishAllowRequest(for: userId) guard AppContextManager.shared.userId.trimmed == userId else { self.checkOfflineUnlockForCurrentUser() return } guard response.code == "0" else { print("[AppUnlock] unlock allow failed: \(response.message ?? "unknown error")") return } self.completedAllowUserIds.insert(userId) for record in response.model?.locks ?? [] { self.applyUnlockAndEnqueueReceipt(record: record, userId: userId) } }, onError: { [weak self] error in self?.finishAllowRequest(for: userId) print("[AppUnlock] unlock allow request failed: \(error.gatewayMessage ?? error.localizedDescription)") }) } } func accountDidLogout(userId: String) { performOnMain { [weak self] in guard let self else { return } let userId = userId.trimmed self.completedAllowUserIds.remove(userId) if self.allowRequestUserId == userId { self.allowDisposable?.dispose() self.allowDisposable = nil self.allowRequestUserId = "" } if self.observedUserId == userId { self.observedUserId = "" } self.locallyUnlockedTokensByUserId.removeValue(forKey: userId) self.locallyUnlockedAllUserIds.remove(userId) self.localUnlockSuppressionExpirations.removeValue(forKey: userId) } } @discardableResult func handleIncoming(topic: String, payload: String?) -> Bool { guard let payload, let data = payload.data(using: .utf8), let message = try? JSONDecoder().decode(MqttIncomingMessage.self, from: data), message.type == MqttType.appUnlock.rawValue else { return false } print("📩 收到消息 -> 主题:\(topic),内容:\(payload)") performOnMain { [weak self] in guard let self else { return } guard let body = message.data?.app_unlock else { return } NotificationCenter.default.post(name: .lockDistractAppsDidChange, object: nil) guard !self.consumeExpectedOutgoingEcho(payload) else { return } let currentUserId = AppContextManager.shared.userId.trimmed let topicUserId = topic.replacingOccurrences(of: "smartdrive/", with: "").trimmed let targetUserId = body.user_id?.trimmed ?? "" guard !currentUserId.isEmpty, targetUserId == currentUserId || (targetUserId.isEmpty && topicUserId == currentUserId) else { return } var record = PhoneLockRecord(mqtt: body) record.userId = currentUserId self.applyUnlockAndEnqueueReceipt(record: record, userId: currentUserId) } return true } func applyUnlock(tokens: [String]) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.applyUnlock(tokens: tokens) } return } lockStateRevision &+= 1 rememberLocalUnlock(tokens: tokens, userId: AppContextManager.shared.userId.trimmed) if #available(iOS 16.0, *) { AppRestrictManager.shared.applyRemoteUnlock(tokens: tokens) } if tokens.isEmpty { PhoneLockSession.currentLocks = [] } else { let tokenSet = Set(tokens) PhoneLockSession.currentLocks = PhoneLockSession.currentLocks.compactMap { record in var updated = record updated.tokens = record.tokens.filter { !tokenSet.contains($0) } return updated.tokens.isEmpty ? nil : updated } } if PhoneLockSession.currentLocks.isEmpty { LockedAppPopView.dismiss() } } func registerIncomingLock(tokens: [String]) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.registerIncomingLock(tokens: tokens) } return } let userId = AppContextManager.shared.userId.trimmed guard !userId.isEmpty else { return } lockStateRevision &+= 1 locallyUnlockedAllUserIds.remove(userId) guard var unlockedTokens = locallyUnlockedTokensByUserId[userId] else { return } unlockedTokens.subtract(tokens) if unlockedTokens.isEmpty { locallyUnlockedTokensByUserId.removeValue(forKey: userId) } else { locallyUnlockedTokensByUserId[userId] = unlockedTokens } if locallyUnlockedTokensByUserId[userId] == nil { localUnlockSuppressionExpirations.removeValue(forKey: userId) } } func currentLockStateRevision() -> UInt64 { if !Thread.isMainThread { assertionFailure("Lock state revision must be read on the main thread") } return lockStateRevision } func canApplyLockResponse(startedAt revision: UInt64, userId: String) -> Bool { if !Thread.isMainThread { assertionFailure("Lock response must be checked on the main thread") } return revision == lockStateRevision && userId == AppContextManager.shared.userId.trimmed } func locksForRestoration( _ locks: [PhoneLockRecord], startedAt revision: UInt64, userId: String ) -> [PhoneLockRecord]? { guard canApplyLockResponse(startedAt: revision, userId: userId) else { return nil } removeExpiredLocalUnlockSuppression(for: userId) let pendingScope = pendingUnlockScope(for: userId) if locallyUnlockedAllUserIds.contains(userId) || pendingScope.unlockAll { return [] } let unlockedTokens = locallyUnlockedTokensByUserId[userId, default: []] .union(pendingScope.tokens) guard !unlockedTokens.isEmpty else { return locks } return locks.compactMap { record in var filteredRecord = record filteredRecord.tokens = record.tokens.filter { !unlockedTokens.contains($0) } return filteredRecord.tokens.isEmpty ? nil : filteredRecord } } func mqttDidConnect() { performOnMain { [weak self] in self?.checkOfflineUnlockForCurrentUser() self?.flushPendingReceipts() } } func mqttDidDisconnect() { performOnMain { [weak self] in self?.inFlightReceipts.removeAll() self?.expectedOutgoingEchoes.removeAll() } } func mqttDidAcknowledgePublish(id: UInt16) { performOnMain { [weak self] in guard let self, let receipt = self.inFlightReceipts.removeValue(forKey: id) else { return } self.pendingReceipts.removeAll { $0 == receipt } self.savePendingReceipts() self.flushPendingReceipts() } } private func applyUnlockAndEnqueueReceipt(record: PhoneLockRecord, userId: String) { applyUnlock(tokens: record.tokens) guard let payload = makeReceiptPayload(record: record, userId: userId) else { print("[AppUnlock] failed to encode unlock receipt") return } let receipt = PendingAppUnlockReceipt(userId: userId, payload: payload) pendingReceipts.append(receipt) savePendingReceipts() flushPendingReceipts() } private func makeReceiptPayload(record: PhoneLockRecord, userId: String) -> String? { let os = record.os.trimmed.isEmpty ? "ios" : record.os.trimmed let payload = MqttAppUnlockReceiptPayload( type: MqttType.appUnlock.rawValue, data: MqttAppUnlockReceiptData( app_unlock: MqttAppUnlockReceiptBody( os: os, group_key: record.groupKey, group_name: record.groupName, user_id: userId, token: record.tokens ) ), extra: "" ) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] guard let data = try? encoder.encode(payload) else { return nil } return String(data: data, encoding: .utf8) } private func flushPendingReceipts() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.flushPendingReceipts() } return } let userId = AppContextManager.shared.userId.trimmed guard MQTTService.shared.isConnected, !userId.isEmpty else { return } let inFlight = Set(inFlightReceipts.values.map(\.identifier)) for receipt in pendingReceipts where receipt.userId == userId && !inFlight.contains(receipt.identifier) { registerExpectedOutgoingEcho(receipt.payload) let messageId = MQTTService.shared.publish( topic: "smartdrive/\(userId)", message: receipt.payload, qos: .qos1 ) guard messageId >= 0, messageId <= Int(UInt16.max) else { cancelExpectedOutgoingEcho(receipt.payload) break } inFlightReceipts[UInt16(messageId)] = receipt } } private func finishAllowRequest(for userId: String) { guard allowRequestUserId == userId else { return } allowRequestUserId = "" allowDisposable = nil } private func savePendingReceipts() { guard let data = try? JSONEncoder().encode(pendingReceipts) else { return } UserDefaults.standard.set(data, forKey: pendingReceiptsKey) } private func rememberLocalUnlock(tokens: [String], userId: String) { guard !userId.isEmpty else { return } localUnlockSuppressionExpirations[userId] = Date().addingTimeInterval(120) if tokens.isEmpty { locallyUnlockedAllUserIds.insert(userId) locallyUnlockedTokensByUserId.removeValue(forKey: userId) return } locallyUnlockedTokensByUserId[userId, default: []].formUnion(tokens) } private func removeExpiredLocalUnlockSuppression(for userId: String) { guard let expiresAt = localUnlockSuppressionExpirations[userId], expiresAt <= Date() else { return } locallyUnlockedTokensByUserId.removeValue(forKey: userId) locallyUnlockedAllUserIds.remove(userId) localUnlockSuppressionExpirations.removeValue(forKey: userId) } private func pendingUnlockScope(for userId: String) -> (unlockAll: Bool, tokens: Set) { var unlockAll = false var tokens: Set = [] for receipt in pendingReceipts where receipt.userId == userId { guard let data = receipt.payload.data(using: .utf8), let payload = try? JSONDecoder().decode(MqttAppUnlockReceiptPayload.self, from: data) else { continue } let receiptTokens = payload.data.app_unlock.token if receiptTokens.isEmpty { unlockAll = true } else { tokens.formUnion(receiptTokens) } } return (unlockAll, tokens) } private func restoreLocalUnlockSuppression(from receipt: PendingAppUnlockReceipt) { guard let data = receipt.payload.data(using: .utf8), let payload = try? JSONDecoder().decode(MqttAppUnlockReceiptPayload.self, from: data) else { return } rememberLocalUnlock( tokens: payload.data.app_unlock.token, userId: receipt.userId ) } private func registerExpectedOutgoingEcho(_ payload: String) { removeExpiredOutgoingEchoes() var expected = expectedOutgoingEchoes[payload] ?? ExpectedAppUnlockEcho(count: 0, expiresAt: .distantPast) expected.count += 1 expected.expiresAt = Date().addingTimeInterval(60) expectedOutgoingEchoes[payload] = expected } private func cancelExpectedOutgoingEcho(_ payload: String) { guard var expected = expectedOutgoingEchoes[payload] else { return } expected.count -= 1 if expected.count > 0 { expectedOutgoingEchoes[payload] = expected } else { expectedOutgoingEchoes.removeValue(forKey: payload) } } private func consumeExpectedOutgoingEcho(_ payload: String) -> Bool { removeExpiredOutgoingEchoes() guard expectedOutgoingEchoes[payload] != nil else { return false } cancelExpectedOutgoingEcho(payload) return true } private func removeExpiredOutgoingEchoes() { let now = Date() expectedOutgoingEchoes = expectedOutgoingEchoes.filter { $0.value.expiresAt > now } } private func performOnMain(_ work: @escaping () -> Void) { if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } } } /// 手机信息上报数据 private struct MqttPhoneInfo: Codable { let brand: String let model: String let memory_total: String let memory_used: String let battery: String let network: String let brightness: String let volume: String let weather: String let use_time: String let unlock_count: String } private struct MqttPhoneReportData: Codable { let user_id: String let group_key: String let phone: MqttPhoneInfo } private struct MqttPhoneReportPayload: Codable { let type: String let data: MqttPhoneReportData let extra: String } private struct MqttPhoneUsageExtra: Codable { let count: Int } private struct MqttPhoneUsageItem: Codable { let day: String let appName: String let packageName: String let usageTime: Int let extra: MqttPhoneUsageExtra enum CodingKeys: String, CodingKey { case day case appName = "app_name" case packageName = "package" case usageTime = "usage_time" case extra } } private struct MqttPhoneUsageData: Codable { let userId: String let groupKey: String let phoneUsage: [MqttPhoneUsageItem] enum CodingKeys: String, CodingKey { case userId = "user_id" case groupKey = "group_key" case phoneUsage = "phone_usage" } } private struct MqttPhoneUsagePayload: Codable { let type: String let data: MqttPhoneUsageData let extra: String } private struct MqttPhoneUsageSnapshot: Equatable { let day: String let count: Int } // MARK: - MQTTService /// MQTT 5.0 服务,管理连接、订阅和消息收发 final class MQTTService: NSObject { static let shared = MQTTService() private var mqtt: CocoaMQTT5? private(set) var isConnected = false // MARK: - 连接状态回调 var onConnected: (() -> Void)? var onDisconnected: (() -> Void)? /// 全局接收回调(所有未匹配 topicCallback 的消息) var onMessageReceived: ((CocoaMQTT5Message, UInt16, MqttPublishProperties) -> Void)? /// 按 topic 订阅的回调(优先匹配) private var topicCallbacks: [String: (CocoaMQTT5Message) -> Void] = [:] // MARK: - 配置 private var host: String { "emqx.batiao8.com" } private var port: UInt16 { 1883 } /// 当前 clientID,切换用户时可更新后重连 private(set) var clientID: String = "" private var userName = "batiao" private var password = "Batiao12B" private var topic = "smartdrive/" // MARK: - 手机信息上报 private let phoneReportInterval: TimeInterval = 30 * 60 private let phoneReportRetryInterval: TimeInterval = 60 private let phoneNetworkMonitor = PhoneNetworkStatusMonitor() private var phoneReportGroupKey = "" private var phoneReportLocation: CLLocation? private var phoneReportTimer: Timer? private var lastPhoneReportDate: Date? private var isPhoneReportInFlight = false private var phoneReportGeneration = 0 private var lastObservedPhoneUsage: MqttPhoneUsageSnapshot? private var pendingPhoneUsage: MqttPhoneUsageSnapshot? override private init() { super.init() UIDevice.current.isBatteryMonitoringEnabled = true phoneNetworkMonitor.start() NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(appUsageCountDidChange), name: .unlockCountDidChange, object: nil ) let usage = Self.currentPhoneUsageSnapshot() lastObservedPhoneUsage = usage if usage.count > 0 { pendingPhoneUsage = usage } } // MARK: - 连接 func connect() { guard !isConnected else { return } if clientID.isEmpty { clientID = "smartdrive_\(AppContextManager.shared.userId)" } let mqtt = CocoaMQTT5(clientID: clientID, host: host, port: port) mqtt.username = userName mqtt.password = password mqtt.cleanSession = true mqtt.keepAlive = 30 mqtt.autoReconnect = true mqtt.autoReconnectTimeInterval = 5 mqtt.delegate = self mqtt.logLevel = .warning // MQTT 5 连接属性(对应 Android 端配置) let connProps = MqttConnectProperties() connProps.sessionExpiryInterval = 30 connProps.receiveMaximum = 10 connProps.maximumPacketSize = 10240 connProps.topicAliasMaximum = 0 mqtt.connectProperties = connProps // Will 消息 let will = CocoaMQTT5Message(topic: "willtopic", payload: []) will.qos = .qos1 will.retained = false mqtt.willMessage = will self.mqtt = mqtt _ = mqtt.connect() } // MARK: - 断开 func disconnect() { mqtt?.disconnect() isConnected = false invalidatePhoneReportTimer() AppUnlockCoordinator.shared.mqttDidDisconnect() } // MARK: - 切换用户 /// 更新 clientID 并重连(切换用户后调用) func updateClientID(_ newID: String) { resetPhoneReportState() clientID = newID disconnect() connect() } // MARK: - 订阅主题 /// - Parameters: /// - topic: 主题 /// - qos: 服务质量 /// - callback: 可选,该 topic 的专用回调,收到消息时优先于此回调 func subscribe(topic: String, qos: CocoaMQTTQoS = .qos1, callback: ((CocoaMQTT5Message) -> Void)? = nil) { let subscription = MqttSubscription(topic: topic, qos: qos) subscription.noLocal = false subscription.retainAsPublished = true subscription.retainHandling = .none mqtt?.subscribe([subscription]) if let cb = callback { topicCallbacks[topic] = cb } } // MARK: - 取消订阅 func unsubscribe(topic: String) { mqtt?.unsubscribe(topic) topicCallbacks.removeValue(forKey: topic) } /// 订阅指定圈子的所有成员位置 topic: smartdrive/ func subscribeGroupMembers(_ memberIds: [String]) { for id in memberIds { subscribe(topic: "\(topic)\(id)") } } /// 取消订阅上一批成员 func unsubscribeGroupMembers(_ memberIds: [String]) { for id in memberIds { mqtt?.unsubscribe("\(topic)\(id)") } } // MARK: - 发布消息 @discardableResult func publish(topic: String, message: String, qos: CocoaMQTTQoS = .qos1) -> Int { let properties = MqttPublishProperties() return mqtt?.publish(topic, withString: message, qos: qos, DUP: false, retained: false, properties: properties) ?? -1 } @discardableResult func publish(topic: String, data: Data, qos: CocoaMQTTQoS = .qos1) -> Int { let properties = MqttPublishProperties() let message = CocoaMQTT5Message(topic: topic, payload: [UInt8](data)) return mqtt?.publish(message, DUP: false, retained: false, properties: properties) ?? -1 } // MARK: - 位置上报 /// 构建并上报位置数据(格式与 Android 一致) func reportLocation(lat: Double, lon: Double, addr: String, speed: CLLocationSpeed, bearing: CLLocationDirection, altitude: CLLocationDistance, accuracy: CLLocationAccuracy) { let battery = UIDevice.current.batteryLevel > 0 ? Int(UIDevice.current.batteryLevel * 100) : 0 let point = Points( lat: lat, lon: lon, addr: addr, time: Int64(Date().timeIntervalSince1970 * 1000), speed: speed, bearing: bearing, altitude: altitude, accuracy: accuracy ) let location = MqttLocation(battery: battery.string, points: [point]) guard let jsonData = try? JSONEncoder().encode(location), let dataDict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { return } // 外层包装,data 是 JSON 对象而非字符串 let payload: [String: Any] = [ "type": MqttType.track.rawValue, "extra": "", "data": dataDict ] publish(topic: "\(topic)\(AppContextManager.shared.userId)", message: payload.toJsonString()) } // MARK: - 签到 func reportSignIn(lat: Double, lon: Double, addr: String) { let signIn = MqttSignIn(topic: nil, latitude: lat, longitude: lon, address: addr) guard let jsonData = try? JSONEncoder().encode(signIn), let dataDict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { return } let payload: [String: Any] = [ "type": MqttType.signIn.rawValue, "data": dataDict, "extra": "", "sort": "" ] publish(topic: "\(topic)\(AppContextManager.shared.userId)", message: payload.toJsonString()) } // MARK: - 手机信息上报 /// 首页提供当前默认圈子和最新定位。切换圈子只更新上下文,不额外触发重复上报。 func updatePhoneReportContext(groupKey: String?, location: CLLocation?) { performOnMain { [weak self] in guard let self else { return } if let groupKey { self.phoneReportGroupKey = groupKey } if let location, CLLocationCoordinate2DIsValid(location.coordinate) { self.phoneReportLocation = location } self.reportPhoneUsageIfNeeded() self.reportPhoneIfNeeded() } } /// MQTT 重连、Timer 和 App 回到前台都通过此入口检查是否到期。 func reportPhoneIfNeeded(force: Bool = false) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.reportPhoneIfNeeded(force: force) } return } guard isConnected, !isPhoneReportInFlight, !phoneReportGroupKey.isEmpty, !AppContextManager.shared.userId.isEmpty else { return } if !force, let lastPhoneReportDate, Date().timeIntervalSince(lastPhoneReportDate) < phoneReportInterval { schedulePhoneReportTimer(after: phoneReportInterval - Date().timeIntervalSince(lastPhoneReportDate)) return } isPhoneReportInFlight = true phoneReportGeneration += 1 let generation = phoneReportGeneration guard let location = phoneReportLocation else { finishPhoneReport(weather: "未知", generation: generation) return } MemberWeatherService.shared.weather(for: location) { [weak self] result in let weather = (try? result.get().text) ?? "未知" self?.finishPhoneReport(weather: weather, generation: generation) } } @objc func applicationDidBecomeActive() { capturePhoneUsageChange() reportPhoneUsageIfNeeded() reportPhoneIfNeeded() } @objc private func appUsageCountDidChange() { performOnMain { [weak self] in self?.capturePhoneUsageChange() } } private func capturePhoneUsageChange() { let usage = Self.currentPhoneUsageSnapshot() guard usage != lastObservedPhoneUsage else { return } lastObservedPhoneUsage = usage pendingPhoneUsage = usage reportPhoneUsageIfNeeded() } private func reportPhoneUsageIfNeeded() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.reportPhoneUsageIfNeeded() } return } let userId = AppContextManager.shared.userId let groupKey = phoneReportGroupKey guard isConnected, let usage = pendingPhoneUsage, !userId.isEmpty, !groupKey.isEmpty else { return } let item = MqttPhoneUsageItem( day: usage.day, appName: Self.appDisplayName, packageName: "cn.zuom8.jisuloca", usageTime: 0, extra: MqttPhoneUsageExtra(count: usage.count) ) let payload = MqttPhoneUsagePayload( type: MqttType.phoneUsage.rawValue, data: MqttPhoneUsageData( userId: userId, groupKey: groupKey, phoneUsage: [item] ), extra: "" ) guard let data = try? JSONEncoder().encode(payload), let message = String(data: data, encoding: .utf8) else { return } let messageId = publish( topic: "\(topic)\(userId)", message: message ) if messageId >= 0, pendingPhoneUsage == usage { pendingPhoneUsage = nil } } private func finishPhoneReport(weather: String, generation: Int) { performOnMain { [weak self] in guard let self, generation == self.phoneReportGeneration else { return } self.isPhoneReportInFlight = false let userId = AppContextManager.shared.userId let groupKey = self.phoneReportGroupKey guard self.isConnected, !userId.isEmpty, !groupKey.isEmpty else { return } let payload = MqttPhoneReportPayload( type: MqttType.phone.rawValue, data: MqttPhoneReportData( user_id: userId, group_key: groupKey, phone: self.collectPhoneInfo(weather: weather) ), extra: "" ) guard let data = try? JSONEncoder().encode(payload), let message = String(data: data, encoding: .utf8) else { self.schedulePhoneReportTimer(after: self.phoneReportRetryInterval) return } let messageId = self.publish( topic: "\(self.topic)\(userId)", message: message ) if messageId >= 0 { self.lastPhoneReportDate = Date() self.schedulePhoneReportTimer(after: self.phoneReportInterval) } else { self.schedulePhoneReportTimer(after: self.phoneReportRetryInterval) } } } private func collectPhoneInfo(weather: String) -> MqttPhoneInfo { let storage = Self.storageInfo() return MqttPhoneInfo( brand: "iphone", model: UIDevice.modelName, memory_total: storage?.total ?? "未知", memory_used: storage?.used ?? "未知", battery: UIDevice.batteryPercent.map { "\($0)%" } ?? "未知", network: phoneNetworkMonitor.statusText, brightness: "\(Int((UIScreen.main.brightness * 100).rounded()))%", volume: "\(Int((AVAudioSession.sharedInstance().outputVolume * 100).rounded()))%", weather: weather, use_time: "\(max(0, UnlockCountManager.shared.todayScreenTimeSeconds))", unlock_count: "\(UnlockCountManager.shared.todayCount)" ) } private func schedulePhoneReportTimer(after interval: TimeInterval) { invalidatePhoneReportTimer() let timer = Timer(timeInterval: max(1, interval), repeats: false) { [weak self] _ in self?.phoneReportTimer = nil self?.reportPhoneIfNeeded() } RunLoop.main.add(timer, forMode: .common) phoneReportTimer = timer } private func invalidatePhoneReportTimer() { performOnMain { [weak self] in self?.phoneReportTimer?.invalidate() self?.phoneReportTimer = nil } } private func resetPhoneReportState() { performOnMain { [weak self] in guard let self else { return } self.phoneReportGeneration += 1 self.phoneReportGroupKey = "" self.phoneReportLocation = nil self.lastPhoneReportDate = nil self.isPhoneReportInFlight = false self.invalidatePhoneReportTimer() } } private func performOnMain(_ work: @escaping () -> Void) { if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } } private static func storageInfo() -> (used: String, total: String)? { let url = URL(fileURLWithPath: NSHomeDirectory()) let keys: Set = [ .volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey ] guard let values = try? url.resourceValues(forKeys: keys), let total = values.volumeTotalCapacity, let available = values.volumeAvailableCapacityForImportantUsage, total > 0 else { return nil } let totalBytes = Int64(total) let usedBytes = max(0, totalBytes - available) return ( used: String(format: "%.1fGB", Double(usedBytes) / 1_000_000_000), total: String(format: "%.0fGB", Double(totalBytes) / 1_000_000_000) ) } private static func currentPhoneUsageSnapshot() -> MqttPhoneUsageSnapshot { let formatter = DateFormatter() formatter.calendar = Calendar.current formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone.current formatter.dateFormat = "yyyy-MM-dd" return MqttPhoneUsageSnapshot( day: formatter.string(from: Date()), count: max(0, UnlockCountManager.shared.todayAppUsageCount) ) } private static var appDisplayName: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "极速定位" } } // MARK: - CocoaMQTT5Delegate extension MQTTService: CocoaMQTT5Delegate { func mqtt5(_ mqtt5: CocoaMQTT5, didConnectAck ack: CocoaMQTTCONNACKReasonCode, connAckData: MqttDecodeConnAck?) { isConnected = true print("MQTT5 connected: \(ack)") // 订阅基础 topic,接收 signIn/join/leave 等非位置消息 subscribe(topic: topic) let userId = AppContextManager.shared.userId.trimmed if !userId.isEmpty { subscribe(topic: "\(topic)\(userId)") } onConnected?() AppUnlockCoordinator.shared.mqttDidConnect() reportPhoneUsageIfNeeded() reportPhoneIfNeeded() } func mqtt5(_ mqtt5: CocoaMQTT5, didPublishMessage message: CocoaMQTT5Message, id: UInt16) { print("MQTT5 published: \(message.topic)") } func mqtt5(_ mqtt5: CocoaMQTT5, didPublishAck id: UInt16, pubAckData: MqttDecodePubAck?) { print("MQTT5 publish ack: \(id)") AppUnlockCoordinator.shared.mqttDidAcknowledgePublish(id: id) } func mqtt5(_ mqtt5: CocoaMQTT5, didPublishRec id: UInt16, pubRecData: MqttDecodePubRec?) {} func mqtt5(_ mqtt5: CocoaMQTT5, didReceiveMessage message: CocoaMQTT5Message, id: UInt16, publishData: MqttDecodePublish?) { if AppUnlockCoordinator.shared.handleIncoming(topic: message.topic, payload: message.string) { return } // 优先 topic 专用回调 if let cb = topicCallbacks[message.topic] { cb(message) return } // 没有专用回调时走全局回调 if let payload = message.string { print("MQTT5 received on \(message.topic): \(payload)") } onMessageReceived?(message, id, MqttPublishProperties()) } func mqtt5(_ mqtt5: CocoaMQTT5, didSubscribeTopics success: NSDictionary, failed: [String], subAckData: MqttDecodeSubAck?) { print("MQTT5 subscribe success: \(success), failed: \(failed)") } func mqtt5(_ mqtt5: CocoaMQTT5, didUnsubscribeTopics topics: [String], unsubAckData: MqttDecodeUnsubAck?) { print("MQTT5 unsubscribe: \(topics)") } func mqtt5(_ mqtt5: CocoaMQTT5, didReceiveDisconnectReasonCode reasonCode: CocoaMQTTDISCONNECTReasonCode) {} func mqtt5(_ mqtt5: CocoaMQTT5, didReceiveAuthReasonCode reasonCode: CocoaMQTTAUTHReasonCode) {} func mqtt5DidPing(_ mqtt5: CocoaMQTT5) {} func mqtt5DidReceivePong(_ mqtt5: CocoaMQTT5) {} func mqtt5DidDisconnect(_ mqtt5: CocoaMQTT5, withError err: Error?) { isConnected = false invalidatePhoneReportTimer() print("MQTT5 disconnected: \(err?.localizedDescription ?? "")") AppUnlockCoordinator.shared.mqttDidDisconnect() onDisconnected?() } } private final class PhoneNetworkStatusMonitor { private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "com.quicklocation.mqtt.network") private let lock = NSLock() private let telephonyInfo = CTTelephonyNetworkInfo() private var path: NWPath? func start() { monitor.pathUpdateHandler = { [weak self] path in self?.lock.lock() self?.path = path self?.lock.unlock() } monitor.start(queue: queue) } var statusText: String { lock.lock() let currentPath = path lock.unlock() guard let currentPath, currentPath.status == .satisfied else { return "无网络" } if currentPath.usesInterfaceType(.wifi) { return "Wi-Fi" } guard currentPath.usesInterfaceType(.cellular) else { return "无网络" } return cellularGeneration } private var cellularGeneration: String { let technologies = telephonyInfo.serviceCurrentRadioAccessTechnology .map { Array($0.values) } ?? [] guard let technology = technologies.first else { return "蜂窝" } switch technology { case CTRadioAccessTechnologyNR, CTRadioAccessTechnologyNRNSA: return "5G" case CTRadioAccessTechnologyLTE: return "4G" case CTRadioAccessTechnologyWCDMA, CTRadioAccessTechnologyHSDPA, CTRadioAccessTechnologyHSUPA, CTRadioAccessTechnologyCDMAEVDORev0, CTRadioAccessTechnologyCDMAEVDORevA, CTRadioAccessTechnologyCDMAEVDORevB, CTRadioAccessTechnologyeHRPD: return "3G" case CTRadioAccessTechnologyGPRS, CTRadioAccessTechnologyEdge, CTRadioAccessTechnologyCDMA1x: return "2G" default: return "蜂窝" } } }