feat(im): 添加即时消息SDK核心功能实现

- 实现了聊天消息发送功能,支持文本、图片、视频、音频、文件等多种消息类型
- 集成了文件上传下载功能,支持多媒体文件的传输和管理
- 添加了群组管理功能,包括创建群组、成员管理、权限控制等操作
- 实现了好友系统,支持好友添加、删除、分组等功能
- 集成了黑名单管理,提供用户屏蔽和解除屏蔽功能
- 添加了会话管理功能,支持对话列表、未读消息统计等
- 实现了历史消息查询和搜索功能
- 添加了实时连接状态管理和自动重连机制
这个提交包含在:
XuqmGroup 2026-05-03 00:11:06 +08:00
父节点 d9c9e4f858
当前提交 d0b263411d
共有 3 个文件被更改,包括 241 次插入94 次删除

查看文件

@ -6,16 +6,14 @@ import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.FileUploadResult
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
data class PreparedAttachment( data class PreparedAttachment(
val upload: FileUploadResult,
val message: ImMessage, val message: ImMessage,
val width: Int? = null, val width: Int? = null,
val height: Int? = null, val height: Int? = null,
@ -29,8 +27,20 @@ class AttachmentRepository(private val context: Context) {
chatType: String, chatType: String,
uri: Uri, uri: Uri,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
): Result<PreparedAttachment> = ): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
sendMedia(targetId, chatType, uri, MediaKind.IMAGE, onProgress) runCatching {
val file = uriToFile(uri)
val meta = resolveMeta(uri)
val sent = ImSDK.sendImageMessage(
toId = targetId,
chatType = chatType,
file = file,
width = meta.width,
height = meta.height,
)
PreparedAttachment(message = sent, width = meta.width, height = meta.height)
}
}
suspend fun sendImageBytes( suspend fun sendImageBytes(
targetId: String, targetId: String,
@ -42,21 +52,15 @@ class AttachmentRepository(private val context: Context) {
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
): Result<PreparedAttachment> = withContext(Dispatchers.IO) { ): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val upload = FileSDK.uploadBytes( val file = File(context.cacheDir, fileName).apply { writeBytes(bytes) }
fileName = fileName, val sent = ImSDK.sendImageMessage(
mimeType = "image/jpeg",
bytes = bytes,
onProgress = onProgress,
)
ImSDK.sendImageMessage(
toId = targetId, toId = targetId,
chatType = chatType, chatType = chatType,
file = upload, file = file,
width = width, width = width,
height = height, height = height,
).let { sent -> )
PreparedAttachment(upload = upload, message = sent, width = width, height = height) PreparedAttachment(message = sent, width = width, height = height)
}
} }
} }
@ -65,24 +69,57 @@ class AttachmentRepository(private val context: Context) {
chatType: String, chatType: String,
uri: Uri, uri: Uri,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
): Result<PreparedAttachment> = ): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
sendMedia(targetId, chatType, uri, MediaKind.VIDEO, onProgress) runCatching {
val file = uriToFile(uri)
val meta = resolveMeta(uri)
val sent = ImSDK.sendVideoMessage(
toId = targetId,
chatType = chatType,
file = file,
width = meta.width,
height = meta.height,
durationMs = meta.durationMs,
)
PreparedAttachment(message = sent, width = meta.width, height = meta.height, durationMs = meta.durationMs)
}
}
suspend fun sendFile( suspend fun sendFile(
targetId: String, targetId: String,
chatType: String, chatType: String,
uri: Uri, uri: Uri,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
): Result<PreparedAttachment> = ): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
sendMedia(targetId, chatType, uri, MediaKind.FILE, onProgress) runCatching {
val file = uriToFile(uri)
val sent = ImSDK.sendFileMessage(
toId = targetId,
chatType = chatType,
file = file,
)
PreparedAttachment(message = sent)
}
}
suspend fun sendAudio( suspend fun sendAudio(
targetId: String, targetId: String,
chatType: String, chatType: String,
uri: Uri, uri: Uri,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
): Result<PreparedAttachment> = ): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
sendMedia(targetId, chatType, uri, MediaKind.AUDIO, onProgress) runCatching {
val file = uriToFile(uri)
val meta = resolveMeta(uri)
val sent = ImSDK.sendAudioMessage(
toId = targetId,
chatType = chatType,
file = file,
durationMs = meta.durationMs,
)
PreparedAttachment(message = sent, durationMs = meta.durationMs)
}
}
suspend fun sendAudioBytes( suspend fun sendAudioBytes(
targetId: String, targetId: String,
@ -93,82 +130,31 @@ class AttachmentRepository(private val context: Context) {
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
): Result<PreparedAttachment> = withContext(Dispatchers.IO) { ): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val upload = FileSDK.uploadBytes( val file = File(context.cacheDir, fileName).apply { writeBytes(bytes) }
fileName = fileName, val sent = ImSDK.sendAudioMessage(
mimeType = "audio/mp4",
bytes = bytes,
onProgress = onProgress,
)
ImSDK.sendAudioMessage(
toId = targetId, toId = targetId,
chatType = chatType, chatType = chatType,
file = upload, file = file,
durationMs = durationMs, durationMs = durationMs,
).let { sent -> )
PreparedAttachment(upload = upload, message = sent, durationMs = durationMs) PreparedAttachment(message = sent, durationMs = durationMs)
}
} }
} }
private suspend fun sendMedia( private fun uriToFile(uri: Uri): File {
targetId: String, val resolver = context.contentResolver
chatType: String, val displayName = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
uri: Uri, ?.use { cursor ->
kind: MediaKind, val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
onProgress: (Int) -> Unit, if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null
): Result<PreparedAttachment> = withContext(Dispatchers.IO) { } ?: uri.lastPathSegment?.substringAfterLast('/') ?: "attachment"
runCatching { val tempFile = File(context.cacheDir, displayName)
val meta = resolveMeta(uri) resolver.openInputStream(uri)?.use { input ->
val thumbnailBytes = when (kind) { tempFile.outputStream().use { output ->
MediaKind.IMAGE -> null input.copyTo(output)
MediaKind.VIDEO -> extractVideoThumbnail(uri)
MediaKind.AUDIO -> null
MediaKind.FILE -> null
} }
val upload = FileSDK.upload(
context = context,
uri = uri,
displayName = meta.displayName,
mimeType = meta.mimeType,
thumbnailBytes = thumbnailBytes,
onProgress = onProgress,
)
val sent = when (kind) {
MediaKind.IMAGE -> ImSDK.sendImageMessage(
toId = targetId,
chatType = chatType,
file = upload,
width = meta.width,
height = meta.height,
)
MediaKind.VIDEO -> ImSDK.sendVideoMessage(
toId = targetId,
chatType = chatType,
file = upload,
width = meta.width,
height = meta.height,
durationMs = meta.durationMs,
)
MediaKind.AUDIO -> ImSDK.sendAudioMessage(
toId = targetId,
chatType = chatType,
file = upload,
durationMs = meta.durationMs,
)
MediaKind.FILE -> ImSDK.sendFileMessage(
toId = targetId,
chatType = chatType,
file = upload,
)
}
PreparedAttachment(
upload = upload,
message = sent,
width = meta.width,
height = meta.height,
durationMs = meta.durationMs,
)
} }
return tempFile
} }
private fun resolveMeta(uri: Uri): AttachmentMeta { private fun resolveMeta(uri: Uri): AttachmentMeta {

查看文件

@ -105,6 +105,16 @@ object FileSDK {
targetFile targetFile
} }
suspend fun upload(
file: File,
thumbnailBytes: ByteArray? = null,
onProgress: (Int) -> Unit = {},
): FileUploadResult {
val mimeType = java.net.URLConnection.guessContentTypeFromName(file.name)
val bytes = file.readBytes()
return uploadBytes(file.name, mimeType, bytes, thumbnailBytes, onProgress)
}
suspend fun downloadToAppFiles( suspend fun downloadToAppFiles(
context: Context, context: Context,
downloadUrl: String, downloadUrl: String,

查看文件

@ -30,6 +30,7 @@ import com.xuqm.sdk.im.model.PageResult
import com.xuqm.sdk.im.model.UserProfile import com.xuqm.sdk.im.model.UserProfile
import com.xuqm.sdk.im.model.BlacklistEntry import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.FileUploadResult import com.xuqm.sdk.file.FileUploadResult
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import android.util.Log import android.util.Log
@ -44,6 +45,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CopyOnWriteArraySet
import java.util.UUID import java.util.UUID
@ -59,6 +61,9 @@ object ImSDK {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activeGroupSubscriptions = linkedSetOf<String>() private val activeGroupSubscriptions = linkedSetOf<String>()
private val listeners = CopyOnWriteArraySet<ImEventListener>() 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 reconnectJob: Job? = null
private var reconnectAttempts = 0 private var reconnectAttempts = 0
@Volatile private var reconnectEnabled = false @Volatile private var reconnectEnabled = false
@ -89,6 +94,10 @@ object ImSDK {
var currentUserId: String = "" var currentUserId: String = ""
private set private set
interface ConversationListener {
fun onConversationsChanged(conversations: List<ConversationData>)
}
init { init {
XuqmSDK.currentLoginSession?.let { onSdkLogin(it) } XuqmSDK.currentLoginSession?.let { onSdkLogin(it) }
} }
@ -141,7 +150,18 @@ object ImSDK {
suspend fun revokeMessage(messageId: String): ImMessage = suspend fun revokeMessage(messageId: String): ImMessage =
withContext(Dispatchers.IO) { api.revokeMessage(messageId, XuqmSDK.appKey).data ?: throw IllegalStateException("revoke message failed") } withContext(Dispatchers.IO) { api.revokeMessage(messageId, XuqmSDK.appKey).data ?: throw IllegalStateException("revoke message failed") }
fun sendImageMessage( 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, toId: String,
chatType: String, chatType: String,
file: FileUploadResult, file: FileUploadResult,
@ -165,7 +185,19 @@ object ImSDK {
) )
} }
fun sendVideoMessage( 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, toId: String,
chatType: String, chatType: String,
file: FileUploadResult, file: FileUploadResult,
@ -191,7 +223,16 @@ object ImSDK {
) )
} }
fun sendFileMessage( 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, toId: String,
chatType: String, chatType: String,
file: FileUploadResult, file: FileUploadResult,
@ -210,7 +251,17 @@ object ImSDK {
) )
} }
fun sendAudioMessage( 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, toId: String,
chatType: String, chatType: String,
file: FileUploadResult, file: FileUploadResult,
@ -625,6 +676,13 @@ object ImSDK {
suspend fun listConversations(): List<ConversationData> = suspend fun listConversations(): List<ConversationData> =
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appKey).data ?: emptyList() } withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appKey).data ?: emptyList() }
.also { result ->
synchronized(conversationsLock) {
_conversations.clear()
_conversations.addAll(result)
}
notifyConversationListeners()
}
suspend fun getTotalUnreadCount(): Int = suspend fun getTotalUnreadCount(): Int =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -720,6 +778,16 @@ object ImSDK {
client?.removeListener(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() { fun disconnect() {
disconnectInternal(clearTokenStore = true) disconnectInternal(clearTokenStore = true)
} }
@ -749,6 +817,7 @@ object ImSDK {
Log.d(TAG, "connectWithToken userId=$currentUserId activeGroups=${activeGroupSubscriptions.size}") Log.d(TAG, "connectWithToken userId=$currentUserId activeGroups=${activeGroupSubscriptions.size}")
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appKey) client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appKey)
client?.addListener(connectionListener) client?.addListener(connectionListener)
client?.addListener(conversationEventListener)
listeners.forEach { client?.addListener(it) } listeners.forEach { client?.addListener(it) }
reconnectEnabled = true reconnectEnabled = true
client?.connect() client?.connect()
@ -832,6 +901,88 @@ object ImSDK {
) )
} }
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?) { private fun scheduleReconnect(reason: String?) {
if (!reconnectEnabled || currentToken.isBlank()) return if (!reconnectEnabled || currentToken.isBlank()) return
if (reconnectJob?.isActive == true) return if (reconnectJob?.isActive == true) return