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

702 行
24 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.CreateGroupRequest
2026-04-21 22:07:29 +08:00
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
2026-04-21 22:07:29 +08:00
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
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.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 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
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)
2026-04-21 22:07:29 +08:00
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<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.appId,
msgType,
keyword,
startTime?.toString(),
endTime?.toString(),
page,
size,
).data ?: 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.appId,
msgType,
keyword,
startTime?.toString(),
endTime?.toString(),
page,
size,
).data ?: emptyList()
}
suspend fun listGroups(): List<ImGroup> =
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
suspend fun createGroup(name: String, memberIds: List<String>, 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<ImGroup> =
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<GroupJoinRequest> =
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<String> =
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<FriendRequest> =
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<BlacklistEntry> =
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 getProfile(userId: String) =
withContext(Dispatchers.IO) { api.getProfile(userId, XuqmSDK.appId).data }
suspend fun updateProfile(
userId: String,
nickname: String? = null,
avatar: String? = null,
gender: String? = null,
) = withContext(Dispatchers.IO) {
api.updateProfile(userId, XuqmSDK.appId, nickname, avatar, gender).data
}
suspend fun listConversations(): List<ConversationData> =
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")
}
}
2026-04-21 22:07:29 +08:00
}