LNIMManager.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. //
  2. // LNIMManager.swift
  3. // Lanu
  4. //
  5. // Created by OneeChan on 2025/11/12.
  6. //
  7. import Foundation
  8. import RTCRoomEngine
  9. import AVFAudio
  10. import AtomicXCore
  11. protocol LNIMManagerNotify {
  12. func onConversationListChanged()
  13. func onIMUserStatusChanged(uid: String, status: V2TIMUserStatusType)
  14. func onVoiceCallBegin()
  15. func onVoiceCallEnd()
  16. func onVoiceCallInfoChanged()
  17. }
  18. extension LNIMManagerNotify {
  19. func onConversationListChanged() {}
  20. func onIMUserStatusChanged(uid: String, status: V2TIMUserStatusType) {}
  21. func onVoiceCallBegin() { }
  22. func onVoiceCallEnd() { }
  23. func onVoiceCallInfoChanged() { }
  24. }
  25. extension String {
  26. var isImOfficialId: Bool {
  27. guard let intValue = Int(self) else { return false }
  28. return intValue <= LNIMManager.maxOfficialId
  29. }
  30. }
  31. enum LNIMCustomErrorCode: Int {
  32. case inBlackList = 120001
  33. case userNotExist = 120002
  34. }
  35. enum LNIMOfficialIds: String {
  36. case officialMessage = "9998"
  37. }
  38. enum LNIMVoiceCallSpeakerType {
  39. case speakerphone
  40. case earpiece
  41. case bluetooth
  42. var toTUIAudioPlaybackDevice: TUIAudioPlaybackDevice? {
  43. switch self {
  44. case .speakerphone: .speakerphone
  45. case .earpiece: .earpiece
  46. case .bluetooth: nil
  47. }
  48. }
  49. }
  50. class LNIMVoiceCallInfo {
  51. let uid: String
  52. var isInCome = false
  53. var beginTime: TimeInterval = 0
  54. var isMute = false
  55. var deviceType: LNIMVoiceCallSpeakerType = .earpiece
  56. init(uid: String) {
  57. self.uid = uid
  58. }
  59. }
  60. class LNIMManager: NSObject {
  61. private static var appId: Int32 {
  62. if LNAppConfig.shared.curEnv == .test {
  63. 20034873
  64. } else {
  65. 80000456
  66. }
  67. }
  68. var offlinePushAppId: Int32 {
  69. if LNAppConfig.shared.curEnv == .test {
  70. // 15846 // 本地调试使用这个
  71. 15847 // 打包 ipa 只能用这个
  72. } else {
  73. 15845
  74. }
  75. }
  76. static var shared = LNIMManager()
  77. static let maxOfficialId = 10000
  78. static let maxMessageInput = 500
  79. static let maxRemarkLength = 16
  80. private(set) var conversationList: [V2TIMConversation] = []
  81. private(set) var userStatus: [String: V2TIMUserStatusType] = [:]
  82. private(set) var voiceCallAvailable = false
  83. private(set) var curCallInfo: LNIMVoiceCallInfo?
  84. private let bellPlayer = LNIMAudioCallBellPlayer()
  85. private override init() {
  86. super.init()
  87. _ = LNIMEmojiManager.shared
  88. LNEventDeliver.addObserver(self)
  89. V2TIMManager.sharedInstance().addConversationListener(listener: self)
  90. V2TIMManager.sharedInstance().addIMSDKListener(listener: self)
  91. }
  92. }
  93. // MARK: 会话列表
  94. extension LNIMManager {
  95. func reloadConversationList(handler: ((Bool) -> Void)? = nil) {
  96. let filter = V2TIMConversationListFilter()
  97. filter.type = .C2C
  98. V2TIMManager.sharedInstance().getConversationListByFilter(
  99. filter: filter,
  100. nextSeq: 0, count: 1000)
  101. { [weak self] list, nextTag, isFinish in
  102. guard let self else { return }
  103. guard var list else {
  104. handler?(false)
  105. return
  106. }
  107. // 按照时间排序
  108. list.sort { $0.userID?.isImOfficialId == true || $0.orderKey > $1.orderKey }
  109. if list.firstIndex(where: { $0.userID?.isImOfficialId == true }) == nil {
  110. // 插入官方消息
  111. let officialId = "c2c_" + LNIMOfficialIds.officialMessage.rawValue
  112. saveDraftFor(officialId, draft: " ")
  113. saveDraftFor(officialId, draft: nil)
  114. }
  115. for item in list {
  116. item.extraInfo = conversationList.first(where: { $0.conversationID == item.conversationID })?.extraInfo ?? LNIMConversationExtraInfo()
  117. }
  118. conversationList = list
  119. handler?(true)
  120. notifyConversationListChanged()
  121. V2TIMManager.sharedInstance().subscribeUserStatus(userIDList: list.compactMap({ $0.userID }), succ: nil)
  122. loadUsersOnlineStatus()
  123. loadUsersRemark()
  124. } fail: { code, err in
  125. handler?(false)
  126. }
  127. }
  128. func cleanConversationUnread(uid: String) {
  129. let conversationId = "c2c_" + uid
  130. V2TIMManager.sharedInstance().cleanConversationUnreadMessageCount(
  131. conversationID: conversationId,
  132. cleanTimestamp: 0, cleanSequence: 0, succ: nil)
  133. }
  134. func saveDraftFor(_ conversationId: String?, draft: String?) {
  135. guard let conversationId else { return }
  136. V2TIMManager.sharedInstance().setConversationDraft(conversationID: conversationId, draftText: draft, succ: nil)
  137. }
  138. }
  139. // MARK: 会话变化回调 V2TIMConversationListener
  140. extension LNIMManager: V2TIMConversationListener {
  141. func onNewConversation(conversationList: [V2TIMConversation]!) {
  142. reloadConversationList()
  143. loadUsersOnlineStatus()
  144. }
  145. func onConversationChanged(conversationList: [V2TIMConversation]!) {
  146. var isChanged = false
  147. var isOrderMessage = false
  148. for item in conversationList {
  149. if let old = self.conversationList.first(where: { $0.conversationID == item.conversationID }),
  150. old.unreadCount != item.unreadCount
  151. || old.lastDisplayDate != item.lastDisplayDate
  152. || old.lastMessage?.msgID != item.lastMessage?.msgID {
  153. isChanged = true
  154. }
  155. if let message = item.lastMessage,
  156. case .order = LNIMMessageData(imMessage: message).type {
  157. isOrderMessage = true
  158. }
  159. }
  160. if isChanged {
  161. reloadConversationList()
  162. }
  163. if isOrderMessage {
  164. LNOrderManager.shared.reloadMyOrderDiscountInfo()
  165. }
  166. }
  167. func onConversationDeleted(conversationIDList: [String]!) {
  168. reloadConversationList()
  169. loadUsersOnlineStatus()
  170. }
  171. }
  172. // MARK: 用户在线状态
  173. extension LNIMManager {
  174. func isUserOnline(uid: String) -> Bool {
  175. userStatus[uid] == .USER_STATUS_ONLINE
  176. }
  177. func getUserOnlineState(uid: String, handler: @escaping (Bool) -> Void) {
  178. V2TIMManager.sharedInstance().getUserStatus(userIDList: [uid])
  179. { [weak self] userStatusList in
  180. guard let self else { return }
  181. userStatusList?.forEach {
  182. if let uid = $0.userID {
  183. self.userStatus[uid] = $0.statusType
  184. self.notifyUserStatusChanged(uid: uid, status: $0.statusType)
  185. }
  186. }
  187. handler(userStatusList?.first(where: { $0.userID == uid })?.statusType == .USER_STATUS_ONLINE)
  188. } fail: { _, _ in }
  189. }
  190. private func loadUsersOnlineStatus() {
  191. let uids = conversationList.compactMap { $0.userID }
  192. guard !uids.isEmpty else { return }
  193. V2TIMManager.sharedInstance().getUserStatus(userIDList: uids)
  194. { [weak self] userStatusList in
  195. guard let self else { return }
  196. userStatusList?.forEach {
  197. if let uid = $0.userID {
  198. self.userStatus[uid] = $0.statusType
  199. self.notifyUserStatusChanged(uid: uid, status: $0.statusType)
  200. }
  201. }
  202. } fail: { _, _ in }
  203. }
  204. private func loadUsersRemark() {
  205. let newItems = conversationList.filter({ $0.extraInfo?.remark == nil })
  206. guard !newItems.isEmpty else { return }
  207. getUsersRemark(uids: newItems.compactMap({ $0.userID })) { [weak self] remarks in
  208. guard let remarks else { return }
  209. guard let self else { return }
  210. for item in newItems {
  211. item.extraInfo?.remark = remarks.first(where: { $0.userNo == item.userID })?.note ?? ""
  212. }
  213. notifyConversationListChanged()
  214. }
  215. }
  216. }
  217. // MARK: 用户在线状态变化回调 V2TIMSDKListener
  218. extension LNIMManager: V2TIMSDKListener {
  219. func onUserStatusChanged(userStatusList: [V2TIMUserStatus]!) {
  220. userStatusList.forEach {
  221. if let uid = $0.userID {
  222. userStatus[uid] = $0.statusType
  223. notifyUserStatusChanged(uid: uid, status: $0.statusType)
  224. // 如果我是被呼叫者,对方下线,不会触发回调,只能在这里进行挂断处理
  225. if uid == curCallInfo?.uid,
  226. curCallInfo?.isInCome == true,
  227. $0.statusType == .USER_STATUS_OFFLINE {
  228. hangupVoiceCall()
  229. }
  230. }
  231. }
  232. }
  233. }
  234. // MARK: 语音通话
  235. extension LNIMManager {
  236. private func checkIfCanCall(uid: String, queue: DispatchQueue = .main, handler: @escaping (Bool) -> Void) {
  237. LNHttpManager.shared.checkIfCanCall(uid: uid) { err in
  238. queue.asyncIfNotGlobal {
  239. handler(err == nil)
  240. }
  241. if let err {
  242. showToast(err.errorDesc)
  243. }
  244. }
  245. }
  246. func makeVoiceCall(uid: String) {
  247. guard !uid.isMyUid else {
  248. showToast(.init(key: "C00014"))
  249. return
  250. }
  251. guard LNRoomManager.shared.curRoom == nil else {
  252. showToast(.init(key: "A00387"))
  253. return
  254. }
  255. LNPermissionHelper.requestMicrophoneAccess { [weak self] granted in
  256. guard let self else { return }
  257. guard granted else {
  258. showToast(.init(key: "B00022"))
  259. return
  260. }
  261. guard curCallInfo == nil else { return }
  262. curCallInfo = .init(uid: uid)
  263. curCallInfo?.isInCome = false
  264. checkIfCanCall(uid: uid) { [weak self] can in
  265. guard let self else { return }
  266. guard can else {
  267. curCallInfo = nil
  268. return
  269. }
  270. let floatingView = LNAudioCallFloatingView()
  271. floatingView.show()
  272. let panel = LNAudioCallPanel()
  273. panel.toCallOut(uid: uid)
  274. panel.popup()
  275. let offlinePushInfo = createOfflinePushInfo()
  276. let param = TUICallParams()
  277. param.offlinePushInfo = offlinePushInfo
  278. TUICallEngine.createInstance().calls(userIdList: [uid], callMediaType: .audio, params: param) { [weak self] in
  279. LNStatisticManager.shared.reportStartCall(uid: uid, success: true)
  280. guard let self else { return }
  281. bellPlayer.startPlay(isInCome: false)
  282. } fail: { [weak panel, weak floatingView] _, err in
  283. LNStatisticManager.shared.reportStartCall(uid: uid, success: false)
  284. showToast(err)
  285. if let panel {
  286. panel.dismiss()
  287. }
  288. if let floatingView {
  289. floatingView.dismiss()
  290. }
  291. }
  292. }
  293. }
  294. }
  295. func rejectVoiceCall() {
  296. TUICallEngine.createInstance().reject { }
  297. fail: { _, err in
  298. showToast(err)
  299. }
  300. }
  301. func acceptVoiceCall() {
  302. TUICallEngine.createInstance().accept { [weak self] in
  303. if let self, let curCallInfo {
  304. LNStatisticManager.shared.reportAcceptCall(uid: curCallInfo.uid, success: true)
  305. }
  306. }
  307. fail: { [weak self] _, err in
  308. if let self, let curCallInfo {
  309. LNStatisticManager.shared.reportAcceptCall(uid: curCallInfo.uid, success: false)
  310. }
  311. showToast(err)
  312. }
  313. }
  314. func hangupVoiceCall() {
  315. TUICallEngine.createInstance().hangup { }
  316. fail: { _, err in
  317. showToast(err)
  318. }
  319. }
  320. func switchVoiceCallMicrophone() {
  321. if curCallInfo?.isMute == true {
  322. TUICallEngine.createInstance().openMicrophone { [weak self] in
  323. guard let self else { return }
  324. curCallInfo?.isMute = false
  325. LNEventDeliver.notifyEvent {
  326. ($0 as? LNIMManagerNotify)?.onVoiceCallInfoChanged()
  327. }
  328. }
  329. fail: { _, err in
  330. showToast(err)
  331. }
  332. } else {
  333. TUICallEngine.createInstance().closeMicrophone()
  334. curCallInfo?.isMute = true
  335. LNEventDeliver.notifyEvent {
  336. ($0 as? LNIMManagerNotify)?.onVoiceCallInfoChanged()
  337. }
  338. }
  339. }
  340. func switchVoiceCallSpeakerType(type: LNIMVoiceCallSpeakerType) {
  341. if let deviceType = type.toTUIAudioPlaybackDevice {
  342. TUICallEngine.createInstance().selectAudioPlaybackDevice(deviceType)
  343. curCallInfo?.deviceType = type
  344. } else {
  345. let session = AVAudioSession.sharedInstance()
  346. do {
  347. // 设置为播放类别,允许蓝牙音频路由
  348. try session.setCategory(.playback, mode: .default, options: [
  349. .allowBluetoothA2DP,
  350. .allowBluetoothHFP
  351. ])
  352. try session.setActive(true) // 激活音频会话
  353. curCallInfo?.deviceType = type
  354. } catch {
  355. print("音频会话配置失败:\(error.localizedDescription)")
  356. }
  357. }
  358. LNEventDeliver.notifyEvent {
  359. ($0 as? LNIMManagerNotify)?.onVoiceCallInfoChanged()
  360. }
  361. }
  362. private func createOfflinePushInfo() -> TUIOfflinePushInfo {
  363. let pushInfo: TUIOfflinePushInfo = TUIOfflinePushInfo()
  364. pushInfo.title = myUserInfo.nickname
  365. pushInfo.desc = .init(key: "C00013")
  366. // iOS push type: if you want user VoIP, please modify type to TUICallIOSOfflinePushTypeVoIP
  367. pushInfo.iOSPushType = .voIP
  368. pushInfo.ignoreIOSBadge = false
  369. pushInfo.iOSSound = "phone_bell.mp3"
  370. pushInfo.androidSound = "phone_ringing"
  371. // VIVO message type: 0-push message, 1-System message(have a higher delivery rate)
  372. pushInfo.androidVIVOClassification = 1
  373. // HuaWei message type: https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/message-classification-0000001149358835
  374. pushInfo.androidHuaWeiCategory = "IM"
  375. return pushInfo
  376. }
  377. }
  378. // MARK: 语音通话变化回调 TUICallObserver
  379. extension LNIMManager: TUICallObserver {
  380. func onCallReceived(callerId: String, calleeIdList: [String],
  381. groupId: String?, callMediaType: TUICallMediaType,
  382. userData: String?)
  383. {
  384. guard curCallInfo == nil else {
  385. return
  386. }
  387. guard LNRoomManager.shared.curRoom == nil else {
  388. // 在麦上,直接拒绝
  389. showToast(.init(key: "A00388"))
  390. rejectVoiceCall()
  391. return
  392. }
  393. curCallInfo = .init(uid: callerId)
  394. curCallInfo?.isInCome = true
  395. bellPlayer.startPlay(isInCome: true)
  396. let floatingView = LNAudioCallFloatingView()
  397. floatingView.show()
  398. let panel = LNAudioCallPanel()
  399. panel.onCallIn(uid: callerId)
  400. panel.popup()
  401. }
  402. func onCallCancelled(callerId: String) {
  403. bellPlayer.stop()
  404. curCallInfo = nil
  405. LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() }
  406. }
  407. func onCallBegin(roomId: TUIRoomId, callMediaType: TUICallMediaType, callRole: TUICallRole) {
  408. curCallInfo?.beginTime = curTime
  409. LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallBegin() }
  410. TUICallEngine.createInstance().selectAudioPlaybackDevice(.earpiece)
  411. TUICallEngine.createInstance().openMicrophone { } fail: { _, _ in }
  412. bellPlayer.stop()
  413. }
  414. func onCallEnd(roomId: TUIRoomId, callMediaType: TUICallMediaType, callRole: TUICallRole, totalTime: Float) {
  415. if let curCallInfo {
  416. LNStatisticManager.shared.reportEndCall(uid: curCallInfo.uid, duration: totalTime)
  417. }
  418. curCallInfo = nil
  419. LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() }
  420. }
  421. func onUserReject(userId: String) { }
  422. func onUserNoResponse(userId: String) { }
  423. func onUserLineBusy(userId: String) {
  424. curCallInfo = nil
  425. LNEventDeliver.notifyEvent { ($0 as? LNIMManagerNotify)?.onVoiceCallEnd() }
  426. bellPlayer.stop()
  427. }
  428. }
  429. // MARK: IM 备注
  430. extension LNIMManager {
  431. func setUserRemark(uid: String, remark: String, queue: DispatchQueue = .main, handler: @escaping (Bool) -> Void) {
  432. LNHttpManager.shared.setUserRemark(uid: uid, remark: remark) { [weak self] err in
  433. queue.asyncIfNotGlobal {
  434. handler(err == nil)
  435. }
  436. if let err {
  437. showToast(err.errorDesc)
  438. } else {
  439. guard let self else { return }
  440. conversationList.first { $0.userID == uid }?.extraInfo?.remark = remark
  441. notifyConversationListChanged()
  442. }
  443. }
  444. }
  445. func getUsersRemark(uids: [String], queue: DispatchQueue = .main, handler: @escaping ([LNIMUserRemarkVO]?) -> Void) {
  446. LNHttpManager.shared.getUsersRemark(uids: uids) { res, err in
  447. queue.asyncIfNotGlobal {
  448. handler(res?.list)
  449. }
  450. }
  451. }
  452. }
  453. // MARK: IM 初始化
  454. extension LNIMManager {
  455. private func getIMSignToken(handler: @escaping (String?) -> Void) {
  456. LNHttpManager.shared.getIMSign { token, err in
  457. handler(token)
  458. }
  459. }
  460. }
  461. extension LNIMManager: LNAccountManagerNotify, LNProfileManagerNotify {
  462. func onUserLogin() {
  463. // 初始化 SDK
  464. let config = V2TIMSDKConfig()
  465. config.logLevel = LNAppConfig.shared.curEnv == .test ? .LOG_DEBUG : .LOG_INFO
  466. config.logListener = { level, log in
  467. guard let log else { return }
  468. if LNAppConfig.shared.curEnv == .test {
  469. Log.d(log)
  470. } else {
  471. Log.i(log)
  472. }
  473. }
  474. guard V2TIMManager.sharedInstance().initSDK(Self.appId, config: config) else {
  475. Log.e("V2TIMManager initSDK failed")
  476. return
  477. }
  478. // 登录
  479. let loginSuccessBlock = { [weak self] in
  480. guard let self else { return }
  481. onUserInfoChanged(userInfo: myUserInfo)
  482. reloadConversationList()
  483. voiceCallAvailable = true
  484. TUICallEngine.createInstance().addObserver(self)
  485. TIMPushManager.registerPush(Self.appId, appKey: "") { _ in
  486. Log.i("TIMPushManager registerPush success")
  487. } fail: { _, err in
  488. Log.e("TIMPushManager registerPush failed:\(err)")
  489. }
  490. }
  491. getIMSignToken { token in
  492. guard let token else {
  493. Log.e("LNIMManager getIMSignToken failed")
  494. return
  495. }
  496. LoginStore.shared.login(sdkAppID: Self.appId, userID: myUid, userSig: token) { error in
  497. switch error {
  498. case .success(let info):
  499. Log.i("LoginStore", "login success \(info)")
  500. loginSuccessBlock()
  501. case .failure(let error):
  502. Log.i("LoginStore", "login failed code:\(error.code), message:\(error.message)")
  503. }
  504. }
  505. }
  506. }
  507. func onUserLogout() {
  508. LoginStore.shared.logout(completion: nil)
  509. TIMPushManager.unRegisterPush { } fail: { _, _ in }
  510. TUICallEngine.destroyInstance()
  511. Self.shared = LNIMManager()
  512. }
  513. func onUserInfoChanged(userInfo: LNUserProfileVO) {
  514. guard userInfo.userNo.isMyUid, !myUserInfo.userNo.isEmpty else { return }
  515. let info = V2TIMUserFullInfo()
  516. info.nickName = myUserInfo.nickname
  517. info.faceURL = myUserInfo.avatar
  518. V2TIMManager.sharedInstance().setSelfInfo(info: info) { }
  519. }
  520. }
  521. extension LNIMManager {
  522. private func notifyConversationListChanged() {
  523. LNEventDeliver.notifyEvent {
  524. ($0 as? LNIMManagerNotify)?.onConversationListChanged()
  525. }
  526. }
  527. private func notifyUserStatusChanged(uid: String, status: V2TIMUserStatusType) {
  528. LNEventDeliver.notifyEvent {
  529. ($0 as? LNIMManagerNotify)?.onIMUserStatusChanged(uid: uid, status: status)
  530. }
  531. }
  532. }