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