feat(chat): 添加聊天功能相关API接口、本地缓存和数据仓库
- 添加DemoApi接口定义用户认证和资料管理API - 实现LocalImCache用于本地存储IM对话和消息历史 - 添加MessageContent模型处理多媒体消息内容 - 创建AttachmentRepository处理图片视频音频文件发送 - 实现AuthRepository管理用户登录注册和会话 - 添加VoiceRecorder支持语音录制功能 - 创建AppDependencies依赖注入容器 - 添加ChatScreen界面组件实现聊天UI逻辑
这个提交包含在:
父节点
59611de3c1
当前提交
bee82637f3
45
README.md
45
README.md
@ -6,10 +6,10 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
XuqmGroup-AndroidSDK/
|
XuqmGroup-AndroidSDK/
|
||||||
├── sdk-core/ # 核心:初始化、HTTP、Token 存储、通用工具/组件
|
├── sdk-core/ # 核心:初始化、HTTP、Token 存储、文件上传、通用工具/组件
|
||||||
├── sdk-im/ # IM:WebSocket 实时通信
|
├── sdk-im/ # IM:WebSocket 实时通信
|
||||||
├── sdk-push/ # 推送:设备 Token 注册
|
├── sdk-push/ # 推送:设备 Token 注册
|
||||||
├── sdk-update/ # 版本管理:检查更新、下载安装、RN 热更新
|
├── sdk-update/ # 版本管理:检查更新、下载安装
|
||||||
└── sample-app/ # 示例 App(Jetpack Compose)
|
└── sample-app/ # 示例 App(Jetpack Compose)
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -141,12 +141,23 @@ val service = RetrofitFactory.create(MyApiService::class.java)
|
|||||||
- 当前会话本地搜索
|
- 当前会话本地搜索
|
||||||
- 输入草稿自动保存
|
- 输入草稿自动保存
|
||||||
- 群设置支持编辑群名和群公告
|
- 群设置支持编辑群名和群公告
|
||||||
|
- 群组扩展 API 已补齐:管理员设置、禁言、解散群
|
||||||
|
- 公开群支持搜索、创建和加群审批
|
||||||
- 会话支持本地删除
|
- 会话支持本地删除
|
||||||
- 显示总未读数
|
- 显示总未读数
|
||||||
- 消息状态直接展示
|
- 消息状态直接展示
|
||||||
- 会话置顶/免打扰/已读/草稿/删除同步服务端
|
- 会话置顶/免打扰/已读/草稿/删除同步服务端
|
||||||
- IM 连接状态提示
|
- IM 连接状态提示
|
||||||
- SDK 登录态恢复后自动重连
|
- SDK 登录态恢复后自动重连
|
||||||
|
- IM token 自动续签,过期前会静默刷新并重连
|
||||||
|
- 群聊支持 `@userId` 提及,并写入 `mentionedUserIds`
|
||||||
|
- 关系链支持好友申请、接受/拒绝、黑名单
|
||||||
|
- 支持图片 / 视频 / 音频 / 文件消息,文件通过独立文件服务上传后再发 IM
|
||||||
|
- 图片支持拍照、摄像、图库选择,文件支持文件管理器选择
|
||||||
|
- 语音支持长按录音、抬手发送
|
||||||
|
- 支持引用回复、群聊已读人数展示
|
||||||
|
- 支持 `LOCATION` / `CUSTOM` / `RICH_TEXT` / `FORWARD` / `QUOTE` / `MERGE` / `CALL_AUDIO` / `CALL_VIDEO` 等通用消息类型发送
|
||||||
|
- 单聊支持已读回执,服务端会把 `READ` 状态推回发送者
|
||||||
|
|
||||||
### ImClient
|
### ImClient
|
||||||
|
|
||||||
@ -173,6 +184,15 @@ imClient.send(SendMessageParams(
|
|||||||
content = "Hello!"
|
content = "Hello!"
|
||||||
))
|
))
|
||||||
|
|
||||||
|
// 群聊提及
|
||||||
|
imClient.send(SendMessageParams(
|
||||||
|
toId = "group_001",
|
||||||
|
chatType = ChatType.GROUP,
|
||||||
|
msgType = MsgType.TEXT,
|
||||||
|
content = "@user_002 你好",
|
||||||
|
mentionedUserIds = "user_002",
|
||||||
|
))
|
||||||
|
|
||||||
// 撤回消息
|
// 撤回消息
|
||||||
imClient.revoke(msgId = "uuid")
|
imClient.revoke(msgId = "uuid")
|
||||||
|
|
||||||
@ -182,7 +202,7 @@ imClient.disconnect()
|
|||||||
|
|
||||||
### 消息类型(MsgType)
|
### 消息类型(MsgType)
|
||||||
|
|
||||||
`TEXT` / `IMAGE` / `VIDEO` / `AUDIO` / `FILE` / `CUSTOM` / `LOCATION` / `NOTIFY` / `RICH_TEXT` / `CALL_AUDIO` / `CALL_VIDEO` / `FORWARD`
|
`TEXT` / `IMAGE` / `VIDEO` / `AUDIO` / `FILE` / `CUSTOM` / `LOCATION` / `NOTIFY` / `RICH_TEXT` / `CALL_AUDIO` / `CALL_VIDEO` / `FORWARD` / `QUOTE` / `MERGE`
|
||||||
|
|
||||||
### ImMessage 结构
|
### ImMessage 结构
|
||||||
|
|
||||||
@ -210,7 +230,7 @@ data class ImMessage(
|
|||||||
|
|
||||||
### 推送接入
|
### 推送接入
|
||||||
|
|
||||||
在 `PushSDK.initialize()` 后由 SDK 自动完成厂商初始化、设备注册与 token 上传,不再要求业务侧单独调用注册接口。
|
在 `XuqmSDK.login()` 成功后,SDK 会自动完成厂商初始化、设备注册与 token 上传,不再要求业务侧单独调用注册接口。`logout()` 时会自动注销当前设备绑定。
|
||||||
|
|
||||||
### 与 IM 联动
|
### 与 IM 联动
|
||||||
|
|
||||||
@ -238,23 +258,6 @@ if (result.needsUpdate) {
|
|||||||
`downloadAndInstall` 会将 APK 下载到 `getExternalFilesDir(null)`,通过 `FileProvider` 触发系统安装。
|
`downloadAndInstall` 会将 APK 下载到 `getExternalFilesDir(null)`,通过 `FileProvider` 触发系统安装。
|
||||||
AndroidManifest 中已配置 `@xml/file_paths`(`external-files-path`)。
|
AndroidManifest 中已配置 `@xml/file_paths`(`external-files-path`)。
|
||||||
|
|
||||||
### 检查 RN Bundle 更新
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
val rnResult = UpdateSDK.checkRnUpdate(
|
|
||||||
appId = "ak_xxx",
|
|
||||||
moduleId = "main",
|
|
||||||
platform = "android",
|
|
||||||
currentVersion = "1.0.0"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (rnResult.needsUpdate) {
|
|
||||||
val filePath = UpdateSDK.downloadBundle(context, rnResult.downloadUrl, "main.android.bundle")
|
|
||||||
// 校验 MD5
|
|
||||||
// 通知 RN 引擎加载新 bundle
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 发版
|
## 发版
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".XuqmSampleApp"
|
android:name=".XuqmSampleApp"
|
||||||
|
|||||||
@ -36,11 +36,19 @@ data class AuthProfile(
|
|||||||
|
|
||||||
data class AuthResult(
|
data class AuthResult(
|
||||||
val demoToken: String,
|
val demoToken: String,
|
||||||
|
val demoTokenExpiresAt: Long? = null,
|
||||||
@SerializedName(value = "imToken", alternate = ["userSig"])
|
@SerializedName(value = "imToken", alternate = ["userSig"])
|
||||||
val userSig: String? = null,
|
val userSig: String? = null,
|
||||||
|
val imTokenExpiresAt: Long? = null,
|
||||||
val profile: AuthProfile,
|
val profile: AuthProfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ImRefreshResult(
|
||||||
|
@SerializedName(value = "imToken", alternate = ["userSig"])
|
||||||
|
val userSig: String,
|
||||||
|
val imTokenExpiresAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class UserData(
|
data class UserData(
|
||||||
val userId: String,
|
val userId: String,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
@ -64,6 +72,9 @@ interface DemoApi {
|
|||||||
@POST("api/demo/auth/register")
|
@POST("api/demo/auth/register")
|
||||||
suspend fun register(@Body request: RegisterRequest): DemoResponse<AuthResult>
|
suspend fun register(@Body request: RegisterRequest): DemoResponse<AuthResult>
|
||||||
|
|
||||||
|
@POST("api/demo/auth/refresh-im")
|
||||||
|
suspend fun refreshImToken(@Query("appId") appId: String = DEMO_APP_ID): DemoResponse<ImRefreshResult>
|
||||||
|
|
||||||
@POST("api/demo/auth/reset-password")
|
@POST("api/demo/auth/reset-password")
|
||||||
suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse<Unit>
|
suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse<Unit>
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.xuqm.sdk.sample.data.local
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.xuqm.sdk.im.model.ConversationData
|
import com.xuqm.sdk.im.model.ConversationData
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
|
import com.xuqm.sdk.sample.data.model.searchableText
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
@ -61,7 +62,8 @@ class LocalImCache(context: Context) {
|
|||||||
|
|
||||||
fun mergeHistory(targetId: String, chatType: String, messages: List<ImMessage>) {
|
fun mergeHistory(targetId: String, chatType: String, messages: List<ImMessage>) {
|
||||||
val merged = (loadHistory(targetId, chatType) + messages)
|
val merged = (loadHistory(targetId, chatType) + messages)
|
||||||
.distinctBy { it.id }
|
.associateBy { it.id }
|
||||||
|
.values
|
||||||
.sortedByDescending { it.createdAt }
|
.sortedByDescending { it.createdAt }
|
||||||
saveHistory(targetId, chatType, merged)
|
saveHistory(targetId, chatType, merged)
|
||||||
}
|
}
|
||||||
@ -180,15 +182,7 @@ class LocalImCache(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ImMessage.matches(keyword: String): Boolean {
|
private fun ImMessage.matches(keyword: String): Boolean {
|
||||||
val plainText = when (msgType.uppercase()) {
|
return searchableText().contains(keyword, ignoreCase = true)
|
||||||
"TEXT" -> runCatching { JSONObject(content).optString("text") }.getOrDefault(content)
|
|
||||||
"NOTIFY" -> runCatching { JSONObject(content).optString("content") }.getOrDefault(content)
|
|
||||||
else -> content
|
|
||||||
}
|
|
||||||
return plainText.contains(keyword, ignoreCase = true) ||
|
|
||||||
fromId.contains(keyword, ignoreCase = true) ||
|
|
||||||
toId.contains(keyword, ignoreCase = true) ||
|
|
||||||
msgType.contains(keyword, ignoreCase = true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId"
|
private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId"
|
||||||
|
|||||||
@ -0,0 +1,104 @@
|
|||||||
|
package com.xuqm.sdk.sample.data.model
|
||||||
|
|
||||||
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class MediaMessageContent(
|
||||||
|
val url: String,
|
||||||
|
val thumbnailUrl: String? = null,
|
||||||
|
val width: Int? = null,
|
||||||
|
val height: Int? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
val durationMs: Long? = null,
|
||||||
|
val hash: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class QuoteMessageContent(
|
||||||
|
val quotedMsgId: String? = null,
|
||||||
|
val quotedContent: String? = null,
|
||||||
|
val text: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MergeMessageContent(
|
||||||
|
val title: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ImMessage.previewText(): String = when (msgType.uppercase()) {
|
||||||
|
"TEXT" -> textContent().ifBlank { content }
|
||||||
|
"IMAGE" -> "[图片]"
|
||||||
|
"VIDEO" -> "[视频]"
|
||||||
|
"AUDIO" -> "[语音]"
|
||||||
|
"FILE" -> "[文件]"
|
||||||
|
"LOCATION" -> "[位置]"
|
||||||
|
"CUSTOM" -> "[自定义]"
|
||||||
|
"RICH_TEXT" -> "[富文本]"
|
||||||
|
"FORWARD" -> "[转发]"
|
||||||
|
"QUOTE" -> quoteContent()?.text?.takeIf { it.isNotBlank() } ?: "[引用]"
|
||||||
|
"MERGE" -> mergeContent()?.title?.takeIf { it.isNotBlank() } ?: "[合并转发]"
|
||||||
|
"CALL_AUDIO" -> "[语音通话]"
|
||||||
|
"CALL_VIDEO" -> "[视频通话]"
|
||||||
|
"REVOKED" -> "[消息已撤回]"
|
||||||
|
"NOTIFY" -> notifyContent().ifBlank { "[通知]" }
|
||||||
|
else -> content
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ImMessage.searchableText(): String {
|
||||||
|
val media = mediaContent()
|
||||||
|
return buildString {
|
||||||
|
append(previewText())
|
||||||
|
append(' ')
|
||||||
|
append(fromId)
|
||||||
|
append(' ')
|
||||||
|
append(toId)
|
||||||
|
append(' ')
|
||||||
|
append(msgType)
|
||||||
|
append(' ')
|
||||||
|
append(content)
|
||||||
|
media?.name?.let { append(' ').append(it) }
|
||||||
|
media?.url?.let { append(' ').append(it) }
|
||||||
|
media?.thumbnailUrl?.let { append(' ').append(it) }
|
||||||
|
quoteContent()?.quotedContent?.let { append(' ').append(it) }
|
||||||
|
quoteContent()?.text?.let { append(' ').append(it) }
|
||||||
|
mergeContent()?.title?.let { append(' ').append(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ImMessage.mediaContent(): MediaMessageContent? = runCatching {
|
||||||
|
val obj = JSONObject(content)
|
||||||
|
val url = obj.optString("url").takeIf { it.isNotBlank() } ?: return null
|
||||||
|
MediaMessageContent(
|
||||||
|
url = url,
|
||||||
|
thumbnailUrl = obj.optString("thumbnailUrl").takeIf { it.isNotBlank() },
|
||||||
|
width = obj.optInt("width").takeIf { it > 0 },
|
||||||
|
height = obj.optInt("height").takeIf { it > 0 },
|
||||||
|
size = obj.optLong("size").takeIf { it > 0 },
|
||||||
|
name = obj.optString("name").takeIf { it.isNotBlank() },
|
||||||
|
mimeType = obj.optString("mimeType").takeIf { it.isNotBlank() },
|
||||||
|
durationMs = obj.optLong("durationMs").takeIf { it > 0 },
|
||||||
|
hash = obj.optString("hash").takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun ImMessage.quoteContent(): QuoteMessageContent? = runCatching {
|
||||||
|
val obj = JSONObject(content)
|
||||||
|
QuoteMessageContent(
|
||||||
|
quotedMsgId = obj.optString("quotedMsgId").takeIf { it.isNotBlank() },
|
||||||
|
quotedContent = obj.optString("quotedContent").takeIf { it.isNotBlank() },
|
||||||
|
text = obj.optString("text").takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun ImMessage.mergeContent(): MergeMessageContent? = runCatching {
|
||||||
|
val obj = JSONObject(content)
|
||||||
|
MergeMessageContent(
|
||||||
|
title = obj.optString("title").takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun ImMessage.textContent(): String =
|
||||||
|
runCatching { JSONObject(content).optString("text") }.getOrDefault(content)
|
||||||
|
|
||||||
|
private fun ImMessage.notifyContent(): String =
|
||||||
|
runCatching { JSONObject(content).optString("content") }.getOrDefault(content)
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
package com.xuqm.sdk.sample.data.repo
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
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.im.ImSDK
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
class AttachmentRepository(private val context: Context) {
|
||||||
|
|
||||||
|
suspend fun sendImage(targetId: String, chatType: String, uri: Uri): Result<Unit> =
|
||||||
|
sendMedia(targetId, chatType, uri, MediaKind.IMAGE)
|
||||||
|
|
||||||
|
suspend fun sendImageBytes(
|
||||||
|
targetId: String,
|
||||||
|
chatType: String,
|
||||||
|
fileName: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
width: Int? = null,
|
||||||
|
height: Int? = null,
|
||||||
|
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val upload = FileSDK.uploadBytes(
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
bytes = bytes,
|
||||||
|
)
|
||||||
|
ImSDK.sendImageMessage(
|
||||||
|
toId = targetId,
|
||||||
|
chatType = chatType,
|
||||||
|
file = upload,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendVideo(targetId: String, chatType: String, uri: Uri): Result<Unit> =
|
||||||
|
sendMedia(targetId, chatType, uri, MediaKind.VIDEO)
|
||||||
|
|
||||||
|
suspend fun sendFile(targetId: String, chatType: String, uri: Uri): Result<Unit> =
|
||||||
|
sendMedia(targetId, chatType, uri, MediaKind.FILE)
|
||||||
|
|
||||||
|
suspend fun sendAudio(targetId: String, chatType: String, uri: Uri): Result<Unit> =
|
||||||
|
sendMedia(targetId, chatType, uri, MediaKind.AUDIO)
|
||||||
|
|
||||||
|
suspend fun sendAudioBytes(
|
||||||
|
targetId: String,
|
||||||
|
chatType: String,
|
||||||
|
fileName: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
durationMs: Long? = null,
|
||||||
|
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val upload = FileSDK.uploadBytes(
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = "audio/mp4",
|
||||||
|
bytes = bytes,
|
||||||
|
)
|
||||||
|
ImSDK.sendAudioMessage(
|
||||||
|
toId = targetId,
|
||||||
|
chatType = chatType,
|
||||||
|
file = upload,
|
||||||
|
durationMs = durationMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendMedia(
|
||||||
|
targetId: String,
|
||||||
|
chatType: String,
|
||||||
|
uri: Uri,
|
||||||
|
kind: MediaKind,
|
||||||
|
): Result<Unit> = 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
|
||||||
|
}
|
||||||
|
val upload = FileSDK.upload(
|
||||||
|
context = context,
|
||||||
|
uri = uri,
|
||||||
|
displayName = meta.displayName,
|
||||||
|
mimeType = meta.mimeType,
|
||||||
|
thumbnailBytes = thumbnailBytes,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveMeta(uri: Uri): AttachmentMeta {
|
||||||
|
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('/')
|
||||||
|
val mimeType = resolver.getType(uri)
|
||||||
|
val size = when {
|
||||||
|
mimeType?.startsWith("image/") == true -> readImageSize(uri)
|
||||||
|
mimeType?.startsWith("video/") == true -> readVideoSize(uri)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val durationMs = if (mimeType?.startsWith("video/") == true || mimeType?.startsWith("audio/") == true) {
|
||||||
|
readMediaDuration(uri)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return AttachmentMeta(
|
||||||
|
displayName = displayName?.takeIf { it.isNotBlank() } ?: "attachment",
|
||||||
|
mimeType = mimeType,
|
||||||
|
width = size?.first,
|
||||||
|
height = size?.second,
|
||||||
|
durationMs = durationMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readImageSize(uri: Uri): Pair<Int, Int>? {
|
||||||
|
return context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeStream(input, null, options)
|
||||||
|
if (options.outWidth > 0 && options.outHeight > 0) {
|
||||||
|
options.outWidth to options.outHeight
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readVideoSize(uri: Uri): Pair<Int, Int>? {
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
return try {
|
||||||
|
retriever.setDataSource(context, uri)
|
||||||
|
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
||||||
|
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
|
||||||
|
if (width > 0 && height > 0) width to height else null
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
runCatching { retriever.release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readMediaDuration(uri: Uri): Long? {
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
return try {
|
||||||
|
retriever.setDataSource(context, uri)
|
||||||
|
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
runCatching { retriever.release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractVideoThumbnail(uri: Uri): ByteArray? {
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
return try {
|
||||||
|
retriever.setDataSource(context, uri)
|
||||||
|
val bitmap: Bitmap = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
|
||||||
|
?: return null
|
||||||
|
ByteArrayOutputStream().use { output ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, output)
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
runCatching { retriever.release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class MediaKind { IMAGE, VIDEO, AUDIO, FILE }
|
||||||
|
|
||||||
|
private data class AttachmentMeta(
|
||||||
|
val displayName: String,
|
||||||
|
val mimeType: String?,
|
||||||
|
val width: Int? = null,
|
||||||
|
val height: Int? = null,
|
||||||
|
val durationMs: Long? = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
|||||||
import androidx.security.crypto.MasterKeys
|
import androidx.security.crypto.MasterKeys
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
import com.xuqm.sdk.sample.data.api.AuthResult
|
import com.xuqm.sdk.sample.data.api.AuthResult
|
||||||
|
import com.xuqm.sdk.sample.data.api.ImRefreshResult
|
||||||
import com.xuqm.sdk.sample.data.api.ChangePasswordRequest
|
import com.xuqm.sdk.sample.data.api.ChangePasswordRequest
|
||||||
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
||||||
import com.xuqm.sdk.sample.data.api.DemoApi
|
import com.xuqm.sdk.sample.data.api.DemoApi
|
||||||
@ -16,10 +17,20 @@ import com.xuqm.sdk.sample.data.api.UpdateProfileRequest
|
|||||||
import com.xuqm.sdk.sample.data.api.UserData
|
import com.xuqm.sdk.sample.data.api.UserData
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
class AuthRepository(context: Context) {
|
class AuthRepository(context: Context) {
|
||||||
|
|
||||||
|
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var refreshJob: Job? = null
|
||||||
|
private val refreshing = AtomicBoolean(false)
|
||||||
|
|
||||||
private val prefs = EncryptedSharedPreferences.create(
|
private val prefs = EncryptedSharedPreferences.create(
|
||||||
"xuqm_demo_auth",
|
"xuqm_demo_auth",
|
||||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
||||||
@ -39,17 +50,39 @@ class AuthRepository(context: Context) {
|
|||||||
fun getCurrentNickname(): String? = prefs.getString("nickname", null)
|
fun getCurrentNickname(): String? = prefs.getString("nickname", null)
|
||||||
fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
|
fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
|
||||||
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
|
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
|
||||||
|
fun getCurrentUserSigExpiresAt(): Long = prefs.getLong("user_sig_expires_at", 0L)
|
||||||
fun isLoggedIn(): Boolean = getDemoToken() != null
|
fun isLoggedIn(): Boolean = getDemoToken() != null
|
||||||
|
|
||||||
private fun saveSession(result: AuthResult) {
|
private fun saveSession(result: AuthResult) {
|
||||||
val profile = result.profile
|
val profile = result.profile
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("demo_token", result.demoToken)
|
.putString("demo_token", result.demoToken)
|
||||||
|
.putLong("demo_token_expires_at", result.demoTokenExpiresAt ?: 0L)
|
||||||
.putString("user_id", profile.userId)
|
.putString("user_id", profile.userId)
|
||||||
.putString("nickname", profile.nickname)
|
.putString("nickname", profile.nickname)
|
||||||
.putString("avatar", profile.avatar)
|
.putString("avatar", profile.avatar)
|
||||||
.putString("user_sig", result.userSig)
|
.putString("user_sig", result.userSig)
|
||||||
|
.putLong("user_sig_expires_at", result.imTokenExpiresAt ?: 0L)
|
||||||
.apply()
|
.apply()
|
||||||
|
scheduleImTokenRefresh(result.imTokenExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveImCredential(result: ImRefreshResult) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString("user_sig", result.userSig)
|
||||||
|
.putLong("user_sig_expires_at", result.imTokenExpiresAt ?: 0L)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleImTokenRefresh(expiresAt: Long?) {
|
||||||
|
refreshJob?.cancel()
|
||||||
|
val tokenExpiresAt = expiresAt ?: 0L
|
||||||
|
if (tokenExpiresAt <= 0L) return
|
||||||
|
val delayMs = (tokenExpiresAt - System.currentTimeMillis() - REFRESH_GRACE_MS).coerceAtLeast(0L)
|
||||||
|
refreshJob = appScope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
refreshImToken()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(userId: String, password: String): Result<UserData> =
|
suspend fun login(userId: String, password: String): Result<UserData> =
|
||||||
@ -120,17 +153,66 @@ class AuthRepository(context: Context) {
|
|||||||
val userId = getCurrentUserId()
|
val userId = getCurrentUserId()
|
||||||
val userSig = getCurrentUserSig()
|
val userSig = getCurrentUserSig()
|
||||||
if (userId.isNullOrBlank() || userSig.isNullOrBlank()) return@runCatching
|
if (userId.isNullOrBlank() || userSig.isNullOrBlank()) return@runCatching
|
||||||
|
val refreshed = if (shouldRefreshImToken()) {
|
||||||
|
refreshImTokenInternal()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
XuqmSDK.login(
|
XuqmSDK.login(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
userSig = userSig,
|
userSig = refreshed?.userSig ?: userSig,
|
||||||
nickname = getCurrentNickname(),
|
nickname = getCurrentNickname(),
|
||||||
avatar = getCurrentAvatar(),
|
avatar = getCurrentAvatar(),
|
||||||
)
|
)
|
||||||
|
if (refreshed != null) {
|
||||||
|
saveImCredential(refreshed)
|
||||||
|
scheduleImTokenRefresh(refreshed.imTokenExpiresAt)
|
||||||
|
} else {
|
||||||
|
scheduleImTokenRefresh(getCurrentUserSigExpiresAt().takeIf { it > 0L })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
|
refreshJob?.cancel()
|
||||||
|
refreshJob = null
|
||||||
XuqmSDK.logout()
|
XuqmSDK.logout()
|
||||||
prefs.edit().clear().apply()
|
prefs.edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshImToken() {
|
||||||
|
if (!refreshing.compareAndSet(false, true)) return
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val refreshed = refreshImTokenInternal() ?: return@withContext
|
||||||
|
saveImCredential(refreshed)
|
||||||
|
XuqmSDK.login(
|
||||||
|
userId = getCurrentUserId() ?: return@withContext,
|
||||||
|
userSig = refreshed.userSig,
|
||||||
|
nickname = getCurrentNickname(),
|
||||||
|
avatar = getCurrentAvatar(),
|
||||||
|
)
|
||||||
|
scheduleImTokenRefresh(refreshed.imTokenExpiresAt)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshing.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshImTokenInternal(): ImRefreshResult? {
|
||||||
|
val appId = DEMO_APP_ID
|
||||||
|
return runCatching {
|
||||||
|
val response = api.refreshImToken(appId)
|
||||||
|
requireNotNull(response.data) { response.message ?: "Refresh IM token failed" }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldRefreshImToken(): Boolean {
|
||||||
|
val expiresAt = getCurrentUserSigExpiresAt()
|
||||||
|
return expiresAt <= 0L || expiresAt - System.currentTimeMillis() <= REFRESH_GRACE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val REFRESH_GRACE_MS = 5 * 60 * 1000L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.xuqm.sdk.sample.data.repo
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.os.SystemClock
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class RecordedVoice(
|
||||||
|
val fileName: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val durationMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
class VoiceRecorder(private val context: Context) {
|
||||||
|
|
||||||
|
private var recorder: MediaRecorder? = null
|
||||||
|
private var outputFile: File? = null
|
||||||
|
private var startedAtMs: Long = 0L
|
||||||
|
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (recorder != null) return true
|
||||||
|
val dir = File(context.cacheDir, "voice-recordings").apply { mkdirs() }
|
||||||
|
val file = File(dir, "voice_${System.currentTimeMillis()}.m4a")
|
||||||
|
outputFile = file
|
||||||
|
return runCatching {
|
||||||
|
recorder = MediaRecorder().apply {
|
||||||
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
|
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
|
setAudioEncodingBitRate(128_000)
|
||||||
|
setAudioSamplingRate(44_100)
|
||||||
|
setOutputFile(file.absolutePath)
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
startedAtMs = SystemClock.elapsedRealtime()
|
||||||
|
true
|
||||||
|
}.getOrElse {
|
||||||
|
releaseAndCleanup()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(): RecordedVoice? {
|
||||||
|
val current = recorder ?: return null
|
||||||
|
val file = outputFile ?: return null
|
||||||
|
return try {
|
||||||
|
current.stop()
|
||||||
|
val durationMs = (SystemClock.elapsedRealtime() - startedAtMs).coerceAtLeast(0L)
|
||||||
|
if (!file.exists() || file.length() <= 0L) {
|
||||||
|
releaseAndCleanup()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
RecordedVoice(
|
||||||
|
fileName = file.name,
|
||||||
|
mimeType = "audio/mp4",
|
||||||
|
bytes = file.readBytes(),
|
||||||
|
durationMs = durationMs,
|
||||||
|
)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
releaseAndCleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
runCatching { recorder?.stop() }
|
||||||
|
releaseAndCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseAndCleanup() {
|
||||||
|
runCatching { recorder?.release() }
|
||||||
|
recorder = null
|
||||||
|
outputFile?.delete()
|
||||||
|
outputFile = null
|
||||||
|
startedAtMs = 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package com.xuqm.sdk.sample.di
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||||
|
import com.xuqm.sdk.sample.data.repo.AttachmentRepository
|
||||||
import com.xuqm.sdk.sample.data.local.LocalContactCache
|
import com.xuqm.sdk.sample.data.local.LocalContactCache
|
||||||
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
|
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
|
||||||
import com.xuqm.sdk.sample.data.local.LocalImCache
|
import com.xuqm.sdk.sample.data.local.LocalImCache
|
||||||
@ -16,12 +17,15 @@ object AppDependencies {
|
|||||||
private set
|
private set
|
||||||
lateinit var localContactCache: LocalContactCache
|
lateinit var localContactCache: LocalContactCache
|
||||||
private set
|
private set
|
||||||
|
lateinit var attachmentRepository: AttachmentRepository
|
||||||
|
private set
|
||||||
|
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
environmentRepository = EnvironmentRepository(context)
|
environmentRepository = EnvironmentRepository(context)
|
||||||
environmentRepository.current()
|
environmentRepository.current()
|
||||||
localImCache = LocalImCache(context)
|
localImCache = LocalImCache(context)
|
||||||
localContactCache = LocalContactCache(context)
|
localContactCache = LocalContactCache(context)
|
||||||
|
attachmentRepository = AttachmentRepository(context.applicationContext)
|
||||||
authRepository = AuthRepository(context)
|
authRepository = AuthRepository(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,24 @@
|
|||||||
package com.xuqm.sdk.sample.ui.chat
|
package com.xuqm.sdk.sample.ui.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@ -27,6 +36,7 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@ -36,17 +46,31 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
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.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
|
import com.xuqm.sdk.sample.data.model.mediaContent
|
||||||
|
import com.xuqm.sdk.sample.data.model.quoteContent
|
||||||
|
import com.xuqm.sdk.sample.data.model.previewText
|
||||||
|
import com.xuqm.sdk.sample.data.repo.VoiceRecorder
|
||||||
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
||||||
import com.xuqm.sdk.ui.InitialAvatar
|
import com.xuqm.sdk.ui.InitialAvatar
|
||||||
import com.xuqm.sdk.ui.SearchBarField
|
import com.xuqm.sdk.ui.SearchBarField
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -62,12 +86,93 @@ fun ChatScreen(
|
|||||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||||
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
|
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
|
||||||
val draftText by viewModel.draftText.collectAsStateWithLifecycle()
|
val draftText by viewModel.draftText.collectAsStateWithLifecycle()
|
||||||
|
val mentionableUserIds by viewModel.mentionableUserIds.collectAsStateWithLifecycle()
|
||||||
|
val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle()
|
||||||
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
|
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
|
||||||
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
||||||
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
||||||
|
val isSendingAttachment by viewModel.isSendingAttachment.collectAsStateWithLifecycle()
|
||||||
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val voiceRecorder = remember { VoiceRecorder(context.applicationContext) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var showSearchBar by remember { mutableStateOf(false) }
|
var showSearchBar by remember { mutableStateOf(false) }
|
||||||
|
val replyTarget = replyTargetMessage
|
||||||
|
var pendingCameraAction by remember { mutableStateOf<CameraAction?>(null) }
|
||||||
|
var pendingCaptureUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
var isRecordingVoice by remember { mutableStateOf(false) }
|
||||||
|
var voiceHint by remember { mutableStateOf("按住说话") }
|
||||||
|
var requestCameraAction: (CameraAction) -> Unit = {}
|
||||||
|
|
||||||
|
val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
if (uri != null) viewModel.sendImage(uri)
|
||||||
|
}
|
||||||
|
val pickVideo = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
if (uri != null) viewModel.sendVideo(uri)
|
||||||
|
}
|
||||||
|
val pickFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri != null) viewModel.sendFile(uri)
|
||||||
|
}
|
||||||
|
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||||
|
val uri = pendingCaptureUri
|
||||||
|
pendingCaptureUri = null
|
||||||
|
if (success && uri != null) {
|
||||||
|
viewModel.sendImage(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val captureVideo = rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo()) { success ->
|
||||||
|
val uri = pendingCaptureUri
|
||||||
|
pendingCaptureUri = null
|
||||||
|
if (success && uri != null) {
|
||||||
|
viewModel.sendVideo(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val startCameraAction: (CameraAction) -> Unit = { action ->
|
||||||
|
when (action) {
|
||||||
|
CameraAction.PHOTO -> {
|
||||||
|
val uri = createCaptureUri(
|
||||||
|
context = context,
|
||||||
|
directoryType = "Pictures",
|
||||||
|
prefix = "photo_${System.currentTimeMillis()}",
|
||||||
|
suffix = ".jpg",
|
||||||
|
)
|
||||||
|
pendingCaptureUri = uri
|
||||||
|
takePicture.launch(uri)
|
||||||
|
}
|
||||||
|
CameraAction.VIDEO -> {
|
||||||
|
val uri = createCaptureUri(
|
||||||
|
context = context,
|
||||||
|
directoryType = "Movies",
|
||||||
|
prefix = "video_${System.currentTimeMillis()}",
|
||||||
|
suffix = ".mp4",
|
||||||
|
)
|
||||||
|
pendingCaptureUri = uri
|
||||||
|
captureVideo.launch(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (!granted) {
|
||||||
|
pendingCameraAction = null
|
||||||
|
return@rememberLauncherForActivityResult
|
||||||
|
}
|
||||||
|
pendingCameraAction?.let(startCameraAction)
|
||||||
|
pendingCameraAction = null
|
||||||
|
}
|
||||||
|
requestCameraAction = { action ->
|
||||||
|
if (hasCameraPermission(context)) {
|
||||||
|
startCameraAction(action)
|
||||||
|
} else {
|
||||||
|
pendingCameraAction = action
|
||||||
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val recordAudioPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (!granted) {
|
||||||
|
voiceHint = "需要麦克风权限"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
|
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
|
||||||
LaunchedEffect(scrollSignal) {
|
LaunchedEffect(scrollSignal) {
|
||||||
@ -122,29 +227,133 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
if (chatType == "GROUP" && mentionableUserIds.isNotEmpty()) {
|
||||||
value = draftText,
|
Row(
|
||||||
onValueChange = viewModel::updateDraft,
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
modifier = Modifier.weight(1f),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
placeholder = { Text("输入消息…") },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
maxLines = 4,
|
) {
|
||||||
shape = RoundedCornerShape(24.dp),
|
Text("提及", style = MaterialTheme.typography.labelSmall)
|
||||||
)
|
mentionableUserIds.take(6).forEach { userId ->
|
||||||
IconButton(
|
TextButton(onClick = { viewModel.appendMention(userId) }) {
|
||||||
onClick = { viewModel.sendText(draftText) },
|
Text("@$userId")
|
||||||
enabled = draftText.isNotBlank(),
|
}
|
||||||
) {
|
}
|
||||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
|
}
|
||||||
}
|
}
|
||||||
}
|
if (replyTarget != null) {
|
||||||
},
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "回复: ${replyTarget.previewText()}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = viewModel::clearReply) {
|
||||||
|
Text("取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draftText,
|
||||||
|
onValueChange = viewModel::updateDraft,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = { Text("输入消息…") },
|
||||||
|
maxLines = 4,
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.sendText(draftText) },
|
||||||
|
enabled = draftText.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
TextButton(onClick = { pickImage.launch("image/*") }) { Text("相册图片") }
|
||||||
|
TextButton(onClick = { requestCameraAction(CameraAction.PHOTO) }) { Text("拍照") }
|
||||||
|
TextButton(onClick = { pickVideo.launch("video/*") }) { Text("相册视频") }
|
||||||
|
TextButton(onClick = { requestCameraAction(CameraAction.VIDEO) }) { Text("摄像") }
|
||||||
|
TextButton(onClick = { pickFile.launch(arrayOf("*/*")) }) { Text("文件管理器") }
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = if (isRecordingVoice) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
if (!hasAudioPermission(context)) {
|
||||||
|
voiceHint = "请先授权麦克风权限"
|
||||||
|
recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
return@detectTapGestures
|
||||||
|
}
|
||||||
|
if (!voiceRecorder.start()) {
|
||||||
|
voiceHint = "录音启动失败"
|
||||||
|
return@detectTapGestures
|
||||||
|
}
|
||||||
|
isRecordingVoice = true
|
||||||
|
voiceHint = "松开发送"
|
||||||
|
try {
|
||||||
|
val released = tryAwaitRelease()
|
||||||
|
if (released) {
|
||||||
|
val recorded = voiceRecorder.stop()
|
||||||
|
if (recorded != null) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
viewModel.sendAudioRecording(recorded)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
voiceHint = "录音失败"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
voiceRecorder.cancel()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isRecordingVoice = false
|
||||||
|
if (!voiceHint.contains("权限") && voiceHint != "录音失败") {
|
||||||
|
voiceHint = "按住说话"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = voiceHint,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (isRecordingVoice) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isSendingAttachment) {
|
||||||
|
Text(
|
||||||
|
text = "附件上传中…",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -170,6 +379,8 @@ fun ChatScreen(
|
|||||||
MessageBubble(
|
MessageBubble(
|
||||||
message = msg,
|
message = msg,
|
||||||
isOwn = msg.fromId == viewModel.currentUserId,
|
isOwn = msg.fromId == viewModel.currentUserId,
|
||||||
|
currentUserId = viewModel.currentUserId,
|
||||||
|
onReply = viewModel::startReply,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (searchResults.isEmpty()) {
|
if (searchResults.isEmpty()) {
|
||||||
@ -197,6 +408,8 @@ fun ChatScreen(
|
|||||||
MessageBubble(
|
MessageBubble(
|
||||||
message = msg,
|
message = msg,
|
||||||
isOwn = msg.fromId == viewModel.currentUserId,
|
isOwn = msg.fromId == viewModel.currentUserId,
|
||||||
|
currentUserId = viewModel.currentUserId,
|
||||||
|
onReply = viewModel::startReply,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isLoadingMore) {
|
if (isLoadingMore) {
|
||||||
@ -217,8 +430,45 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum class CameraAction { PHOTO, VIDEO }
|
||||||
|
|
||||||
|
private fun hasAudioPermission(context: android.content.Context): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasCameraPermission(context: android.content.Context): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.CAMERA,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCaptureUri(
|
||||||
|
context: android.content.Context,
|
||||||
|
directoryType: String,
|
||||||
|
prefix: String,
|
||||||
|
suffix: String,
|
||||||
|
): Uri {
|
||||||
|
val dir = context.getExternalFilesDir(directoryType) ?: context.filesDir
|
||||||
|
val file = File(dir, "$prefix$suffix")
|
||||||
|
return FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
|
private fun MessageBubble(
|
||||||
|
message: ImMessage,
|
||||||
|
isOwn: Boolean,
|
||||||
|
currentUserId: String,
|
||||||
|
onReply: (ImMessage) -> Unit,
|
||||||
|
) {
|
||||||
|
val media = message.mediaContent()
|
||||||
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
|
||||||
@ -244,19 +494,49 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
|
|||||||
color = bubbleColor,
|
color = bubbleColor,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 280.dp)
|
.widthIn(max = 280.dp)
|
||||||
.padding(horizontal = 4.dp),
|
.padding(horizontal = 4.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = { onReply(message) },
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
Text(
|
when (message.msgType.uppercase()) {
|
||||||
text = parseContent(message),
|
"IMAGE" -> ImageBubble(media)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
"VIDEO" -> VideoBubble(media)
|
||||||
)
|
"AUDIO" -> AudioBubble(media)
|
||||||
|
"FILE" -> FileBubble(media)
|
||||||
|
"QUOTE" -> QuoteBubble(message.quoteContent())
|
||||||
|
else -> Text(
|
||||||
|
text = parseContent(message),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val mentionedUserIds = message.mentionedUserIds.orEmpty()
|
||||||
|
if (mentionedUserIds.isNotBlank() &&
|
||||||
|
mentionedUserIds.split(",").map { it.trim() }.contains(currentUserId)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "@我",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (isOwn) {
|
if (isOwn) {
|
||||||
Text(
|
Text(
|
||||||
text = statusLabel(message.status),
|
text = statusLabel(message.status),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
color = MaterialTheme.colorScheme.outline,
|
||||||
)
|
)
|
||||||
|
if (message.chatType.uppercase() == "GROUP") {
|
||||||
|
message.groupReadCount?.takeIf { it > 0 }?.let {
|
||||||
|
Text(
|
||||||
|
text = "${it}人已读",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -267,6 +547,122 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImageBubble(media: com.xuqm.sdk.sample.data.model.MediaMessageContent?) {
|
||||||
|
if (media == null) {
|
||||||
|
Text(text = "[图片]", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val ratio = media.width?.takeIf { it > 0 }?.toFloat()
|
||||||
|
?.div(media.height?.takeIf { it > 0 } ?: 1)
|
||||||
|
?: 1f
|
||||||
|
AsyncImage(
|
||||||
|
model = media.thumbnailUrl ?: media.url,
|
||||||
|
contentDescription = media.name,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 140.dp, max = 260.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(ratio)
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
)
|
||||||
|
if (media.name.isNullOrBlank().not()) {
|
||||||
|
Text(
|
||||||
|
text = media.name,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoBubble(media: com.xuqm.sdk.sample.data.model.MediaMessageContent?) {
|
||||||
|
if (media == null) {
|
||||||
|
Text(text = "[视频]", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val ratio = media.width?.takeIf { it > 0 }?.toFloat()
|
||||||
|
?.div(media.height?.takeIf { it > 0 } ?: 1)
|
||||||
|
?: 1f
|
||||||
|
AsyncImage(
|
||||||
|
model = media.thumbnailUrl ?: media.url,
|
||||||
|
contentDescription = media.name,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 160.dp, max = 260.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(ratio)
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = media.name?.takeIf { it.isNotBlank() } ?: "[视频]",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
media.durationMs?.takeIf { it > 0 }?.let {
|
||||||
|
Text(
|
||||||
|
text = formatDuration(it),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AudioBubble(media: com.xuqm.sdk.sample.data.model.MediaMessageContent?) {
|
||||||
|
if (media == null) {
|
||||||
|
Text(text = "[语音]", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = media.name?.takeIf { it.isNotBlank() } ?: "[语音]",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
media.durationMs?.takeIf { it > 0 }?.let {
|
||||||
|
Text(
|
||||||
|
text = formatDuration(it),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FileBubble(media: com.xuqm.sdk.sample.data.model.MediaMessageContent?) {
|
||||||
|
val title = media?.name?.takeIf { it.isNotBlank() } ?: "文件"
|
||||||
|
Text(
|
||||||
|
text = "[$title]",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
media?.size?.takeIf { it > 0 }?.let {
|
||||||
|
Text(
|
||||||
|
text = formatSize(it),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuoteBubble(content: com.xuqm.sdk.sample.data.model.QuoteMessageContent?) {
|
||||||
|
if (content == null) {
|
||||||
|
Text(text = "[引用]", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!content.quotedContent.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "引用: ${content.quotedContent}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = content.text?.takeIf { it.isNotBlank() } ?: "[引用]",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun statusLabel(status: String): String = when (status.uppercase()) {
|
private fun statusLabel(status: String): String = when (status.uppercase()) {
|
||||||
"SENDING" -> "发送中"
|
"SENDING" -> "发送中"
|
||||||
"SENT" -> "已发送"
|
"SENT" -> "已发送"
|
||||||
@ -287,10 +683,18 @@ private fun parseContent(message: ImMessage): String {
|
|||||||
"TEXT" -> runCatching {
|
"TEXT" -> runCatching {
|
||||||
org.json.JSONObject(message.content).getString("text")
|
org.json.JSONObject(message.content).getString("text")
|
||||||
}.getOrDefault(message.content)
|
}.getOrDefault(message.content)
|
||||||
"IMAGE" -> "[图片]"
|
"IMAGE" -> message.previewText()
|
||||||
"AUDIO" -> "[语音]"
|
"AUDIO" -> "[语音]"
|
||||||
"VIDEO" -> "[视频]"
|
"VIDEO" -> message.previewText()
|
||||||
"FILE" -> "[文件]"
|
"FILE" -> message.previewText()
|
||||||
|
"LOCATION" -> "[位置]"
|
||||||
|
"CUSTOM" -> "[自定义]"
|
||||||
|
"RICH_TEXT" -> "[富文本]"
|
||||||
|
"FORWARD" -> "[转发]"
|
||||||
|
"QUOTE" -> "[引用]"
|
||||||
|
"MERGE" -> "[合并转发]"
|
||||||
|
"CALL_AUDIO" -> "[语音通话]"
|
||||||
|
"CALL_VIDEO" -> "[视频通话]"
|
||||||
"REVOKED" -> "[消息已撤回]"
|
"REVOKED" -> "[消息已撤回]"
|
||||||
"NOTIFY" -> runCatching {
|
"NOTIFY" -> runCatching {
|
||||||
org.json.JSONObject(message.content).getString("content")
|
org.json.JSONObject(message.content).getString("content")
|
||||||
@ -298,3 +702,20 @@ private fun parseContent(message: ImMessage): String {
|
|||||||
else -> message.content
|
else -> message.content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatSize(bytes: Long): String {
|
||||||
|
val kb = bytes / 1024.0
|
||||||
|
val mb = kb / 1024.0
|
||||||
|
return when {
|
||||||
|
mb >= 1 -> String.format("%.1f MB", mb)
|
||||||
|
kb >= 1 -> String.format("%.1f KB", kb)
|
||||||
|
else -> "$bytes B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDuration(ms: Long): String {
|
||||||
|
val totalSeconds = (ms / 1000).coerceAtLeast(0)
|
||||||
|
val minutes = totalSeconds / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return if (minutes > 0) String.format("%d:%02d", minutes, seconds) else "${seconds}s"
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.xuqm.sdk.sample.ui.chat
|
package com.xuqm.sdk.sample.ui.chat
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
@ -7,6 +8,8 @@ import com.xuqm.sdk.im.listener.ImEventListener
|
|||||||
import com.xuqm.sdk.im.model.ConversationData
|
import com.xuqm.sdk.im.model.ConversationData
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
import com.xuqm.sdk.sample.di.AppDependencies
|
import com.xuqm.sdk.sample.di.AppDependencies
|
||||||
|
import com.xuqm.sdk.sample.data.repo.RecordedVoice
|
||||||
|
import com.xuqm.sdk.sample.data.model.previewText
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -35,6 +38,15 @@ class ChatViewModel : ViewModel() {
|
|||||||
private val _draftText = MutableStateFlow("")
|
private val _draftText = MutableStateFlow("")
|
||||||
val draftText: StateFlow<String> = _draftText
|
val draftText: StateFlow<String> = _draftText
|
||||||
|
|
||||||
|
private val _replyTargetMessage = MutableStateFlow<ImMessage?>(null)
|
||||||
|
val replyTargetMessage: StateFlow<ImMessage?> = _replyTargetMessage
|
||||||
|
|
||||||
|
private val _mentionableUserIds = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val mentionableUserIds: StateFlow<List<String>> = _mentionableUserIds
|
||||||
|
|
||||||
|
private val _isSendingAttachment = MutableStateFlow(false)
|
||||||
|
val isSendingAttachment: StateFlow<Boolean> = _isSendingAttachment
|
||||||
|
|
||||||
val currentUserId: String get() = ImSDK.currentUserId
|
val currentUserId: String get() = ImSDK.currentUserId
|
||||||
|
|
||||||
private lateinit var targetId: String
|
private lateinit var targetId: String
|
||||||
@ -51,7 +63,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
ConversationData(
|
ConversationData(
|
||||||
targetId = targetId,
|
targetId = targetId,
|
||||||
chatType = chatType,
|
chatType = chatType,
|
||||||
lastMsgContent = message.content,
|
lastMsgContent = message.previewText(),
|
||||||
lastMsgType = message.msgType,
|
lastMsgType = message.msgType,
|
||||||
lastMsgTime = message.createdAt,
|
lastMsgTime = message.createdAt,
|
||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
@ -63,6 +75,11 @@ class ChatViewModel : ViewModel() {
|
|||||||
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
||||||
}
|
}
|
||||||
requestScrollToBottom()
|
requestScrollToBottom()
|
||||||
|
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.markRead(targetId, chatType) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +91,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
ConversationData(
|
ConversationData(
|
||||||
targetId = targetId,
|
targetId = targetId,
|
||||||
chatType = chatType,
|
chatType = chatType,
|
||||||
lastMsgContent = message.content,
|
lastMsgContent = message.previewText(),
|
||||||
lastMsgType = message.msgType,
|
lastMsgType = message.msgType,
|
||||||
lastMsgTime = message.createdAt,
|
lastMsgTime = message.createdAt,
|
||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
@ -86,12 +103,20 @@ class ChatViewModel : ViewModel() {
|
|||||||
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
||||||
}
|
}
|
||||||
requestScrollToBottom()
|
requestScrollToBottom()
|
||||||
|
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.markRead(targetId, chatType) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(targetId: String, chatType: String) {
|
fun init(targetId: String, chatType: String) {
|
||||||
if (initialized && this.targetId == targetId && this.chatType == chatType) return
|
if (initialized && this.targetId == targetId && this.chatType == chatType) return
|
||||||
|
if (initialized && this.chatType == "GROUP") {
|
||||||
|
ImSDK.unsubscribeGroup(this.targetId)
|
||||||
|
}
|
||||||
this.targetId = targetId
|
this.targetId = targetId
|
||||||
this.chatType = chatType
|
this.chatType = chatType
|
||||||
nextHistoryPage = 0
|
nextHistoryPage = 0
|
||||||
@ -102,8 +127,16 @@ class ChatViewModel : ViewModel() {
|
|||||||
_searchQuery.value = ""
|
_searchQuery.value = ""
|
||||||
_searchResults.value = emptyList()
|
_searchResults.value = emptyList()
|
||||||
_draftText.value = cache.loadDraft(targetId, chatType)
|
_draftText.value = cache.loadDraft(targetId, chatType)
|
||||||
|
_mentionableUserIds.value = emptyList()
|
||||||
|
_replyTargetMessage.value = null
|
||||||
ImSDK.addListener(listener)
|
ImSDK.addListener(listener)
|
||||||
|
if (chatType == "GROUP") {
|
||||||
|
ImSDK.subscribeGroup(targetId)
|
||||||
|
}
|
||||||
loadInitialHistory()
|
loadInitialHistory()
|
||||||
|
if (chatType == "GROUP") {
|
||||||
|
loadMentionableUsers()
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.markRead(targetId, chatType) }
|
runCatching { ImSDK.markRead(targetId, chatType) }
|
||||||
}
|
}
|
||||||
@ -140,7 +173,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
ConversationData(
|
ConversationData(
|
||||||
targetId = targetId,
|
targetId = targetId,
|
||||||
chatType = chatType,
|
chatType = chatType,
|
||||||
lastMsgContent = last.content,
|
lastMsgContent = last.previewText(),
|
||||||
lastMsgType = last.msgType,
|
lastMsgType = last.msgType,
|
||||||
lastMsgTime = last.createdAt,
|
lastMsgTime = last.createdAt,
|
||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
@ -176,7 +209,19 @@ class ChatViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun sendText(content: String) {
|
fun sendText(content: String) {
|
||||||
if (content.isBlank()) return
|
if (content.isBlank()) return
|
||||||
ImSDK.sendMessage(targetId, chatType, "TEXT", content)
|
val replyTarget = _replyTargetMessage.value
|
||||||
|
if (replyTarget != null) {
|
||||||
|
ImSDK.sendQuoteMessage(
|
||||||
|
toId = targetId,
|
||||||
|
chatType = chatType,
|
||||||
|
quotedMsgId = replyTarget.id,
|
||||||
|
quotedContent = replyTarget.previewText(),
|
||||||
|
text = content,
|
||||||
|
)
|
||||||
|
_replyTargetMessage.value = null
|
||||||
|
} else {
|
||||||
|
ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content))
|
||||||
|
}
|
||||||
_draftText.value = ""
|
_draftText.value = ""
|
||||||
cache.clearDraft(targetId, chatType)
|
cache.clearDraft(targetId, chatType)
|
||||||
cache.upsertConversation(
|
cache.upsertConversation(
|
||||||
@ -212,14 +257,73 @@ class ChatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun startReply(message: ImMessage) {
|
||||||
|
_replyTargetMessage.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearReply() {
|
||||||
|
_replyTargetMessage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendImage(uri: Uri) = sendAttachment(uri, AttachmentKind.IMAGE)
|
||||||
|
|
||||||
|
fun sendImageBytes(fileName: String, bytes: ByteArray, width: Int? = null, height: Int? = null) {
|
||||||
|
sendAttachmentBytes(fileName, bytes, AttachmentKind.IMAGE, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendVideo(uri: Uri) = sendAttachment(uri, AttachmentKind.VIDEO)
|
||||||
|
|
||||||
|
fun sendAudio(uri: Uri) = sendAttachment(uri, AttachmentKind.AUDIO)
|
||||||
|
|
||||||
|
fun sendAudioRecording(recording: RecordedVoice) {
|
||||||
|
if (!initialized) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isSendingAttachment.value = true
|
||||||
|
try {
|
||||||
|
runCatching {
|
||||||
|
AppDependencies.attachmentRepository.sendAudioBytes(
|
||||||
|
targetId = targetId,
|
||||||
|
chatType = chatType,
|
||||||
|
fileName = recording.fileName,
|
||||||
|
bytes = recording.bytes,
|
||||||
|
durationMs = recording.durationMs,
|
||||||
|
).getOrThrow()
|
||||||
|
}.onSuccess {
|
||||||
|
cache.upsertConversation(
|
||||||
|
ConversationData(
|
||||||
|
targetId = targetId,
|
||||||
|
chatType = chatType,
|
||||||
|
lastMsgContent = "[语音]",
|
||||||
|
lastMsgType = "AUDIO",
|
||||||
|
lastMsgTime = System.currentTimeMillis(),
|
||||||
|
unreadCount = 0,
|
||||||
|
isMuted = false,
|
||||||
|
isPinned = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isSendingAttachment.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFile(uri: Uri) = sendAttachment(uri, AttachmentKind.FILE)
|
||||||
|
|
||||||
fun clearSearch() {
|
fun clearSearch() {
|
||||||
_searchQuery.value = ""
|
_searchQuery.value = ""
|
||||||
_searchResults.value = emptyList()
|
_searchResults.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun prependMessage(message: ImMessage) {
|
private fun prependMessage(message: ImMessage) {
|
||||||
if (_messages.value.any { it.id == message.id }) return
|
val updated = _messages.value.toMutableList()
|
||||||
_messages.value = listOf(message) + _messages.value
|
val index = updated.indexOfFirst { it.id == message.id }
|
||||||
|
if (index >= 0) {
|
||||||
|
updated[index] = message
|
||||||
|
} else {
|
||||||
|
updated.add(0, message)
|
||||||
|
}
|
||||||
|
_messages.value = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> =
|
private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> =
|
||||||
@ -229,6 +333,85 @@ class ChatViewModel : ViewModel() {
|
|||||||
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
|
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun appendMention(userId: String) {
|
||||||
|
val current = _draftText.value
|
||||||
|
val next = if (current.isBlank()) "@$userId " else "$current @$userId "
|
||||||
|
updateDraft(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMentionableUsers() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.getGroupInfo(targetId) }
|
||||||
|
.map { group ->
|
||||||
|
group?.memberIds.orEmpty()
|
||||||
|
.split(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
.onSuccess { _mentionableUserIds.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractMentionedUserIds(content: String): String? {
|
||||||
|
if (chatType != "GROUP") return null
|
||||||
|
val ids = _mentionableUserIds.value.filter { mentionId ->
|
||||||
|
content.contains("@$mentionId")
|
||||||
|
}
|
||||||
|
return ids.joinToString(",").takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendAttachment(uri: Uri, kind: AttachmentKind) {
|
||||||
|
if (!initialized) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isSendingAttachment.value = true
|
||||||
|
try {
|
||||||
|
runCatching {
|
||||||
|
when (kind) {
|
||||||
|
AttachmentKind.IMAGE -> AppDependencies.attachmentRepository.sendImage(targetId, chatType, uri)
|
||||||
|
AttachmentKind.VIDEO -> AppDependencies.attachmentRepository.sendVideo(targetId, chatType, uri)
|
||||||
|
AttachmentKind.AUDIO -> AppDependencies.attachmentRepository.sendAudio(targetId, chatType, uri)
|
||||||
|
AttachmentKind.FILE -> AppDependencies.attachmentRepository.sendFile(targetId, chatType, uri)
|
||||||
|
}.getOrThrow()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isSendingAttachment.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendAttachmentBytes(
|
||||||
|
fileName: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
kind: AttachmentKind,
|
||||||
|
width: Int? = null,
|
||||||
|
height: Int? = null,
|
||||||
|
) {
|
||||||
|
if (!initialized) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isSendingAttachment.value = true
|
||||||
|
try {
|
||||||
|
runCatching {
|
||||||
|
when (kind) {
|
||||||
|
AttachmentKind.IMAGE -> AppDependencies.attachmentRepository.sendImageBytes(
|
||||||
|
targetId = targetId,
|
||||||
|
chatType = chatType,
|
||||||
|
fileName = fileName,
|
||||||
|
bytes = bytes,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
)
|
||||||
|
else -> error("Unsupported bytes attachment kind: $kind")
|
||||||
|
}.getOrThrow()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isSendingAttachment.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class AttachmentKind { IMAGE, VIDEO, AUDIO, FILE }
|
||||||
|
|
||||||
private fun isRelevant(message: ImMessage): Boolean {
|
private fun isRelevant(message: ImMessage): Boolean {
|
||||||
return if (chatType == "GROUP") {
|
return if (chatType == "GROUP") {
|
||||||
message.chatType == "GROUP" && message.toId == targetId
|
message.chatType == "GROUP" && message.toId == targetId
|
||||||
@ -238,6 +421,9 @@ class ChatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
if (initialized && chatType == "GROUP") {
|
||||||
|
ImSDK.unsubscribeGroup(targetId)
|
||||||
|
}
|
||||||
ImSDK.removeListener(listener)
|
ImSDK.removeListener(listener)
|
||||||
initialized = false
|
initialized = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
|
import com.xuqm.sdk.im.model.BlacklistEntry
|
||||||
|
import com.xuqm.sdk.im.model.FriendRequest
|
||||||
import com.xuqm.sdk.sample.data.api.UserData
|
import com.xuqm.sdk.sample.data.api.UserData
|
||||||
import com.xuqm.sdk.sample.data.local.LocalContactCache
|
import com.xuqm.sdk.sample.data.local.LocalContactCache
|
||||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||||
@ -47,9 +49,17 @@ class ContactViewModel(
|
|||||||
private val _searchResults = MutableStateFlow<List<UserData>>(emptyList())
|
private val _searchResults = MutableStateFlow<List<UserData>>(emptyList())
|
||||||
val searchResults: StateFlow<List<UserData>> = _searchResults
|
val searchResults: StateFlow<List<UserData>> = _searchResults
|
||||||
|
|
||||||
|
private val _friendRequests = MutableStateFlow<List<FriendRequest>>(emptyList())
|
||||||
|
val friendRequests: StateFlow<List<FriendRequest>> = _friendRequests
|
||||||
|
|
||||||
|
private val _blacklist = MutableStateFlow<List<BlacklistEntry>>(emptyList())
|
||||||
|
val blacklist: StateFlow<List<BlacklistEntry>> = _blacklist
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_friends.value = cache.resolveFriends(cache.loadFriendIds())
|
_friends.value = cache.resolveFriends(cache.loadFriendIds())
|
||||||
loadFriends()
|
loadFriends()
|
||||||
|
loadFriendRequests()
|
||||||
|
loadBlacklist()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadFriends() {
|
fun loadFriends() {
|
||||||
@ -84,7 +94,7 @@ class ContactViewModel(
|
|||||||
|
|
||||||
fun addFriend(userId: String) {
|
fun addFriend(userId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.addFriend(userId) }
|
runCatching { ImSDK.sendFriendRequest(userId) }
|
||||||
.onSuccess { loadFriends() }
|
.onSuccess { loadFriends() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -95,6 +105,48 @@ class ContactViewModel(
|
|||||||
.onSuccess { loadFriends() }
|
.onSuccess { loadFriends() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadFriendRequests() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.listFriendRequests() }
|
||||||
|
.onSuccess { _friendRequests.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptFriendRequest(requestId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.acceptFriendRequest(requestId) }
|
||||||
|
.onSuccess { loadFriends(); loadFriendRequests() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rejectFriendRequest(requestId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.rejectFriendRequest(requestId) }
|
||||||
|
.onSuccess { loadFriendRequests() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBlacklist() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.listBlacklist() }
|
||||||
|
.onSuccess { _blacklist.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addToBlacklist(userId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.addToBlacklist(userId) }
|
||||||
|
.onSuccess { loadBlacklist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeFromBlacklist(userId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.removeFromBlacklist(userId) }
|
||||||
|
.onSuccess { loadBlacklist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -108,6 +160,8 @@ fun ContactScreen(
|
|||||||
) {
|
) {
|
||||||
val friends by viewModel.friends.collectAsStateWithLifecycle()
|
val friends by viewModel.friends.collectAsStateWithLifecycle()
|
||||||
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
|
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
|
||||||
|
val friendRequests by viewModel.friendRequests.collectAsStateWithLifecycle()
|
||||||
|
val blacklist by viewModel.blacklist.collectAsStateWithLifecycle()
|
||||||
var keyword by remember { mutableStateOf("") }
|
var keyword by remember { mutableStateOf("") }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
@ -120,6 +174,32 @@ fun ContactScreen(
|
|||||||
placeholder = "搜索用户 ID 或昵称",
|
placeholder = "搜索用户 ID 或昵称",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (friendRequests.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"好友申请(${friendRequests.size})",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
LazyColumn {
|
||||||
|
items(friendRequests, key = { it.id }) { request ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(request.fromUserId, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(request.remark.orEmpty(), style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { viewModel.acceptFriendRequest(request.id) }) { Text("接受") }
|
||||||
|
TextButton(onClick = { viewModel.rejectFriendRequest(request.id) }) { Text("拒绝") }
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (keyword.isBlank()) {
|
if (keyword.isBlank()) {
|
||||||
Text(
|
Text(
|
||||||
"联系人(${friends.size})",
|
"联系人(${friends.size})",
|
||||||
@ -152,14 +232,37 @@ fun ContactScreen(
|
|||||||
color = MaterialTheme.colorScheme.outline)
|
color = MaterialTheme.colorScheme.outline)
|
||||||
}
|
}
|
||||||
if (!isFriend) {
|
if (!isFriend) {
|
||||||
TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("添加好友") }
|
TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("申请好友") }
|
||||||
} else {
|
} else {
|
||||||
TextButton(onClick = { onOpenChat(user.userId) }) { Text("发消息") }
|
TextButton(onClick = { onOpenChat(user.userId) }) { Text("发消息") }
|
||||||
}
|
}
|
||||||
|
TextButton(onClick = { viewModel.addToBlacklist(user.userId) }) { Text("拉黑") }
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (blacklist.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"黑名单(${blacklist.size})",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
LazyColumn {
|
||||||
|
items(blacklist, key = { it.id }) { entry ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(entry.blockedUserId, modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = { viewModel.removeFromBlacklist(entry.blockedUserId) }) {
|
||||||
|
Text("移除")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,8 +52,9 @@ fun ConversationScreen(
|
|||||||
conversations.filter { conversation ->
|
conversations.filter { conversation ->
|
||||||
if (query.isBlank()) return@filter true
|
if (query.isBlank()) return@filter true
|
||||||
val keyword = query.trim()
|
val keyword = query.trim()
|
||||||
|
val preview = conversationPreview(conversation)
|
||||||
conversation.targetId.contains(keyword, ignoreCase = true) ||
|
conversation.targetId.contains(keyword, ignoreCase = true) ||
|
||||||
(conversation.lastMsgContent ?: "").contains(keyword, ignoreCase = true) ||
|
preview.contains(keyword, ignoreCase = true) ||
|
||||||
conversation.chatType.contains(keyword, ignoreCase = true)
|
conversation.chatType.contains(keyword, ignoreCase = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,7 +149,7 @@ private fun ConversationItem(
|
|||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
conversation.lastMsgContent ?: "",
|
conversationPreview(conversation),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
color = MaterialTheme.colorScheme.outline,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@ -178,3 +179,24 @@ private fun ConversationItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun conversationPreview(conversation: ConversationData): String {
|
||||||
|
return when (conversation.lastMsgType?.uppercase()) {
|
||||||
|
"TEXT" -> conversation.lastMsgContent.orEmpty()
|
||||||
|
"IMAGE" -> "[图片]"
|
||||||
|
"VIDEO" -> "[视频]"
|
||||||
|
"AUDIO" -> "[语音]"
|
||||||
|
"FILE" -> "[文件]"
|
||||||
|
"LOCATION" -> "[位置]"
|
||||||
|
"CUSTOM" -> "[自定义]"
|
||||||
|
"RICH_TEXT" -> "[富文本]"
|
||||||
|
"FORWARD" -> "[转发]"
|
||||||
|
"QUOTE" -> "[引用]"
|
||||||
|
"MERGE" -> "[合并转发]"
|
||||||
|
"CALL_AUDIO" -> "[语音通话]"
|
||||||
|
"CALL_VIDEO" -> "[视频通话]"
|
||||||
|
"REVOKED" -> "[消息已撤回]"
|
||||||
|
"NOTIFY" -> conversation.lastMsgContent.orEmpty().takeIf { it.isNotBlank() } ?: "[通知]"
|
||||||
|
else -> conversation.lastMsgContent.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -40,7 +40,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
|
import com.xuqm.sdk.ui.SearchBarField
|
||||||
import com.xuqm.sdk.im.model.ImGroup
|
import com.xuqm.sdk.im.model.ImGroup
|
||||||
|
import com.xuqm.sdk.im.model.GroupJoinRequest
|
||||||
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -51,6 +53,8 @@ fun GroupListScreen(
|
|||||||
viewModel: GroupViewModel = viewModel(),
|
viewModel: GroupViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val groups by viewModel.groups.collectAsStateWithLifecycle()
|
val groups by viewModel.groups.collectAsStateWithLifecycle()
|
||||||
|
val publicGroups by viewModel.publicGroups.collectAsStateWithLifecycle()
|
||||||
|
val publicGroupQuery by viewModel.publicGroupQuery.collectAsStateWithLifecycle()
|
||||||
var showCreateDialog by remember { mutableStateOf(false) }
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -63,6 +67,21 @@ fun GroupListScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
|
SearchBarField(
|
||||||
|
value = publicGroupQuery,
|
||||||
|
onValueChange = viewModel::searchPublicGroups,
|
||||||
|
placeholder = "搜索公开群",
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "我的群聊",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
items(groups, key = { it.id }) { group ->
|
items(groups, key = { it.id }) { group ->
|
||||||
GroupItem(
|
GroupItem(
|
||||||
group = group,
|
group = group,
|
||||||
@ -71,15 +90,32 @@ fun GroupListScreen(
|
|||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
if (publicGroups.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "公开群",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(publicGroups, key = { "public-${it.id}" }) { group ->
|
||||||
|
PublicGroupItem(
|
||||||
|
group = group,
|
||||||
|
onOpen = { onOpenGroupChat(group.id, group.name) },
|
||||||
|
onJoin = { viewModel.requestJoinGroup(group.id) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCreateDialog) {
|
if (showCreateDialog) {
|
||||||
CreateGroupDialog(
|
CreateGroupDialog(
|
||||||
onDismiss = { showCreateDialog = false },
|
onDismiss = { showCreateDialog = false },
|
||||||
onCreate = { name, memberIds ->
|
onCreate = { name, memberIds, groupType ->
|
||||||
showCreateDialog = false
|
showCreateDialog = false
|
||||||
viewModel.createGroup(name, memberIds) { group ->
|
viewModel.createGroup(name, memberIds, groupType) { group ->
|
||||||
onOpenGroupChat(group.id, group.name)
|
onOpenGroupChat(group.id, group.name)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -103,15 +139,42 @@ private fun GroupItem(group: ImGroup, onClick: () -> Unit, onSettings: () -> Uni
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
color = MaterialTheme.colorScheme.outline,
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = group.groupType?.let { "类型:$it" } ?: "类型:WORK",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TextButton(onClick = onSettings) { Text("设置") }
|
TextButton(onClick = onSettings) { Text("设置") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<String>) -> Unit) {
|
private fun PublicGroupItem(group: ImGroup, onOpen: () -> Unit, onJoin: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(group.name, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
"群 ID: ${group.id}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onOpen) { Text("查看") }
|
||||||
|
TextButton(onClick = onJoin) { Text("申请加入") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<String>, String) -> Unit) {
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var memberInput by remember { mutableStateOf("") }
|
var memberInput by remember { mutableStateOf("") }
|
||||||
|
var isPublicGroup by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@ -130,6 +193,9 @@ private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<Str
|
|||||||
label = { Text("成员 ID(逗号分隔)") },
|
label = { Text("成员 ID(逗号分隔)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
TextButton(onClick = { isPublicGroup = !isPublicGroup }) {
|
||||||
|
Text(if (isPublicGroup) "群类型:公开群" else "群类型:普通群")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
@ -137,7 +203,7 @@ private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<Str
|
|||||||
onClick = {
|
onClick = {
|
||||||
val members = memberInput.split(",").map { it.trim() }.filter { it.isNotBlank() }
|
val members = memberInput.split(",").map { it.trim() }.filter { it.isNotBlank() }
|
||||||
val allMembers = (members + listOf(ImSDK.currentUserId)).distinct()
|
val allMembers = (members + listOf(ImSDK.currentUserId)).distinct()
|
||||||
onCreate(name.trim(), allMembers)
|
onCreate(name.trim(), allMembers, if (isPublicGroup) "PUBLIC" else "WORK")
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank(),
|
enabled = name.isNotBlank(),
|
||||||
) { Text("创建") }
|
) { Text("创建") }
|
||||||
@ -154,6 +220,7 @@ fun GroupSettingsScreen(
|
|||||||
viewModel: GroupViewModel = viewModel(),
|
viewModel: GroupViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val group by viewModel.currentGroup.collectAsStateWithLifecycle()
|
val group by viewModel.currentGroup.collectAsStateWithLifecycle()
|
||||||
|
val joinRequests by viewModel.joinRequests.collectAsStateWithLifecycle()
|
||||||
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
||||||
var showEditDialog by remember { mutableStateOf(false) }
|
var showEditDialog by remember { mutableStateOf(false) }
|
||||||
var editName by remember { mutableStateOf("") }
|
var editName by remember { mutableStateOf("") }
|
||||||
@ -163,6 +230,11 @@ fun GroupSettingsScreen(
|
|||||||
LaunchedEffect(group) {
|
LaunchedEffect(group) {
|
||||||
editName = group?.name.orEmpty()
|
editName = group?.name.orEmpty()
|
||||||
editAnnouncement = group?.announcement.orEmpty()
|
editAnnouncement = group?.announcement.orEmpty()
|
||||||
|
if (group?.groupType?.equals("PUBLIC", ignoreCase = true) == true) {
|
||||||
|
viewModel.loadJoinRequests(groupId)
|
||||||
|
} else {
|
||||||
|
viewModel.clearJoinRequests()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -191,6 +263,8 @@ fun GroupSettingsScreen(
|
|||||||
Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall,
|
Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.outline)
|
color = MaterialTheme.colorScheme.outline)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("群类型: ${g.groupType ?: "WORK"}", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium)
|
Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium)
|
||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@ -199,6 +273,24 @@ fun GroupSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
if (g.groupType?.equals("PUBLIC", ignoreCase = true) == true && isOwner) {
|
||||||
|
Text("加群申请", style = MaterialTheme.typography.titleSmall)
|
||||||
|
joinRequests.filter { it.status.equals("PENDING", ignoreCase = true) }.forEach { request ->
|
||||||
|
JoinRequestRow(
|
||||||
|
request = request,
|
||||||
|
onAccept = { viewModel.acceptJoinRequest(g.id, request.id) },
|
||||||
|
onReject = { viewModel.rejectJoinRequest(g.id, request.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (joinRequests.none { it.status.equals("PENDING", ignoreCase = true) }) {
|
||||||
|
Text(
|
||||||
|
"暂无待处理申请",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
Text("成员", style = MaterialTheme.typography.titleSmall)
|
Text("成员", style = MaterialTheme.typography.titleSmall)
|
||||||
val memberIds = g.memberIds.split(",").filter { it.isNotBlank() }
|
val memberIds = g.memberIds.split(",").filter { it.isNotBlank() }
|
||||||
memberIds.forEach { memberId ->
|
memberIds.forEach { memberId ->
|
||||||
@ -208,6 +300,12 @@ fun GroupSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
Text(memberId, modifier = Modifier.weight(1f))
|
Text(memberId, modifier = Modifier.weight(1f))
|
||||||
if (g.creatorId == ImSDK.currentUserId && memberId != ImSDK.currentUserId) {
|
if (g.creatorId == ImSDK.currentUserId && memberId != ImSDK.currentUserId) {
|
||||||
|
TextButton(onClick = { viewModel.setRole(g.id, memberId, "ADMIN") }) {
|
||||||
|
Text("设管")
|
||||||
|
}
|
||||||
|
TextButton(onClick = { viewModel.muteMember(g.id, memberId, 60) }) {
|
||||||
|
Text("禁言1h")
|
||||||
|
}
|
||||||
TextButton(onClick = { viewModel.removeMember(g.id, memberId) }) {
|
TextButton(onClick = { viewModel.removeMember(g.id, memberId) }) {
|
||||||
Text("移除", color = MaterialTheme.colorScheme.error)
|
Text("移除", color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
@ -215,6 +313,14 @@ fun GroupSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
if (isOwner) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.dismissGroup(g.id) { onNavigateBack() } },
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("解散群聊") }
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { viewModel.leaveGroup(g.id) { onNavigateBack() } },
|
onClick = { viewModel.leaveGroup(g.id) { onNavigateBack() } },
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||||
@ -265,3 +371,29 @@ fun GroupSettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun JoinRequestRow(
|
||||||
|
request: GroupJoinRequest,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onReject: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(request.requesterId, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (!request.remark.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = request.remark!!,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
TextButton(onClick = onAccept) { Text("通过") }
|
||||||
|
TextButton(onClick = onReject) { Text("拒绝") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.xuqm.sdk.sample.ui.group
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
|
import com.xuqm.sdk.im.model.GroupJoinRequest
|
||||||
import com.xuqm.sdk.im.model.ImGroup
|
import com.xuqm.sdk.im.model.ImGroup
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@ -13,10 +14,22 @@ class GroupViewModel : ViewModel() {
|
|||||||
private val _groups = MutableStateFlow<List<ImGroup>>(emptyList())
|
private val _groups = MutableStateFlow<List<ImGroup>>(emptyList())
|
||||||
val groups: StateFlow<List<ImGroup>> = _groups
|
val groups: StateFlow<List<ImGroup>> = _groups
|
||||||
|
|
||||||
|
private val _publicGroups = MutableStateFlow<List<ImGroup>>(emptyList())
|
||||||
|
val publicGroups: StateFlow<List<ImGroup>> = _publicGroups
|
||||||
|
|
||||||
|
private val _publicGroupQuery = MutableStateFlow("")
|
||||||
|
val publicGroupQuery: StateFlow<String> = _publicGroupQuery
|
||||||
|
|
||||||
private val _currentGroup = MutableStateFlow<ImGroup?>(null)
|
private val _currentGroup = MutableStateFlow<ImGroup?>(null)
|
||||||
val currentGroup: StateFlow<ImGroup?> = _currentGroup
|
val currentGroup: StateFlow<ImGroup?> = _currentGroup
|
||||||
|
|
||||||
init { loadGroups() }
|
private val _joinRequests = MutableStateFlow<List<GroupJoinRequest>>(emptyList())
|
||||||
|
val joinRequests: StateFlow<List<GroupJoinRequest>> = _joinRequests
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadGroups()
|
||||||
|
searchPublicGroups("")
|
||||||
|
}
|
||||||
|
|
||||||
fun loadGroups() {
|
fun loadGroups() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -32,9 +45,17 @@ class GroupViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createGroup(name: String, memberIds: List<String>, onSuccess: (ImGroup) -> Unit) {
|
fun searchPublicGroups(keyword: String) {
|
||||||
|
_publicGroupQuery.value = keyword
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.createGroup(name, memberIds) }
|
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
|
||||||
|
.onSuccess { _publicGroups.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createGroup(name: String, memberIds: List<String>, groupType: String, onSuccess: (ImGroup) -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.createGroup(name, memberIds, groupType) }
|
||||||
.onSuccess { group -> group?.let { onSuccess(it); loadGroups() } }
|
.onSuccess { group -> group?.let { onSuccess(it); loadGroups() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -46,6 +67,27 @@ class GroupViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setRole(groupId: String, userId: String, role: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.setGroupRole(groupId, userId, role) }
|
||||||
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun muteMember(groupId: String, userId: String, minutes: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) }
|
||||||
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissGroup(groupId: String, onSuccess: () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.dismissGroup(groupId) }
|
||||||
|
.onSuccess { loadGroups(); onSuccess() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun removeMember(groupId: String, userId: String) {
|
fun removeMember(groupId: String, userId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.removeGroupMember(groupId, userId) }
|
runCatching { ImSDK.removeGroupMember(groupId, userId) }
|
||||||
@ -59,4 +101,35 @@ class GroupViewModel : ViewModel() {
|
|||||||
.onSuccess { loadGroups(); onSuccess() }
|
.onSuccess { loadGroups(); onSuccess() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun requestJoinGroup(groupId: String, remark: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.sendGroupJoinRequest(groupId, remark) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadJoinRequests(groupId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.listGroupJoinRequests(groupId) }
|
||||||
|
.onSuccess { _joinRequests.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearJoinRequests() {
|
||||||
|
_joinRequests.value = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptJoinRequest(groupId: String, requestId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.acceptGroupJoinRequest(groupId, requestId) }
|
||||||
|
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rejectJoinRequest(groupId: String, requestId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { ImSDK.rejectGroupJoinRequest(groupId, requestId) }
|
||||||
|
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import androidx.compose.material.icons.filled.Person
|
|||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.SystemUpdate
|
import androidx.compose.material.icons.filled.SystemUpdate
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
@ -19,19 +22,26 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
|
import com.xuqm.sdk.update.UpdateSDK
|
||||||
|
import com.xuqm.sdk.update.model.UpdateInfo
|
||||||
import com.xuqm.sdk.sample.ui.contact.ContactScreen
|
import com.xuqm.sdk.sample.ui.contact.ContactScreen
|
||||||
import com.xuqm.sdk.sample.ui.conversation.ConversationScreen
|
import com.xuqm.sdk.sample.ui.conversation.ConversationScreen
|
||||||
import com.xuqm.sdk.sample.ui.group.GroupListScreen
|
import com.xuqm.sdk.sample.ui.group.GroupListScreen
|
||||||
import com.xuqm.sdk.sample.ui.profile.ProfileScreen
|
import com.xuqm.sdk.sample.ui.profile.ProfileScreen
|
||||||
import com.xuqm.sdk.sample.ui.update.UpdateScreen
|
import com.xuqm.sdk.sample.ui.update.UpdateScreen
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
||||||
|
|
||||||
private data class BottomTab(val label: String, val icon: ImageVector)
|
private data class BottomTab(val label: String, val icon: ImageVector)
|
||||||
@ -54,6 +64,19 @@ fun MainScreen(
|
|||||||
) {
|
) {
|
||||||
var selectedTab by remember { mutableIntStateOf(0) }
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var updateChecked by remember { mutableStateOf(false) }
|
||||||
|
var pendingUpdate by remember { mutableStateOf<UpdateInfo?>(null) }
|
||||||
|
var downloadProgress by remember { mutableStateOf(-1) }
|
||||||
|
var isDownloading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!updateChecked) {
|
||||||
|
updateChecked = true
|
||||||
|
pendingUpdate = UpdateSDK.checkAppUpdate(context)?.takeIf { it.needsUpdate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@ -97,4 +120,48 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingUpdate?.let { update ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
if (!update.forceUpdate) pendingUpdate = null
|
||||||
|
},
|
||||||
|
title = { Text("发现新版本 ${update.versionName}") },
|
||||||
|
text = {
|
||||||
|
androidx.compose.foundation.layout.Column {
|
||||||
|
Text(update.changeLog.ifBlank { "无更新说明" })
|
||||||
|
if (downloadProgress in 0..99) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text("下载中 $downloadProgress%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (update.downloadUrl.isNotBlank() && !isDownloading) {
|
||||||
|
isDownloading = true
|
||||||
|
scope.launch {
|
||||||
|
UpdateSDK.downloadAndInstall(context, update.downloadUrl) { progress ->
|
||||||
|
downloadProgress = progress
|
||||||
|
}
|
||||||
|
isDownloading = false
|
||||||
|
pendingUpdate = null
|
||||||
|
downloadProgress = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(if (isDownloading) "处理中" else "立即更新")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
if (!update.forceUpdate) {
|
||||||
|
Button(onClick = { pendingUpdate = null }) {
|
||||||
|
Text("稍后")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package com.xuqm.sdk.sample.ui.update
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@ -15,17 +14,17 @@ import androidx.compose.material3.LinearProgressIndicator
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.xuqm.sdk.update.UpdateSDK
|
import com.xuqm.sdk.update.UpdateSDK
|
||||||
import com.xuqm.sdk.update.model.UpdateInfo
|
import com.xuqm.sdk.update.model.UpdateInfo
|
||||||
import com.xuqm.sdk.sample.di.AppDependencies
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -69,6 +68,12 @@ fun UpdateScreen(viewModel: UpdateViewModel = viewModel()) {
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (state.appUpdate == null && !state.isChecking) {
|
||||||
|
viewModel.checkAppUpdate(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@ -15,6 +15,9 @@ object XuqmSDK {
|
|||||||
lateinit var config: SDKConfig
|
lateinit var config: SDKConfig
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
lateinit var appContext: Context
|
||||||
|
private set
|
||||||
|
|
||||||
lateinit var tokenStore: TokenStore
|
lateinit var tokenStore: TokenStore
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@ -28,6 +31,7 @@ object XuqmSDK {
|
|||||||
logLevel: LogLevel = LogLevel.WARN,
|
logLevel: LogLevel = LogLevel.WARN,
|
||||||
) {
|
) {
|
||||||
config = SDKConfig(appId, logLevel)
|
config = SDKConfig(appId, logLevel)
|
||||||
|
appContext = context.applicationContext
|
||||||
tokenStore = TokenStore(context.applicationContext)
|
tokenStore = TokenStore(context.applicationContext)
|
||||||
ApiClient.init(config, tokenStore)
|
ApiClient.init(config, tokenStore)
|
||||||
initialized = true
|
initialized = true
|
||||||
|
|||||||
@ -6,6 +6,7 @@ data class ServiceEndpoints(
|
|||||||
val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im",
|
val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im",
|
||||||
val pushBaseUrl: String = "https://dev.xuqinmin.com/",
|
val pushBaseUrl: String = "https://dev.xuqinmin.com/",
|
||||||
val updateBaseUrl: String = "https://update.dev.xuqinmin.com/",
|
val updateBaseUrl: String = "https://update.dev.xuqinmin.com/",
|
||||||
|
val fileBaseUrl: String = "https://file.dev.xuqinmin.com/",
|
||||||
)
|
)
|
||||||
|
|
||||||
object ServiceEndpointRegistry {
|
object ServiceEndpointRegistry {
|
||||||
@ -19,6 +20,7 @@ object ServiceEndpointRegistry {
|
|||||||
val imWsUrl: String get() = current.imWsUrl
|
val imWsUrl: String get() = current.imWsUrl
|
||||||
val pushBaseUrl: String get() = current.pushBaseUrl
|
val pushBaseUrl: String get() = current.pushBaseUrl
|
||||||
val updateBaseUrl: String get() = current.updateBaseUrl
|
val updateBaseUrl: String get() = current.updateBaseUrl
|
||||||
|
val fileBaseUrl: String get() = current.fileBaseUrl
|
||||||
|
|
||||||
fun configure(endpoints: ServiceEndpoints) {
|
fun configure(endpoints: ServiceEndpoints) {
|
||||||
current = endpoints
|
current = endpoints
|
||||||
@ -30,8 +32,9 @@ object ServiceEndpointRegistry {
|
|||||||
controlBaseUrl = "http://$normalizedHost:8081/",
|
controlBaseUrl = "http://$normalizedHost:8081/",
|
||||||
imApiBaseUrl = "http://$normalizedHost:8082/",
|
imApiBaseUrl = "http://$normalizedHost:8082/",
|
||||||
imWsUrl = "ws://$normalizedHost:8082/ws/im",
|
imWsUrl = "ws://$normalizedHost:8082/ws/im",
|
||||||
pushBaseUrl = "http://$normalizedHost:8081/",
|
pushBaseUrl = "http://$normalizedHost:8083/",
|
||||||
updateBaseUrl = "http://$normalizedHost:8084/",
|
updateBaseUrl = "http://$normalizedHost:8084/",
|
||||||
|
fileBaseUrl = "http://$normalizedHost:8086/",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,127 @@
|
|||||||
|
package com.xuqm.sdk.file
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import com.xuqm.sdk.core.ServiceEndpointRegistry
|
||||||
|
import com.xuqm.sdk.network.ApiClient
|
||||||
|
import okio.BufferedSink
|
||||||
|
import okio.source
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import retrofit2.http.Multipart
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Part
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
data class FileUploadResult(
|
||||||
|
val url: String,
|
||||||
|
val thumbnailUrl: String? = null,
|
||||||
|
val hash: String,
|
||||||
|
val size: Long,
|
||||||
|
val originalName: String? = null,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
val ext: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ApiResponse<T>(
|
||||||
|
val code: Int,
|
||||||
|
val status: String,
|
||||||
|
val data: T?,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private interface FileApi {
|
||||||
|
@Multipart
|
||||||
|
@POST("api/file/upload")
|
||||||
|
suspend fun upload(
|
||||||
|
@Part file: MultipartBody.Part,
|
||||||
|
@Part thumbnail: MultipartBody.Part? = null,
|
||||||
|
): ApiResponse<FileUploadResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
object FileSDK {
|
||||||
|
|
||||||
|
private val api: FileApi
|
||||||
|
get() = ApiClient.create(FileApi::class.java, ServiceEndpointRegistry.fileBaseUrl)
|
||||||
|
|
||||||
|
suspend fun upload(
|
||||||
|
context: Context,
|
||||||
|
uri: Uri,
|
||||||
|
displayName: String? = null,
|
||||||
|
mimeType: String? = null,
|
||||||
|
thumbnailBytes: ByteArray? = null,
|
||||||
|
): FileUploadResult {
|
||||||
|
val resolvedName = displayName?.takeIf { it.isNotBlank() } ?: resolveDisplayName(context, uri)
|
||||||
|
val resolvedMimeType = mimeType?.takeIf { it.isNotBlank() } ?: context.contentResolver.getType(uri)
|
||||||
|
val filePart = MultipartBody.Part.createFormData(
|
||||||
|
"file",
|
||||||
|
resolvedName,
|
||||||
|
UriRequestBody(context, uri, resolvedMimeType),
|
||||||
|
)
|
||||||
|
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { bytes ->
|
||||||
|
MultipartBody.Part.createFormData(
|
||||||
|
"thumbnail",
|
||||||
|
"${resolvedName.substringBeforeLast('.', resolvedName)}_thumb.jpg",
|
||||||
|
bytes.toRequestBody("image/jpeg".toMediaTypeOrNull()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return requireNotNull(api.upload(filePart, thumbnailPart).data) {
|
||||||
|
"File upload failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uploadBytes(
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String?,
|
||||||
|
bytes: ByteArray,
|
||||||
|
thumbnailBytes: ByteArray? = null,
|
||||||
|
): FileUploadResult {
|
||||||
|
val filePart = MultipartBody.Part.createFormData(
|
||||||
|
"file",
|
||||||
|
fileName,
|
||||||
|
bytes.toRequestBody(mimeType?.toMediaTypeOrNull()),
|
||||||
|
)
|
||||||
|
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { thumbBytes ->
|
||||||
|
MultipartBody.Part.createFormData(
|
||||||
|
"thumbnail",
|
||||||
|
"${fileName.substringBeforeLast('.', fileName)}_thumb.jpg",
|
||||||
|
thumbBytes.toRequestBody("image/jpeg".toMediaTypeOrNull()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return requireNotNull(api.upload(filePart, thumbnailPart).data) {
|
||||||
|
"File upload failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveDisplayName(context: Context, uri: Uri): String {
|
||||||
|
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||||
|
?.use { cursor ->
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
||||||
|
val value = cursor.getString(nameIndex)
|
||||||
|
if (!value.isNullOrBlank()) return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fallback = uri.lastPathSegment?.substringAfterLast('/')
|
||||||
|
return fallback?.takeIf { it.isNotBlank() }
|
||||||
|
?: String.format(Locale.US, "upload_%d", System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
private class UriRequestBody(
|
||||||
|
private val context: Context,
|
||||||
|
private val uri: Uri,
|
||||||
|
private val mimeType: String?,
|
||||||
|
) : RequestBody() {
|
||||||
|
|
||||||
|
override fun contentType() = mimeType?.toMediaTypeOrNull()
|
||||||
|
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
sink.writeAll(input.source())
|
||||||
|
} ?: error("Failed to open input stream for $uri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,14 +3,12 @@ package com.xuqm.sdk.im
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.xuqm.sdk.im.listener.ImEventListener
|
import com.xuqm.sdk.im.listener.ImEventListener
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
|
import java.net.URI
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@ -21,8 +19,11 @@ class ImClient(
|
|||||||
) {
|
) {
|
||||||
private var webSocket: WebSocket? = null
|
private var webSocket: WebSocket? = null
|
||||||
private val listeners = CopyOnWriteArrayList<ImEventListener>()
|
private val listeners = CopyOnWriteArrayList<ImEventListener>()
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + Job())
|
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
|
private val subscriptions = mutableMapOf<String, String>()
|
||||||
|
private var subscriptionSeed = 0
|
||||||
|
private var connected = false
|
||||||
|
private var inboundBuffer = StringBuilder()
|
||||||
|
|
||||||
private val okhttp = OkHttpClient.Builder()
|
private val okhttp = OkHttpClient.Builder()
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
@ -30,19 +31,151 @@ class ImClient(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
|
disconnect(closeSocket = false)
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(wsUrl)
|
.url(wsUrl)
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
.build()
|
||||||
webSocket = okhttp.newWebSocket(request, object : WebSocketListener() {
|
webSocket = okhttp.newWebSocket(request, object : WebSocketListener() {
|
||||||
override fun onOpen(ws: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
listeners.forEach { it.onConnected() }
|
sendConnectFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(ws: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
handleIncoming(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
connected = false
|
||||||
|
listeners.forEach { it.onDisconnected(t.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
connected = false
|
||||||
|
listeners.forEach { it.onDisconnected(reason) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(destination: String) {
|
||||||
|
val subscriptionId = synchronized(subscriptions) {
|
||||||
|
if (subscriptions.containsKey(destination)) return
|
||||||
|
val id = nextSubscriptionId()
|
||||||
|
subscriptions[destination] = id
|
||||||
|
id
|
||||||
|
}
|
||||||
|
if (connected) {
|
||||||
|
sendSubscribe(destination, subscriptionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsubscribe(destination: String) {
|
||||||
|
val subscriptionId = synchronized(subscriptions) { subscriptions.remove(destination) } ?: return
|
||||||
|
if (connected) {
|
||||||
|
sendFrame("UNSUBSCRIBE", mapOf("id" to subscriptionId), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
msgType: String,
|
||||||
|
content: String,
|
||||||
|
mentionedUserIds: String? = null,
|
||||||
|
) {
|
||||||
|
val payload = linkedMapOf(
|
||||||
|
"appId" to appId,
|
||||||
|
"toId" to toId,
|
||||||
|
"chatType" to chatType,
|
||||||
|
"msgType" to msgType,
|
||||||
|
"content" to content,
|
||||||
|
)
|
||||||
|
if (!mentionedUserIds.isNullOrBlank()) {
|
||||||
|
payload["mentionedUserIds"] = mentionedUserIds
|
||||||
|
}
|
||||||
|
sendFrame(
|
||||||
|
"SEND",
|
||||||
|
mapOf(
|
||||||
|
"destination" to "/app/chat.send",
|
||||||
|
"content-type" to "application/json",
|
||||||
|
),
|
||||||
|
gson.toJson(payload),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun revokeMessage(messageId: String) {
|
||||||
|
sendFrame(
|
||||||
|
"SEND",
|
||||||
|
mapOf(
|
||||||
|
"destination" to "/app/chat.revoke",
|
||||||
|
"content-type" to "application/json",
|
||||||
|
),
|
||||||
|
gson.toJson(
|
||||||
|
mapOf(
|
||||||
|
"appId" to appId,
|
||||||
|
"messageId" to messageId,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListener(listener: ImEventListener) = listeners.add(listener)
|
||||||
|
fun removeListener(listener: ImEventListener) = listeners.remove(listener)
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
disconnect(closeSocket = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendConnectFrame() {
|
||||||
|
connected = false
|
||||||
|
sendFrame(
|
||||||
|
"CONNECT",
|
||||||
|
mapOf(
|
||||||
|
"accept-version" to "1.2",
|
||||||
|
"heart-beat" to "0,0",
|
||||||
|
"host" to URI.create(wsUrl).host.orEmpty(),
|
||||||
|
"Authorization" to "Bearer $token",
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIncoming(chunk: String) {
|
||||||
|
if (chunk.isBlank()) return
|
||||||
|
inboundBuffer.append(chunk)
|
||||||
|
while (true) {
|
||||||
|
val terminator = inboundBuffer.indexOf("\u0000")
|
||||||
|
if (terminator < 0) return
|
||||||
|
val frame = inboundBuffer.substring(0, terminator)
|
||||||
|
inboundBuffer = StringBuilder(inboundBuffer.substring(terminator + 1))
|
||||||
|
if (frame.isNotBlank()) {
|
||||||
|
handleFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFrame(frame: String) {
|
||||||
|
val parts = frame.split("\n\n", limit = 2)
|
||||||
|
val headerLines = parts.firstOrNull().orEmpty().split("\n").filter { it.isNotBlank() }
|
||||||
|
val command = headerLines.firstOrNull()?.trim().orEmpty()
|
||||||
|
val headers = parseHeaders(headerLines.drop(1))
|
||||||
|
val body = parts.getOrNull(1).orEmpty()
|
||||||
|
|
||||||
|
when (command.uppercase()) {
|
||||||
|
"CONNECTED" -> {
|
||||||
|
connected = true
|
||||||
|
listeners.forEach { it.onConnected() }
|
||||||
|
sendSubscribe("/user/queue/messages", nextSubscriptionId(prefix = "user"))
|
||||||
|
val pendingSubscriptions = synchronized(subscriptions) { subscriptions.toMap() }
|
||||||
|
pendingSubscriptions.forEach { (destination, id) ->
|
||||||
|
if (destination != "/user/queue/messages") {
|
||||||
|
sendSubscribe(destination, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"MESSAGE" -> {
|
||||||
runCatching {
|
runCatching {
|
||||||
val msg = gson.fromJson(text, ImMessage::class.java)
|
val msg = gson.fromJson(body, ImMessage::class.java)
|
||||||
if (msg.chatType == "GROUP") {
|
if (msg.chatType.uppercase() == "GROUP") {
|
||||||
listeners.forEach { it.onGroupMessage(msg) }
|
listeners.forEach { it.onGroupMessage(msg) }
|
||||||
} else {
|
} else {
|
||||||
listeners.forEach { it.onMessage(msg) }
|
listeners.forEach { it.onMessage(msg) }
|
||||||
@ -51,31 +184,94 @@ class ImClient(
|
|||||||
listeners.forEach { it.onError("Parse error: ${e.message}") }
|
listeners.forEach { it.onError("Parse error: ${e.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"ERROR" -> {
|
||||||
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
|
val reason = body.ifBlank { headers["message"].orEmpty() }
|
||||||
listeners.forEach { it.onDisconnected(t.message) }
|
listeners.forEach { it.onError(reason.ifBlank { "STOMP error" }) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
|
|
||||||
listeners.forEach { it.onDisconnected(reason) }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(toId: String, chatType: String, msgType: String, content: String) {
|
private fun sendSubscribe(destination: String, subscriptionId: String) {
|
||||||
val payload = mapOf(
|
sendFrame(
|
||||||
"appId" to appId, "toId" to toId,
|
"SUBSCRIBE",
|
||||||
"chatType" to chatType, "msgType" to msgType,
|
mapOf(
|
||||||
"content" to content,
|
"id" to subscriptionId,
|
||||||
|
"destination" to destination,
|
||||||
|
),
|
||||||
|
null,
|
||||||
)
|
)
|
||||||
webSocket?.send(gson.toJson(mapOf("type" to "chat.send", "data" to payload)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addListener(listener: ImEventListener) = listeners.add(listener)
|
private fun sendFrame(command: String, headers: Map<String, String>, body: String?) {
|
||||||
fun removeListener(listener: ImEventListener) = listeners.remove(listener)
|
val socket = webSocket ?: return
|
||||||
|
val frame = buildString {
|
||||||
|
append(command).append('\n')
|
||||||
|
headers.forEach { (key, value) ->
|
||||||
|
append(escapeHeader(key)).append(':').append(escapeHeader(value)).append('\n')
|
||||||
|
}
|
||||||
|
append('\n')
|
||||||
|
if (body != null) {
|
||||||
|
append(body)
|
||||||
|
}
|
||||||
|
append('\u0000')
|
||||||
|
}
|
||||||
|
socket.send(frame)
|
||||||
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
private fun parseHeaders(lines: List<String>): Map<String, String> {
|
||||||
webSocket?.close(1000, "User disconnect")
|
val headers = linkedMapOf<String, String>()
|
||||||
|
lines.forEach { line ->
|
||||||
|
val index = line.indexOf(':')
|
||||||
|
if (index <= 0) return@forEach
|
||||||
|
val key = unescapeHeader(line.substring(0, index))
|
||||||
|
val value = unescapeHeader(line.substring(index + 1))
|
||||||
|
headers[key] = value
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextSubscriptionId(prefix: String = "sub"): String {
|
||||||
|
subscriptionSeed += 1
|
||||||
|
return "$prefix-$subscriptionSeed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnect(closeSocket: Boolean) {
|
||||||
|
connected = false
|
||||||
|
synchronized(subscriptions) { subscriptions.clear() }
|
||||||
|
inboundBuffer = StringBuilder()
|
||||||
|
if (closeSocket) {
|
||||||
|
webSocket?.close(1000, "User disconnect")
|
||||||
|
}
|
||||||
webSocket = null
|
webSocket = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun escapeHeader(value: String): String =
|
||||||
|
value.replace("\\", "\\\\")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace(":", "\\c")
|
||||||
|
|
||||||
|
private fun unescapeHeader(value: String): String {
|
||||||
|
val builder = StringBuilder()
|
||||||
|
var index = 0
|
||||||
|
while (index < value.length) {
|
||||||
|
val ch = value[index]
|
||||||
|
if (ch == '\\' && index + 1 < value.length) {
|
||||||
|
when (value[index + 1]) {
|
||||||
|
'r' -> builder.append('\r')
|
||||||
|
'n' -> builder.append('\n')
|
||||||
|
'c' -> builder.append(':')
|
||||||
|
'\\' -> builder.append('\\')
|
||||||
|
else -> {
|
||||||
|
builder.append(value[index + 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += 2
|
||||||
|
} else {
|
||||||
|
builder.append(ch)
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,19 +6,28 @@ import com.xuqm.sdk.core.ServiceEndpointRegistry
|
|||||||
import com.xuqm.sdk.im.api.AddMemberRequest
|
import com.xuqm.sdk.im.api.AddMemberRequest
|
||||||
import com.xuqm.sdk.im.api.CreateGroupRequest
|
import com.xuqm.sdk.im.api.CreateGroupRequest
|
||||||
import com.xuqm.sdk.im.api.ImApi
|
import com.xuqm.sdk.im.api.ImApi
|
||||||
|
import com.xuqm.sdk.im.api.MuteGroupMemberRequest
|
||||||
import com.xuqm.sdk.im.api.SetMutedRequest
|
import com.xuqm.sdk.im.api.SetMutedRequest
|
||||||
import com.xuqm.sdk.im.api.SetPinnedRequest
|
import com.xuqm.sdk.im.api.SetPinnedRequest
|
||||||
|
import com.xuqm.sdk.im.api.SetGroupRoleRequest
|
||||||
import com.xuqm.sdk.im.api.UpdateGroupRequest
|
import com.xuqm.sdk.im.api.UpdateGroupRequest
|
||||||
import com.xuqm.sdk.im.listener.ImEventListener
|
import com.xuqm.sdk.im.listener.ImEventListener
|
||||||
import com.xuqm.sdk.im.model.ImConnectionState
|
import com.xuqm.sdk.im.model.ImConnectionState
|
||||||
import com.xuqm.sdk.im.model.ConversationData
|
import com.xuqm.sdk.im.model.ConversationData
|
||||||
|
import com.xuqm.sdk.im.model.GroupJoinRequest
|
||||||
import com.xuqm.sdk.im.model.ImGroup
|
import com.xuqm.sdk.im.model.ImGroup
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
|
import com.xuqm.sdk.im.model.BlacklistEntry
|
||||||
|
import com.xuqm.sdk.im.model.FriendRequest
|
||||||
|
import com.xuqm.sdk.file.FileUploadResult
|
||||||
import com.xuqm.sdk.network.ApiClient
|
import com.xuqm.sdk.network.ApiClient
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
object ImSDK {
|
object ImSDK {
|
||||||
|
|
||||||
@ -69,29 +78,324 @@ object ImSDK {
|
|||||||
suspend fun loginWithToken(userId: String, token: String) =
|
suspend fun loginWithToken(userId: String, token: String) =
|
||||||
loginWithUserSig(userId, token)
|
loginWithUserSig(userId, token)
|
||||||
|
|
||||||
fun sendMessage(toId: String, chatType: String, msgType: String, content: String) {
|
fun sendMessage(
|
||||||
client?.sendMessage(toId, chatType, msgType, content)
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
msgType: String,
|
||||||
|
content: String,
|
||||||
|
mentionedUserIds: String? = null,
|
||||||
|
) {
|
||||||
|
client?.sendMessage(toId, chatType, msgType, content, mentionedUserIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendTextMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
content: String,
|
||||||
|
mentionedUserIds: String? = null,
|
||||||
|
) {
|
||||||
|
sendMessage(toId, chatType, "TEXT", content, mentionedUserIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendImageMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
file: FileUploadResult,
|
||||||
|
width: Int? = null,
|
||||||
|
height: Int? = null,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "IMAGE",
|
||||||
|
content = buildMediaPayload(
|
||||||
|
url = file.url,
|
||||||
|
thumbnailUrl = file.thumbnailUrl,
|
||||||
|
name = file.originalName,
|
||||||
|
mimeType = file.mimeType,
|
||||||
|
size = file.size,
|
||||||
|
hash = file.hash,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendVideoMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
file: FileUploadResult,
|
||||||
|
width: Int? = null,
|
||||||
|
height: Int? = null,
|
||||||
|
durationMs: Long? = null,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "VIDEO",
|
||||||
|
content = buildMediaPayload(
|
||||||
|
url = file.url,
|
||||||
|
thumbnailUrl = file.thumbnailUrl,
|
||||||
|
name = file.originalName,
|
||||||
|
mimeType = file.mimeType,
|
||||||
|
size = file.size,
|
||||||
|
hash = file.hash,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
durationMs = durationMs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFileMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
file: FileUploadResult,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "FILE",
|
||||||
|
content = buildMediaPayload(
|
||||||
|
url = file.url,
|
||||||
|
name = file.originalName,
|
||||||
|
mimeType = file.mimeType,
|
||||||
|
size = file.size,
|
||||||
|
hash = file.hash,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendAudioMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
file: FileUploadResult,
|
||||||
|
durationMs: Long? = null,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "AUDIO",
|
||||||
|
content = buildMediaPayload(
|
||||||
|
url = file.url,
|
||||||
|
name = file.originalName,
|
||||||
|
mimeType = file.mimeType,
|
||||||
|
size = file.size,
|
||||||
|
hash = file.hash,
|
||||||
|
durationMs = durationMs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendLocationMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
title: String? = null,
|
||||||
|
address: String? = null,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "LOCATION",
|
||||||
|
content = JSONObject().apply {
|
||||||
|
put("lat", latitude)
|
||||||
|
put("lng", longitude)
|
||||||
|
title?.takeIf { it.isNotBlank() }?.let { put("title", it) }
|
||||||
|
address?.takeIf { it.isNotBlank() }?.let { put("address", it) }
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendCustomMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
data: String,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "CUSTOM",
|
||||||
|
content = JSONObject().apply {
|
||||||
|
put("data", data)
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendRichTextMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
html: String,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "RICH_TEXT",
|
||||||
|
content = JSONObject().apply {
|
||||||
|
put("html", html)
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendForwardMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
originalSender: String,
|
||||||
|
originalContent: String,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "FORWARD",
|
||||||
|
content = JSONObject().apply {
|
||||||
|
put("originalSender", originalSender)
|
||||||
|
put("originalContent", originalContent)
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendCallAudioMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
action: String,
|
||||||
|
) {
|
||||||
|
sendCallSignalMessage(toId, chatType, "CALL_AUDIO", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendCallVideoMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
action: String,
|
||||||
|
) {
|
||||||
|
sendCallSignalMessage(toId, chatType, "CALL_VIDEO", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendNotifyMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "NOTIFY",
|
||||||
|
content = JSONObject().apply {
|
||||||
|
put("title", title)
|
||||||
|
put("content", content)
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendQuoteMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
quotedMsgId: String,
|
||||||
|
quotedContent: String,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "QUOTE",
|
||||||
|
content = JSONObject().apply {
|
||||||
|
put("quotedMsgId", quotedMsgId)
|
||||||
|
put("quotedContent", quotedContent)
|
||||||
|
put("text", text)
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMergeMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
title: String,
|
||||||
|
msgList: List<String>,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = "MERGE",
|
||||||
|
content = JSONObject().apply {
|
||||||
|
put("title", title)
|
||||||
|
put("msgList", JSONArray(msgList))
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribeGroup(groupId: String) {
|
||||||
|
client?.subscribe("/topic/group/$groupId")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsubscribeGroup(groupId: String) {
|
||||||
|
client?.unsubscribe("/topic/group/$groupId")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
|
suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
|
||||||
|
fetchHistoryWithFilters(toId, page, size)
|
||||||
|
|
||||||
|
suspend fun fetchHistoryWithFilters(
|
||||||
|
toId: String,
|
||||||
|
page: Int = 0,
|
||||||
|
size: Int = 20,
|
||||||
|
msgType: String? = null,
|
||||||
|
keyword: String? = null,
|
||||||
|
startTime: LocalDateTime? = null,
|
||||||
|
endTime: LocalDateTime? = null,
|
||||||
|
): List<ImMessage> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
api.fetchHistory(toId, XuqmSDK.appId, page, size).data ?: emptyList()
|
api.fetchHistory(
|
||||||
|
toId,
|
||||||
|
XuqmSDK.appId,
|
||||||
|
msgType,
|
||||||
|
keyword,
|
||||||
|
startTime?.toString(),
|
||||||
|
endTime?.toString(),
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
).data ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
|
suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
|
||||||
|
fetchGroupHistoryWithFilters(groupId, page, size)
|
||||||
|
|
||||||
|
suspend fun fetchGroupHistoryWithFilters(
|
||||||
|
groupId: String,
|
||||||
|
page: Int = 0,
|
||||||
|
size: Int = 20,
|
||||||
|
msgType: String? = null,
|
||||||
|
keyword: String? = null,
|
||||||
|
startTime: LocalDateTime? = null,
|
||||||
|
endTime: LocalDateTime? = null,
|
||||||
|
): List<ImMessage> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
api.fetchGroupHistory(groupId, XuqmSDK.appId, page, size).data ?: emptyList()
|
api.fetchGroupHistory(
|
||||||
|
groupId,
|
||||||
|
XuqmSDK.appId,
|
||||||
|
msgType,
|
||||||
|
keyword,
|
||||||
|
startTime?.toString(),
|
||||||
|
endTime?.toString(),
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
).data ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun listGroups(): List<ImGroup> =
|
suspend fun listGroups(): List<ImGroup> =
|
||||||
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
|
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
|
||||||
|
|
||||||
suspend fun createGroup(name: String, memberIds: List<String>): ImGroup? =
|
suspend fun createGroup(name: String, memberIds: List<String>, groupType: String = "WORK"): ImGroup? =
|
||||||
withContext(Dispatchers.IO) { api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds)).data }
|
withContext(Dispatchers.IO) {
|
||||||
|
api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds, groupType)).data
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getGroupInfo(groupId: String): ImGroup? =
|
suspend fun getGroupInfo(groupId: String): ImGroup? =
|
||||||
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
|
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
|
||||||
|
|
||||||
|
suspend fun listPublicGroups(keyword: String? = null): List<ImGroup> =
|
||||||
|
withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appId, keyword).data ?: emptyList() }
|
||||||
|
|
||||||
suspend fun updateGroupInfo(groupId: String, name: String? = null, announcement: String? = null) =
|
suspend fun updateGroupInfo(groupId: String, name: String? = null, announcement: String? = null) =
|
||||||
withContext(Dispatchers.IO) { api.updateGroupInfo(groupId, UpdateGroupRequest(name, announcement)) }
|
withContext(Dispatchers.IO) { api.updateGroupInfo(groupId, UpdateGroupRequest(name, announcement)) }
|
||||||
|
|
||||||
@ -104,6 +408,27 @@ object ImSDK {
|
|||||||
suspend fun leaveGroup(groupId: String) =
|
suspend fun leaveGroup(groupId: String) =
|
||||||
withContext(Dispatchers.IO) { api.removeGroupMember(groupId, currentUserId) }
|
withContext(Dispatchers.IO) { api.removeGroupMember(groupId, currentUserId) }
|
||||||
|
|
||||||
|
suspend fun setGroupRole(groupId: String, userId: String, role: String) =
|
||||||
|
withContext(Dispatchers.IO) { api.setGroupRole(groupId, SetGroupRoleRequest(userId, role)) }
|
||||||
|
|
||||||
|
suspend fun muteGroupMember(groupId: String, userId: String, minutes: Long) =
|
||||||
|
withContext(Dispatchers.IO) { api.muteGroupMember(groupId, MuteGroupMemberRequest(userId, minutes)) }
|
||||||
|
|
||||||
|
suspend fun dismissGroup(groupId: String) =
|
||||||
|
withContext(Dispatchers.IO) { api.dismissGroup(groupId) }
|
||||||
|
|
||||||
|
suspend fun sendGroupJoinRequest(groupId: String, remark: String? = null): GroupJoinRequest? =
|
||||||
|
withContext(Dispatchers.IO) { api.sendGroupJoinRequest(groupId, XuqmSDK.appId, remark).data }
|
||||||
|
|
||||||
|
suspend fun listGroupJoinRequests(groupId: String): List<GroupJoinRequest> =
|
||||||
|
withContext(Dispatchers.IO) { api.listGroupJoinRequests(groupId, XuqmSDK.appId).data ?: emptyList() }
|
||||||
|
|
||||||
|
suspend fun acceptGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? =
|
||||||
|
withContext(Dispatchers.IO) { api.acceptGroupJoinRequest(groupId, requestId, XuqmSDK.appId).data }
|
||||||
|
|
||||||
|
suspend fun rejectGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? =
|
||||||
|
withContext(Dispatchers.IO) { api.rejectGroupJoinRequest(groupId, requestId, XuqmSDK.appId).data }
|
||||||
|
|
||||||
suspend fun listFriends(): List<String> =
|
suspend fun listFriends(): List<String> =
|
||||||
withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() }
|
withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() }
|
||||||
|
|
||||||
@ -113,6 +438,27 @@ object ImSDK {
|
|||||||
suspend fun removeFriend(friendId: String) =
|
suspend fun removeFriend(friendId: String) =
|
||||||
withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appId) }
|
withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appId) }
|
||||||
|
|
||||||
|
suspend fun listFriendRequests(direction: String = "incoming"): List<FriendRequest> =
|
||||||
|
withContext(Dispatchers.IO) { api.listFriendRequests(XuqmSDK.appId, direction).data ?: emptyList() }
|
||||||
|
|
||||||
|
suspend fun sendFriendRequest(friendId: String, remark: String? = null): FriendRequest? =
|
||||||
|
withContext(Dispatchers.IO) { api.sendFriendRequest(XuqmSDK.appId, friendId, remark).data }
|
||||||
|
|
||||||
|
suspend fun acceptFriendRequest(requestId: String): FriendRequest? =
|
||||||
|
withContext(Dispatchers.IO) { api.acceptFriendRequest(requestId, XuqmSDK.appId).data }
|
||||||
|
|
||||||
|
suspend fun rejectFriendRequest(requestId: String): FriendRequest? =
|
||||||
|
withContext(Dispatchers.IO) { api.rejectFriendRequest(requestId, XuqmSDK.appId).data }
|
||||||
|
|
||||||
|
suspend fun listBlacklist(): List<BlacklistEntry> =
|
||||||
|
withContext(Dispatchers.IO) { api.listBlacklist(XuqmSDK.appId).data ?: emptyList() }
|
||||||
|
|
||||||
|
suspend fun addToBlacklist(blockedUserId: String): BlacklistEntry? =
|
||||||
|
withContext(Dispatchers.IO) { api.addToBlacklist(XuqmSDK.appId, blockedUserId).data }
|
||||||
|
|
||||||
|
suspend fun removeFromBlacklist(blockedUserId: String) =
|
||||||
|
withContext(Dispatchers.IO) { api.removeFromBlacklist(XuqmSDK.appId, blockedUserId) }
|
||||||
|
|
||||||
suspend fun listConversations(): List<ConversationData> =
|
suspend fun listConversations(): List<ConversationData> =
|
||||||
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() }
|
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() }
|
||||||
|
|
||||||
@ -175,4 +521,42 @@ object ImSDK {
|
|||||||
XuqmSDK.tokenStore.clear()
|
XuqmSDK.tokenStore.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildMediaPayload(
|
||||||
|
url: String,
|
||||||
|
thumbnailUrl: String? = null,
|
||||||
|
name: String? = null,
|
||||||
|
mimeType: String? = null,
|
||||||
|
size: Long? = null,
|
||||||
|
hash: String? = null,
|
||||||
|
width: Int? = null,
|
||||||
|
height: Int? = null,
|
||||||
|
durationMs: Long? = null,
|
||||||
|
): String = JSONObject().apply {
|
||||||
|
put("url", url)
|
||||||
|
thumbnailUrl?.takeIf { it.isNotBlank() }?.let { put("thumbnailUrl", it) }
|
||||||
|
name?.takeIf { it.isNotBlank() }?.let { put("name", it) }
|
||||||
|
mimeType?.takeIf { it.isNotBlank() }?.let { put("mimeType", it) }
|
||||||
|
size?.takeIf { it > 0 }?.let { put("size", it) }
|
||||||
|
hash?.takeIf { it.isNotBlank() }?.let { put("hash", it) }
|
||||||
|
width?.takeIf { it > 0 }?.let { put("width", it) }
|
||||||
|
height?.takeIf { it > 0 }?.let { put("height", it) }
|
||||||
|
durationMs?.takeIf { it > 0 }?.let { put("durationMs", it) }
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
private fun sendCallSignalMessage(
|
||||||
|
toId: String,
|
||||||
|
chatType: String,
|
||||||
|
msgType: String,
|
||||||
|
action: String,
|
||||||
|
) {
|
||||||
|
sendMessage(
|
||||||
|
toId = toId,
|
||||||
|
chatType = chatType,
|
||||||
|
msgType = msgType,
|
||||||
|
content = JSONObject().apply {
|
||||||
|
put("action", action)
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
package com.xuqm.sdk.im.api
|
package com.xuqm.sdk.im.api
|
||||||
|
|
||||||
import com.xuqm.sdk.im.model.ConversationData
|
import com.xuqm.sdk.im.model.ConversationData
|
||||||
|
import com.xuqm.sdk.im.model.BlacklistEntry
|
||||||
|
import com.xuqm.sdk.im.model.FriendRequest
|
||||||
import com.xuqm.sdk.im.model.ImGroup
|
import com.xuqm.sdk.im.model.ImGroup
|
||||||
|
import com.xuqm.sdk.im.model.GroupJoinRequest
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
@ -27,7 +30,7 @@ data class LoginRequest(
|
|||||||
|
|
||||||
data class LoginResponse(val token: String)
|
data class LoginResponse(val token: String)
|
||||||
|
|
||||||
data class CreateGroupRequest(val name: String, val memberIds: List<String>)
|
data class CreateGroupRequest(val name: String, val memberIds: List<String>, val groupType: String? = null)
|
||||||
|
|
||||||
data class UpdateGroupRequest(val name: String? = null, val announcement: String? = null)
|
data class UpdateGroupRequest(val name: String? = null, val announcement: String? = null)
|
||||||
|
|
||||||
@ -37,6 +40,10 @@ data class SetPinnedRequest(val pinned: Boolean)
|
|||||||
|
|
||||||
data class SetMutedRequest(val muted: Boolean)
|
data class SetMutedRequest(val muted: Boolean)
|
||||||
|
|
||||||
|
data class SetGroupRoleRequest(val userId: String, val role: String)
|
||||||
|
|
||||||
|
data class MuteGroupMemberRequest(val userId: String, val minutes: Long)
|
||||||
|
|
||||||
interface ImApi {
|
interface ImApi {
|
||||||
|
|
||||||
@POST("api/im/auth/login")
|
@POST("api/im/auth/login")
|
||||||
@ -46,6 +53,10 @@ interface ImApi {
|
|||||||
suspend fun fetchHistory(
|
suspend fun fetchHistory(
|
||||||
@Path("toId") toId: String,
|
@Path("toId") toId: String,
|
||||||
@Query("appId") appId: String,
|
@Query("appId") appId: String,
|
||||||
|
@Query("msgType") msgType: String? = null,
|
||||||
|
@Query("keyword") keyword: String? = null,
|
||||||
|
@Query("startTime") startTime: String? = null,
|
||||||
|
@Query("endTime") endTime: String? = null,
|
||||||
@Query("page") page: Int,
|
@Query("page") page: Int,
|
||||||
@Query("size") size: Int,
|
@Query("size") size: Int,
|
||||||
): ApiResponse<List<ImMessage>>
|
): ApiResponse<List<ImMessage>>
|
||||||
@ -54,6 +65,10 @@ interface ImApi {
|
|||||||
suspend fun fetchGroupHistory(
|
suspend fun fetchGroupHistory(
|
||||||
@Path("groupId") groupId: String,
|
@Path("groupId") groupId: String,
|
||||||
@Query("appId") appId: String,
|
@Query("appId") appId: String,
|
||||||
|
@Query("msgType") msgType: String? = null,
|
||||||
|
@Query("keyword") keyword: String? = null,
|
||||||
|
@Query("startTime") startTime: String? = null,
|
||||||
|
@Query("endTime") endTime: String? = null,
|
||||||
@Query("page") page: Int,
|
@Query("page") page: Int,
|
||||||
@Query("size") size: Int,
|
@Query("size") size: Int,
|
||||||
): ApiResponse<List<ImMessage>>
|
): ApiResponse<List<ImMessage>>
|
||||||
@ -61,6 +76,12 @@ interface ImApi {
|
|||||||
@GET("api/im/groups")
|
@GET("api/im/groups")
|
||||||
suspend fun listGroups(@Query("appId") appId: String): ApiResponse<List<ImGroup>>
|
suspend fun listGroups(@Query("appId") appId: String): ApiResponse<List<ImGroup>>
|
||||||
|
|
||||||
|
@GET("api/im/groups/public")
|
||||||
|
suspend fun listPublicGroups(
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
@Query("keyword") keyword: String? = null,
|
||||||
|
): ApiResponse<List<ImGroup>>
|
||||||
|
|
||||||
@POST("api/im/groups")
|
@POST("api/im/groups")
|
||||||
suspend fun createGroup(
|
suspend fun createGroup(
|
||||||
@Query("appId") appId: String,
|
@Query("appId") appId: String,
|
||||||
@ -91,6 +112,48 @@ interface ImApi {
|
|||||||
@DELETE("api/im/groups/{groupId}/members/me")
|
@DELETE("api/im/groups/{groupId}/members/me")
|
||||||
suspend fun leaveGroup(@Path("groupId") groupId: String): ApiResponse<Unit>
|
suspend fun leaveGroup(@Path("groupId") groupId: String): ApiResponse<Unit>
|
||||||
|
|
||||||
|
@POST("api/im/groups/{groupId}/roles")
|
||||||
|
suspend fun setGroupRole(
|
||||||
|
@Path("groupId") groupId: String,
|
||||||
|
@Body request: SetGroupRoleRequest,
|
||||||
|
): ApiResponse<ImGroup>
|
||||||
|
|
||||||
|
@POST("api/im/groups/{groupId}/mute")
|
||||||
|
suspend fun muteGroupMember(
|
||||||
|
@Path("groupId") groupId: String,
|
||||||
|
@Body request: MuteGroupMemberRequest,
|
||||||
|
): ApiResponse<ImGroup>
|
||||||
|
|
||||||
|
@DELETE("api/im/groups/{groupId}")
|
||||||
|
suspend fun dismissGroup(@Path("groupId") groupId: String): ApiResponse<Unit>
|
||||||
|
|
||||||
|
@POST("api/im/groups/{groupId}/join-requests")
|
||||||
|
suspend fun sendGroupJoinRequest(
|
||||||
|
@Path("groupId") groupId: String,
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
@Query("remark") remark: String? = null,
|
||||||
|
): ApiResponse<GroupJoinRequest>
|
||||||
|
|
||||||
|
@GET("api/im/groups/{groupId}/join-requests")
|
||||||
|
suspend fun listGroupJoinRequests(
|
||||||
|
@Path("groupId") groupId: String,
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
): ApiResponse<List<GroupJoinRequest>>
|
||||||
|
|
||||||
|
@POST("api/im/groups/{groupId}/join-requests/{requestId}/accept")
|
||||||
|
suspend fun acceptGroupJoinRequest(
|
||||||
|
@Path("groupId") groupId: String,
|
||||||
|
@Path("requestId") requestId: String,
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
): ApiResponse<GroupJoinRequest>
|
||||||
|
|
||||||
|
@POST("api/im/groups/{groupId}/join-requests/{requestId}/reject")
|
||||||
|
suspend fun rejectGroupJoinRequest(
|
||||||
|
@Path("groupId") groupId: String,
|
||||||
|
@Path("requestId") requestId: String,
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
): ApiResponse<GroupJoinRequest>
|
||||||
|
|
||||||
@GET("api/im/friends")
|
@GET("api/im/friends")
|
||||||
suspend fun listFriends(@Query("appId") appId: String): ApiResponse<List<String>>
|
suspend fun listFriends(@Query("appId") appId: String): ApiResponse<List<String>>
|
||||||
|
|
||||||
@ -106,6 +169,46 @@ interface ImApi {
|
|||||||
@Query("appId") appId: String,
|
@Query("appId") appId: String,
|
||||||
): ApiResponse<Unit>
|
): ApiResponse<Unit>
|
||||||
|
|
||||||
|
@GET("api/im/friend-requests")
|
||||||
|
suspend fun listFriendRequests(
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
@Query("direction") direction: String = "incoming",
|
||||||
|
): ApiResponse<List<FriendRequest>>
|
||||||
|
|
||||||
|
@POST("api/im/friend-requests")
|
||||||
|
suspend fun sendFriendRequest(
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
@Query("toUserId") toUserId: String,
|
||||||
|
@Query("remark") remark: String? = null,
|
||||||
|
): ApiResponse<FriendRequest>
|
||||||
|
|
||||||
|
@POST("api/im/friend-requests/{requestId}/accept")
|
||||||
|
suspend fun acceptFriendRequest(
|
||||||
|
@Path("requestId") requestId: String,
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
): ApiResponse<FriendRequest>
|
||||||
|
|
||||||
|
@POST("api/im/friend-requests/{requestId}/reject")
|
||||||
|
suspend fun rejectFriendRequest(
|
||||||
|
@Path("requestId") requestId: String,
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
): ApiResponse<FriendRequest>
|
||||||
|
|
||||||
|
@GET("api/im/blacklist")
|
||||||
|
suspend fun listBlacklist(@Query("appId") appId: String): ApiResponse<List<BlacklistEntry>>
|
||||||
|
|
||||||
|
@POST("api/im/blacklist")
|
||||||
|
suspend fun addToBlacklist(
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
@Query("blockedUserId") blockedUserId: String,
|
||||||
|
): ApiResponse<BlacklistEntry>
|
||||||
|
|
||||||
|
@DELETE("api/im/blacklist")
|
||||||
|
suspend fun removeFromBlacklist(
|
||||||
|
@Query("appId") appId: String,
|
||||||
|
@Query("blockedUserId") blockedUserId: String,
|
||||||
|
): ApiResponse<Unit>
|
||||||
|
|
||||||
@GET("api/im/conversations")
|
@GET("api/im/conversations")
|
||||||
suspend fun listConversations(@Query("appId") appId: String): ApiResponse<List<ConversationData>>
|
suspend fun listConversations(@Query("appId") appId: String): ApiResponse<List<ConversationData>>
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
package com.xuqm.sdk.im.model
|
package com.xuqm.sdk.im.model
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
data class ImMessage(
|
data class ImMessage(
|
||||||
val id: String,
|
val id: String,
|
||||||
val appId: String,
|
val appId: String,
|
||||||
|
@SerializedName(value = "fromId", alternate = ["fromUserId"])
|
||||||
val fromId: String,
|
val fromId: String,
|
||||||
val toId: String,
|
val toId: String,
|
||||||
val chatType: String,
|
val chatType: String,
|
||||||
@ -10,6 +13,7 @@ data class ImMessage(
|
|||||||
val content: String,
|
val content: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val mentionedUserIds: String? = null,
|
val mentionedUserIds: String? = null,
|
||||||
|
val groupReadCount: Int? = null,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -27,6 +31,7 @@ data class ConversationData(
|
|||||||
data class ImGroup(
|
data class ImGroup(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val groupType: String? = null,
|
||||||
val creatorId: String,
|
val creatorId: String,
|
||||||
val memberIds: String,
|
val memberIds: String,
|
||||||
val adminIds: String,
|
val adminIds: String,
|
||||||
@ -40,11 +45,41 @@ data class UserProfile(
|
|||||||
val avatar: String? = null,
|
val avatar: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class FriendRequest(
|
||||||
|
val id: String,
|
||||||
|
val appId: String,
|
||||||
|
val fromUserId: String,
|
||||||
|
val toUserId: String,
|
||||||
|
val remark: String? = null,
|
||||||
|
val status: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
val reviewedAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GroupJoinRequest(
|
||||||
|
val id: String,
|
||||||
|
val appId: String,
|
||||||
|
val groupId: String,
|
||||||
|
val requesterId: String,
|
||||||
|
val remark: String? = null,
|
||||||
|
val status: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
val reviewedAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BlacklistEntry(
|
||||||
|
val id: String,
|
||||||
|
val appId: String,
|
||||||
|
val userId: String,
|
||||||
|
val blockedUserId: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
enum class ChatType { SINGLE, GROUP }
|
enum class ChatType { SINGLE, GROUP }
|
||||||
|
|
||||||
enum class MsgType {
|
enum class MsgType {
|
||||||
TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY,
|
TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY,
|
||||||
RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD
|
RICH_TEXT, CALL_AUDIO, CALL_VIDEO, FORWARD, QUOTE, MERGE, REVOKED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class MsgStatus { SENDING, SENT, DELIVERED, READ, FAILED, REVOKED }
|
enum class MsgStatus { SENDING, SENT, DELIVERED, READ, FAILED, REVOKED }
|
||||||
|
|||||||
@ -9,11 +9,13 @@ import com.xuqm.sdk.utils.DeviceUtils
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
object PushSDK {
|
object PushSDK {
|
||||||
|
|
||||||
private val api: PushApi get() = ApiClient.create(PushApi::class.java, ServiceEndpointRegistry.pushBaseUrl)
|
private val api: PushApi get() = ApiClient.create(PushApi::class.java, ServiceEndpointRegistry.pushBaseUrl)
|
||||||
private val scope = CoroutineScope(Dispatchers.IO)
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
private val registeredUserId = AtomicReference<String?>(null)
|
||||||
|
|
||||||
fun registerDevice(context: Context, userId: String) {
|
fun registerDevice(context: Context, userId: String) {
|
||||||
XuqmSDK.requireInit()
|
XuqmSDK.requireInit()
|
||||||
@ -27,6 +29,7 @@ object PushSDK {
|
|||||||
vendor = vendor,
|
vendor = vendor,
|
||||||
token = deviceId,
|
token = deviceId,
|
||||||
)
|
)
|
||||||
|
registeredUserId.set(userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -34,7 +37,21 @@ object PushSDK {
|
|||||||
fun unregisterDevice(userId: String) {
|
fun unregisterDevice(userId: String) {
|
||||||
XuqmSDK.requireInit()
|
XuqmSDK.requireInit()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { api.unregisterDevice(XuqmSDK.appId, userId) }
|
runCatching {
|
||||||
|
api.unregisterDevice(XuqmSDK.appId, userId)
|
||||||
|
registeredUserId.compareAndSet(userId, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onSdkLogin(session: com.xuqm.sdk.XuqmLoginSession) {
|
||||||
|
val context = runCatching { XuqmSDK.appContext }.getOrNull() ?: return
|
||||||
|
if (registeredUserId.get() == session.userId) return
|
||||||
|
registerDevice(context, session.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSdkLogout() {
|
||||||
|
val userId = registeredUserId.getAndSet(null) ?: return
|
||||||
|
unregisterDevice(userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户