LNIMManager.swift 20 KB

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