feat(chat): 添加聊天界面和文件更新SDK功能

- 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型
- 集成IM消息收发功能,实现消息气泡显示和用户头像占位符
- 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像
- 实现语音录制和播放功能,包含按住说话交互和权限处理
- 添加群组提及功能,支持@用户和提及候选列表显示
- 实现消息回复和引用功能,支持消息长按回复操作
- 添加本地消息搜索功能,支持搜索当前会话的历史消息
- 实现文件上传下载功能,集成FileSDK进行文件传输管理
- 添加应用更新检查功能,集成UpdateSDK支持版本更新
- 实现消息状态显示,包括发送、送达、已读等状态标识
- 添加群组已读人数统计,显示消息在群聊中的阅读情况
- 实现草稿保存和恢复功能,支持断点续聊体验
- 添加连接状态横幅,实时显示IM服务连接状态
- 实现滚动加载更多历史消息,优化大量消息的性能表现
- 添加多媒体文件下载保存功能,支持保存到应用专属目录
这个提交包含在:
XuqmGroup 2026-04-28 20:11:38 +08:00
父节点 dcb263edc6
当前提交 0425c988ae
共有 3 个文件被更改,包括 68 次插入3 次删除

查看文件

@ -44,6 +44,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -68,10 +69,10 @@ import com.xuqm.sdk.ui.SearchBarField
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.xuqm.sdk.file.FileSDK
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
@ -514,6 +515,8 @@ private fun MessageBubble(
onReply: (ImMessage) -> Unit, onReply: (ImMessage) -> Unit,
) { ) {
val media = message.mediaContent() val media = message.mediaContent()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val arrangement = if (isOwn) Arrangement.End else Arrangement.Start val arrangement = if (isOwn) Arrangement.End else Arrangement.Start
val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surfaceVariant
@ -557,6 +560,27 @@ private fun MessageBubble(
style = MaterialTheme.typography.bodyMedium, 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() val mentionedUserIds = message.mentionedUserIds.orEmpty()
if (mentionedUserIds.isNotBlank() && if (mentionedUserIds.isNotBlank() &&
mentionedUserIds.split(",").map { it.trim() }.contains(currentUserId) 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" 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? { private fun mentionQueryAtCursor(value: TextFieldValue): String? {
if (!value.selection.collapsed) return null if (!value.selection.collapsed) return null
val cursor = value.selection.end.coerceIn(0, value.text.length) val cursor = value.selection.end.coerceIn(0, value.text.length)

查看文件

@ -4,12 +4,15 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import java.io.File
data class FileUploadResult( data class FileUploadResult(
val url: String, val url: String,
@ -90,4 +93,31 @@ object FileSDK {
"File upload failed" "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
}
} }

查看文件

@ -5,7 +5,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK 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.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.update.api.UpdateApi import com.xuqm.sdk.update.api.UpdateApi
@ -53,7 +53,7 @@ object UpdateSDK {
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val apkFile = File(context.getExternalFilesDir(null), "update.apk") val apkFile = File(context.getExternalFilesDir(null), "update.apk")
FileTransfer.downloadToFile(downloadUrl, apkFile, onProgress) FileSDK.downloadToFile(downloadUrl, apkFile, onProgress)
withContext(Dispatchers.Main) { installApk(context, apkFile) } withContext(Dispatchers.Main) { installApk(context, apkFile) }
} }