diff --git a/QuickLocation/API/UserAPI.swift b/QuickLocation/API/UserAPI.swift index f7962508..e4fab30d 100644 --- a/QuickLocation/API/UserAPI.swift +++ b/QuickLocation/API/UserAPI.swift @@ -70,6 +70,9 @@ enum UserAPI { /// 查询本机离线期间是否允许解锁 case phoneUnlockAllow(os: String) + /// 确认本机已执行解锁 + case phoneUnlockAllowConfirm(os: String, groupKey: String, userId: String, tokens: [String]) + /// 用户IM Token case imToken @@ -172,7 +175,7 @@ extension UserAPI: MultiTargetProtocol { return "mapi/phone/unlock/request" case .phoneUnlock: return "mapi/phone/unlock" - case .phoneUnlockAllow: + case .phoneUnlockAllow, .phoneUnlockAllowConfirm: return "mapi/phone/unlock/allow" case .imToken: return "mapi/openim/user/token/get" @@ -358,6 +361,17 @@ extension UserAPI: MultiTargetProtocol { parameters: ["os": os], encoding: URLEncoding.queryString ) + + case let .phoneUnlockAllowConfirm(os, groupKey, userId, tokens): + return .requestParameters( + parameters: [ + "os": os, + "group_key": groupKey, + "user_id": userId, + "token": tokens + ], + encoding: JSONEncoding() + ) case .imToken: var params = Parameters() diff --git a/QuickLocation/Common/Constant.swift b/QuickLocation/Common/Constant.swift index 7d5eda72..aeb79fc1 100644 --- a/QuickLocation/Common/Constant.swift +++ b/QuickLocation/Common/Constant.swift @@ -79,6 +79,18 @@ extension Notification.Name { static let lockDistractAppsDidChange = Notification.Name("lockDistractAppsDidChange") } +enum GroupDataChangeUserInfoKey { + static let groupKey = "group_key" + static let userId = "user_id" + static let operation = "operation" + static let source = "source" +} + +enum GroupDataChangeSource { + static let api = "api" + static let mqtt = "mqtt" +} + // MARK: - 普通常量 /// KeyWindow diff --git a/QuickLocation/Core/Extension/UITextField+Extensions.swift b/QuickLocation/Core/Extension/UITextField+Extensions.swift index 9ed97171..c8008cb9 100644 --- a/QuickLocation/Core/Extension/UITextField+Extensions.swift +++ b/QuickLocation/Core/Extension/UITextField+Extensions.swift @@ -8,6 +8,12 @@ import Foundation import UIKit +extension UITextInput { + var isComposingText: Bool { + markedTextRange != nil + } +} + extension UITextField { func placeholderColor(placeholder: String, color: UIColor, font: UIFont = UIFont.systemFont(ofSize:15) ) { // 字体大小 @@ -23,5 +29,52 @@ extension UITextField { self.leftView = leftView self.leftViewMode = UITextField.ViewMode.always } - + + @discardableResult + func limitCommittedText(to maximumLength: Int) -> String? { + guard !isComposingText else { return nil } + let currentText = text ?? "" + guard maximumLength > 0, currentText.count > maximumLength else { + return currentText + } + + let cursorOffset = selectedTextRange.map { + offset(from: beginningOfDocument, to: $0.start) + } ?? (currentText as NSString).length + let limitedText = String(currentText.prefix(maximumLength)) + text = limitedText + restoreCursor(to: cursorOffset, text: limitedText) + return limitedText + } + + private func restoreCursor(to offset: Int, text: String) { + let validOffset = min(max(0, offset), (text as NSString).length) + guard let cursorPosition = position(from: beginningOfDocument, offset: validOffset) else { return } + selectedTextRange = textRange(from: cursorPosition, to: cursorPosition) + } +} + +extension UITextView { + @discardableResult + func limitCommittedText(to maximumLength: Int) -> String? { + guard !isComposingText else { return nil } + let currentText = text ?? "" + guard maximumLength > 0, currentText.count > maximumLength else { + return currentText + } + + let cursorOffset = selectedTextRange.map { + offset(from: beginningOfDocument, to: $0.start) + } ?? (currentText as NSString).length + let limitedText = String(currentText.prefix(maximumLength)) + text = limitedText + restoreCursor(to: cursorOffset, text: limitedText) + return limitedText + } + + private func restoreCursor(to offset: Int, text: String) { + let validOffset = min(max(0, offset), (text as NSString).length) + guard let cursorPosition = position(from: beginningOfDocument, offset: validOffset) else { return } + selectedTextRange = textRange(from: cursorPosition, to: cursorPosition) + } } diff --git a/QuickLocation/Manager/MQTT/MQTTService.swift b/QuickLocation/Manager/MQTT/MQTTService.swift index 0441a7dd..983cb2bc 100644 --- a/QuickLocation/Manager/MQTT/MQTTService.swift +++ b/QuickLocation/Manager/MQTT/MQTTService.swift @@ -137,7 +137,7 @@ extension PhoneLockRecord { } } -private struct MqttAppUnlockReceiptBody: Codable { +private struct LegacyMqttAppUnlockReceiptBody: Codable { let os: String let group_key: String let group_name: String @@ -145,27 +145,21 @@ private struct MqttAppUnlockReceiptBody: Codable { let token: [String] } -private struct MqttAppUnlockReceiptData: Codable { - let app_unlock: MqttAppUnlockReceiptBody +private struct LegacyMqttAppUnlockReceiptData: Codable { + let app_unlock: LegacyMqttAppUnlockReceiptBody } -private struct MqttAppUnlockReceiptPayload: Codable { +private struct LegacyMqttAppUnlockReceiptPayload: Codable { let type: String - let data: MqttAppUnlockReceiptData + let data: LegacyMqttAppUnlockReceiptData let extra: String } -private struct PendingAppUnlockReceipt: Codable, Equatable { +private struct LegacyPendingAppUnlockReceipt: Codable { 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 @@ -180,18 +174,35 @@ private struct PendingAppUnlockReceipt: Codable, Equatable { } } -private struct ExpectedAppUnlockEcho { - var count: Int - var expiresAt: Date +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 pendingReceiptsKey = "AppUnlockPendingReceipts" - private var pendingReceipts: [PendingAppUnlockReceipt] - private var inFlightReceipts: [UInt16: PendingAppUnlockReceipt] = [:] - private var expectedOutgoingEchoes: [String: ExpectedAppUnlockEcho] = [:] + private let pendingConfirmationsKey = "AppUnlockPendingConfirmations" + private let legacyPendingReceiptsKey = "AppUnlockPendingReceipts" + private var pendingConfirmations: [PendingAppUnlockConfirmation] + private var inFlightConfirmation: PendingAppUnlockConfirmation? private var allowRequestUserId = "" private var completedAllowUserIds: Set = [] private var observedUserId = "" @@ -200,18 +211,28 @@ final class AppUnlockCoordinator { private var locallyUnlockedAllUserIds: Set = [] private var localUnlockSuppressionExpirations: [String: Date] = [:] private var allowDisposable: Disposable? + private var confirmationDisposable: Disposable? + private let confirmationNetworkMonitor = NWPathMonitor() + private let confirmationNetworkQueue = DispatchQueue(label: "com.quicklocation.app-unlock-confirmation") private var observers: [NSObjectProtocol] = [] private var isStarted = false private init() { - if let data = UserDefaults.standard.data(forKey: pendingReceiptsKey), - let receipts = try? JSONDecoder().decode([PendingAppUnlockReceipt].self, from: data) { - pendingReceipts = receipts + 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 { - pendingReceipts = [] + pendingConfirmations = [] } - for receipt in pendingReceipts { - restoreLocalUnlockSuppression(from: receipt) + for confirmation in pendingConfirmations { + rememberLocalUnlock(tokens: confirmation.tokens, userId: confirmation.userId) } } @@ -219,6 +240,13 @@ final class AppUnlockCoordinator { 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, @@ -226,6 +254,17 @@ final class AppUnlockCoordinator { 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( @@ -235,10 +274,11 @@ final class AppUnlockCoordinator { queue: .main ) { [weak self] _ in self?.checkOfflineUnlockForCurrentUser() + self?.retryPendingConfirmations() } ) self.checkOfflineUnlockForCurrentUser() - self.flushPendingReceipts() + self.retryPendingConfirmations() } } @@ -249,8 +289,10 @@ final class AppUnlockCoordinator { 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 } @@ -277,7 +319,7 @@ final class AppUnlockCoordinator { } self.completedAllowUserIds.insert(userId) for record in response.model?.locks ?? [] { - self.applyUnlockAndEnqueueReceipt(record: record, userId: userId) + self.applyUnlockAndEnqueueConfirmation(record: record, userId: userId) } }, onError: { [weak self] error in self?.finishAllowRequest(for: userId) @@ -299,6 +341,11 @@ final class AppUnlockCoordinator { 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) @@ -317,7 +364,6 @@ final class AppUnlockCoordinator { 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 @@ -327,7 +373,7 @@ final class AppUnlockCoordinator { var record = PhoneLockRecord(mqtt: body) record.userId = currentUserId - self.applyUnlockAndEnqueueReceipt(record: record, userId: currentUserId) + self.applyUnlockAndEnqueueConfirmation(record: record, userId: currentUserId) } return true } @@ -420,86 +466,69 @@ final class AppUnlockCoordinator { } } - 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) { + private func applyUnlockAndEnqueueConfirmation(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 confirmation = PendingAppUnlockConfirmation( + userId: userId, + os: record.os.trimmed.isEmpty ? "ios" : record.os.trimmed, + groupKey: record.groupKey, + tokens: record.tokens ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(payload) else { return nil } - return String(data: data, encoding: .utf8) + 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 flushPendingReceipts() { + private func retryPendingConfirmations() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in - self?.flushPendingReceipts() + self?.retryPendingConfirmations() } 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 + 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 } - inFlightReceipts[UInt16(messageId)] = receipt - } + self.pendingConfirmations.removeAll { $0.identifier == confirmation.identifier } + self.savePendingConfirmations() + self.retryPendingConfirmations() + NotificationCenter.default.post(name: .lockDistractAppsDidChange, object: nil) + }, 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) { @@ -508,9 +537,17 @@ final class AppUnlockCoordinator { allowDisposable = nil } - private func savePendingReceipts() { - guard let data = try? JSONEncoder().encode(pendingReceipts) else { return } - UserDefaults.standard.set(data, forKey: pendingReceiptsKey) + 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) { @@ -535,57 +572,33 @@ final class AppUnlockCoordinator { private func pendingUnlockScope(for userId: String) -> (unlockAll: Bool, tokens: Set) { var unlockAll = false var tokens: Set = [] - 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 { + for confirmation in pendingConfirmations where confirmation.userId == userId { + if confirmation.tokens.isEmpty { unlockAll = true } else { - tokens.formUnion(receiptTokens) + tokens.formUnion(confirmation.tokens) } } return (unlockAll, tokens) } - private func restoreLocalUnlockSuppression(from receipt: PendingAppUnlockReceipt) { + private static func makeConfirmation( + from receipt: LegacyPendingAppUnlockReceipt + ) -> PendingAppUnlockConfirmation? { 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) + let payload = try? JSONDecoder().decode(LegacyMqttAppUnlockReceiptPayload.self, from: data) else { + return nil } - } - - 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 } + 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) { @@ -696,6 +709,38 @@ final class MQTTService: NSObject { private var password = "Batiao12B" private var topic = "smartdrive/" + 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 @@ -770,7 +815,6 @@ final class MQTTService: NSObject { mqtt?.disconnect() isConnected = false invalidatePhoneReportTimer() - AppUnlockCoordinator.shared.mqttDidDisconnect() } // MARK: - 切换用户 @@ -1139,7 +1183,6 @@ extension MQTTService: CocoaMQTT5Delegate { subscribe(topic: "\(topic)\(userId)") } onConnected?() - AppUnlockCoordinator.shared.mqttDidConnect() reportPhoneUsageIfNeeded() reportPhoneIfNeeded() } @@ -1150,12 +1193,12 @@ extension MQTTService: CocoaMQTT5Delegate { 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?) { + postGroupDataChangeIfNeeded(payload: message.string) if AppUnlockCoordinator.shared.handleIncoming(topic: message.topic, payload: message.string) { return } @@ -1191,7 +1234,6 @@ extension MQTTService: CocoaMQTT5Delegate { isConnected = false invalidatePhoneReportTimer() print("MQTT5 disconnected: \(err?.localizedDescription ?? "")") - AppUnlockCoordinator.shared.mqttDidDisconnect() onDisconnected?() } } diff --git a/QuickLocation/Manager/URL/URLManager.swift b/QuickLocation/Manager/URL/URLManager.swift index eb3362c9..80ec0bd4 100644 --- a/QuickLocation/Manager/URL/URLManager.swift +++ b/QuickLocation/Manager/URL/URLManager.swift @@ -52,7 +52,7 @@ extension DefaultsKeys { openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002 openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010 #elseif AdHoc - setupApiEnv(1) + setupApiEnv(0) openIM_API = "https://imapi.zuom8.cn" openIM_WS = "ws://imws.zuom8.cn" #else @@ -86,12 +86,12 @@ extension DefaultsKeys { case -1: // UAT return "https://jsapi.zuom8.cn/" default: // SIT - return "http://172.16.10.20:9243/" + return "http://172.16.10.21:9243/" } } static let presetServers: [(title: String, url: String, env: Int)] = [ - ("开发", "http://172.16.10.20:9243/", 0), + ("开发", "http://172.16.10.21:9243/", 0), ("正式", "https://jsapi.zuom8.cn/", 1) ] diff --git a/QuickLocation/Section/AppRestrict/SelectActivityVC.swift b/QuickLocation/Section/AppRestrict/SelectActivityVC.swift index a7210f1c..c5561c61 100644 --- a/QuickLocation/Section/AppRestrict/SelectActivityVC.swift +++ b/QuickLocation/Section/AppRestrict/SelectActivityVC.swift @@ -48,6 +48,7 @@ final class SelectActivityVC: BaseViewController { private let activityIndicator = UIActivityIndicatorView(style: .medium) private var items: [AppCatalogItem] = AppCatalogStore.sharedItems private var searchTask: Task? + private var searchRevision = 0 override func viewDidLoad() { super.viewDidLoad() @@ -83,8 +84,15 @@ final class SelectActivityVC: BaseViewController { @objc private func searchChanged() { let query = searchField.text ?? "" + searchRevision += 1 + let revision = searchRevision searchTask?.cancel() + guard searchField.markedTextRange == nil else { + activityIndicator.stopAnimating() + return + } + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { activityIndicator.stopAnimating() @@ -105,22 +113,29 @@ final class SelectActivityVC: BaseViewController { let remote = try await ITunesSearchService.search(term: trimmed) guard !Task.isCancelled else { return } await MainActor.run { + guard self.searchRevision == revision else { return } self.activityIndicator.stopAnimating() self.items = ITunesSearchService.mergedResults(local: localHits, remote: remote) self.tableView.reloadData() } } catch { + guard !Task.isCancelled, !Self.isCancellation(error) else { return } await MainActor.run { + guard self.searchRevision == revision else { return } self.activityIndicator.stopAnimating() - if self.items.isEmpty { - self.items = localHits - self.tableView.reloadData() - } + self.items = localHits + self.tableView.reloadData() DLToast.show(text: "搜索失败,请检查网络") } } } } + + private static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError { return true } + let error = error as NSError + return error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled + } } extension SelectActivityVC: UITableViewDataSource, UITableViewDelegate { diff --git a/QuickLocation/Section/Group/CreateGroup/CreateGroupVC.swift b/QuickLocation/Section/Group/CreateGroup/CreateGroupVC.swift index 210e94e3..0b8a78bb 100644 --- a/QuickLocation/Section/Group/CreateGroup/CreateGroupVC.swift +++ b/QuickLocation/Section/Group/CreateGroup/CreateGroupVC.swift @@ -57,10 +57,18 @@ class CreateGroupVC: BaseViewController { private func bindViewModel() { rootView.groupNameTF.rx.text.orEmpty + .compactMap { [weak rootView] text in + guard rootView?.groupNameTF.isComposingText == false else { return nil } + return String(text.prefix(10)) + } .bind(to: viewModel.groupName) .disposed(by: disposeBag) rootView.groupContentTV.rx.text.orEmpty + .compactMap { [weak rootView] text in + guard rootView?.groupContentTV.isComposingText == false else { return nil } + return String(text.prefix(50)) + } .bind(to: viewModel.groupDesc) .disposed(by: disposeBag) @@ -74,6 +82,12 @@ class CreateGroupVC: BaseViewController { self?.rootView.configureGroupTemplates(templates) }) .disposed(by: disposeBag) + + viewModel.output.createSucceeded + .emit(onNext: { [weak self] in + self?.handleCreateSucceeded() + }) + .disposed(by: disposeBag) viewModel.output.sectionedItems .observe(on: MainScheduler.instance) @@ -100,6 +114,20 @@ class CreateGroupVC: BaseViewController { rootView.tagView.rx.setDelegate(self) .disposed(by: disposeBag) } + + private func handleCreateSucceeded() { + DLToast.showSuccess(text: "创建成功") + if let navigationController { + if navigationController.viewControllers.first === self, + navigationController.presentingViewController != nil { + navigationController.dismiss(animated: true) + } else { + navigationController.popViewController(animated: true) + } + } else { + dismiss(animated: true) + } + } // MARK: - dataSource private lazy var dataSource: RxCollectionViewSectionedReloadDataSource = { diff --git a/QuickLocation/Section/Group/CreateGroup/CreateGroupView.swift b/QuickLocation/Section/Group/CreateGroup/CreateGroupView.swift index 6d1f2b50..0b828a63 100644 --- a/QuickLocation/Section/Group/CreateGroup/CreateGroupView.swift +++ b/QuickLocation/Section/Group/CreateGroup/CreateGroupView.swift @@ -28,11 +28,9 @@ class CreateGroupView: UIView { private func setupRx() { groupNameTF.rx.text.orEmpty - .subscribe(onNext: { [weak self] text in + .subscribe(onNext: { [weak self] _ in guard let self = self else { return } - if text.count > 10 { - self.groupNameTF.text = String(text.prefix(10)) - } + self.groupNameTF.limitCommittedText(to: 10) }) .disposed(by: disposeBag) @@ -44,25 +42,17 @@ class CreateGroupView: UIView { .throttle(.milliseconds(100), scheduler: MainScheduler.instance) .subscribe(onNext: { [weak self] in guard let self = self else { return } - let count = self.groupContentTV.text.count - self.placeholderLab.isHidden = count != 0 - - if count > self.limitCount { - self.groupContentTV.text = String(self.groupContentTV.text.prefix(self.limitCount)) - self.groupContentTV.selectedRange = NSRange(location: self.limitCount, length: 0) - return - } + self.placeholderLab.isHidden = !self.groupContentTV.text.isEmpty + guard let text = self.groupContentTV.limitCommittedText(to: self.limitCount) else { return } + self.placeholderLab.isHidden = !text.isEmpty }) .disposed(by: disposeBag) addTagTF.rx.text.orEmpty - .subscribe(onNext: { [weak self] text in + .subscribe(onNext: { [weak self] _ in guard let self = self else { return } - if text.count > self.tagMaxLength { - self.addTagTF.text = String(text.prefix(self.tagMaxLength)) - } - let count = self.addTagTF.text?.count ?? 0 - self.addTagCountLab.text = "\(count)/\(self.tagMaxLength)" + guard let text = self.addTagTF.limitCommittedText(to: self.tagMaxLength) else { return } + self.addTagCountLab.text = "\(text.count)/\(self.tagMaxLength)" }) .disposed(by: disposeBag) diff --git a/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift b/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift index ce7a243d..03a54c2d 100644 --- a/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift +++ b/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift @@ -28,6 +28,7 @@ class CreateGroupViewModel { struct Output { var sectionedItems: Observable<[GroupTagListSectionModel]> var groupTemplates: Observable<[GroupTemplateModel]> + var createSucceeded: Signal } let input: Input @@ -36,6 +37,7 @@ class CreateGroupViewModel { private let sectionedItems = PublishSubject<[GroupTagListSectionModel]>() private let groupTemplates = PublishSubject<[GroupTemplateModel]>() + private let createSucceeded = PublishRelay() private var tagList = ["私密", "游戏", "运动", "美食", "自驾", "聚会", "旅行", "学习"] @@ -123,11 +125,8 @@ class CreateGroupViewModel { requestData: ["group_name": name, "group_type": groupType, "description": desc, - "labels": selectedTagList]).subscribe(onNext: { response in - DLToast.showSuccess(text: "创建成功") { - NotificationCenter.default.post(name: .RefreshIMGroupListNotification, object: nil) - AppRouter.shared.popOrDismiss() - } + "labels": selectedTagList]).subscribe(onNext: { [weak self] _ in + self?.createSucceeded.accept(()) }, onError: { (error) in guard let code = error.underlyingError?.code else { return } @@ -146,7 +145,8 @@ class CreateGroupViewModel { input = Input() output = Output( sectionedItems: sectionedItems.asObservable(), - groupTemplates: groupTemplates.asObservable() + groupTemplates: groupTemplates.asObservable(), + createSucceeded: createSucceeded.asSignal() ) } } diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index 8be5fabe..0b76f225 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -33,6 +33,8 @@ final class GroupChatVC: BaseViewController { /// mention 列表:首行 @所有人 + 成员 private var mentionRows: [(userId: String?, nickname: String)] = [] private var isMentionPickerVisible = false + private var groupRefreshWorkItem: DispatchWorkItem? + private var groupInfoRequestID = UUID() /// 加载更早历史前记录,用于保持阅读位置 private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)? @@ -261,7 +263,15 @@ final class GroupChatVC: BaseViewController { NotificationCenter.default.rx.notification(.RefreshUserConfigNotification) .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] _ in - self?.requestGroupInfoByKey() + self?.scheduleGroupInfoRefresh() + }) + .disposed(by: disposeBag) + + NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] notification in + guard let self, self.shouldRefreshGroupInfo(for: notification) else { return } + self.scheduleGroupInfoRefresh() }) .disposed(by: disposeBag) @@ -521,7 +531,7 @@ final class GroupChatVC: BaseViewController { } private func showMentionPicker() { - mentionRows = [(nil, "所有人")] + viewModel.mentionCandidates().map { ($0.user_id, $0.showName) } + refreshMentionRows() rootView.mentionTableView.reloadData() rootView.mentionPickerView.isHidden = false isMentionPickerVisible = true @@ -536,6 +546,14 @@ final class GroupChatVC: BaseViewController { isMentionPickerVisible = false } + private func refreshMentionRows() { + mentionRows = [(nil, "所有人")] + + viewModel.mentionCandidates().map { ($0.user_id, $0.showName) } + if isMentionPickerVisible { + rootView.mentionTableView.reloadData() + } + } + private func insertMention(userId: String?, nickname: String, replaceTriggerAt: Bool = true) { hideMentionPicker() let insert: String @@ -812,7 +830,8 @@ final class GroupChatVC: BaseViewController { currentVoiceHover = .none bringInputOverlaysToFront() - let url = URL(fileURLWithPath: NSTemporaryDirectory() + "voice_\(Int(Date().timeIntervalSince1970)).caf") + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voice_\(UUID().uuidString).wav") recordFileURL = url let startCapture: (Bool) -> Void = { [weak self] enableSpeech in @@ -876,7 +895,12 @@ final class GroupChatVC: BaseViewController { deleteRecordFile() return } - sendVoiceMessage(url: url, durationMs: duration * 1000) + guard let durationMs = validVoiceDurationMs(at: url) else { + dl.show(text: "录音文件无效,请重试") + deleteRecordFile() + return + } + sendVoiceMessage(url: url, durationMs: durationMs) case .convert: deleteRecordFile() DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in @@ -900,6 +924,20 @@ final class GroupChatVC: BaseViewController { recordFileURL = nil } + private func validVoiceDurationMs(at url: URL) -> Int? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let fileSize = attributes[.size] as? NSNumber, + fileSize.intValue > 44, + let audioFile = try? AVAudioFile(forReading: url), + audioFile.length > 0, + audioFile.processingFormat.sampleRate > 0 else { + return nil + } + let duration = Double(audioFile.length) / audioFile.processingFormat.sampleRate + guard duration >= 0.5, duration.isFinite else { return nil } + return max(1_000, Int((duration * 1_000).rounded())) + } + private func sendVoiceMessage(url: URL, durationMs: Int) { let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: durationMs) viewModel.cacheMessage(msg) @@ -907,23 +945,53 @@ final class GroupChatVC: BaseViewController { recvID: "", groupID: viewModel.groupId, offlinePushInfo: nil, - onSuccess: { [weak self] _ in - self?.viewModel.onReceiveMessage(msg) + onSuccess: { [weak self] returnedMsg in + DispatchQueue.main.async { + guard let self else { return } + let sentMessage = returnedMsg ?? msg + if sentMessage.soundElem == nil { + sentMessage.soundElem = msg.soundElem + } + sentMessage.soundElem?.soundPath = url.path + self.viewModel.onReceiveMessage(sentMessage) + } }, onProgress: nil as OIMNumberCallback?, - onFailure: { code, errMsg in + onFailure: { [weak self] code, errMsg in print("Voice send failed: \(code) \(errMsg ?? "")") + DispatchQueue.main.async { + self?.dl.show(text: (errMsg?.isEmpty == false ? errMsg : "语音发送失败,请重试") ?? "语音发送失败,请重试") + } }) } // MARK: - API - private func requestGroupInfoByKey() { + private func shouldRefreshGroupInfo(for notification: Notification) -> Bool { + guard let groupKey = notification.userInfo?[GroupDataChangeUserInfoKey.groupKey] as? String, + !groupKey.isEmpty else { return true } + return groupKey == viewModel.groupId + } + + private func scheduleGroupInfoRefresh() { + groupRefreshWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.requestGroupInfoByKey(showError: false) + } + groupRefreshWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work) + } + + private func requestGroupInfoByKey(showError: Bool = true) { + let requestID = UUID() + groupInfoRequestID = requestID GroupService.groupUsers(groupKey: viewModel.groupId) .subscribe(onNext: { [weak self] response in - guard let self else { return } + guard let self, self.groupInfoRequestID == requestID else { return } guard response.isValid(for: self.viewModel.groupId), let model = response.model else { - DLToast.showError(text: response.message ?? "获取圈子成员失败") + if showError { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + } return } self.viewModel.groupModel = model @@ -935,9 +1003,13 @@ final class GroupChatVC: BaseViewController { self.rootView.groupNameLabel.text = "\(model.name)(\(response.list.count))" self.rootView.groupAvatarView.setGroupIcon(model) self.rootView.reviewBtn.isHidden = !model.is_owner + self.refreshMentionRows() // self.rootView.reviewDotView.isHidden = response.reviewCount == 0 - }, onError: { error in - DLToast.showError(text: error.localizedDescription) + }, onError: { [weak self] error in + guard let self, self.groupInfoRequestID == requestID else { return } + if showError { + DLToast.showError(text: error.localizedDescription) + } }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index 32fe0aa4..10077b37 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -802,7 +802,7 @@ final class GroupChatViewModel { let type = msg.contentType.rawValue if type == 103, let sound = msg.soundElem { content = "\(sound.duration)" - voiceUrl = sound.sourceUrl ?? "" + voiceUrl = Self.voiceLocation(from: sound) } else if type == 102, let pic = msg.pictureElem { let layout = Self.pictureLayout(from: pic) imageUrl = layout.url @@ -828,7 +828,7 @@ final class GroupChatViewModel { } } else if let sound = msg.soundElem { content = "\(sound.duration)" - voiceUrl = sound.sourceUrl ?? "" + voiceUrl = Self.voiceLocation(from: sound) } else if let pic = msg.pictureElem { let layout = Self.pictureLayout(from: pic) imageUrl = layout.url @@ -904,6 +904,14 @@ final class GroupChatViewModel { return trimmed.isEmpty ? nil : trimmed } + private static func voiceLocation(from sound: OIMSoundElem) -> String { + if let localPath = nonEmpty(sound.soundPath), + FileManager.default.fileExists(atPath: localPath) { + return localPath + } + return nonEmpty(sound.sourceUrl) ?? nonEmpty(sound.soundPath) ?? "" + } + private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview { let id = msg.clientMsgID let name = msg.senderNickname ?? getUserNickName(id: msg.sendID ?? "") diff --git a/QuickLocation/Section/Group/GroupChat/VoicePlayerManager.swift b/QuickLocation/Section/Group/GroupChat/VoicePlayerManager.swift index b955503e..072b5938 100644 --- a/QuickLocation/Section/Group/GroupChat/VoicePlayerManager.swift +++ b/QuickLocation/Section/Group/GroupChat/VoicePlayerManager.swift @@ -7,89 +7,239 @@ import AVFoundation -final class VoicePlayerManager { +final class VoicePlayerManager: NSObject { static let shared = VoicePlayerManager() - private var player: AVPlayer? - private var timeObserver: Any? + enum State { case idle, playing, paused } + + private var player: AVAudioPlayer? + private var downloadTask: URLSessionDataTask? + private let audioCache = NSCache() private var currentURL: String? + private var playbackID = UUID() private var onStateChange: ((State) -> Void)? private var onFinished: (() -> Void)? - - enum State { case idle, playing, paused } + private weak var playbackView: VoicePlaybackView? private(set) var state: State = .idle - private init() {} + private override init() { + super.init() + audioCache.totalCostLimit = 20 * 1_024 * 1_024 + } func play(urlString: String, playbackView: VoicePlaybackView, onStateChange: @escaping (State) -> Void, onFinished: @escaping () -> Void) { - // Stop current if different source - if currentURL != urlString { - stop() + let value = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + onFinished() + return } - guard let url = URL(string: urlString) else { return } - currentURL = urlString + if currentURL == value, state == .paused, let player { + guard activateAudioSession() else { + stop() + return + } + self.playbackView = playbackView + self.onStateChange = onStateChange + self.onFinished = onFinished + player.play() + updateState(.playing) + return + } + + stop() + let playbackID = UUID() + self.playbackID = playbackID + currentURL = value + self.playbackView = playbackView self.onStateChange = onStateChange self.onFinished = onFinished - if player == nil { - player = AVPlayer(url: url) + if let localURL = Self.localFileURL(from: value) { + startPlayer(url: localURL, expectedURL: value) + return + } + guard let remoteURL = Self.remoteURL(from: value) else { + finishCurrentPlayback() + return + } + if let cachedData = audioCache.object(forKey: value as NSString) { + startPlayer(data: cachedData as Data, expectedURL: value) + return } - if state == .paused { - player?.play() - state = .playing - onStateChange(.playing) - playbackView.play() - } else { - player?.replaceCurrentItem(with: AVPlayerItem(url: url)) - player?.play() - state = .playing - onStateChange(.playing) - playbackView.play() - - if let obs = timeObserver { - player?.removeTimeObserver(obs) - } - timeObserver = player?.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 10), - queue: .main) { [weak self] time in - guard let self = self, - let duration = self.player?.currentItem?.duration, - duration.isValid, !duration.isIndefinite, - CMTimeCompare(time, duration) >= 0 else { return } - self.stop() - self.onFinished?() + downloadTask = URLSession.shared.dataTask(with: remoteURL) { [weak self] data, response, error in + DispatchQueue.main.async { + guard let self, + self.playbackID == playbackID, + self.currentURL == value else { return } + self.downloadTask = nil + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 200 + guard let data, error == nil, (200..<300).contains(statusCode), !data.isEmpty else { + print("Voice download failed: \(error?.localizedDescription ?? "HTTP \(statusCode)")") + self.finishCurrentPlayback() + return + } + self.audioCache.setObject(data as NSData, forKey: value as NSString, cost: data.count) + self.startPlayer(data: data, expectedURL: value) } } + downloadTask?.resume() } - func isCurrent(url: String) -> Bool { currentURL == url } + func isCurrent(url: String) -> Bool { + currentURL == url.trimmingCharacters(in: .whitespacesAndNewlines) + } func pause() { + guard state == .playing else { return } player?.pause() - state = .paused - onStateChange?(.paused) + updateState(.paused) } func stop() { + downloadTask?.cancel() + downloadTask = nil + playbackID = UUID() player?.pause() - player?.replaceCurrentItem(with: nil) - if let obs = timeObserver { - player?.removeTimeObserver(obs) - timeObserver = nil + player?.stop() + player = nil + finishCurrentPlayback() + } + + private func startPlayer(url: URL, expectedURL: String) { + do { + let player = try AVAudioPlayer(contentsOf: url) + try start(player: player, expectedURL: expectedURL) + } catch { + print("Voice playback failed: \(error.localizedDescription)") + finishCurrentPlayback() } - let prevFinished = onFinished + } + + private func startPlayer(data: Data, expectedURL: String) { + do { + let player = try AVAudioPlayer(data: data) + try start(player: player, expectedURL: expectedURL) + } catch { + print("Voice playback failed: \(error.localizedDescription)") + finishCurrentPlayback() + } + } + + private func start(player: AVAudioPlayer, expectedURL: String) throws { + guard currentURL == expectedURL else { return } + guard activateAudioSession() else { + finishCurrentPlayback() + return + } + player.delegate = self + _ = player.prepareToPlay() + guard player.duration > 0 else { + throw NSError( + domain: "VoicePlayerManager", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "invalid audio data"] + ) + } + self.player = player + guard player.play() else { + self.player = nil + throw NSError( + domain: "VoicePlayerManager", + code: -2, + userInfo: [NSLocalizedDescriptionKey: "audio player did not start"] + ) + } + updateState(.playing) + } + + private func updateState(_ state: State) { + self.state = state + onStateChange?(state) + if state == .playing { + playbackView?.play() + } else { + playbackView?.stop() + } + } + + private func finishCurrentPlayback() { + let previousFinished = onFinished + let previousPlaybackView = playbackView onStateChange = nil onFinished = nil + playbackView = nil player = nil currentURL = nil state = .idle - prevFinished?() + previousPlaybackView?.stop() + previousFinished?() + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + private func activateAudioSession() -> Bool { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .spokenAudio) + try session.setActive(true) + return true + } catch { + print("Voice audio session failed: \(error.localizedDescription)") + return false + } + } + + private static func localFileURL(from value: String) -> URL? { + if let url = URL(string: value), url.isFileURL, + FileManager.default.fileExists(atPath: url.path) { + return url + } + if FileManager.default.fileExists(atPath: value) { + return URL(fileURLWithPath: value) + } + let relativePath = value.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let localURL = documentsURL.appendingPathComponent(relativePath) + if FileManager.default.fileExists(atPath: localURL.path) { + return localURL + } + } + return nil + } + + private static func remoteURL(from value: String) -> URL? { + if let url = URL(string: value), let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" { + return url + } + guard let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: encoded), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + return url + } +} + +extension VoicePlayerManager: AVAudioPlayerDelegate { + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + guard self.player === player else { return } + finishCurrentPlayback() + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + guard self.player === player else { return } + if let error { + print("Voice decode failed: \(error.localizedDescription)") + } + finishCurrentPlayback() } } diff --git a/QuickLocation/Section/Group/GroupChat/VoiceRecordView.swift b/QuickLocation/Section/Group/GroupChat/VoiceRecordView.swift index c3a30f10..4fa2f2fd 100644 --- a/QuickLocation/Section/Group/GroupChat/VoiceRecordView.swift +++ b/QuickLocation/Section/Group/GroupChat/VoiceRecordView.swift @@ -840,7 +840,20 @@ final class ChatVoiceCapture: @unchecked Sendable { throw NSError(domain: "ChatVoiceCapture", code: -1, userInfo: [NSLocalizedDescriptionKey: "invalid audio format"]) } - audioFile = try AVAudioFile(forWriting: fileURL, settings: format.settings) + let fileSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: format.sampleRate, + AVNumberOfChannelsKey: format.channelCount, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false + ] + audioFile = try AVAudioFile( + forWriting: fileURL, + settings: fileSettings, + commonFormat: format.commonFormat, + interleaved: format.isInterleaved + ) if enableSpeech, let recognizer, recognizer.isAvailable { let request = SFSpeechAudioBufferRecognitionRequest() diff --git a/QuickLocation/Section/Group/GroupViewController.swift b/QuickLocation/Section/Group/GroupViewController.swift index 931f1eda..e2255399 100644 --- a/QuickLocation/Section/Group/GroupViewController.swift +++ b/QuickLocation/Section/Group/GroupViewController.swift @@ -21,6 +21,9 @@ final class GroupViewController: BaseViewController { private var itineraryGroupModel: GroupModel? private var itinerarySchedules: [ScheduleModel] = [] private var itineraryGroupKey: String = "" + private var groupRefreshWorkItem: DispatchWorkItem? + private var groupInfoRequestID = UUID() + private var hasAppeared = false override func loadView() { rootView = GroupView(frame: UIScreen.main.bounds) @@ -34,27 +37,41 @@ final class GroupViewController: BaseViewController { observeTableViews() bindItineraryPage() requestRecommandGroup() - requestGroupInfo() + requestGroupInfo(refreshIM: true) guard let config = AppContextManager.shared.systemConfig else { return } rootView.cycleScrollView.imageURLStringsGroup = config.groupBannerList } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if hasAppeared { + scheduleGroupInfoRefresh() + } else { + hasAppeared = true + } + } + // MARK: - OpenIM private var hasSetupIMListeners = false + private var hasRequestedIMData = false + private var isRequestingIMData = false /// 进入页面加载 IM 会话:圈子集合已由业务接口给出,这里只补未读/最后一条 private func loadIMData() { - if AppContextManager.shared.isGuest { return } + guard !AppContextManager.shared.isGuest, !isRequestingIMData else { return } + isRequestingIMData = true DLToast.showLoading() GroupIMService.shared.ensureLogin { [weak self] success in DispatchQueue.main.async { guard let self else { return } + self.isRequestingIMData = false guard success else { DLToast.dismiss() DLToast.showError(text: "IM登录失败") return } + self.hasRequestedIMData = true self.setupIMListenersIfNeeded() self.refreshIMData() } @@ -163,14 +180,14 @@ final class GroupViewController: BaseViewController { // MQTT join/leave/dismiss 后刷新群列表 NotificationCenter.default.rx.notification(.RefreshIMGroupListNotification) .subscribe(onNext: { [weak self] _ in - self?.requestGroupInfo() + self?.scheduleGroupInfoRefresh() }) .disposed(by: disposeBag) NotificationCenter.default.rx.notification(.RefreshUserConfigNotification) .subscribe(onNext: { [weak self] _ in self?.requestRecommandGroup() - self?.requestGroupInfo() + self?.scheduleGroupInfoRefresh() guard let config = AppContextManager.shared.systemConfig else { return } self?.rootView.cycleScrollView.imageURLStringsGroup = config.groupBannerList @@ -179,7 +196,7 @@ final class GroupViewController: BaseViewController { NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification) .subscribe(onNext: { [weak self] _ in - self?.refreshItineraryPage() + self?.scheduleGroupInfoRefresh() }) .disposed(by: disposeBag) } @@ -208,20 +225,23 @@ final class GroupViewController: BaseViewController { // MARK: - 行程页 private func refreshItineraryPage() { - let cached = AppContextManager.shared.defaultGroupKey - if !cached.isEmpty { - applyItineraryGroupKey(cached) + if let itineraryGroupModel { + applyItineraryGroupModel(itineraryGroupModel) return } GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self = self, let model = response.model else { return } - self.itineraryGroupModel = model - let current = model.groups.first(where: { $0.group_key == model.default_group_key }) - self.rootView.itineraryPage.updateGroupName(current?.name ?? "") - self.applyItineraryGroupKey(model.default_group_key) + self.applyItineraryGroupModel(model) }).disposed(by: disposeBag) } + private func applyItineraryGroupModel(_ model: GroupModel) { + itineraryGroupModel = model + let current = model.groups.first(where: { $0.group_key == model.default_group_key }) + rootView.itineraryPage.updateGroupName(current?.name ?? "") + applyItineraryGroupKey(current?.group_key ?? "") + } + private func applyItineraryGroupKey(_ groupKey: String) { if itineraryGroupKey != groupKey { itineraryGroupKey = groupKey @@ -279,9 +299,7 @@ final class GroupViewController: BaseViewController { GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in guard let self = self, let key = groupKey else { return } GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) - .subscribe(onNext: { _ in - NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) - }) + .subscribe() .disposed(by: self.disposeBag) } } @@ -352,11 +370,29 @@ final class GroupViewController: BaseViewController { }).disposed(by: disposeBag) } - private func requestGroupInfo() { - GroupService.groupInfo().subscribe { response in - guard let model = response.model else { return } - self.viewModel.applyBusinessGroups(model.groups) - self.loadIMData() - }.disposed(by: disposeBag) + private func scheduleGroupInfoRefresh() { + groupRefreshWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.requestGroupInfo() + } + groupRefreshWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work) + } + + private func requestGroupInfo(refreshIM: Bool = false) { + let requestID = UUID() + groupInfoRequestID = requestID + GroupService.groupInfo() + .subscribe(onNext: { [weak self] response in + guard let self, + self.groupInfoRequestID == requestID, + let model = response.model else { return } + self.viewModel.applyBusinessGroups(model.groups) + self.applyItineraryGroupModel(model) + if refreshIM || !self.hasRequestedIMData { + self.loadIMData() + } + }) + .disposed(by: disposeBag) } } diff --git a/QuickLocation/Section/Home/GroupListPopView.swift b/QuickLocation/Section/Home/GroupListPopView.swift index 609fd94d..d716aaa4 100644 --- a/QuickLocation/Section/Home/GroupListPopView.swift +++ b/QuickLocation/Section/Home/GroupListPopView.swift @@ -30,6 +30,7 @@ class GroupListPopView: UIView { private var selectedGroupKey: String = "" private var currentGroupOnlineCount: Int? private var onlineCountGroupKey: String = "" + private weak var presentationOwner: AnyObject? private var groupList: [GroupInfoModel] = [] { didSet { @@ -186,10 +187,28 @@ class GroupListPopView: UIView { // MARK: - Public extension GroupListPopView { + static func updateIfVisible(groupModel: GroupModel, + currentMembers: [GroupMemberModel]? = nil, + presentationOwner: AnyObject) { + let pop = GroupListPopView.shared + guard pop.superview != nil, + pop.presentationOwner === presentationOwner else { return } + + let selectedGroupKey = pop.selectedGroupKey + pop.currentGroupOnlineCount = currentMembers.map { $0.filter(\.is_online).count } + pop.groupModel = groupModel + if groupModel.groups.contains(where: { $0.group_key == selectedGroupKey }) { + pop.selectedGroupKey = selectedGroupKey + } + pop.onlineCountGroupKey = groupModel.default_group_key + pop.tableView.reloadData() + } + /// 通过 window 从底部弹出圈子列表 static func show(groupModel: GroupModel, currentMembers: [GroupMemberModel]? = nil, selectedKey: String? = nil, + presentationOwner: AnyObject? = nil, completion: @escaping ((String?) -> Void)) { guard let superView = kKeyWindow else { return } @@ -204,6 +223,7 @@ extension GroupListPopView { let highlightKey = (selectedKey?.isEmpty == false) ? selectedKey! : groupModel.default_group_key pop.selectedGroupKey = highlightKey pop.onlineCountGroupKey = highlightKey + pop.presentationOwner = presentationOwner pop.tableView.reloadData() pop.completion = { text in completion(text) @@ -231,6 +251,7 @@ extension GroupListPopView { pop.infoView.transform = pop.slideOffTransform() } completion: { _ in pop.infoView.transform = .identity + pop.presentationOwner = nil pop.removeFromSuperview() } } diff --git a/QuickLocation/Section/Home/GroupMemberView2.swift b/QuickLocation/Section/Home/GroupMemberView2.swift index a707fad8..88c5053b 100644 --- a/QuickLocation/Section/Home/GroupMemberView2.swift +++ b/QuickLocation/Section/Home/GroupMemberView2.swift @@ -55,6 +55,21 @@ class GroupMemberView2: UIView { refreshRealtimeInteraction() } + func clearMemberInfo() { + currentMemberModel = nil + memberNameLab.text = "" + ownView.isHidden = true + relationIconView.configure(relationIdx: "") + updateMoodBadge(0) + statusDotView.backgroundColor = UIColor(hexStr: "#D8D8D8") + statusLab.text = "" + locationLab.text = "" + distanceView.isHidden = true + applyPhoneUsage(nil) + updateRealtimeInteractionVisibility(isCurrentUser: true) + refreshRealtimeInteraction() + } + func updatePhoneUsage(_ phoneUsage: PhoneUsageTodayModel?, for userId: String) { guard currentMemberModel?.user_id == userId else { return } applyPhoneUsage(phoneUsage) diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 15fdaf0f..55b66408 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -75,8 +75,28 @@ class HomeViewController: BaseViewController { private var popupQueue: PopupQueueManager? private static var sosPlayerKey: UInt8 = 0 private var groupRefreshWorkItem: DispatchWorkItem? - private var groupSwitchWorkItem: DispatchWorkItem? + private var groupDataRetryWorkItem: DispatchWorkItem? private var groupInfoRequestID = UUID() + private var pendingGroupDataChanges: [GroupDataChange] = [] + private var hasAppeared = false + + private struct GroupDataChange { + let groupKey: String + let userId: String + let operation: String + let source: String + + init(notification: Notification) { + groupKey = notification.userInfo?[GroupDataChangeUserInfoKey.groupKey] as? String ?? "" + userId = notification.userInfo?[GroupDataChangeUserInfoKey.userId] as? String ?? "" + operation = notification.userInfo?[GroupDataChangeUserInfoKey.operation] as? String ?? "" + source = notification.userInfo?[GroupDataChangeUserInfoKey.source] as? String ?? "" + } + + var isMQTT: Bool { + source == GroupDataChangeSource.mqtt + } + } override func loadView() { #if !targetEnvironment(simulator) @@ -111,6 +131,11 @@ class HomeViewController: BaseViewController { rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized refreshPigeonInbox() queryUnlockRequests() + if hasAppeared { + scheduleGroupDataRefresh() + } else { + hasAppeared = true + } } override func viewDidDisappear(_ animated: Bool) { @@ -273,12 +298,7 @@ class HomeViewController: BaseViewController { // 切换圈子 rootView.groupMemberView.changeGroupBtn.rx.tap.subscribe(onNext: { [weak self] in - guard let self = self, let groupModel = self.viewModel.groupModel else { return } - GroupListPopView.show(groupModel: groupModel, - currentMembers: self.viewModel.memberList) { [weak self] groupKey in - guard let self = self, let key = groupKey else { return } - self.requestOperateGroup(groupKey: key) - } + self?.showLatestGroupPicker() }).disposed(by: disposeBag) // 顶部圈子 @@ -363,7 +383,7 @@ class HomeViewController: BaseViewController { // 圈子刷新 NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification, object: nil) .subscribe { [weak self] notification in - self?.requestGroupInfo() + self?.scheduleGroupDataRefresh(change: GroupDataChange(notification: notification)) }.disposed(by: disposeBag) // 查找位置(查看成员位置) @@ -383,7 +403,6 @@ class HomeViewController: BaseViewController { } // 切换到成员所在圈子,完成后定位 GroupService.operate(opType: "setdefault", requestData: ["group_key": group_key]).subscribe { _ in - self.requestGroupInfo() if let c = coord, CLLocationCoordinate2DIsValid(c) { self.rootView.mapView.setCenter(c, animated: true) self.rootView.mapView.setZoomLevel(16, animated: true) @@ -427,8 +446,10 @@ class HomeViewController: BaseViewController { guard let self = self else { return } let items = sections.flatMap(\.items) guard !items.isEmpty else { + self.selectedMemberId = "" self.refreshCoupleMemberList([]) self.rootView.setSOSGradientActive(false) + self.rootView.groupMemberView.clearMemberInfo() return } let stillValid = items.contains { $0.user_id == self.selectedMemberId } @@ -579,46 +600,213 @@ class HomeViewController: BaseViewController { }.disposed(by: disposeBag) } - private func requestGroupInfo(isDefaultGroup: Bool=true) { + private func scheduleGroupDataRefresh(change: GroupDataChange? = nil) { + if let change { + pendingGroupDataChanges.append(change) + } + groupRefreshWorkItem?.cancel() + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + let changes = self.pendingGroupDataChanges + self.pendingGroupDataChanges.removeAll() + self.requestGroupInfo( + changes: changes, + retryAllowed: changes.contains(where: \.isMQTT), + showError: false + ) + } + groupRefreshWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work) + } + + private func requestGroupInfo(changes: [GroupDataChange] = [], + retryAllowed: Bool = false, + showError: Bool = true) { let requestID = UUID() groupInfoRequestID = requestID GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self, - self.groupInfoRequestID == requestID, - let model = response.model else { return } + self.groupInfoRequestID == requestID else { return } - let previousGroupKey = self.viewModel.groupModel?.default_group_key - let requestedGroupKey = model.default_group_key - let isSwitchingGroup = previousGroupKey != nil && previousGroupKey != requestedGroupKey + guard let model = response.model else { + if showError { + DLToast.showError(text: response.message ?? "获取圈子信息失败") + } + if retryAllowed { + self.scheduleGroupDataRetry(changes: changes) + } + return + } - self.viewModel.groupModel = model - MQTTService.shared.updatePhoneReportContext( - groupKey: requestedGroupKey, - location: self.phoneReportLocation + self.applyGroupModel( + model, + requestID: requestID, + changes: changes, + retryAllowed: retryAllowed, + showError: showError ) - NotificationCenter.default.post(name: .RefreshIMGroupListNotification, object: nil) - - if isDefaultGroup { - self.rootView.groupMemberView.groupNameLab.text = self.viewModel.groupName + }, onError: { [weak self] error in + guard let self, self.groupInfoRequestID == requestID else { return } + if showError { + DLToast.showError(text: error.localizedDescription) } - - if isSwitchingGroup || requestedGroupKey.isEmpty { - self.clearPhoneUsageState() - self.viewModel.clearMembers() - self.syncMemberAnnotations([]) - self.refreshMQTTSubscriptions([]) + if retryAllowed { + self.scheduleGroupDataRetry(changes: changes) } - - guard !requestedGroupKey.isEmpty else { return } - self.requestGroupMembers(groupKey: requestedGroupKey, - requestID: requestID) - }, onError: { error in - DLToast.showError(text: error.localizedDescription) }).disposed(by: disposeBag) } - private func requestGroupMembers(groupKey: String, requestID: UUID) { + private func applyGroupModel(_ model: GroupModel, + requestID: UUID, + changes: [GroupDataChange], + retryAllowed: Bool, + showError: Bool) { + let previousGroupKey = viewModel.groupModel?.default_group_key ?? "" + let requestedGroupKey = model.default_group_key + let isSwitchingGroup = previousGroupKey != requestedGroupKey + let hasRequestedGroup = model.groups.contains { $0.group_key == requestedGroupKey } + + viewModel.groupModel = model + rootView.groupMemberView.groupNameLab.text = viewModel.groupName.isEmpty + ? "我的圈子" + : viewModel.groupName + MQTTService.shared.updatePhoneReportContext( + groupKey: hasRequestedGroup ? requestedGroupKey : "", + location: phoneReportLocation + ) + + let pickerMembers = isSwitchingGroup ? nil : viewModel.memberList + GroupListPopView.updateIfVisible( + groupModel: model, + currentMembers: pickerMembers, + presentationOwner: self + ) + + if isSwitchingGroup || requestedGroupKey.isEmpty || !hasRequestedGroup { + clearPhoneUsageState() + viewModel.clearMembers() + syncMemberAnnotations([]) + refreshMQTTSubscriptions([]) + } + + guard !requestedGroupKey.isEmpty, hasRequestedGroup else { + if retryAllowed, shouldRetryGroupList(model: model, changes: changes) { + scheduleGroupDataRetry(changes: changes) + } + return + } + + let shouldRetryList = retryAllowed && shouldRetryGroupList(model: model, changes: changes) + requestGroupMembers( + groupKey: requestedGroupKey, + requestID: requestID, + changes: changes, + retryAllowed: retryAllowed && !shouldRetryList, + showError: showError + ) + if shouldRetryList { + scheduleGroupDataRetry(changes: changes) + } + } + + private func shouldRetryGroupList(model: GroupModel, + changes: [GroupDataChange]) -> Bool { + let currentUserId = AppContextManager.shared.userId + let groupKeys = Set(model.groups.map(\.group_key)) + return changes.contains { change in + guard change.isMQTT, !change.groupKey.isEmpty else { return false } + switch change.operation { + case "join": + return (change.userId.isEmpty || change.userId == currentUserId) + && !groupKeys.contains(change.groupKey) + case "leave", "kick": + return change.userId == currentUserId && groupKeys.contains(change.groupKey) + case "dismiss": + return groupKeys.contains(change.groupKey) + default: + return false + } + } + } + + private func shouldRetryMembers(_ members: [GroupMemberModel], + groupKey: String, + changes: [GroupDataChange]) -> Bool { + let memberIds = Set(members.map(\.user_id)) + return changes.contains { change in + guard change.isMQTT, + change.groupKey == groupKey, + !change.userId.isEmpty else { return false } + switch change.operation { + case "join": + return !memberIds.contains(change.userId) + case "leave", "kick": + return memberIds.contains(change.userId) + default: + return false + } + } + } + + private func scheduleGroupDataRetry(changes: [GroupDataChange]) { + groupDataRetryWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.requestGroupInfo(changes: changes, retryAllowed: false, showError: false) + } + groupDataRetryWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: work) + } + + private func showLatestGroupPicker() { + groupDataRetryWorkItem?.cancel() + let cachedModel = viewModel.groupModel + let requestID = UUID() + groupInfoRequestID = requestID + GroupService.groupInfo() + .subscribe(onNext: { [weak self] response in + guard let self, self.groupInfoRequestID == requestID else { return } + guard let model = response.model else { + if let cachedModel { + self.presentGroupPicker(groupModel: cachedModel) + } + return + } + + self.applyGroupModel( + model, + requestID: requestID, + changes: [], + retryAllowed: false, + showError: false + ) + self.presentGroupPicker(groupModel: model) + }, onError: { [weak self] _ in + guard let self, + self.groupInfoRequestID == requestID, + let cachedModel else { return } + self.presentGroupPicker(groupModel: cachedModel) + }) + .disposed(by: disposeBag) + } + + private func presentGroupPicker(groupModel: GroupModel) { + GroupListPopView.show( + groupModel: groupModel, + currentMembers: viewModel.memberList, + presentationOwner: self + ) { [weak self] groupKey in + guard let self, let groupKey else { return } + self.requestOperateGroup(groupKey: groupKey) + } + } + + private func requestGroupMembers(groupKey: String, + requestID: UUID, + changes: [GroupDataChange] = [], + retryAllowed: Bool = false, + showError: Bool = true) { GroupService.groupUsers(groupKey: groupKey) .subscribe(onNext: { [weak self] response in guard let self, @@ -626,7 +814,12 @@ class HomeViewController: BaseViewController { self.viewModel.groupModel?.default_group_key == groupKey else { return } guard response.isValid(for: groupKey) else { - DLToast.showError(text: response.message ?? "获取圈子成员失败") + if showError { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + } + if retryAllowed { + self.scheduleGroupDataRetry(changes: changes) + } return } @@ -636,11 +829,27 @@ class HomeViewController: BaseViewController { self.refreshMQTTSubscriptions(self.viewModel.memberList) self.updateOnlineCount() self.requestSelectedMemberPhoneUsage(groupKey: groupKey) + if let groupModel = self.viewModel.groupModel { + GroupListPopView.updateIfVisible( + groupModel: groupModel, + currentMembers: self.viewModel.memberList, + presentationOwner: self + ) + } + if retryAllowed, + self.shouldRetryMembers(response.list, groupKey: groupKey, changes: changes) { + self.scheduleGroupDataRetry(changes: changes) + } }, onError: { [weak self] error in guard let self, self.groupInfoRequestID == requestID, self.viewModel.groupModel?.default_group_key == groupKey else { return } - DLToast.showError(text: error.localizedDescription) + if showError { + DLToast.showError(text: error.localizedDescription) + } + if retryAllowed { + self.scheduleGroupDataRetry(changes: changes) + } }) .disposed(by: disposeBag) } @@ -715,6 +924,7 @@ class HomeViewController: BaseViewController { } private func requestOperateGroup(groupKey: String) { + groupDataRetryWorkItem?.cancel() if viewModel.groupModel?.default_group_key != groupKey { groupInfoRequestID = UUID() clearPhoneUsageState() @@ -724,9 +934,7 @@ class HomeViewController: BaseViewController { } GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey]) - .subscribe(onNext: { _ in - NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) - }, onError: { error in + .subscribe(onNext: { _ in }, onError: { error in DLToast.showError(text: error.localizedDescription) }) .disposed(by: disposeBag) @@ -1143,35 +1351,6 @@ class HomeViewController: BaseViewController { // MARK: - MQTT extension HomeViewController { - /// join/leave/dismiss 防抖刷新(500ms 内多次触发只调一次) - private func debounceGroupSwitch(groupKey: String) { - groupSwitchWorkItem?.cancel() - let work = DispatchWorkItem { [weak self] in - self?.requestOperateGroup(groupKey: groupKey) - } - groupSwitchWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) - } - - private func debounceGroupRefresh() { - groupRefreshWorkItem?.cancel() - let work = DispatchWorkItem { [weak self] in - self?.requestGroupMembersFromCache() - } - groupRefreshWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) - } - - private func requestGroupMembersFromCache() { - let groupKey = AppContextManager.shared.defaultGroupKey - guard !groupKey.isEmpty else { - requestGroupInfo() - return - } - let requestID = UUID() - groupInfoRequestID = requestID - requestGroupMembers(groupKey: groupKey, requestID: requestID) - } /// 切换圈子时刷新 MQTT 订阅 private func refreshMQTTSubscriptions(_ members: [GroupMemberModel]) { // let currentId = AppContextManager.shared.userId @@ -1320,13 +1499,14 @@ extension HomeViewController { self?.applyIncomingLocks([record]) } case "emote": // 快捷消息、表情 - guard let userId = msg.data?.user_id, userId == AppContextManager.shared.userId, // 只接收发给我的 + guard let targetUserId = msg.data?.user_id, + targetUserId == AppContextManager.shared.userId, // 只接收发给我的 let index = msg.data?.index, index > 9, - let gk = msg.data?.group_key, gk.components(separatedBy: "/").count >= 2 else { break } + let groupKey = msg.data?.group_key, + groupKey.components(separatedBy: "/").count >= 2 else { break } let firstChar = index.string.prefix(1) let lastChar = String(index.string.suffix(index.string.count-1)) - let parts = gk.components(separatedBy: "/") - let emoteUserId = parts[1] + let emoteUserId = userId if firstChar == "1" || firstChar == "2" { // 表情 let emojiFileName = firstChar == "1" ? "normal_\(lastChar)" : "fun_\(lastChar)" @@ -1359,54 +1539,8 @@ extension HomeViewController { guard let member = self.viewModel.memberList.first(where: { $0.user_id == userId }) else { return } self.showSOSReceiveAlert(for: member) } - case "join": // 圈子有成员加入 - guard let gk = msg.data?.group_key, - let defaultGk = self.viewModel.groupModel?.default_group_key, - gk == defaultGk else { - self.requestGroupInfo(isDefaultGroup: false) - return - } - debounceGroupRefresh() - case "leave": // 圈子有成员离开 - guard let userId = msg.data?.user_id, let gk = msg.data?.group_key, - let defaultGk = self.viewModel.groupModel?.default_group_key, - defaultGk.hasPrefix(gk) else { - self.requestGroupInfo(isDefaultGroup: false) - return - } - // 是当前选中圈子就移除成员 - if userId == AppContextManager.shared.userId { // 当前用户就切换到第一个圈子 - guard let model = self.viewModel.groupModel, - let groupInfoModel = model.groups.first else { return } - if model.groups.count > 1 { - self.debounceGroupSwitch(groupKey: groupInfoModel.group_key) - } - else { - debounceGroupRefresh() - } - } - else { // 移除成员 - guard self.viewModel.memberIsExist(userId: userId) else { return } - self.viewModel.removeMember(userId: userId) - self.removeAnnotation(userId: userId) -// self.rootView.groupMemberView.setupCountData(self.viewModel.memberList.count, 1) - MQTTService.shared.unsubscribe(topic: "smartdrive/\(userId)") - } - case "dismiss": // 圈子解散 - guard let gk = msg.data?.group_key, - let defaultGk = self.viewModel.groupModel?.default_group_key, - gk == defaultGk, - let model = self.viewModel.groupModel, - let groupInfoModel = model.groups.first else { - self.requestGroupInfo(isDefaultGroup: false) - return - } - if model.groups.count > 1 { - self.debounceGroupSwitch(groupKey: groupInfoModel.group_key) - } - else { - debounceGroupRefresh() - } + case "join", "leave", "dismiss", "kick": + break default: print("📩 未处理 type=\(msg.type ?? "")") diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift index 33ba83be..be0cd0c2 100644 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift @@ -89,7 +89,6 @@ final class PhoneReportDetailVC: BaseViewController { guard let self, let key = groupKey else { return } GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) .subscribe(onNext: { [weak self] _ in - NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) self?.navigationController?.popViewController(animated: true) }) .disposed(by: self.disposeBag) diff --git a/QuickLocation/Section/Launch/LaunchViewController.swift b/QuickLocation/Section/Launch/LaunchViewController.swift index e5a96cc9..5213c0b0 100644 --- a/QuickLocation/Section/Launch/LaunchViewController.swift +++ b/QuickLocation/Section/Launch/LaunchViewController.swift @@ -74,8 +74,9 @@ class LaunchViewController: BaseViewController { guard let this = self else { return } #if DEBUG || AdHoc this.showServerSwitchAlert() - #endif + #else this.getUserConfig() + #endif } }).disposed(by: disposeBag) } diff --git a/QuickLocation/Section/LockDistract/LockDistractView.swift b/QuickLocation/Section/LockDistract/LockDistractView.swift index 378980ef..ae46502c 100644 --- a/QuickLocation/Section/LockDistract/LockDistractView.swift +++ b/QuickLocation/Section/LockDistract/LockDistractView.swift @@ -127,16 +127,16 @@ final class LockDistractView: UIView { lockBtn.isHidden = primaryAction == .hidden switch primaryAction { case .lock: - tipLab.text = "锁定功能只有在圈主的情况下才能使用哟~" + tipLab.text = "锁定功能需要在对方开启权限情况下才能使用哟~" lockBtn.setTitle("锁定", for: .normal) case .requestUnlock: tipLab.text = "已被其他圈主锁定,请先申请解锁哟~" lockBtn.setTitle("请求解锁", for: .normal) case .unlock: - tipLab.text = "已被你锁定,解锁后才能重新设置哟~" + tipLab.text = "TA已经被你锁定,解锁后才能重新设置哟~" lockBtn.setTitle("解锁", for: .normal) case .hidden: - tipLab.text = "该成员的APP已被其他圈主锁定" + tipLab.text = "该成员的APP已被其他圈主锁定~" } } @@ -236,13 +236,10 @@ final class LockDistractView: UIView { private func setupCopyLimit() { copyTF.rx.text.orEmpty - .subscribe(onNext: { [weak self] text in + .subscribe(onNext: { [weak self] _ in guard let self else { return } - if text.count > self.copyMaxLength { - self.copyTF.text = String(text.prefix(self.copyMaxLength)) - } - let count = self.copyTF.text?.count ?? 0 - self.copyCountLab.text = "\(count)/\(self.copyMaxLength)" + guard let text = self.copyTF.limitCommittedText(to: self.copyMaxLength) else { return } + self.copyCountLab.text = "\(text.count)/\(self.copyMaxLength)" }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Mine/EmergencyContact/EmergencyContactAddView.swift b/QuickLocation/Section/Mine/EmergencyContact/EmergencyContactAddView.swift index f19ba737..39d06a7c 100644 --- a/QuickLocation/Section/Mine/EmergencyContact/EmergencyContactAddView.swift +++ b/QuickLocation/Section/Mine/EmergencyContact/EmergencyContactAddView.swift @@ -14,12 +14,11 @@ class EmergencyContactAddView: UIView { var disposeBag = DisposeBag() private func setupRx() { - nameTF.rx.text - .map { text -> String? in - guard let text = text else { return nil } - return String(text.prefix(10)) - }.bind(to: nameTF.rx.text) - .disposed(by: disposeBag) + let committedName = nameTF.rx.text.orEmpty + .compactMap { [weak self] _ in + self?.nameTF.limitCommittedText(to: 10) + } + .share(replay: 1) phoneTF.rx.text .map { text -> String? in @@ -28,7 +27,7 @@ class EmergencyContactAddView: UIView { }.bind(to: phoneTF.rx.text) .disposed(by: disposeBag) - let valid = Observable.combineLatest(nameTF.rx.text.orEmpty, phoneTF.rx.text.orEmpty) + let valid = Observable.combineLatest(committedName, phoneTF.rx.text.orEmpty) .map { name, phone in !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && phone.count == 11 } diff --git a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift index fc6b630e..61fd8f49 100644 --- a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift +++ b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift @@ -96,12 +96,9 @@ final class FeedbackVC: BaseViewController { rootView.updateCategory(selectedIndex: sender.tag) } - private func handleDetailChange(_ text: String) { - let limitedText = text.count > 200 ? String(text.prefix(200)) : text - if limitedText != text { - rootView.detailTextView.text = limitedText - rootView.detailTextView.selectedRange = NSRange(location: limitedText.count, length: 0) - } + private func handleDetailChange(_: String) { + guard !rootView.detailTextView.isComposingText, + let limitedText = rootView.detailTextView.limitCommittedText(to: 200) else { return } rootView.updateDetail(text: limitedText) } diff --git a/QuickLocation/Section/Mine/MinePhotoWallView.swift b/QuickLocation/Section/Mine/MinePhotoWallView.swift index c14a9a6c..0ad81311 100644 --- a/QuickLocation/Section/Mine/MinePhotoWallView.swift +++ b/QuickLocation/Section/Mine/MinePhotoWallView.swift @@ -66,9 +66,9 @@ final class MinePhotoWallView: UIView { members: [GroupMemberModel], ownerUserId: String, preferUserId: String, - memberLimit: Int = -1 + isCoupleGroup: Bool = false ) { - isPairMode = memberLimit == 2 + isPairMode = isCoupleGroup && members.count >= 2 self.members = isPairMode ? Array(members.prefix(2)) : members self.ownerUserId = ownerUserId self.clipColorIndices = Self.makeClipColorIndices(for: self.members) diff --git a/QuickLocation/Section/Mine/MineViewController.swift b/QuickLocation/Section/Mine/MineViewController.swift index 2338fd45..60484b88 100644 --- a/QuickLocation/Section/Mine/MineViewController.swift +++ b/QuickLocation/Section/Mine/MineViewController.swift @@ -117,27 +117,35 @@ final class MineViewController: BaseViewController { DLToast.showError(text: response.message ?? "获取圈子成员失败") return } - self?.reloadPhotoWall(members: response.list, groupKey: groupKey) + self?.reloadPhotoWall( + members: response.list, + groupKey: groupKey, + groupInfo: response.model + ) }, onError: { error in DLToast.showError(text: error.localizedDescription) }) .disposed(by: disposeBag) } - private func reloadPhotoWall(members: [GroupMemberModel], groupKey: String) { + private func reloadPhotoWall( + members: [GroupMemberModel], + groupKey: String, + groupInfo: GroupInfoModel? = nil + ) { let sorted = viewModel.sortedMembers(members, groupKey: groupKey) photoWallMembers = sorted let ownerId = sorted.first { GroupOwnerMatcher.matches(userId: $0.user_id, groupKey: groupKey) }?.user_id ?? "" - let limit = groupModel?.groups.first { + let currentGroup = groupInfo ?? groupModel?.groups.first { $0.group_key == groupKey - }?.group_template?.limit ?? -1 + } rootView.photoWall.reload( members: sorted, ownerUserId: ownerId, preferUserId: AppContextManager.shared.userId, - memberLimit: limit + isCoupleGroup: currentGroup?.isCoupleGroup == true ) } diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift index b07ae8e4..89992312 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift @@ -580,24 +580,11 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { } @objc private func captionChanged() { - let text = captionField.text ?? "" - if text.count > 20 { - captionField.text = String(text.prefix(20)) - } - captionCountLabel.text = "\((captionField.text ?? "").count)/20" + guard let text = captionField.limitCommittedText(to: 20) else { return } + captionCountLabel.text = "\(text.count)/20" updatePreviewOverlays() } - func textField( - _ textField: UITextField, - shouldChangeCharactersIn range: NSRange, - replacementString string: String - ) -> Bool { - guard let current = textField.text, - let stringRange = Range(range, in: current) else { return true } - return current.replacingCharacters(in: stringRange, with: string).count <= 20 - } - func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() return true diff --git a/QuickLocation/Section/TextInput/TextInputViewController.swift b/QuickLocation/Section/TextInput/TextInputViewController.swift index 153756a9..3777b401 100644 --- a/QuickLocation/Section/TextInput/TextInputViewController.swift +++ b/QuickLocation/Section/TextInput/TextInputViewController.swift @@ -102,6 +102,9 @@ final class TextInputViewController: UIViewController { // MARK: - Binding private func setupBinding() { + textView.text = textRelay.value + updateCommittedText() + Observable.merge( textView.rx.didChange.asObservable(), textView.rx.text.map { _ in () }, @@ -109,37 +112,10 @@ final class TextInputViewController: UIViewController { ) .throttle(.milliseconds(100), scheduler: MainScheduler.instance) .subscribe(onNext: { [weak self] in - guard let self = self else { return } - - if self.textView.text.last == "\n" { - self.textView.text = String(self.textView.text.dropLast()) - self.textView.resignFirstResponder() - return - } - - let count = self.textView.text.count - - if count > self.maxLength { - self.textView.text = String(self.textView.text.prefix(self.maxLength)) - self.textView.selectedRange = NSRange(location: self.maxLength, length: 0) - return - } - self.countLabel.text = "\(count)/\(self.maxLength)" + self?.updateCommittedText() }) .disposed(by: disposeBag) - textRelay.asObservable() - .bind(to: textView.rx.text) - .disposed(by: disposeBag) - - // 确定 - textView.rx.text.orEmpty.map { text in - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return !trimmed.isEmpty - } - .bind(to: confirmBtn.rx.isEnabled) - .disposed(by: disposeBag) - confirmBtn.rx.tap.subscribe(onNext: { [weak self] in guard let self = self, let text = self.textView.text else { return } self.confirmAction?(text) @@ -148,6 +124,28 @@ final class TextInputViewController: UIViewController { .disposed(by: disposeBag) } + private func updateCommittedText() { + guard !textView.isComposingText else { return } + + if textView.text.last == "\n" { + textView.text = String(textView.text.dropLast()) + textView.selectedRange = NSRange( + location: (textView.text as NSString).length, + length: 0 + ) + textView.resignFirstResponder() + } + + let text: String + if maxLength > 0 { + text = textView.limitCommittedText(to: maxLength) ?? textView.text + } else { + text = textView.text + } + countLabel.text = maxLength > 0 ? "\(text.count)/\(maxLength)" : "" + confirmBtn.isEnabled = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + // MARK: - Views lazy var navBgView: UIImageView = { let iv = UIImageView() diff --git a/QuickLocation/Service/GroupService.swift b/QuickLocation/Service/GroupService.swift index abefbf5c..61072f00 100644 --- a/QuickLocation/Service/GroupService.swift +++ b/QuickLocation/Service/GroupService.swift @@ -5,11 +5,60 @@ // Created by 八条 on 2026/6/1. // +import Foundation import RxSwift import Moya struct GroupService { static let disposeBag = DisposeBag() + + private static let notifyingOperations: Set = [ + "create", "join", "setdefault", "editmateinfo" + ] + + private static func postGroupDataChange(operation: String, + requestData: [String: Any], + responseData: [String: Any]? = nil) { + var userInfo: [AnyHashable: Any] = [ + GroupDataChangeUserInfoKey.operation: operation, + GroupDataChangeUserInfoKey.source: GroupDataChangeSource.api + ] + + let responseGroupKey = responseData?[GroupDataChangeUserInfoKey.groupKey] as? String + if let groupKey = requestData[GroupDataChangeUserInfoKey.groupKey] as? String + ?? responseGroupKey, + !groupKey.isEmpty { + userInfo[GroupDataChangeUserInfoKey.groupKey] = groupKey + } + + let userId = requestData[GroupDataChangeUserInfoKey.userId] as? String + ?? requestData["review_userid"] as? String + ?? requestData["kick_userid"] as? String + ?? (requestData["kick_userid"] as? [String])?.first + if let userId, !userId.isEmpty { + userInfo[GroupDataChangeUserInfoKey.userId] = userId + } + + let post = { + NotificationCenter.default.post( + name: .RefreshGroupInfoNotification, + object: nil, + userInfo: userInfo + ) + } + if Thread.isMainThread { + post() + } else { + DispatchQueue.main.async(execute: post) + } + } + + private static func notifyGroupInfoChange(operation: String, + requestData: [String: Any]) -> (T) -> Void { + { _ in + postGroupDataChange(operation: operation, requestData: requestData) + } + } /// 所有圈子信息 static func groupInfo() -> Observable { @@ -17,8 +66,8 @@ struct GroupService { return APIProvider.request(token: api) .map(UserGroupResponse.self) .map { response in - if let key = response.model?.default_group_key, !key.isEmpty { - AppContextManager.shared.defaultGroupKey = key + if let model = response.model { + AppContextManager.shared.defaultGroupKey = model.default_group_key } return response } @@ -57,6 +106,14 @@ struct GroupService { } return response } + .do(onSuccess: { response in + guard notifyingOperations.contains(opType) else { return } + postGroupDataChange( + operation: opType, + requestData: requestData, + responseData: response.data + ) + }) .asObservable() } @@ -83,6 +140,7 @@ struct GroupService { let api = GroupAPI.operate(opType: "changename", requestData: requestData).multiTarget return APIProvider.request(token: api) .map(GroupInfoResponse.self) + .do(onSuccess: notifyGroupInfoChange(operation: "changename", requestData: requestData)) .asObservable() } @@ -93,6 +151,7 @@ struct GroupService { let api = GroupAPI.operate(opType: "changeicon", requestData: requestData).multiTarget return APIProvider.request(token: api) .map(GroupInfoResponse.self) + .do(onSuccess: notifyGroupInfoChange(operation: "changeicon", requestData: requestData)) .asObservable() } @@ -103,6 +162,7 @@ struct GroupService { let api = GroupAPI.operate(opType: "changedescription", requestData: requestData).multiTarget return APIProvider.request(token: api) .map(GroupInfoResponse.self) + .do(onSuccess: notifyGroupInfoChange(operation: "changedescription", requestData: requestData)) .asObservable() } @@ -113,6 +173,7 @@ struct GroupService { let api = GroupAPI.operate(opType: "changereview", requestData: requestData).multiTarget return APIProvider.request(token: api) .map(GroupInfoResponse.self) + .do(onSuccess: notifyGroupInfoChange(operation: "changereview", requestData: requestData)) .asObservable() } @@ -123,6 +184,7 @@ struct GroupService { let api = GroupAPI.operate(opType: "changelabels", requestData: requestData).multiTarget return APIProvider.request(token: api) .map(GroupInfoResponse.self) + .do(onSuccess: notifyGroupInfoChange(operation: "changelabels", requestData: requestData)) .asObservable() } @@ -141,6 +203,7 @@ struct GroupService { let api = GroupAPI.operate(opType: "review", requestData: requestData).multiTarget return APIProvider.request(token: api) .map(ResponseModel.self) + .do(onSuccess: notifyGroupInfoChange(operation: "review", requestData: requestData)) .asObservable() } @@ -149,6 +212,7 @@ struct GroupService { let api = GroupAPI.operate(opType: "kick", requestData: requestData).multiTarget return APIProvider.request(token: api) .map(ResponseModel.self) + .do(onSuccess: notifyGroupInfoChange(operation: "kick", requestData: requestData)) .asObservable() } @@ -159,6 +223,7 @@ struct GroupService { let api = GroupAPI.operate(opType: "leave", requestData: requestData).multiTarget return APIProvider.request(token: api) .map(GroupInfoResponse.self) + .do(onSuccess: notifyGroupInfoChange(operation: "leave", requestData: requestData)) .asObservable() } @@ -167,6 +232,7 @@ struct GroupService { let api = GroupAPI.operate(opType: "dismiss", requestData: requestData).multiTarget return APIProvider.request(token: api) .map(GroupInfoResponse.self) + .do(onSuccess: notifyGroupInfoChange(operation: "dismiss", requestData: requestData)) .asObservable() } } diff --git a/QuickLocation/Service/UserService.swift b/QuickLocation/Service/UserService.swift index 01532f46..27a2b083 100644 --- a/QuickLocation/Service/UserService.swift +++ b/QuickLocation/Service/UserService.swift @@ -191,6 +191,24 @@ struct UserService { .map(PhoneUnlockAllowResponse.self) .asObservable() } + + /// 确认本机已执行解锁 + static func phoneUnlockAllowConfirm( + os: String = "ios", + groupKey: String, + userId: String, + tokens: [String] + ) -> Observable { + let api = UserAPI.phoneUnlockAllowConfirm( + os: os, + groupKey: groupKey, + userId: userId, + tokens: tokens + ).multiTarget + return APIProvider.request(token: api, handle: false) + .map(ResponseModel.self) + .asObservable() + } static func imToken() -> Observable { let api = UserAPI.imToken.multiTarget