feat(im): 添加即时消息SDK核心功能实现
- 实现了聊天消息发送功能,支持文本、图片、视频、音频、文件等多种消息类型 - 集成了文件上传下载功能,支持多媒体文件的传输和管理 - 添加了群组管理功能,包括创建群组、成员管理、权限控制等操作 - 实现了好友系统,支持好友添加、删除、分组等功能 - 集成了黑名单管理,提供用户屏蔽和解除屏蔽功能 - 添加了会话管理功能,支持对话列表、未读消息统计等 - 实现了历史消息查询和搜索功能 - 添加了实时连接状态管理和自动重连机制
这个提交包含在:
父节点
d9c9e4f858
当前提交
d0b263411d
@ -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
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户