// // LNIMManager.swift // Lanu // // Created by OneeChan on 2025/11/12. // import Foundation import RTCRoomEngine import AVFAudio import AtomicXCore protocol LNIMManagerNotify { func onConversationListChanged() func onIMUserStatusChanged(uid: String, status: V2TIMUserStatusType) func onVoiceCallBegin() func onVoiceCallEnd() func onVoiceCallInfoChanged() } extension LNIMManagerNotify { func onConversationListChanged() {} func onIMUserStatusChanged(uid: String, status: V2TIMUserStatusType) {} func onVoiceCallBegin() { } func onVoiceCallEnd() { } func onVoiceCallInfoChanged() { } } extension String { var isImOfficialId: Bool { guard let intValue = Int(self) else { return false } return intValue <= LNIMManager.maxOfficialId } } enum LNIMCustomErrorCode: Int { case inBlackList = 120001 case userNotExist = 120002 } enum LNIMOfficialIds: String { case officialMessage = "9998" } enum LNIMVoiceCallSpeakerType { case speakerphone case earpiece case bluetooth var toTUIAudioPlaybackDevice: TUIAudioPlaybackDevice? { switch self { case .speakerphone: .speakerphone case .earpiece: .earpiece case .bluetooth: nil } } } class LNIMVoiceCallInfo { let uid: String var isInCome = false var beginTime: TimeInterval = 0 var isMute = false var deviceType: LNIMVoiceCallSpeakerType = .earpiece init(uid: String) { self.uid = uid } } class LNIMManager: NSObject { private static var appId: Int32 { if LNAppConfig.shared.curEnv == .test { 20034873 } else { 80000456 } } var offlinePushAppId: Int32 { if LNAppConfig.shared.curEnv == .test { // 15846 // 本地调试使用这个 15847 // 打包 ipa 只能用这个 } else { 15845 } } static var shared = LNIMManager() static let maxOfficialId = 10000 static let maxMessageInput = 500 static let maxRemarkLength = 16 private(set) var conversationList: [V2TIMConversation] = [] private(set) var userStatus: [String: V2TIMUserStatusType] = [:] private(set) var voiceCallAvailable = false private(set) var curCallInfo: LNIMVoiceCallInfo? private let bellPlayer = LNIMAudioCallBellPlayer() private override init() { super.init() _ = LNIMEmojiManager.shared LNEventDeliver.addObserver(self) V2TIMManager.sharedInstance().addConversationListener(listener: self) V2TIMManager.sharedInstance().addIMSDKListener(listener: self) } } // MARK: 会话列表 extension LNIMManager { func reloadConversationList(handler: ((Bool) -> Void)? = nil) { let filter = V2TIMConversationListFilter() filter.type = .C2C V2TIMManager.sharedInstance().getConversationListByFilter( filter: filter, nextSeq: 0, count: 1000) { [weak self] list, nextTag, isFinish in guard let self else { return } guard var list else { handler?(false) return } // 按照时间排序 list.sort { $0.userID?.isImOfficialId == true || $0.orderKey > $1.orderKey } if list.firstIndex(where: { $0.userID?.isImOfficialId == true }) == nil { // 插入官方消息 let officialId = "c2c_" + LNIMOfficialIds.officialMessage.rawValue saveDraftFor(officialId, draft: " ") saveDraftFor(officialId, draft: nil) } for item in list { item.extraInfo = conversationList.first(where: { $0.conversationID == item.conversationID })?.extraInfo ?? LNIMConversationExtraInfo() } conversationList = list handler?(true) notifyConversationListChanged() V2TIMManager.sharedInstance().subscribeUserStatus(userIDList: list.compactMap({ $0.userID }), succ: nil) loadUsersOnlineStatus() loadUsersRemark() } fail: { code, err in handler?(false) } } func cleanConversationUnread(uid: String) { let conversationId = "c2c_" + uid V2TIMManager.sharedInstance().cleanConversationUnreadMessageCount( conversationID: conversationId, cleanTimestamp: 0, cleanSequence: 0, succ: nil) } func saveDraftFor(_ conversationId: String?, draft: String?) { guard let conversationId else { return } V2TIMManager.sharedInstance().setConversationDraft(conversationID: conversationId, draftText: draft, succ: nil) } } // MARK: 会话变化回调 V2TIMConversationListener extension LNIMManager: V2TIMConversationListener { func onNewConversation(conversationList: [V2TIMConversation]!) { reloadConversationList() loadUsersOnlineStatus() } func onConversationChanged(conversationList: [V2TIMConversation]!) { var isChanged = false var isOrderMessage = false for item in conversationList { if let old = self.conversationList.first(where: { $0.conversationID == item.conversationID }), old.unreadCount != item.unreadCount || old.lastDisplayDate != item.lastDisplayDate || old.lastMessage?.msgID != item.lastMessage?.msgID { isChanged = true } if let message = item.lastMessage, case .order = LNIMMessageData(imMessage: message).type { isOrderMessage = true } } if isChanged { reloadConversationList() } if isOrderMessage { LNOrderManager.shared.reloadMyOrderDiscountInfo() } } func onConversationDeleted(conversationIDList: [String]!) { reloadConversationList() loadUsersOnlineStatus() } } // MARK: 用户在线状态 extension LNIMManager { func isUserOnline(uid: String) -> Bool { userStatus[uid] == .USER_STATUS_ONLINE } func getUserOnlineState(uid: String, handler: @escaping (Bool) -> Void) { V2TIMManager.sharedInstance().getUserStatus(userIDList: [uid]) { [weak self] userStatusList in guard let self else { return } userStatusList?.forEach { if let uid = $0.userID { self.userStatus[uid] = $0.statusType self.notifyUserStatusChanged(uid: uid, status: $0.statusType) } } handler(userStatusList?.first(where: { $0.userID == uid })?.statusType == .USER_STATUS_ONLINE) } fail: { _, _ in } } private func loadUsersOnlineStatus() { let uids = conversationList.compactMap { $0.userID } guard !uids.isEmpty else { return } V2TIMManager.sharedInstance().getUserStatus(userIDList: uids) { [weak self] userStatusList in guard let self else { return } userStatusList?.forEach { if let uid = $0.userID { self.userStatus[uid] = $0.statusType self.notifyUserStatusChanged(uid: uid, status: $0.statusType) } } } fail: { _, _ in } } private func loadUsersRemark() { let newItems = conversationList.filter({ $0.extraInfo?.remark == nil }) guard !newItems.isEmpty else { return } getUsersRemark(uids: newItems.compactMap({ $0.userID })) { [weak self] remarks in guard let remarks else { return } guard let self else { return } for item in newItems { item.extraInfo?.remark = remarks.first(where: { $0.userNo == item.userID })?.note ?? "" } notifyConversationListChanged() } } } // MARK: 用户在线状态变化回调 V2TIMSDKListener extension LNIMManager: V2TIMSDKListener { func onUserStatusChanged(userStatusList: [V2TIMUserStatus]!) { userStatusList.forEach { if let uid = $0.userID { userStatus[uid] = $0.statusType notifyUserStatusChanged(uid: uid, status: $0.statusType) // 如果我是被呼叫者,对方下线,不会触发回调,只能在这里进行挂断处理 if uid == curCallInfo?.uid, curCallInfo?.isInCome == true, $0.statusType == .USER_STATUS_OFFLINE { hangupVoiceCall() } } } } } // MARK: 语音通话 extension LNIMManager { private func checkIfCanCall(uid: String, queue: DispatchQueue = .main, handler: @escaping (Bool) -> Void) { LNHttpManager.shared.checkIfCanCall(uid: uid) { err in queue.asyncIfNotGlobal { handler(err == nil) } if let err { showToast(err.errorDesc) } } } func makeVoiceCall(uid: String) { guard !uid.isMyUid else { showToast(.init(key: "C00014")) return } guard LNRoomManager.shared.curRoom == nil else { showToast(.init(key: "A00387")) return } LNPermissionHelper.requestMicrophoneAccess { [weak self] granted in guard let self else { return } guard granted else { showToast(.init(key: "B00022")) return } guard curCallInfo == nil else { return } curCallInfo = .init(uid: uid) curCallInfo?.isInCome = false checkIfCanCall(uid: uid) { [weak self] can in guard let self else { return } guard can else { curCallInfo = nil return } let floatingView = LNAudioCallFloatingView() floatingView.show() let panel = LNAudioCallPanel() panel.toCallOut(uid: uid) panel.popup() let offlinePushInfo = createOfflinePushInfo() let param = TUICallParams() param.offlinePushInfo = offlinePushInfo TUICallEngine.createInstance().calls(userIdList: [uid], callMediaType: .audio, params: param) { [weak self] in LNStatisticManager.shared.reportStartCall(uid: uid, success: true) guard let self else { return } bellPlayer.startPlay(isInCome: false) } fail: { [weak panel, weak floatingView] _, err in LNStatisticManager.shared.reportStartCall(uid: uid, success: false) showToast(err) if let panel { panel.dismiss() } if let floatingView { floatingView.dismiss() } } } } } func rejectVoiceCall() { TUICallEngine.createInstance().reject { } fail: { _, err in showToast(err) } } func acceptVoiceCall() { TUICallEngine.createInstance().accept { [weak self] in if let self, let curCallInfo { LNStatisticManager.shared.reportAcceptCall(uid: curCallInfo.uid, success: true) } } fail: { [weak self] _, err in if let self, let curCallInfo { LNStatisticManager.shared.reportAcceptCall(uid: curCallInfo.uid, success: false) } showToast(err) } } func hangupVoiceCall() { TUICallEngine.createInstance().hangup { } fail: { _, err in showToast(err) } } func switchVoiceCallMicrophone() { if curCallInfo?.isMute == true { TUICallEngine.createInstance().openMicrophone { [weak self] in guard let self else { return } curCallInfo?.isMute = false LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallInfoChanged() } } fail: { _, err in showToast(err) } } else { TUICallEngine.createInstance().closeMicrophone() curCallInfo?.isMute = true LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallInfoChanged() } } } func switchVoiceCallSpeakerType(type: LNIMVoiceCallSpeakerType) { if let deviceType = type.toTUIAudioPlaybackDevice { TUICallEngine.createInstance().selectAudioPlaybackDevice(deviceType) curCallInfo?.deviceType = type } else { let session = AVAudioSession.sharedInstance() do { // 设置为播放类别,允许蓝牙音频路由 try session.setCategory(.playback, mode: .default, options: [ .allowBluetoothA2DP, .allowBluetoothHFP ]) try session.setActive(true) // 激活音频会话 curCallInfo?.deviceType = type } catch { print("音频会话配置失败:\(error.localizedDescription)") } } LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallInfoChanged() } } private func createOfflinePushInfo() -> TUIOfflinePushInfo { let pushInfo: TUIOfflinePushInfo = TUIOfflinePushInfo() pushInfo.title = myUserInfo.nickname pushInfo.desc = .init(key: "C00013") // iOS push type: if you want user VoIP, please modify type to TUICallIOSOfflinePushTypeVoIP pushInfo.iOSPushType = .voIP pushInfo.ignoreIOSBadge = false pushInfo.iOSSound = "phone_bell.mp3" pushInfo.androidSound = "phone_ringing" // VIVO message type: 0-push message, 1-System message(have a higher delivery rate) pushInfo.androidVIVOClassification = 1 // HuaWei message type: https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/message-classification-0000001149358835 pushInfo.androidHuaWeiCategory = "IM" return pushInfo } } // MARK: 语音通话变化回调 TUICallObserver extension LNIMManager: TUICallObserver { func onCallReceived(callerId: String, calleeIdList: [String], groupId: String?, callMediaType: TUICallMediaType, userData: String?) { guard curCallInfo == nil else { return } guard LNRoomManager.shared.curRoom == nil else { // 在直播间,直接拒绝 showToast(.init(key: "A00388")) rejectVoiceCall() return } curCallInfo = .init(uid: callerId) curCallInfo?.isInCome = true bellPlayer.startPlay(isInCome: true) let floatingView = LNAudioCallFloatingView() floatingView.show() let panel = LNAudioCallPanel() panel.onCallIn(uid: callerId) panel.popup() } func onCallCancelled(callerId: String) { bellPlayer.stop() curCallInfo = nil LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() } } func onCallBegin(roomId: TUIRoomId, callMediaType: TUICallMediaType, callRole: TUICallRole) { curCallInfo?.beginTime = curTime LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallBegin() } TUICallEngine.createInstance().selectAudioPlaybackDevice(.earpiece) TUICallEngine.createInstance().openMicrophone { } fail: { _, _ in } bellPlayer.stop() } func onCallEnd(roomId: TUIRoomId, callMediaType: TUICallMediaType, callRole: TUICallRole, totalTime: Float) { if let curCallInfo { LNStatisticManager.shared.reportEndCall(uid: curCallInfo.uid, duration: totalTime) } curCallInfo = nil LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() } } func onUserReject(userId: String) { } func onUserNoResponse(userId: String) { } func onUserLineBusy(userId: String) { curCallInfo = nil LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() } bellPlayer.stop() } } // MARK: IM 备注 extension LNIMManager { func setUserRemark(uid: String, remark: String, queue: DispatchQueue = .main, handler: @escaping (Bool) -> Void) { LNHttpManager.shared.setUserRemark(uid: uid, remark: remark) { [weak self] err in queue.asyncIfNotGlobal { handler(err == nil) } if let err { showToast(err.errorDesc) } else { guard let self else { return } conversationList.first { $0.userID == uid }?.extraInfo?.remark = remark notifyConversationListChanged() } } } func getUsersRemark(uids: [String], queue: DispatchQueue = .main, handler: @escaping ([LNIMUserRemarkVO]?) -> Void) { LNHttpManager.shared.getUsersRemark(uids: uids) { res, err in queue.asyncIfNotGlobal { handler(res?.list) } } } } // MARK: IM 初始化 extension LNIMManager { private func getIMSignToken(handler: @escaping (String?) -> Void) { LNHttpManager.shared.getIMSign { token, err in handler(token) } } } extension LNIMManager: LNAccountManagerNotify, LNProfileManagerNotify { func onUserLogin() { // 初始化 SDK let config = V2TIMSDKConfig() config.logLevel = LNAppConfig.shared.curEnv == .test ? .LOG_DEBUG : .LOG_INFO config.logListener = { level, log in guard let log else { return } if LNAppConfig.shared.curEnv == .test { Log.d(log) } else { Log.i(log) } } guard V2TIMManager.sharedInstance().initSDK(Self.appId, config: config) else { Log.e("V2TIMManager initSDK failed") return } // 登录 let loginSuccessBlock = { [weak self] in guard let self else { return } onUserInfoChanged(userInfo: myUserInfo) reloadConversationList() voiceCallAvailable = true TUICallEngine.createInstance().addObserver(self) TIMPushManager.registerPush(Self.appId, appKey: "") { _ in Log.i("TIMPushManager registerPush success") } fail: { _, err in Log.e("TIMPushManager registerPush failed:\(err)") } } getIMSignToken { token in guard let token else { Log.e("LNIMManager getIMSignToken failed") return } LoginStore.shared.login(sdkAppID: Self.appId, userID: myUid, userSig: token) { error in switch error { case .success(let info): Log.i("LoginStore", "login success \(info)") loginSuccessBlock() case .failure(let error): Log.i("LoginStore", "login failed code:\(error.code), message:\(error.message)") } } } } func onUserLogout() { LoginStore.shared.logout(completion: nil) TIMPushManager.unRegisterPush { } fail: { _, _ in } TUICallEngine.destroyInstance() Self.shared = LNIMManager() } func onUserInfoChanged(userInfo: LNUserProfileVO) { guard userInfo.userNo.isMyUid, !myUserInfo.userNo.isEmpty else { return } let info = V2TIMUserFullInfo() info.nickName = myUserInfo.nickname info.faceURL = myUserInfo.avatar V2TIMManager.sharedInstance().setSelfInfo(info: info) { } } } extension LNIMManager { private func notifyConversationListChanged() { LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onConversationListChanged() } } private func notifyUserStatusChanged(uid: String, status: V2TIMUserStatusType) { LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onIMUserStatusChanged(uid: uid, status: status) } } }