package com.xuqm.sdk.im import com.xuqm.sdk.XuqmLoginSession import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.im.api.AddMemberRequest import com.xuqm.sdk.im.api.CreateGroupRequest import com.xuqm.sdk.im.api.ImApi import com.xuqm.sdk.im.api.MuteGroupMemberRequest import com.xuqm.sdk.im.api.SetMutedRequest import com.xuqm.sdk.im.api.SetPinnedRequest import com.xuqm.sdk.im.api.SetGroupRoleRequest import com.xuqm.sdk.im.api.UpdateGroupRequest import com.xuqm.sdk.im.listener.ImEventListener import com.xuqm.sdk.im.model.ImConnectionState import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.im.model.GroupJoinRequest import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.BlacklistEntry import com.xuqm.sdk.im.model.FriendRequest import com.xuqm.sdk.file.FileUploadResult import com.xuqm.sdk.network.ApiClient import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.json.JSONArray import org.json.JSONObject import java.time.LocalDateTime import java.util.concurrent.CopyOnWriteArraySet import java.util.UUID object ImSDK { private const val TAG = "XuqmImSDK" private var client: ImClient? = null private val api: ImApi get() = ApiClient.create(ImApi::class.java, ServiceEndpointRegistry.imApiBaseUrl) private const val MAX_RECONNECT_ATTEMPTS = 5 private val RECONNECT_BACKOFF_MS = longArrayOf(1_000L, 2_000L, 5_000L, 10_000L, 30_000L) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val activeGroupSubscriptions = linkedSetOf() private val listeners = CopyOnWriteArraySet() private var reconnectJob: Job? = null private var reconnectAttempts = 0 @Volatile private var reconnectEnabled = false @Volatile private var currentToken: String = "" private val connectionListener = object : ImEventListener { override fun onConnected() { reconnectAttempts = 0 reconnectJob?.cancel() reconnectJob = null _connectionState.value = ImConnectionState.Connected resubscribeActiveGroups() } override fun onDisconnected(reason: String?) { _connectionState.value = ImConnectionState.Disconnected(reason) scheduleReconnect(reason) } override fun onError(error: String) { if (error.startsWith("Parse error", ignoreCase = true)) return _connectionState.value = ImConnectionState.Disconnected(error) scheduleReconnect(error) } } private val _connectionState = MutableStateFlow(ImConnectionState.Disconnected("未连接")) val connectionState: StateFlow = _connectionState var currentUserId: String = "" private set init { XuqmSDK.currentLoginSession?.let { onSdkLogin(it) } } suspend fun login( userId: String, userSig: String, nickname: String? = null, avatar: String? = null, ) = withContext(Dispatchers.IO) { XuqmSDK.requireInit() currentUserId = userId connectWithToken(userSig) } suspend fun loginWithUserSig(userId: String, userSig: String) = withContext(Dispatchers.IO) { XuqmSDK.requireInit() currentUserId = userId connectWithToken(userSig) } @Deprecated("Use loginWithUserSig(userId, userSig) instead.") suspend fun loginWithToken(userId: String, token: String) = loginWithUserSig(userId, token) fun sendMessage( toId: String, chatType: String, msgType: String, content: String, mentionedUserIds: String? = null, ): ImMessage { val message = buildOutgoingMessage( messageId = UUID.randomUUID().toString(), toId = toId, chatType = chatType, msgType = msgType, content = content, mentionedUserIds = mentionedUserIds, ) val sent = client?.sendMessage( messageId = message.id, toId = toId, chatType = chatType, msgType = msgType, content = content, mentionedUserIds = mentionedUserIds, ) == true Log.d(TAG, "sendMessage id=${message.id} toId=$toId chatType=$chatType msgType=$msgType contentLength=${content.length} mentioned=${mentionedUserIds.orEmpty()} sent=$sent") return if (sent) message else message.copy(status = "FAILED") } fun sendTextMessage( toId: String, chatType: String, content: String, mentionedUserIds: String? = null, ): ImMessage { return sendMessage(toId, chatType, "TEXT", content, mentionedUserIds) } fun sendImageMessage( toId: String, chatType: String, file: FileUploadResult, width: Int? = null, height: Int? = null, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "IMAGE", content = buildMediaPayload( url = file.url, thumbnailUrl = file.thumbnailUrl, name = file.originalName, mimeType = file.mimeType, size = file.size, hash = file.hash, width = width, height = height, ), ) } fun sendVideoMessage( toId: String, chatType: String, file: FileUploadResult, width: Int? = null, height: Int? = null, durationMs: Long? = null, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "VIDEO", content = buildMediaPayload( url = file.url, thumbnailUrl = file.thumbnailUrl, name = file.originalName, mimeType = file.mimeType, size = file.size, hash = file.hash, width = width, height = height, durationMs = durationMs, ), ) } fun sendFileMessage( toId: String, chatType: String, file: FileUploadResult, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "FILE", content = buildMediaPayload( url = file.url, name = file.originalName, mimeType = file.mimeType, size = file.size, hash = file.hash, ), ) } fun sendAudioMessage( toId: String, chatType: String, file: FileUploadResult, durationMs: Long? = null, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "AUDIO", content = buildMediaPayload( url = file.url, name = file.originalName, mimeType = file.mimeType, size = file.size, hash = file.hash, durationMs = durationMs, ), ) } fun sendLocationMessage( toId: String, chatType: String, latitude: Double, longitude: Double, title: String? = null, address: String? = null, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "LOCATION", content = JSONObject().apply { put("lat", latitude) put("lng", longitude) title?.takeIf { it.isNotBlank() }?.let { put("title", it) } address?.takeIf { it.isNotBlank() }?.let { put("address", it) } }.toString(), ) } fun sendCustomMessage( toId: String, chatType: String, data: String, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "CUSTOM", content = JSONObject().apply { put("data", data) }.toString(), ) } fun sendRichTextMessage( toId: String, chatType: String, html: String, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "RICH_TEXT", content = JSONObject().apply { put("html", html) }.toString(), ) } fun sendForwardMessage( toId: String, chatType: String, originalSender: String, originalContent: String, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "FORWARD", content = JSONObject().apply { put("originalSender", originalSender) put("originalContent", originalContent) }.toString(), ) } fun sendCallAudioMessage( toId: String, chatType: String, action: String, ): ImMessage { return sendCallSignalMessage(toId, chatType, "CALL_AUDIO", action) } fun sendCallVideoMessage( toId: String, chatType: String, action: String, ): ImMessage { return sendCallSignalMessage(toId, chatType, "CALL_VIDEO", action) } fun sendNotifyMessage( toId: String, chatType: String, title: String, content: String, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "NOTIFY", content = JSONObject().apply { put("title", title) put("content", content) }.toString(), ) } fun sendQuoteMessage( toId: String, chatType: String, quotedMsgId: String, quotedContent: String, text: String, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "QUOTE", content = JSONObject().apply { put("quotedMsgId", quotedMsgId) put("quotedContent", quotedContent) put("text", text) }.toString(), ) } fun sendMergeMessage( toId: String, chatType: String, title: String, msgList: List, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = "MERGE", content = JSONObject().apply { put("title", title) put("msgList", JSONArray(msgList)) }.toString(), ) } fun subscribeGroup(groupId: String) { Log.d(TAG, "subscribeGroup groupId=$groupId") synchronized(activeGroupSubscriptions) { activeGroupSubscriptions.add(groupId) } client?.subscribe("/topic/group/$groupId") } fun unsubscribeGroup(groupId: String) { Log.d(TAG, "unsubscribeGroup groupId=$groupId") synchronized(activeGroupSubscriptions) { activeGroupSubscriptions.remove(groupId) } client?.unsubscribe("/topic/group/$groupId") } suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List = fetchHistoryWithFilters(toId, page, size) suspend fun fetchHistoryWithFilters( toId: String, page: Int = 0, size: Int = 20, msgType: String? = null, keyword: String? = null, startTime: LocalDateTime? = null, endTime: LocalDateTime? = null, ): List = withContext(Dispatchers.IO) { api.fetchHistory( toId, XuqmSDK.appId, msgType, keyword, startTime?.toString(), endTime?.toString(), page, size, ).data ?: emptyList() } suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List = fetchGroupHistoryWithFilters(groupId, page, size) suspend fun fetchGroupHistoryWithFilters( groupId: String, page: Int = 0, size: Int = 20, msgType: String? = null, keyword: String? = null, startTime: LocalDateTime? = null, endTime: LocalDateTime? = null, ): List = withContext(Dispatchers.IO) { api.fetchGroupHistory( groupId, XuqmSDK.appId, msgType, keyword, startTime?.toString(), endTime?.toString(), page, size, ).data ?: emptyList() } suspend fun listGroups(): List = withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() } suspend fun createGroup(name: String, memberIds: List, groupType: String = "WORK"): ImGroup? = withContext(Dispatchers.IO) { api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds, groupType)).data } suspend fun getGroupInfo(groupId: String): ImGroup? = withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data } suspend fun listPublicGroups(keyword: String? = null): List = withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appId, keyword).data ?: emptyList() } suspend fun updateGroupInfo(groupId: String, name: String? = null, announcement: String? = null) = withContext(Dispatchers.IO) { api.updateGroupInfo(groupId, UpdateGroupRequest(name, announcement)) } suspend fun addGroupMember(groupId: String, userId: String) = withContext(Dispatchers.IO) { api.addGroupMember(groupId, AddMemberRequest(userId)) } suspend fun removeGroupMember(groupId: String, userId: String) = withContext(Dispatchers.IO) { api.removeGroupMember(groupId, userId) } suspend fun leaveGroup(groupId: String) = withContext(Dispatchers.IO) { api.removeGroupMember(groupId, currentUserId) } suspend fun setGroupRole(groupId: String, userId: String, role: String) = withContext(Dispatchers.IO) { api.setGroupRole(groupId, SetGroupRoleRequest(userId, role)) } suspend fun muteGroupMember(groupId: String, userId: String, minutes: Long) = withContext(Dispatchers.IO) { api.muteGroupMember(groupId, MuteGroupMemberRequest(userId, minutes)) } suspend fun dismissGroup(groupId: String) = withContext(Dispatchers.IO) { api.dismissGroup(groupId) } suspend fun sendGroupJoinRequest(groupId: String, remark: String? = null): GroupJoinRequest? = withContext(Dispatchers.IO) { api.sendGroupJoinRequest(groupId, XuqmSDK.appId, remark).data } suspend fun listGroupJoinRequests(groupId: String): List = withContext(Dispatchers.IO) { api.listGroupJoinRequests(groupId, XuqmSDK.appId).data ?: emptyList() } suspend fun acceptGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? = withContext(Dispatchers.IO) { api.acceptGroupJoinRequest(groupId, requestId, XuqmSDK.appId).data } suspend fun rejectGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? = withContext(Dispatchers.IO) { api.rejectGroupJoinRequest(groupId, requestId, XuqmSDK.appId).data } suspend fun listFriends(): List = withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() } suspend fun addFriend(friendId: String) = withContext(Dispatchers.IO) { api.addFriend(XuqmSDK.appId, friendId) } suspend fun removeFriend(friendId: String) = withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appId) } suspend fun listFriendRequests(direction: String = "incoming"): List = withContext(Dispatchers.IO) { api.listFriendRequests(XuqmSDK.appId, direction).data ?: emptyList() } suspend fun sendFriendRequest(friendId: String, remark: String? = null): FriendRequest? = withContext(Dispatchers.IO) { api.sendFriendRequest(XuqmSDK.appId, friendId, remark).data } suspend fun acceptFriendRequest(requestId: String): FriendRequest? = withContext(Dispatchers.IO) { api.acceptFriendRequest(requestId, XuqmSDK.appId).data } suspend fun rejectFriendRequest(requestId: String): FriendRequest? = withContext(Dispatchers.IO) { api.rejectFriendRequest(requestId, XuqmSDK.appId).data } suspend fun listBlacklist(): List = withContext(Dispatchers.IO) { api.listBlacklist(XuqmSDK.appId).data ?: emptyList() } suspend fun addToBlacklist(blockedUserId: String): BlacklistEntry? = withContext(Dispatchers.IO) { api.addToBlacklist(XuqmSDK.appId, blockedUserId).data } suspend fun removeFromBlacklist(blockedUserId: String) = withContext(Dispatchers.IO) { api.removeFromBlacklist(XuqmSDK.appId, blockedUserId) } suspend fun listConversations(): List = withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() } suspend fun getTotalUnreadCount(): Int = withContext(Dispatchers.IO) { runCatching { listConversations().sumOf { it.unreadCount } }.getOrDefault(0) } suspend fun setConversationPinned(targetId: String, chatType: String, pinned: Boolean) = withContext(Dispatchers.IO) { api.setConversationPinned(targetId, chatType, SetPinnedRequest(pinned)) } suspend fun setConversationMuted(targetId: String, chatType: String, muted: Boolean) = withContext(Dispatchers.IO) { api.setConversationMuted(targetId, chatType, SetMutedRequest(muted)) } suspend fun markRead(targetId: String, chatType: String = "SINGLE") = withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appId, chatType) } suspend fun setDraft(targetId: String, chatType: String, draft: String) = withContext(Dispatchers.IO) { api.setDraft(targetId, XuqmSDK.appId, chatType, draft) } suspend fun deleteConversation(targetId: String, chatType: String) = withContext(Dispatchers.IO) { api.deleteConversation(targetId, XuqmSDK.appId, chatType) } fun addListener(listener: ImEventListener) { Log.d(TAG, "addListener listener=${listener.javaClass.name}") listeners.add(listener) client?.addListener(listener) } fun removeListener(listener: ImEventListener) { Log.d(TAG, "removeListener listener=${listener.javaClass.name}") listeners.remove(listener) client?.removeListener(listener) } fun disconnect() { disconnectInternal(clearTokenStore = true) } fun onSdkLogin(session: XuqmLoginSession) { XuqmSDK.requireInit() currentUserId = session.userId connectWithToken(session.userSig) } fun onSdkLogout() { disconnectInternal(clearTokenStore = false) } private fun connectWithToken(token: String) { reconnectEnabled = false reconnectJob?.cancel() reconnectJob = null XuqmSDK.tokenStore.saveToken(token) client?.disconnect() _connectionState.value = ImConnectionState.Connecting currentToken = token Log.d(TAG, "connectWithToken userId=$currentUserId activeGroups=${activeGroupSubscriptions.size}") client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId) client?.addListener(connectionListener) listeners.forEach { client?.addListener(it) } reconnectEnabled = true client?.connect() } private fun disconnectInternal(clearTokenStore: Boolean) { reconnectEnabled = false reconnectJob?.cancel() reconnectJob = null reconnectAttempts = 0 client?.disconnect() client = null currentUserId = "" currentToken = "" synchronized(activeGroupSubscriptions) { activeGroupSubscriptions.clear() } _connectionState.value = ImConnectionState.Disconnected("已断开") if (clearTokenStore) { XuqmSDK.tokenStore.clear() } } private fun buildMediaPayload( url: String, thumbnailUrl: String? = null, name: String? = null, mimeType: String? = null, size: Long? = null, hash: String? = null, width: Int? = null, height: Int? = null, durationMs: Long? = null, ): String = JSONObject().apply { put("url", url) thumbnailUrl?.takeIf { it.isNotBlank() }?.let { put("thumbnailUrl", it) } name?.takeIf { it.isNotBlank() }?.let { put("name", it) } mimeType?.takeIf { it.isNotBlank() }?.let { put("mimeType", it) } size?.takeIf { it > 0 }?.let { put("size", it) } hash?.takeIf { it.isNotBlank() }?.let { put("hash", it) } width?.takeIf { it > 0 }?.let { put("width", it) } height?.takeIf { it > 0 }?.let { put("height", it) } durationMs?.takeIf { it > 0 }?.let { put("durationMs", it) } }.toString() private fun sendCallSignalMessage( toId: String, chatType: String, msgType: String, action: String, ): ImMessage { return sendMessage( toId = toId, chatType = chatType, msgType = msgType, content = JSONObject().apply { put("action", action) }.toString(), ) } private fun buildOutgoingMessage( messageId: String, toId: String, chatType: String, msgType: String, content: String, mentionedUserIds: String? = null, ): ImMessage { return ImMessage( id = messageId, appId = XuqmSDK.appId, fromId = currentUserId, toId = toId, chatType = chatType, msgType = msgType, content = content, status = "SENDING", mentionedUserIds = mentionedUserIds?.takeIf { it.isNotBlank() }, createdAt = System.currentTimeMillis(), ) } private fun scheduleReconnect(reason: String?) { if (!reconnectEnabled || currentToken.isBlank()) return if (reconnectJob?.isActive == true) return if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { Log.w(TAG, "scheduleReconnect stop attempts=$reconnectAttempts reason=$reason") return } val delayMs = RECONNECT_BACKOFF_MS[reconnectAttempts.coerceAtMost(RECONNECT_BACKOFF_MS.lastIndex)] Log.w(TAG, "scheduleReconnect attempts=${reconnectAttempts + 1} delayMs=$delayMs reason=$reason") reconnectJob = scope.launch { delay(delayMs) if (!reconnectEnabled || currentToken.isBlank()) return@launch reconnectAttempts += 1 _connectionState.value = ImConnectionState.Connecting Log.d(TAG, "reconnect attempt=$reconnectAttempts") client = ImClient(ServiceEndpointRegistry.imWsUrl, currentToken, XuqmSDK.appId) client?.addListener(connectionListener) listeners.forEach { client?.addListener(it) } client?.connect() } } private fun resubscribeActiveGroups() { val groups = synchronized(activeGroupSubscriptions) { activeGroupSubscriptions.toList() } groups.forEach { groupId -> client?.subscribe("/topic/group/$groupId") } } }