jsdw_ios/QuickLocation/Manager/MQTT/MQTTService.swift

724 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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/<memberId>
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<URLResourceKey> = [
.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 "蜂窝"
}
}
}