diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt index ab9ab06..f721da8 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt @@ -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 = - sendMedia(targetId, chatType, uri, MediaKind.IMAGE, onProgress) + ): Result = 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 = 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 = - sendMedia(targetId, chatType, uri, MediaKind.VIDEO, onProgress) + ): Result = 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 = - sendMedia(targetId, chatType, uri, MediaKind.FILE, onProgress) + ): Result = 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 = - sendMedia(targetId, chatType, uri, MediaKind.AUDIO, onProgress) + ): Result = 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 = 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 = 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 { diff --git a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt index 2066039..0d8d392 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt @@ -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, diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt index e755120..7c00099 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt @@ -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() private val listeners = CopyOnWriteArraySet() + private val conversationListeners = CopyOnWriteArraySet() + private val _conversations = mutableListOf() + 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) + } + 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 = 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