From 0425c988ae83b43bdf2593d33f1e38aa53c4f974 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Apr 2026 20:11:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=B7=BB=E5=8A=A0=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=95=8C=E9=9D=A2=E5=92=8C=E6=96=87=E4=BB=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0SDK=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型 - 集成IM消息收发功能,实现消息气泡显示和用户头像占位符 - 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像 - 实现语音录制和播放功能,包含按住说话交互和权限处理 - 添加群组提及功能,支持@用户和提及候选列表显示 - 实现消息回复和引用功能,支持消息长按回复操作 - 添加本地消息搜索功能,支持搜索当前会话的历史消息 - 实现文件上传下载功能,集成FileSDK进行文件传输管理 - 添加应用更新检查功能,集成UpdateSDK支持版本更新 - 实现消息状态显示,包括发送、送达、已读等状态标识 - 添加群组已读人数统计,显示消息在群聊中的阅读情况 - 实现草稿保存和恢复功能,支持断点续聊体验 - 添加连接状态横幅,实时显示IM服务连接状态 - 实现滚动加载更多历史消息,优化大量消息的性能表现 - 添加多媒体文件下载保存功能,支持保存到应用专属目录 --- .../com/xuqm/sdk/sample/ui/chat/ChatScreen.kt | 37 ++++++++++++++++++- .../main/java/com/xuqm/sdk/file/FileSDK.kt | 30 +++++++++++++++ .../java/com/xuqm/sdk/update/UpdateSDK.kt | 4 +- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt index 3fd43c3..d316bff 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.layout.ContentScale @@ -68,10 +69,10 @@ import com.xuqm.sdk.ui.SearchBarField import coil3.compose.AsyncImage import androidx.compose.ui.platform.LocalContext import androidx.core.content.FileProvider +import com.xuqm.sdk.file.FileSDK import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import java.io.File -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.TextRange @@ -514,6 +515,8 @@ private fun MessageBubble( onReply: (ImMessage) -> Unit, ) { val media = message.mediaContent() + val context = LocalContext.current + val scope = rememberCoroutineScope() val arrangement = if (isOwn) Arrangement.End else Arrangement.Start val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant @@ -557,6 +560,27 @@ private fun MessageBubble( style = MaterialTheme.typography.bodyMedium, ) } + if (media?.url.isNullOrBlank().not() && message.msgType.uppercase() in setOf("IMAGE", "VIDEO", "AUDIO", "FILE")) { + TextButton( + onClick = { + scope.launch { + runCatching { + val saved = FileSDK.downloadToAppFiles( + context = context, + downloadUrl = media.url, + fileName = media.name?.takeIf { it.isNotBlank() } ?: defaultDownloadFileName(message), + directoryName = "demo-downloads", + ) + Toast.makeText(context, "已保存到 ${saved.absolutePath}", Toast.LENGTH_SHORT).show() + }.onFailure { + Toast.makeText(context, "下载失败:${it.message ?: "未知错误"}", Toast.LENGTH_SHORT).show() + } + } + }, + ) { + Text("下载") + } + } val mentionedUserIds = message.mentionedUserIds.orEmpty() if (mentionedUserIds.isNotBlank() && mentionedUserIds.split(",").map { it.trim() }.contains(currentUserId) @@ -765,6 +789,17 @@ private fun formatDuration(ms: Long): String { return if (minutes > 0) String.format("%d:%02d", minutes, seconds) else "${seconds}s" } +private fun defaultDownloadFileName(message: ImMessage): String { + val suffix = when (message.msgType.uppercase()) { + "IMAGE" -> "jpg" + "VIDEO" -> "mp4" + "AUDIO" -> "m4a" + "FILE" -> "bin" + else -> "dat" + } + return "${message.msgType.lowercase()}_${message.id}.$suffix" +} + private fun mentionQueryAtCursor(value: TextFieldValue): String? { if (!value.selection.collapsed) return null val cursor = value.selection.end.coerceIn(0, value.text.length) 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 f642375..5b4faeb 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 @@ -4,12 +4,15 @@ import android.content.Context import android.net.Uri import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.network.ApiClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part +import java.io.File data class FileUploadResult( val url: String, @@ -90,4 +93,31 @@ object FileSDK { "File upload failed" } } + + suspend fun downloadToFile( + downloadUrl: String, + targetFile: File, + onProgress: (Int) -> Unit = {}, + ): File = withContext(Dispatchers.IO) { + FileTransfer.downloadToFile(downloadUrl, targetFile, onProgress) + targetFile + } + + suspend fun downloadToAppFiles( + context: Context, + downloadUrl: String, + fileName: String? = null, + directoryName: String? = null, + onProgress: (Int) -> Unit = {}, + ): File = withContext(Dispatchers.IO) { + val dir = if (directoryName.isNullOrBlank()) { + context.getExternalFilesDir(null) ?: context.filesDir + } else { + File(context.getExternalFilesDir(null) ?: context.filesDir, directoryName).apply { mkdirs() } + } + val resolvedName = fileName?.takeIf { it.isNotBlank() } ?: downloadUrl.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "download.bin" + val target = File(dir, resolvedName) + FileTransfer.downloadToFile(downloadUrl, target, onProgress) + target + } } diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt index b2552c0..4a47aa7 100644 --- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.net.Uri import androidx.core.content.FileProvider import com.xuqm.sdk.XuqmSDK -import com.xuqm.sdk.file.FileTransfer +import com.xuqm.sdk.file.FileSDK import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.update.api.UpdateApi @@ -53,7 +53,7 @@ object UpdateSDK { onProgress: (Int) -> Unit = {}, ) = withContext(Dispatchers.IO) { val apkFile = File(context.getExternalFilesDir(null), "update.apk") - FileTransfer.downloadToFile(downloadUrl, apkFile, onProgress) + FileSDK.downloadToFile(downloadUrl, apkFile, onProgress) withContext(Dispatchers.Main) { installApk(context, apkFile) } }