// // MQTTService.swift // QuickLocation // // Created by 八条 on 2026/6/12. // import Foundation import CocoaMQTT import UIKit import CoreLocation import Network import CoreTelephony import AVFoundation // 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 使用次数上报 } /// 单点位置 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? } /// 手机信息上报数据 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 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() } // 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) onConnected?() 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?) { // 优先 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?() } } 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 "蜂窝" } } }