XuqmGroup-AndroidSDK/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt

1016 行
38 KiB
Kotlin

2026-04-21 22:07:29 +08:00
package com.xuqm.sdk.im
import com.xuqm.sdk.XuqmLoginSession
2026-04-21 22:07:29 +08:00
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.im.api.AddMemberRequest
import com.xuqm.sdk.im.api.AttributeKeysRequest
import com.xuqm.sdk.im.api.CreateGroupRequest
import com.xuqm.sdk.im.api.GroupReadReceiptRequest
2026-04-21 22:07:29 +08:00
import com.xuqm.sdk.im.api.ImApi
import com.xuqm.sdk.im.model.BatchFriendRequest
import com.xuqm.sdk.im.model.BatchRequestIds
import com.xuqm.sdk.im.model.BatchUserIds
import com.xuqm.sdk.im.model.EditMessageRequest
import com.xuqm.sdk.im.model.ModifyMemberInfoRequest
import com.xuqm.sdk.im.api.MuteGroupMemberRequest
import com.xuqm.sdk.im.api.SetGroupRoleRequest
import com.xuqm.sdk.im.api.TransferOwnerRequest
import com.xuqm.sdk.im.api.UpdateGroupRequest
2026-04-21 22:07:29 +08:00
import com.xuqm.sdk.im.listener.ImEventListener
import com.xuqm.sdk.im.model.BlacklistCheckResult
import com.xuqm.sdk.im.model.ConversationGroupItem
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.GroupReadReceiptSummary
import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.im.model.PageResult
import com.xuqm.sdk.im.model.UserProfile
import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.FileUploadResult
2026-04-21 22:07:29 +08:00
import com.xuqm.sdk.network.ApiClient
import android.util.Log
import kotlinx.coroutines.CoroutineScope
2026-04-21 22:07:29 +08:00
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.io.File
import java.time.LocalDateTime
import java.util.concurrent.CopyOnWriteArraySet
import java.util.UUID
2026-04-21 22:07:29 +08:00
object ImSDK {
private const val TAG = "XuqmImSDK"
2026-04-21 22:07:29 +08:00
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<String>()
private val listeners = CopyOnWriteArraySet<ImEventListener>()
private val conversationListeners = CopyOnWriteArraySet<ConversationListener>()
private val _conversations = mutableListOf<ConversationData>()
private val conversationsLock = Any()
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>(ImConnectionState.Disconnected("未连接"))
val connectionState: StateFlow<ImConnectionState> = _connectionState
var currentUserId: String = ""
private set
interface ConversationListener {
fun onConversationsChanged(conversations: List<ConversationData>)
}
init {
XuqmSDK.currentLoginSession?.let { onSdkLogin(it) }
}
suspend fun login(userId: String, userSig: String) = withContext(Dispatchers.IO) {
XuqmSDK.requireInit()
currentUserId = userId
connectWithToken(userSig)
}
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)
}
suspend fun editMessage(messageId: String, content: String): ImMessage =
withContext(Dispatchers.IO) { api.editMessage(messageId, XuqmSDK.appKey, EditMessageRequest(content)).data ?: throw IllegalStateException("edit message failed") }
suspend fun revokeMessage(messageId: String): ImMessage =
withContext(Dispatchers.IO) { api.revokeMessage(messageId, XuqmSDK.appKey).data ?: throw IllegalStateException("revoke message failed") }
suspend fun sendImageMessage(
toId: String,
chatType: String,
file: File,
width: Int? = null,
height: Int? = null,
): ImMessage = withContext(Dispatchers.IO) {
val result = FileSDK.upload(file)
sendImageMessageWithUploadResult(toId, chatType, result, width, height)
}
private fun sendImageMessageWithUploadResult(
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,
),
)
}
suspend fun sendVideoMessage(
toId: String,
chatType: String,
file: File,
width: Int? = null,
height: Int? = null,
durationMs: Long? = null,
): ImMessage = withContext(Dispatchers.IO) {
val result = FileSDK.upload(file)
sendVideoMessageWithUploadResult(toId, chatType, result, width, height, durationMs)
}
private fun sendVideoMessageWithUploadResult(
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,
),
)
}
suspend fun sendFileMessage(
toId: String,
chatType: String,
file: File,
): ImMessage = withContext(Dispatchers.IO) {
val result = FileSDK.upload(file)
sendFileMessageWithUploadResult(toId, chatType, result)
}
private fun sendFileMessageWithUploadResult(
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,
),
)
}
suspend fun sendAudioMessage(
toId: String,
chatType: String,
file: File,
durationMs: Long? = null,
): ImMessage = withContext(Dispatchers.IO) {
val result = FileSDK.upload(file)
sendAudioMessageWithUploadResult(toId, chatType, result, durationMs)
}
private fun sendAudioMessageWithUploadResult(
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<String>,
): 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")
2026-04-21 22:07:29 +08:00
}
suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
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<ImMessage> =
withContext(Dispatchers.IO) {
api.fetchHistory(
toId,
XuqmSDK.appKey,
msgType,
keyword,
startTime?.toString(),
endTime?.toString(),
page,
size,
).data?.content ?: emptyList()
}
suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
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<ImMessage> =
withContext(Dispatchers.IO) {
api.fetchGroupHistory(
groupId,
XuqmSDK.appKey,
msgType,
keyword,
startTime?.toString(),
endTime?.toString(),
page,
size,
).data?.content ?: emptyList()
}
suspend fun locateHistoryPage(
toId: String,
messageId: String,
pageSize: Int = 20,
maxPages: Int = 20,
): List<ImMessage>? = locatePage(
maxPages = maxPages,
loadPage = { page -> fetchHistory(toId, page, pageSize) },
messageId = messageId,
pageSize = pageSize,
)
suspend fun locateGroupHistoryPage(
groupId: String,
messageId: String,
pageSize: Int = 20,
maxPages: Int = 20,
): List<ImMessage>? = locatePage(
maxPages = maxPages,
loadPage = { page -> fetchGroupHistory(groupId, page, pageSize) },
messageId = messageId,
pageSize = pageSize,
)
private suspend fun locatePage(
maxPages: Int,
loadPage: suspend (Int) -> List<ImMessage>,
messageId: String,
pageSize: Int,
): List<ImMessage>? {
repeat(maxPages.coerceAtLeast(1)) { page ->
val messages = loadPage(page)
if (messages.any { it.id == messageId }) {
return messages
}
if (messages.size < pageSize) {
return null
}
}
return null
}
suspend fun listGroups(): List<ImGroup> =
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appKey).data ?: emptyList() }
suspend fun createGroup(name: String, memberIds: List<String>, groupType: String = "WORK"): ImGroup? =
withContext(Dispatchers.IO) {
api.createGroup(XuqmSDK.appKey, CreateGroupRequest(name, memberIds, groupType)).data
}
suspend fun getGroupInfo(groupId: String): ImGroup? =
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
suspend fun listGroupMembers(groupId: String): List<UserProfile> =
withContext(Dispatchers.IO) { api.listGroupMembers(groupId, XuqmSDK.appKey).data ?: emptyList() }
suspend fun searchGroupMembers(groupId: String, keyword: String, size: Int = 20): List<UserProfile> =
withContext(Dispatchers.IO) { api.searchGroupMembers(groupId, XuqmSDK.appKey, keyword, size).data ?: emptyList() }
suspend fun listPublicGroups(keyword: String? = null): List<ImGroup> =
withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appKey, keyword).data ?: emptyList() }
suspend fun searchUsers(keyword: String, size: Int = 20): List<UserProfile> =
withContext(Dispatchers.IO) { api.searchUsers(XuqmSDK.appKey, keyword, size).data ?: emptyList() }
suspend fun searchGroups(keyword: String, size: Int = 20): List<ImGroup> =
withContext(Dispatchers.IO) { api.searchGroups(XuqmSDK.appKey, keyword, size).data ?: emptyList() }
suspend fun searchMessages(
keyword: String? = null,
chatType: String? = null,
msgType: String? = null,
startTime: LocalDateTime? = null,
endTime: LocalDateTime? = null,
page: Int = 0,
size: Int = 20,
): PageResult<ImMessage> =
withContext(Dispatchers.IO) {
api.searchMessages(
XuqmSDK.appKey,
keyword,
chatType,
msgType,
startTime?.toString(),
endTime?.toString(),
page,
size,
).data ?: PageResult()
}
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 transferGroupOwner(groupId: String, newOwnerId: String): ImGroup? =
withContext(Dispatchers.IO) { api.transferGroupOwner(groupId, TransferOwnerRequest(newOwnerId)).data }
suspend fun updateGroupAttributes(groupId: String, attributes: Map<String, Any?>): ImGroup? =
withContext(Dispatchers.IO) { api.updateGroupAttributes(groupId, attributes).data }
suspend fun removeGroupAttributes(groupId: String, keys: List<String>): ImGroup? =
withContext(Dispatchers.IO) { api.removeGroupAttributes(groupId, AttributeKeysRequest(keys)).data }
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.appKey, remark).data }
suspend fun listGroupJoinRequests(groupId: String): List<GroupJoinRequest> =
withContext(Dispatchers.IO) { api.listGroupJoinRequests(groupId, XuqmSDK.appKey).data ?: emptyList() }
suspend fun acceptGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? =
withContext(Dispatchers.IO) { api.acceptGroupJoinRequest(groupId, requestId, XuqmSDK.appKey).data }
suspend fun rejectGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? =
withContext(Dispatchers.IO) { api.rejectGroupJoinRequest(groupId, requestId, XuqmSDK.appKey).data }
suspend fun listFriends(): List<String> =
withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appKey).data ?: emptyList() }
suspend fun addFriend(friendId: String) =
withContext(Dispatchers.IO) { api.addFriend(XuqmSDK.appKey, friendId) }
suspend fun removeAllFriends() =
withContext(Dispatchers.IO) { api.removeAllFriends(XuqmSDK.appKey) }
suspend fun removeFriend(friendId: String) =
withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appKey) }
suspend fun setFriendGroup(friendId: String, groupName: String? = null) =
withContext(Dispatchers.IO) { api.setFriendGroup(friendId, XuqmSDK.appKey, groupName) }
suspend fun listFriendGroups(): List<String> =
withContext(Dispatchers.IO) { api.listFriendGroups(XuqmSDK.appKey).data ?: emptyList() }
suspend fun listFriendsByGroup(groupName: String): List<String> =
withContext(Dispatchers.IO) { api.listFriendsByGroup(groupName, XuqmSDK.appKey).data ?: emptyList() }
suspend fun listFriendRequests(direction: String = "incoming"): List<FriendRequest> =
withContext(Dispatchers.IO) { api.listFriendRequests(XuqmSDK.appKey, direction).data ?: emptyList() }
suspend fun sendFriendRequest(friendId: String, remark: String? = null): FriendRequest? =
withContext(Dispatchers.IO) { api.sendFriendRequest(XuqmSDK.appKey, friendId, remark).data }
suspend fun acceptFriendRequest(requestId: String): FriendRequest? =
withContext(Dispatchers.IO) { api.acceptFriendRequest(requestId, XuqmSDK.appKey).data }
suspend fun rejectFriendRequest(requestId: String): FriendRequest? =
withContext(Dispatchers.IO) { api.rejectFriendRequest(requestId, XuqmSDK.appKey).data }
suspend fun listBlacklist(): List<BlacklistEntry> =
withContext(Dispatchers.IO) { api.listBlacklist(XuqmSDK.appKey).data ?: emptyList() }
suspend fun addToBlacklist(blockedUserId: String): BlacklistEntry? =
withContext(Dispatchers.IO) { api.addToBlacklist(XuqmSDK.appKey, blockedUserId).data }
suspend fun removeFromBlacklist(blockedUserId: String) =
withContext(Dispatchers.IO) { api.removeFromBlacklist(XuqmSDK.appKey, blockedUserId) }
suspend fun checkBlacklist(targetUserId: String): BlacklistCheckResult? =
withContext(Dispatchers.IO) { api.checkBlacklist(XuqmSDK.appKey, targetUserId).data }
suspend fun getProfile(userId: String) =
withContext(Dispatchers.IO) { api.getProfile(userId, XuqmSDK.appKey).data }
suspend fun updateProfile(
userId: String,
nickname: String? = null,
avatar: String? = null,
gender: String? = null,
) = withContext(Dispatchers.IO) {
api.updateProfile(userId, XuqmSDK.appKey, nickname, avatar, gender).data
}
suspend fun listConversations(): List<ConversationData> =
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appKey).data ?: emptyList() }
.also { result ->
synchronized(conversationsLock) {
_conversations.clear()
_conversations.addAll(result)
}
notifyConversationListeners()
}
suspend fun getTotalUnreadCount(): Int =
withContext(Dispatchers.IO) {
runCatching { listConversations().sumOf { it.unreadCount } }.getOrDefault(0)
}
suspend fun offlineMessageCount(): Int =
withContext(Dispatchers.IO) {
api.offlineMessageCount(XuqmSDK.appKey).data?.get("count") ?: 0
}
suspend fun syncOfflineMessages(): List<ImMessage> =
withContext(Dispatchers.IO) {
api.syncOfflineMessages(XuqmSDK.appKey).data ?: emptyList()
}
suspend fun setConversationPinned(targetId: String, chatType: String, pinned: Boolean) =
withContext(Dispatchers.IO) {
api.setConversationPinned(targetId, XuqmSDK.appKey, chatType, pinned)
}
suspend fun setConversationMuted(targetId: String, chatType: String, muted: Boolean) =
withContext(Dispatchers.IO) {
api.setConversationMuted(targetId, XuqmSDK.appKey, chatType, muted)
}
suspend fun setConversationHidden(targetId: String, chatType: String, hidden: Boolean) =
withContext(Dispatchers.IO) {
api.setConversationHidden(targetId, XuqmSDK.appKey, chatType, hidden)
}
suspend fun setConversationGroup(targetId: String, chatType: String, groupName: String? = null) =
withContext(Dispatchers.IO) {
api.setConversationGroup(targetId, XuqmSDK.appKey, chatType, groupName)
}
suspend fun listConversationGroups(): List<String> =
withContext(Dispatchers.IO) { api.listConversationGroups(XuqmSDK.appKey).data ?: emptyList() }
suspend fun listConversationGroupItems(groupName: String): List<ConversationGroupItem> =
withContext(Dispatchers.IO) { api.listConversationGroupItems(groupName, XuqmSDK.appKey).data ?: emptyList() }
suspend fun markRead(targetId: String, chatType: String = "SINGLE") =
withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appKey, chatType) }
suspend fun setDraft(targetId: String, chatType: String, draft: String) =
withContext(Dispatchers.IO) { api.setDraft(targetId, XuqmSDK.appKey, chatType, draft) }
suspend fun deleteConversation(targetId: String, chatType: String) =
withContext(Dispatchers.IO) { api.deleteConversation(targetId, XuqmSDK.appKey, chatType) }
suspend fun adminGroupReadReceipts(groupId: String, messageIds: List<String>): List<GroupReadReceiptSummary> =
withContext(Dispatchers.IO) {
api.adminGroupReadReceipts(groupId, XuqmSDK.appKey, GroupReadReceiptRequest(messageIds)).data ?: emptyList()
}
suspend fun batchAddFriends(friendIds: List<String>) =
withContext(Dispatchers.IO) { api.batchAddFriends(XuqmSDK.appKey, BatchFriendRequest(friendIds)) }
suspend fun batchRemoveFriends(friendIds: List<String>) =
withContext(Dispatchers.IO) { api.batchRemoveFriends(XuqmSDK.appKey, BatchFriendRequest(friendIds)) }
suspend fun batchAcceptFriendRequests(requestIds: List<String>) =
withContext(Dispatchers.IO) { api.batchAcceptFriendRequests(XuqmSDK.appKey, BatchRequestIds(requestIds)) }
suspend fun batchRejectFriendRequests(requestIds: List<String>) =
withContext(Dispatchers.IO) { api.batchRejectFriendRequests(XuqmSDK.appKey, BatchRequestIds(requestIds)) }
suspend fun batchAddGroupMembers(groupId: String, userIds: List<String>) =
withContext(Dispatchers.IO) { api.batchAddGroupMembers(groupId, XuqmSDK.appKey, BatchUserIds(userIds)) }
suspend fun batchRemoveGroupMembers(groupId: String, userIds: List<String>) =
withContext(Dispatchers.IO) { api.batchRemoveGroupMembers(groupId, XuqmSDK.appKey, BatchUserIds(userIds)) }
suspend fun batchAcceptGroupJoinRequests(groupId: String, requestIds: List<String>) =
withContext(Dispatchers.IO) { api.batchAcceptGroupJoinRequests(groupId, XuqmSDK.appKey, BatchRequestIds(requestIds)) }
suspend fun batchRejectGroupJoinRequests(groupId: String, requestIds: List<String>) =
withContext(Dispatchers.IO) { api.batchRejectGroupJoinRequests(groupId, XuqmSDK.appKey, BatchRequestIds(requestIds)) }
suspend fun modifyGroupMemberInfo(groupId: String, userId: String, nickname: String? = null, role: String? = null) =
withContext(Dispatchers.IO) { api.modifyGroupMemberInfo(groupId, userId, XuqmSDK.appKey, ModifyMemberInfoRequest(nickname, role)) }
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 addConversationListener(listener: ConversationListener) {
Log.d(TAG, "addConversationListener listener=${listener.javaClass.name}")
conversationListeners.add(listener)
}
fun removeConversationListener(listener: ConversationListener) {
Log.d(TAG, "removeConversationListener listener=${listener.javaClass.name}")
conversationListeners.remove(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) {
if (currentToken == token && client != null) {
Log.d(TAG, "connectWithToken skipped: already connecting/connected with same token")
return
}
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.appKey)
client?.addListener(connectionListener)
client?.addListener(conversationEventListener)
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.appKey,
fromId = currentUserId,
toId = toId,
chatType = chatType,
msgType = msgType,
content = content,
status = "SENDING",
mentionedUserIds = mentionedUserIds?.takeIf { it.isNotBlank() },
createdAt = System.currentTimeMillis(),
)
}
private val conversationEventListener = object : ImEventListener {
override fun onMessage(message: ImMessage) { onMessageReceived(message) }
override fun onGroupMessage(message: ImMessage) { onMessageReceived(message) }
override fun onRead(message: ImMessage) { onReadReceived(message) }
override fun onRevoke(message: ImMessage) { onRevokeReceived(message) }
}
private fun getConversationTargetId(message: ImMessage): String {
return if (message.chatType.uppercase() == "GROUP") {
message.toId
} else {
if (message.fromId == currentUserId) message.toId else message.fromId
}
}
private fun onMessageReceived(message: ImMessage) {
val targetId = getConversationTargetId(message)
val chatType = message.chatType.uppercase()
synchronized(conversationsLock) {
val index = _conversations.indexOfFirst { it.targetId == targetId && it.chatType == chatType }
val existing = _conversations.getOrNull(index)
val updated = if (existing != null) {
existing.copy(
lastMsgContent = message.content,
lastMsgType = message.msgType,
lastMsgTime = message.createdAt,
unreadCount = if (message.fromId != currentUserId) existing.unreadCount + 1 else existing.unreadCount
)
} else {
ConversationData(
targetId = targetId,
chatType = chatType,
lastMsgContent = message.content,
lastMsgType = message.msgType,
lastMsgTime = message.createdAt,
unreadCount = if (message.fromId != currentUserId) 1 else 0
)
}
if (index >= 0) {
_conversations[index] = updated
} else {
_conversations.add(updated)
}
_conversations.sortByDescending { it.lastMsgTime }
}
notifyConversationListeners()
}
private fun onReadReceived(message: ImMessage) {
val targetId = getConversationTargetId(message)
val chatType = message.chatType.uppercase()
synchronized(conversationsLock) {
val index = _conversations.indexOfFirst { it.targetId == targetId && it.chatType == chatType }
if (index < 0) return
val existing = _conversations[index]
_conversations[index] = existing.copy(unreadCount = 0)
}
notifyConversationListeners()
}
private fun onRevokeReceived(message: ImMessage) {
val targetId = getConversationTargetId(message)
val chatType = message.chatType.uppercase()
synchronized(conversationsLock) {
val index = _conversations.indexOfFirst { it.targetId == targetId && it.chatType == chatType }
if (index < 0) return
val existing = _conversations[index]
val updated = if (existing.lastMsgTime == message.createdAt) {
existing.copy(lastMsgContent = "消息已撤回", lastMsgType = "REVOKED")
} else {
existing
}
_conversations[index] = updated
}
notifyConversationListeners()
}
private fun notifyConversationListeners() {
val snapshot = synchronized(conversationsLock) { _conversations.toList() }
conversationListeners.forEach { it.onConversationsChanged(snapshot) }
}
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.appKey)
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")
}
}
2026-04-21 22:07:29 +08:00
}