2026-04-21 22:07:29 +08:00
|
|
|
package com.xuqm.sdk.im
|
|
|
|
|
|
2026-04-27 19:23:11 +08:00
|
|
|
import com.xuqm.sdk.XuqmLoginSession
|
2026-04-21 22:07:29 +08:00
|
|
|
import com.xuqm.sdk.XuqmSDK
|
2026-04-27 19:30:06 +08:00
|
|
|
import com.xuqm.sdk.core.ServiceEndpointRegistry
|
2026-04-27 17:18:55 +08:00
|
|
|
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
|
2026-04-28 09:45:20 +08:00
|
|
|
import com.xuqm.sdk.im.api.MuteGroupMemberRequest
|
2026-04-27 17:18:55 +08:00
|
|
|
import com.xuqm.sdk.im.api.SetMutedRequest
|
|
|
|
|
import com.xuqm.sdk.im.api.SetPinnedRequest
|
2026-04-28 09:45:20 +08:00
|
|
|
import com.xuqm.sdk.im.api.SetGroupRoleRequest
|
2026-04-27 17:18:55 +08:00
|
|
|
import com.xuqm.sdk.im.api.UpdateGroupRequest
|
2026-04-21 22:07:29 +08:00
|
|
|
import com.xuqm.sdk.im.listener.ImEventListener
|
2026-04-27 19:41:26 +08:00
|
|
|
import com.xuqm.sdk.im.model.ImConnectionState
|
2026-04-27 17:18:55 +08:00
|
|
|
import com.xuqm.sdk.im.model.ConversationData
|
2026-04-28 09:45:20 +08:00
|
|
|
import com.xuqm.sdk.im.model.GroupJoinRequest
|
2026-04-27 17:18:55 +08:00
|
|
|
import com.xuqm.sdk.im.model.ImGroup
|
|
|
|
|
import com.xuqm.sdk.im.model.ImMessage
|
2026-04-28 09:45:20 +08:00
|
|
|
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
|
2026-04-28 16:08:06 +08:00
|
|
|
import android.util.Log
|
|
|
|
|
import kotlinx.coroutines.CoroutineScope
|
2026-04-21 22:07:29 +08:00
|
|
|
import kotlinx.coroutines.Dispatchers
|
2026-04-28 16:08:06 +08:00
|
|
|
import kotlinx.coroutines.Job
|
|
|
|
|
import kotlinx.coroutines.SupervisorJob
|
|
|
|
|
import kotlinx.coroutines.delay
|
|
|
|
|
import kotlinx.coroutines.launch
|
2026-04-27 17:18:55 +08:00
|
|
|
import kotlinx.coroutines.withContext
|
2026-04-27 19:41:26 +08:00
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
|
|
|
import kotlinx.coroutines.flow.StateFlow
|
2026-04-28 09:45:20 +08:00
|
|
|
import org.json.JSONArray
|
|
|
|
|
import org.json.JSONObject
|
|
|
|
|
import java.time.LocalDateTime
|
2026-04-28 16:08:06 +08:00
|
|
|
import java.util.concurrent.CopyOnWriteArraySet
|
|
|
|
|
import java.util.UUID
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
object ImSDK {
|
|
|
|
|
|
2026-04-28 16:08:06 +08:00
|
|
|
private const val TAG = "XuqmImSDK"
|
|
|
|
|
|
2026-04-21 22:07:29 +08:00
|
|
|
private var client: ImClient? = null
|
2026-04-27 19:30:06 +08:00
|
|
|
private val api: ImApi get() = ApiClient.create(ImApi::class.java, ServiceEndpointRegistry.imApiBaseUrl)
|
2026-04-28 16:08:06 +08:00
|
|
|
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 = ""
|
2026-04-27 19:41:26 +08:00
|
|
|
private val connectionListener = object : ImEventListener {
|
|
|
|
|
override fun onConnected() {
|
2026-04-28 16:08:06 +08:00
|
|
|
reconnectAttempts = 0
|
|
|
|
|
reconnectJob?.cancel()
|
|
|
|
|
reconnectJob = null
|
2026-04-27 19:41:26 +08:00
|
|
|
_connectionState.value = ImConnectionState.Connected
|
2026-04-28 16:08:06 +08:00
|
|
|
resubscribeActiveGroups()
|
2026-04-27 19:41:26 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onDisconnected(reason: String?) {
|
|
|
|
|
_connectionState.value = ImConnectionState.Disconnected(reason)
|
2026-04-28 16:08:06 +08:00
|
|
|
scheduleReconnect(reason)
|
2026-04-27 19:41:26 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onError(error: String) {
|
2026-04-28 16:08:06 +08:00
|
|
|
if (error.startsWith("Parse error", ignoreCase = true)) return
|
2026-04-27 19:41:26 +08:00
|
|
|
_connectionState.value = ImConnectionState.Disconnected(error)
|
2026-04-28 16:08:06 +08:00
|
|
|
scheduleReconnect(error)
|
2026-04-27 19:41:26 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
private val _connectionState = MutableStateFlow<ImConnectionState>(ImConnectionState.Disconnected("未连接"))
|
|
|
|
|
val connectionState: StateFlow<ImConnectionState> = _connectionState
|
2026-04-27 17:18:55 +08:00
|
|
|
|
|
|
|
|
var currentUserId: String = ""
|
|
|
|
|
private set
|
|
|
|
|
|
2026-04-27 19:23:11 +08:00
|
|
|
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) =
|
2026-04-27 17:18:55 +08:00
|
|
|
withContext(Dispatchers.IO) {
|
|
|
|
|
XuqmSDK.requireInit()
|
|
|
|
|
currentUserId = userId
|
2026-04-27 19:23:11 +08:00
|
|
|
connectWithToken(userSig)
|
2026-04-27 19:00:54 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 19:23:11 +08:00
|
|
|
@Deprecated("Use loginWithUserSig(userId, userSig) instead.")
|
2026-04-27 19:00:54 +08:00
|
|
|
suspend fun loginWithToken(userId: String, token: String) =
|
2026-04-27 19:23:11 +08:00
|
|
|
loginWithUserSig(userId, token)
|
2026-04-21 22:07:29 +08:00
|
|
|
|
2026-04-28 09:45:20 +08:00
|
|
|
fun sendMessage(
|
|
|
|
|
toId: String,
|
|
|
|
|
chatType: String,
|
|
|
|
|
msgType: String,
|
|
|
|
|
content: String,
|
|
|
|
|
mentionedUserIds: String? = null,
|
2026-04-28 16:08:06 +08:00
|
|
|
): 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")
|
2026-04-28 09:45:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun sendTextMessage(
|
|
|
|
|
toId: String,
|
|
|
|
|
chatType: String,
|
|
|
|
|
content: String,
|
|
|
|
|
mentionedUserIds: String? = null,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(toId, chatType, "TEXT", content, mentionedUserIds)
|
2026-04-28 09:45:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun sendImageMessage(
|
|
|
|
|
toId: String,
|
|
|
|
|
chatType: String,
|
|
|
|
|
file: FileUploadResult,
|
|
|
|
|
width: Int? = null,
|
|
|
|
|
height: Int? = null,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
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,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
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,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
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,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
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,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
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,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
toId = toId,
|
|
|
|
|
chatType = chatType,
|
|
|
|
|
msgType = "CUSTOM",
|
|
|
|
|
content = JSONObject().apply {
|
|
|
|
|
put("data", data)
|
|
|
|
|
}.toString(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun sendRichTextMessage(
|
|
|
|
|
toId: String,
|
|
|
|
|
chatType: String,
|
|
|
|
|
html: String,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
toId = toId,
|
|
|
|
|
chatType = chatType,
|
|
|
|
|
msgType = "RICH_TEXT",
|
|
|
|
|
content = JSONObject().apply {
|
|
|
|
|
put("html", html)
|
|
|
|
|
}.toString(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun sendForwardMessage(
|
|
|
|
|
toId: String,
|
|
|
|
|
chatType: String,
|
|
|
|
|
originalSender: String,
|
|
|
|
|
originalContent: String,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
toId = toId,
|
|
|
|
|
chatType = chatType,
|
|
|
|
|
msgType = "FORWARD",
|
|
|
|
|
content = JSONObject().apply {
|
|
|
|
|
put("originalSender", originalSender)
|
|
|
|
|
put("originalContent", originalContent)
|
|
|
|
|
}.toString(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun sendCallAudioMessage(
|
|
|
|
|
toId: String,
|
|
|
|
|
chatType: String,
|
|
|
|
|
action: String,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendCallSignalMessage(toId, chatType, "CALL_AUDIO", action)
|
2026-04-28 09:45:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun sendCallVideoMessage(
|
|
|
|
|
toId: String,
|
|
|
|
|
chatType: String,
|
|
|
|
|
action: String,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendCallSignalMessage(toId, chatType, "CALL_VIDEO", action)
|
2026-04-28 09:45:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun sendNotifyMessage(
|
|
|
|
|
toId: String,
|
|
|
|
|
chatType: String,
|
|
|
|
|
title: String,
|
|
|
|
|
content: String,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
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,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
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>,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
toId = toId,
|
|
|
|
|
chatType = chatType,
|
|
|
|
|
msgType = "MERGE",
|
|
|
|
|
content = JSONObject().apply {
|
|
|
|
|
put("title", title)
|
|
|
|
|
put("msgList", JSONArray(msgList))
|
|
|
|
|
}.toString(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun subscribeGroup(groupId: String) {
|
2026-04-28 16:08:06 +08:00
|
|
|
Log.d(TAG, "subscribeGroup groupId=$groupId")
|
|
|
|
|
synchronized(activeGroupSubscriptions) {
|
|
|
|
|
activeGroupSubscriptions.add(groupId)
|
|
|
|
|
}
|
2026-04-28 09:45:20 +08:00
|
|
|
client?.subscribe("/topic/group/$groupId")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun unsubscribeGroup(groupId: String) {
|
2026-04-28 16:08:06 +08:00
|
|
|
Log.d(TAG, "unsubscribeGroup groupId=$groupId")
|
|
|
|
|
synchronized(activeGroupSubscriptions) {
|
|
|
|
|
activeGroupSubscriptions.remove(groupId)
|
|
|
|
|
}
|
2026-04-28 09:45:20 +08:00
|
|
|
client?.unsubscribe("/topic/group/$groupId")
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 17:18:55 +08:00
|
|
|
suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
|
2026-04-28 09:45:20 +08:00
|
|
|
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> =
|
2026-04-27 17:18:55 +08:00
|
|
|
withContext(Dispatchers.IO) {
|
2026-04-28 09:45:20 +08:00
|
|
|
api.fetchHistory(
|
|
|
|
|
toId,
|
|
|
|
|
XuqmSDK.appId,
|
|
|
|
|
msgType,
|
|
|
|
|
keyword,
|
|
|
|
|
startTime?.toString(),
|
|
|
|
|
endTime?.toString(),
|
|
|
|
|
page,
|
|
|
|
|
size,
|
|
|
|
|
).data ?: emptyList()
|
2026-04-27 17:18:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
|
2026-04-28 09:45:20 +08:00
|
|
|
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> =
|
2026-04-27 17:18:55 +08:00
|
|
|
withContext(Dispatchers.IO) {
|
2026-04-28 09:45:20 +08:00
|
|
|
api.fetchGroupHistory(
|
|
|
|
|
groupId,
|
|
|
|
|
XuqmSDK.appId,
|
|
|
|
|
msgType,
|
|
|
|
|
keyword,
|
|
|
|
|
startTime?.toString(),
|
|
|
|
|
endTime?.toString(),
|
|
|
|
|
page,
|
|
|
|
|
size,
|
|
|
|
|
).data ?: emptyList()
|
2026-04-27 17:18:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
suspend fun listGroups(): List<ImGroup> =
|
2026-04-27 19:00:54 +08:00
|
|
|
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
|
2026-04-27 17:18:55 +08:00
|
|
|
|
2026-04-28 09:45:20 +08:00
|
|
|
suspend fun createGroup(name: String, memberIds: List<String>, groupType: String = "WORK"): ImGroup? =
|
|
|
|
|
withContext(Dispatchers.IO) {
|
|
|
|
|
api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds, groupType)).data
|
|
|
|
|
}
|
2026-04-27 17:18:55 +08:00
|
|
|
|
|
|
|
|
suspend fun getGroupInfo(groupId: String): ImGroup? =
|
|
|
|
|
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
|
|
|
|
|
|
2026-04-28 09:45:20 +08:00
|
|
|
suspend fun listPublicGroups(keyword: String? = null): List<ImGroup> =
|
|
|
|
|
withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appId, keyword).data ?: emptyList() }
|
|
|
|
|
|
2026-04-27 17:18:55 +08:00
|
|
|
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) =
|
2026-04-27 19:00:54 +08:00
|
|
|
withContext(Dispatchers.IO) { api.removeGroupMember(groupId, currentUserId) }
|
2026-04-27 17:18:55 +08:00
|
|
|
|
2026-04-28 09:45:20 +08:00
|
|
|
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 }
|
|
|
|
|
|
2026-04-27 19:00:54 +08:00
|
|
|
suspend fun listFriends(): List<String> =
|
|
|
|
|
withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() }
|
2026-04-27 17:18:55 +08:00
|
|
|
|
|
|
|
|
suspend fun addFriend(friendId: String) =
|
2026-04-27 19:00:54 +08:00
|
|
|
withContext(Dispatchers.IO) { api.addFriend(XuqmSDK.appId, friendId) }
|
2026-04-27 17:18:55 +08:00
|
|
|
|
|
|
|
|
suspend fun removeFriend(friendId: String) =
|
2026-04-27 19:00:54 +08:00
|
|
|
withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appId) }
|
2026-04-27 17:18:55 +08:00
|
|
|
|
2026-04-28 09:45:20 +08:00
|
|
|
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) }
|
|
|
|
|
|
2026-04-28 16:55:12 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 17:18:55 +08:00
|
|
|
suspend fun listConversations(): List<ConversationData> =
|
2026-04-27 19:00:54 +08:00
|
|
|
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() }
|
2026-04-27 17:18:55 +08:00
|
|
|
|
2026-04-27 23:41:58 +08:00
|
|
|
suspend fun getTotalUnreadCount(): Int =
|
|
|
|
|
withContext(Dispatchers.IO) {
|
|
|
|
|
runCatching { listConversations().sumOf { it.unreadCount } }.getOrDefault(0)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 17:18:55 +08:00
|
|
|
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") =
|
2026-04-27 19:00:54 +08:00
|
|
|
withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appId, chatType) }
|
2026-04-27 17:18:55 +08:00
|
|
|
|
2026-04-27 23:41:58 +08:00
|
|
|
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) }
|
|
|
|
|
|
2026-04-28 16:08:06 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-27 17:18:55 +08:00
|
|
|
|
|
|
|
|
fun disconnect() {
|
2026-04-27 19:23:11 +08:00
|
|
|
disconnectInternal(clearTokenStore = true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun onSdkLogin(session: XuqmLoginSession) {
|
|
|
|
|
XuqmSDK.requireInit()
|
|
|
|
|
currentUserId = session.userId
|
|
|
|
|
connectWithToken(session.userSig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun onSdkLogout() {
|
|
|
|
|
disconnectInternal(clearTokenStore = false)
|
2026-04-27 17:18:55 +08:00
|
|
|
}
|
2026-04-27 19:00:54 +08:00
|
|
|
|
|
|
|
|
private fun connectWithToken(token: String) {
|
2026-04-28 16:08:06 +08:00
|
|
|
reconnectEnabled = false
|
|
|
|
|
reconnectJob?.cancel()
|
|
|
|
|
reconnectJob = null
|
2026-04-27 19:00:54 +08:00
|
|
|
XuqmSDK.tokenStore.saveToken(token)
|
|
|
|
|
client?.disconnect()
|
2026-04-27 19:41:26 +08:00
|
|
|
_connectionState.value = ImConnectionState.Connecting
|
2026-04-28 16:08:06 +08:00
|
|
|
currentToken = token
|
|
|
|
|
Log.d(TAG, "connectWithToken userId=$currentUserId activeGroups=${activeGroupSubscriptions.size}")
|
2026-04-27 19:30:06 +08:00
|
|
|
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId)
|
2026-04-27 19:41:26 +08:00
|
|
|
client?.addListener(connectionListener)
|
2026-04-28 16:08:06 +08:00
|
|
|
listeners.forEach { client?.addListener(it) }
|
|
|
|
|
reconnectEnabled = true
|
2026-04-27 19:00:54 +08:00
|
|
|
client?.connect()
|
|
|
|
|
}
|
2026-04-27 19:23:11 +08:00
|
|
|
|
|
|
|
|
private fun disconnectInternal(clearTokenStore: Boolean) {
|
2026-04-28 16:08:06 +08:00
|
|
|
reconnectEnabled = false
|
|
|
|
|
reconnectJob?.cancel()
|
|
|
|
|
reconnectJob = null
|
|
|
|
|
reconnectAttempts = 0
|
2026-04-27 19:23:11 +08:00
|
|
|
client?.disconnect()
|
|
|
|
|
client = null
|
|
|
|
|
currentUserId = ""
|
2026-04-28 16:08:06 +08:00
|
|
|
currentToken = ""
|
|
|
|
|
synchronized(activeGroupSubscriptions) {
|
|
|
|
|
activeGroupSubscriptions.clear()
|
|
|
|
|
}
|
2026-04-27 19:41:26 +08:00
|
|
|
_connectionState.value = ImConnectionState.Disconnected("已断开")
|
2026-04-27 19:23:11 +08:00
|
|
|
if (clearTokenStore) {
|
|
|
|
|
XuqmSDK.tokenStore.clear()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-28 09:45:20 +08:00
|
|
|
|
|
|
|
|
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,
|
2026-04-28 16:08:06 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return sendMessage(
|
2026-04-28 09:45:20 +08:00
|
|
|
toId = toId,
|
|
|
|
|
chatType = chatType,
|
|
|
|
|
msgType = msgType,
|
|
|
|
|
content = JSONObject().apply {
|
|
|
|
|
put("action", action)
|
|
|
|
|
}.toString(),
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-04-28 16:08:06 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
}
|