jsdw_ios/QuickLocation/Manager/MQTT/MQTTService.swift

1259 lines
43 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
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<String> = []
private var observedUserId = ""
private var lockStateRevision: UInt64 = 0
private var locallyUnlockedTokensByUserId: [String: Set<String>] = [:]
private var locallyUnlockedAllUserIds: Set<String> = []
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<String>) {
var unlockAll = false
var tokens: Set<String> = []
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/<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)
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 "蜂窝"
}
}
}