jsdw_ios/QuickLocation/Manager/MQTT/MQTTService.swift

1369 lines
48 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 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<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 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<String>) {
var unlockAll = false
var tokens: Set<String> = []
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<String>] = [:]
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<String> {
lockStateSubscriptionsByOwner.values.reduce(into: Set<String>()) { 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/<memberId>
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?()
}
}