// // MQTTService.swift // QuickLocation // // Created by 八条 on 2026/6/12. // import Foundation import CocoaMQTT import UIKit import CoreLocation import Network 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 case lockAppsChanged = "lockAppsChanged" // 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? } private struct MqttLockAppsChangedData: Codable, Equatable { let userId: String let groupKey: String enum CodingKeys: String, CodingKey { case userId = "user_id" case groupKey = "group_key" } } private struct MqttLockAppsChangedPayload: Codable { let type: String let data: MqttLockAppsChangedData let extra: String } 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 LegacyMqttAppUnlockReceiptBody: Codable { let os: String let group_key: String let group_name: String let user_id: String let token: [String] } private struct LegacyMqttAppUnlockReceiptData: Codable { let app_unlock: LegacyMqttAppUnlockReceiptBody } private struct LegacyMqttAppUnlockReceiptPayload: Codable { let type: String let data: LegacyMqttAppUnlockReceiptData let extra: String } private struct LegacyPendingAppUnlockReceipt: Codable { let identifier: String let userId: String let payload: String 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 PendingAppUnlockConfirmation: Codable, Equatable { let identifier: String let userId: String let os: String let groupKey: String let tokens: [String] init( identifier: String = UUID().uuidString, userId: String, os: String, groupKey: String, tokens: [String] ) { self.identifier = identifier self.userId = userId self.os = os self.groupKey = groupKey self.tokens = tokens } } final class AppUnlockCoordinator { static let shared = AppUnlockCoordinator() private let pendingConfirmationsKey = "AppUnlockPendingConfirmations" private let legacyPendingReceiptsKey = "AppUnlockPendingReceipts" private var pendingConfirmations: [PendingAppUnlockConfirmation] private var inFlightConfirmation: PendingAppUnlockConfirmation? 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 confirmationDisposable: Disposable? private let confirmationNetworkMonitor = NWPathMonitor() private let confirmationNetworkQueue = DispatchQueue(label: "com.quicklocation.app-unlock-confirmation") private var observers: [NSObjectProtocol] = [] private var isStarted = false private init() { if let data = UserDefaults.standard.data(forKey: pendingConfirmationsKey), let confirmations = try? JSONDecoder().decode([PendingAppUnlockConfirmation].self, from: data) { pendingConfirmations = confirmations } else if let data = UserDefaults.standard.data(forKey: legacyPendingReceiptsKey), let receipts = try? JSONDecoder().decode([LegacyPendingAppUnlockReceipt].self, from: data) { pendingConfirmations = receipts.compactMap(Self.makeConfirmation(from:)) if let migratedData = try? JSONEncoder().encode(pendingConfirmations) { UserDefaults.standard.set(migratedData, forKey: pendingConfirmationsKey) UserDefaults.standard.removeObject(forKey: legacyPendingReceiptsKey) } } else { pendingConfirmations = [] } for confirmation in pendingConfirmations { rememberLocalUnlock(tokens: confirmation.tokens, userId: confirmation.userId) } } func start() { performOnMain { [weak self] in guard let self, !self.isStarted else { return } self.isStarted = true self.confirmationNetworkMonitor.pathUpdateHandler = { [weak self] path in guard path.status == .satisfied else { return } self?.performOnMain { [weak self] in self?.retryPendingConfirmations() } } self.confirmationNetworkMonitor.start(queue: self.confirmationNetworkQueue) self.observers.append( NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in self?.checkOfflineUnlockForCurrentUser() self?.retryPendingConfirmations() } ) self.observers.append( NotificationCenter.default.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main ) { [weak self] _ in let userId = AppContextManager.shared.userId.trimmed self?.completedAllowUserIds.remove(userId) } ) self.observers.append( NotificationCenter.default.addObserver( forName: .RefreshUserConfigNotification, object: nil, queue: .main ) { [weak self] _ in self?.checkOfflineUnlockForCurrentUser() self?.retryPendingConfirmations() } ) self.checkOfflineUnlockForCurrentUser() self.retryPendingConfirmations() } } 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.cancelConfirmationForDifferentUser(userId) self.observedUserId = userId self.completedAllowUserIds.remove(userId) self.retryPendingConfirmations() } 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.applyUnlockAndEnqueueConfirmation(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 = "" } if self.inFlightConfirmation?.userId == userId { self.confirmationDisposable?.dispose() self.confirmationDisposable = nil self.inFlightConfirmation = nil } 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 } 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.applyUnlockAndEnqueueConfirmation(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 } } private func applyUnlockAndEnqueueConfirmation(record: PhoneLockRecord, userId: String) { applyUnlock(tokens: record.tokens) let confirmation = PendingAppUnlockConfirmation( userId: userId, os: record.os.trimmed.isEmpty ? "ios" : record.os.trimmed, groupKey: record.groupKey, tokens: record.tokens ) let isPending = pendingConfirmations.contains { $0.userId == confirmation.userId && $0.os == confirmation.os && $0.groupKey == confirmation.groupKey && $0.tokens == confirmation.tokens } if !isPending { pendingConfirmations.append(confirmation) savePendingConfirmations() } retryPendingConfirmations() } private func retryPendingConfirmations() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.retryPendingConfirmations() } return } let userId = AppContextManager.shared.userId.trimmed guard !userId.isEmpty else { return } cancelConfirmationForDifferentUser(userId) guard inFlightConfirmation == nil, let confirmation = pendingConfirmations.first(where: { $0.userId == userId }) else { return } inFlightConfirmation = confirmation confirmationDisposable = UserService.phoneUnlockAllowConfirm( os: confirmation.os, groupKey: confirmation.groupKey, userId: confirmation.userId, tokens: confirmation.tokens ) .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] response in guard let self, self.inFlightConfirmation?.identifier == confirmation.identifier else { return } self.inFlightConfirmation = nil self.confirmationDisposable = nil guard response.code == "0" else { print("[AppUnlock] unlock confirmation failed: \(response.message ?? "unknown error")") return } MQTTService.shared.reportLockAppsChanged( userId: confirmation.userId, groupKey: confirmation.groupKey ) self.pendingConfirmations.removeAll { $0.identifier == confirmation.identifier } self.savePendingConfirmations() self.retryPendingConfirmations() }, onError: { [weak self] error in guard let self, self.inFlightConfirmation?.identifier == confirmation.identifier else { return } self.inFlightConfirmation = nil self.confirmationDisposable = nil print("[AppUnlock] unlock confirmation request failed: \(error.gatewayMessage ?? error.localizedDescription)") }) } private func finishAllowRequest(for userId: String) { guard allowRequestUserId == userId else { return } allowRequestUserId = "" allowDisposable = nil } private func cancelConfirmationForDifferentUser(_ userId: String) { guard let confirmation = inFlightConfirmation, confirmation.userId != userId else { return } confirmationDisposable?.dispose() confirmationDisposable = nil inFlightConfirmation = nil } private func savePendingConfirmations() { guard let data = try? JSONEncoder().encode(pendingConfirmations) else { return } UserDefaults.standard.set(data, forKey: pendingConfirmationsKey) } 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 confirmation in pendingConfirmations where confirmation.userId == userId { if confirmation.tokens.isEmpty { unlockAll = true } else { tokens.formUnion(confirmation.tokens) } } return (unlockAll, tokens) } private static func makeConfirmation( from receipt: LegacyPendingAppUnlockReceipt ) -> PendingAppUnlockConfirmation? { guard let data = receipt.payload.data(using: .utf8), let payload = try? JSONDecoder().decode(LegacyMqttAppUnlockReceiptPayload.self, from: data) else { return nil } let body = payload.data.app_unlock let userId = receipt.userId.trimmed.isEmpty ? body.user_id.trimmed : receipt.userId.trimmed guard !userId.isEmpty else { return nil } return PendingAppUnlockConfirmation( identifier: receipt.identifier, userId: userId, os: body.os.trimmed.isEmpty ? "ios" : body.os.trimmed, groupKey: body.group_key, tokens: body.token ) } 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] = [:] private var lockStateSubscriptionsByOwner: [UUID: Set] = [:] private var pendingLockAppsChangedEvents: [MqttLockAppsChangedData] = [] // 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/" private var lockStateSubscriptionTopics: Set { lockStateSubscriptionsByOwner.values.reduce(into: Set()) { result, topics in result.formUnion(topics) } } private func postLockDistractAppsChangeIfNeeded(topic: String, payload: String?) { guard let payload, let data = payload.data(using: .utf8), let message = try? JSONDecoder().decode(MqttIncomingMessage.self, from: data), let type = message.type else { return } let topicUserId = topic.replacingOccurrences(of: self.topic, with: "").trimmed let userId: String let groupKey: String switch type { case MqttType.lockApp.rawValue: userId = message.data?.lock_app?.user_id?.trimmed ?? topicUserId groupKey = message.data?.lock_app?.group_key?.trimmed ?? "" case MqttType.appUnlock.rawValue: userId = message.data?.app_unlock?.user_id?.trimmed ?? topicUserId groupKey = message.data?.app_unlock?.group_key?.trimmed ?? "" case MqttType.lockAppsChanged.rawValue: userId = message.data?.user_id?.trimmed ?? topicUserId groupKey = message.data?.group_key?.trimmed ?? "" default: return } postLockDistractAppsChange(userId: userId, groupKey: groupKey) } private func postLockDistractAppsChange(userId: String, groupKey: String) { let post = { NotificationCenter.default.post( name: .lockDistractAppsDidChange, object: nil, userInfo: [ LockDistractAppsChangeUserInfoKey.userId: userId, LockDistractAppsChangeUserInfoKey.groupKey: groupKey ] ) } if Thread.isMainThread { post() } else { DispatchQueue.main.async(execute: post) } } private func postGroupDataChangeIfNeeded(payload: String?) { guard let payload, let data = payload.data(using: .utf8), let message = try? JSONDecoder().decode(MqttIncomingMessage.self, from: data), let operation = message.type, ["join", "leave", "dismiss", "kick"].contains(operation) else { return } var userInfo: [AnyHashable: Any] = [ GroupDataChangeUserInfoKey.operation: operation, GroupDataChangeUserInfoKey.source: GroupDataChangeSource.mqtt ] if let groupKey = message.data?.group_key, !groupKey.isEmpty { userInfo[GroupDataChangeUserInfoKey.groupKey] = groupKey } if let userId = message.data?.user_id, !userId.isEmpty { userInfo[GroupDataChangeUserInfoKey.userId] = userId } let post = { NotificationCenter.default.post( name: .RefreshGroupInfoNotification, object: nil, userInfo: userInfo ) } if Thread.isMainThread { post() } else { DispatchQueue.main.sync(execute: post) } } // MARK: - 手机信息上报 private let phoneReportInterval: TimeInterval = 30 * 60 private let phoneReportRetryInterval: TimeInterval = 60 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 PhoneStatusCollector.shared.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() } // 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 } } func updateLockStateSubscriptions(owner: UUID, userIds: [String]) { performOnMain { [weak self] in guard let self else { return } let previousTopics = self.lockStateSubscriptionTopics let nextOwnerTopics = Set( userIds .map(\.trimmed) .filter { !$0.isEmpty } .map { "\(self.topic)\($0)" } ) if nextOwnerTopics.isEmpty { self.lockStateSubscriptionsByOwner.removeValue(forKey: owner) } else { self.lockStateSubscriptionsByOwner[owner] = nextOwnerTopics } let nextTopics = self.lockStateSubscriptionTopics for subscriptionTopic in nextTopics.subtracting(previousTopics) { self.subscribe(topic: subscriptionTopic) } for subscriptionTopic in previousTopics.subtracting(nextTopics) where self.topicCallbacks[subscriptionTopic] == nil && !self.isRequiredBaseSubscription(subscriptionTopic) { self.mqtt?.unsubscribe(subscriptionTopic) } } } // 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 { let subscriptionTopic = "\(topic)\(id)" topicCallbacks.removeValue(forKey: subscriptionTopic) if !lockStateSubscriptionTopics.contains(subscriptionTopic), !isRequiredBaseSubscription(subscriptionTopic) { mqtt?.unsubscribe(subscriptionTopic) } } } func reportLockAppsChanged(userId: String, groupKey: String) { performOnMain { [weak self] in guard let self else { return } let event = MqttLockAppsChangedData( userId: userId.trimmed, groupKey: groupKey.trimmed ) guard !event.userId.isEmpty else { return } self.postLockDistractAppsChange(userId: event.userId, groupKey: event.groupKey) if !self.pendingLockAppsChangedEvents.contains(event) { self.pendingLockAppsChangedEvents.append(event) } self.flushPendingLockAppsChangedEvents() } } private func flushPendingLockAppsChangedEvents() { guard isConnected, !pendingLockAppsChangedEvents.isEmpty else { return } var unsentEvents: [MqttLockAppsChangedData] = [] for event in pendingLockAppsChangedEvents { let payload = MqttLockAppsChangedPayload( type: MqttType.lockAppsChanged.rawValue, data: event, extra: "" ) guard let data = try? JSONEncoder().encode(payload), let message = String(data: data, encoding: .utf8), publish(topic: "\(topic)\(event.userId)", message: message) >= 0 else { unsentEvents.append(event) continue } } pendingLockAppsChangedEvents = unsentEvents } private func isRequiredBaseSubscription(_ subscriptionTopic: String) -> Bool { if subscriptionTopic == topic { return true } let currentUserId = AppContextManager.shared.userId.trimmed return !currentUserId.isEmpty && subscriptionTopic == "\(topic)\(currentUserId)" } // 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 snap = PhoneStatusCollector.shared.snapshot() return MqttPhoneInfo( brand: "iphone", model: snap.model, memory_total: snap.memoryTotal ?? "未知", memory_used: snap.memoryUsed ?? "未知", battery: snap.batteryText, network: snap.network, brightness: snap.brightnessText, volume: snap.volumeText, 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 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)") } for subscriptionTopic in Set(topicCallbacks.keys).union(lockStateSubscriptionTopics) { subscribe(topic: subscriptionTopic) } onConnected?() flushPendingLockAppsChangedEvents() 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)") } func mqtt5(_ mqtt5: CocoaMQTT5, didPublishRec id: UInt16, pubRecData: MqttDecodePubRec?) {} func mqtt5(_ mqtt5: CocoaMQTT5, didReceiveMessage message: CocoaMQTT5Message, id: UInt16, publishData: MqttDecodePublish?) { postGroupDataChangeIfNeeded(payload: message.string) postLockDistractAppsChangeIfNeeded(topic: message.topic, payload: message.string) 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 ?? "")") onDisconnected?() } }