| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585 |
- //
- // LNIMManager.swift
- // Lanu
- //
- // Created by OneeChan on 2025/11/12.
- //
- import Foundation
- import RTCRoomEngine
- import AVFAudio
- 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 {
- 20030346
- } 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 = 200
- 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
- }
-
- 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
- }
- 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 {
- func onUserLogin() {
- // 初始化 SDK
- let config = V2TIMSDKConfig()
- config.logLevel = .LOG_INFO
- 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 }
- Log.d("V2TIMManager login success")
- reloadConversationList()
- 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
- }
- if V2TIMManager.sharedInstance().getLoginUser()?.isMyUid == true {
- loginSuccessBlock()
- } else {
- V2TIMManager.sharedInstance().login(userID: myUid, userSig: token, succ: loginSuccessBlock) { _, err in
- Log.e("V2TIMManager login failed err: \(err ?? "")")
- }
- }
-
- TUICallEngine.createInstance().`init`(Self.appId, userId: myUid, userSig: token) { [weak self] in
- guard let self else { return }
- Log.i("TUICallEngine init success")
- voiceCallAvailable = true
- TUICallEngine.createInstance().addObserver(self)
- } fail: { _, err in
- Log.e("TUICallEngine init failed: \(err ?? "")")
- }
-
- LNRoomManager.shared.login(appId: Self.appId, token: token)
- }
- }
-
- func onUserLogout() {
- V2TIMManager.sharedInstance().logout(succ: nil)
- TIMPushManager.unRegisterPush { } fail: { _, _ in }
- TUICallEngine.destroyInstance()
-
- Self.shared = LNIMManager()
- }
- }
- 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)
- }
- }
- }
|