XuqmGroup-AndroidSDK/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt
XuqmGroup 3fe411738d docs(sdk): 更新跨平台SDK设计规范至v1.1版本
- 更新版本号至v1.1,状态调整为Android已落地
- 添加私有化与公有平台完全独立的原则说明
- 增加Android配置文件路径规范
- 修改platformUrl为可选参数,默认使用内置公有平台地址
- 添加awaitInitialization和retryInitialization方法
- 调整API响应格式,统一返回data结构
- 优化各子SDK配置流程和错误处理机制
- 更新ImSDK登录逻辑,支持userSig为空时跳过连接
- 添加platformType和platformConfig属性
- 标记login/logout方法为已废弃,合并至setUserInfo
- 修改PushSDK方法签名,将Promise改为void
- 添加PushSDK.currentRegistration和detectVendor方法
- 更新Android Kotlin代码示例和完整示例
- 重构服务端接口要求章节,明确各子SDK独立配置接口
- 更新测试代码,替换login/logout为setUserInfo
- 调整UI组件中的推送设置调用方式
2026-06-15 15:51:58 +08:00

1038 行
39 KiB
Kotlin

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

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.AttributeKeysRequest
import com.xuqm.sdk.im.api.CreateGroupRequest
import com.xuqm.sdk.im.api.GroupReadReceiptRequest
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
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
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.io.File
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<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
@Volatile
private var currentUserSig: String? = null
interface ConversationListener {
fun onConversationsChanged(conversations: List<ConversationData>)
}
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) }
}
init {
XuqmSDK.currentLoginSession?.let { onSdkLogin(it) }
}
suspend fun login(userId: String, userSig: String) = withContext(Dispatchers.IO) {
XuqmSDK.requireInit()
if (currentUserId == userId && currentUserSig == userSig) {
return@withContext
}
currentUserId = userId
currentUserSig = userSig
connectWithToken(userSig)
}
/**
* 刷新 userSigIM 登录凭证过期时调用)。
* 等效于用新 userSig 重新调用 [XuqmSDK.setUserInfo]。
*/
suspend fun refreshToken(userSig: String) = withContext(Dispatchers.IO) {
XuqmSDK.requireInit()
if (currentUserId.isBlank()) return@withContext
if (currentUserSig == userSig) return@withContext
currentUserSig = userSig
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")
}
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()
if (session.userSig.isBlank()) return // IM 未提供 userSig,跳过连接Push/Update 仍生效)
if (currentUserId == session.userId && currentUserSig == session.userSig) return
currentUserId = session.userId
currentUserSig = session.userSig
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 = ""
currentUserSig = null
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,
appKey = XuqmSDK.appKey,
fromId = currentUserId,
toId = toId,
chatType = chatType,
msgType = msgType,
content = content,
status = "SENDING",
mentionedUserIds = mentionedUserIds?.takeIf { it.isNotBlank() },
createdAt = System.currentTimeMillis(),
)
}
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")
}
}
}