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

查看文件

@ -105,6 +105,16 @@ object FileSDK {
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(
context: Context,
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.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
@ -44,6 +45,7 @@ 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
@ -59,6 +61,9 @@ object ImSDK {
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
@ -89,6 +94,10 @@ object ImSDK {
var currentUserId: String = ""
private set
interface ConversationListener {
fun onConversationsChanged(conversations: List<ConversationData>)
}
init {
XuqmSDK.currentLoginSession?.let { onSdkLogin(it) }
}
@ -141,7 +150,18 @@ object ImSDK {
suspend fun revokeMessage(messageId: String): ImMessage =
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,
chatType: String,
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,
chatType: String,
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,
chatType: String,
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,
chatType: String,
file: FileUploadResult,
@ -625,6 +676,13 @@ object ImSDK {
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) {
@ -720,6 +778,16 @@ object ImSDK {
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)
}
@ -749,6 +817,7 @@ object ImSDK {
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()
@ -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?) {
if (!reconnectEnabled || currentToken.isBlank()) return
if (reconnectJob?.isActive == true) return