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/
|
||||
├── sdk-core/ # 核心:初始化、HTTP、Token 存储、通用工具/组件
|
||||
├── sdk-core/ # 核心:初始化、HTTP、Token 存储、文件上传、通用工具/组件
|
||||
├── sdk-im/ # IM:WebSocket 实时通信
|
||||
├── sdk-push/ # 推送:设备 Token 注册
|
||||
├── sdk-update/ # 版本管理:检查更新、下载安装、RN 热更新
|
||||
├── sdk-update/ # 版本管理:检查更新、下载安装
|
||||
└── sample-app/ # 示例 App(Jetpack Compose)
|
||||
```
|
||||
|
||||
@ -141,12 +141,23 @@ val service = RetrofitFactory.create(MyApiService::class.java)
|
||||
- 当前会话本地搜索
|
||||
- 输入草稿自动保存
|
||||
- 群设置支持编辑群名和群公告
|
||||
- 群组扩展 API 已补齐:管理员设置、禁言、解散群
|
||||
- 公开群支持搜索、创建和加群审批
|
||||
- 会话支持本地删除
|
||||
- 显示总未读数
|
||||
- 消息状态直接展示
|
||||
- 会话置顶/免打扰/已读/草稿/删除同步服务端
|
||||
- IM 连接状态提示
|
||||
- SDK 登录态恢复后自动重连
|
||||
- IM token 自动续签,过期前会静默刷新并重连
|
||||
- 群聊支持 `@userId` 提及,并写入 `mentionedUserIds`
|
||||
- 关系链支持好友申请、接受/拒绝、黑名单
|
||||
- 支持图片 / 视频 / 音频 / 文件消息,文件通过独立文件服务上传后再发 IM
|
||||
- 图片支持拍照、摄像、图库选择,文件支持文件管理器选择
|
||||
- 语音支持长按录音、抬手发送
|
||||
- 支持引用回复、群聊已读人数展示
|
||||
- 支持 `LOCATION` / `CUSTOM` / `RICH_TEXT` / `FORWARD` / `QUOTE` / `MERGE` / `CALL_AUDIO` / `CALL_VIDEO` 等通用消息类型发送
|
||||
- 单聊支持已读回执,服务端会把 `READ` 状态推回发送者
|
||||
|
||||
### ImClient
|
||||
|
||||
@ -173,6 +184,15 @@ imClient.send(SendMessageParams(
|
||||
content = "Hello!"
|
||||
))
|
||||
|
||||
// 群聊提及
|
||||
imClient.send(SendMessageParams(
|
||||
toId = "group_001",
|
||||
chatType = ChatType.GROUP,
|
||||
msgType = MsgType.TEXT,
|
||||
content = "@user_002 你好",
|
||||
mentionedUserIds = "user_002",
|
||||
))
|
||||
|
||||
// 撤回消息
|
||||
imClient.revoke(msgId = "uuid")
|
||||
|
||||
@ -182,7 +202,7 @@ imClient.disconnect()
|
||||
|
||||
### 消息类型(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 结构
|
||||
|
||||
@ -210,7 +230,7 @@ data class ImMessage(
|
||||
|
||||
### 推送接入
|
||||
|
||||
在 `PushSDK.initialize()` 后由 SDK 自动完成厂商初始化、设备注册与 token 上传,不再要求业务侧单独调用注册接口。
|
||||
在 `XuqmSDK.login()` 成功后,SDK 会自动完成厂商初始化、设备注册与 token 上传,不再要求业务侧单独调用注册接口。`logout()` 时会自动注销当前设备绑定。
|
||||
|
||||
### 与 IM 联动
|
||||
|
||||
@ -238,23 +258,6 @@ if (result.needsUpdate) {
|
||||
`downloadAndInstall` 会将 APK 下载到 `getExternalFilesDir(null)`,通过 `FileProvider` 触发系统安装。
|
||||
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.ACCESS_NETWORK_STATE" />
|
||||
<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
|
||||
android:name=".XuqmSampleApp"
|
||||
|
||||
@ -36,11 +36,19 @@ data class AuthProfile(
|
||||
|
||||
data class AuthResult(
|
||||
val demoToken: String,
|
||||
val demoTokenExpiresAt: Long? = null,
|
||||
@SerializedName(value = "imToken", alternate = ["userSig"])
|
||||
val userSig: String? = null,
|
||||
val imTokenExpiresAt: Long? = null,
|
||||
val profile: AuthProfile,
|
||||
)
|
||||
|
||||
data class ImRefreshResult(
|
||||
@SerializedName(value = "imToken", alternate = ["userSig"])
|
||||
val userSig: String,
|
||||
val imTokenExpiresAt: Long? = null,
|
||||
)
|
||||
|
||||
data class UserData(
|
||||
val userId: String,
|
||||
val nickname: String,
|
||||
@ -64,6 +72,9 @@ interface DemoApi {
|
||||
@POST("api/demo/auth/register")
|
||||
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")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse<Unit>
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package com.xuqm.sdk.sample.data.local
|
||||
import android.content.Context
|
||||
import com.xuqm.sdk.im.model.ConversationData
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.sample.data.model.searchableText
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@ -61,7 +62,8 @@ class LocalImCache(context: Context) {
|
||||
|
||||
fun mergeHistory(targetId: String, chatType: String, messages: List<ImMessage>) {
|
||||
val merged = (loadHistory(targetId, chatType) + messages)
|
||||
.distinctBy { it.id }
|
||||
.associateBy { it.id }
|
||||
.values
|
||||
.sortedByDescending { it.createdAt }
|
||||
saveHistory(targetId, chatType, merged)
|
||||
}
|
||||
@ -180,15 +182,7 @@ class LocalImCache(context: Context) {
|
||||
}
|
||||
|
||||
private fun ImMessage.matches(keyword: String): Boolean {
|
||||
val plainText = when (msgType.uppercase()) {
|
||||
"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)
|
||||
return searchableText().contains(keyword, ignoreCase = true)
|
||||
}
|
||||
|
||||
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 com.xuqm.sdk.XuqmSDK
|
||||
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.DEMO_APP_ID
|
||||
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.config.SampleEnvironmentConfig
|
||||
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.CoroutineScope
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
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(
|
||||
"xuqm_demo_auth",
|
||||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
||||
@ -39,17 +50,39 @@ class AuthRepository(context: Context) {
|
||||
fun getCurrentNickname(): String? = prefs.getString("nickname", null)
|
||||
fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
|
||||
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
|
||||
fun getCurrentUserSigExpiresAt(): Long = prefs.getLong("user_sig_expires_at", 0L)
|
||||
fun isLoggedIn(): Boolean = getDemoToken() != null
|
||||
|
||||
private fun saveSession(result: AuthResult) {
|
||||
val profile = result.profile
|
||||
prefs.edit()
|
||||
.putString("demo_token", result.demoToken)
|
||||
.putLong("demo_token_expires_at", result.demoTokenExpiresAt ?: 0L)
|
||||
.putString("user_id", profile.userId)
|
||||
.putString("nickname", profile.nickname)
|
||||
.putString("avatar", profile.avatar)
|
||||
.putString("user_sig", result.userSig)
|
||||
.putLong("user_sig_expires_at", result.imTokenExpiresAt ?: 0L)
|
||||
.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> =
|
||||
@ -120,17 +153,66 @@ class AuthRepository(context: Context) {
|
||||
val userId = getCurrentUserId()
|
||||
val userSig = getCurrentUserSig()
|
||||
if (userId.isNullOrBlank() || userSig.isNullOrBlank()) return@runCatching
|
||||
val refreshed = if (shouldRefreshImToken()) {
|
||||
refreshImTokenInternal()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
XuqmSDK.login(
|
||||
userId = userId,
|
||||
userSig = userSig,
|
||||
userSig = refreshed?.userSig ?: userSig,
|
||||
nickname = getCurrentNickname(),
|
||||
avatar = getCurrentAvatar(),
|
||||
)
|
||||
if (refreshed != null) {
|
||||
saveImCredential(refreshed)
|
||||
scheduleImTokenRefresh(refreshed.imTokenExpiresAt)
|
||||
} else {
|
||||
scheduleImTokenRefresh(getCurrentUserSigExpiresAt().takeIf { it > 0L })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
XuqmSDK.logout()
|
||||
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 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.repo.EnvironmentRepository
|
||||
import com.xuqm.sdk.sample.data.local.LocalImCache
|
||||
@ -16,12 +17,15 @@ object AppDependencies {
|
||||
private set
|
||||
lateinit var localContactCache: LocalContactCache
|
||||
private set
|
||||
lateinit var attachmentRepository: AttachmentRepository
|
||||
private set
|
||||
|
||||
fun init(context: Context) {
|
||||
environmentRepository = EnvironmentRepository(context)
|
||||
environmentRepository.current()
|
||||
localImCache = LocalImCache(context)
|
||||
localContactCache = LocalContactCache(context)
|
||||
attachmentRepository = AttachmentRepository(context.applicationContext)
|
||||
authRepository = AuthRepository(context)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,24 @@
|
||||
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.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@ -27,6 +36,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@ -36,17 +46,31 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.xuqm.sdk.im.ImSDK
|
||||
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.ui.InitialAvatar
|
||||
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.launch
|
||||
import java.io.File
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -62,12 +86,93 @@ fun ChatScreen(
|
||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val searchResults by viewModel.searchResults.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 isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
||||
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
||||
val isSendingAttachment by viewModel.isSendingAttachment.collectAsStateWithLifecycle()
|
||||
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
val context = LocalContext.current
|
||||
val voiceRecorder = remember { VoiceRecorder(context.applicationContext) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
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(scrollSignal) {
|
||||
@ -122,11 +227,46 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (chatType == "GROUP" && mentionableUserIds.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("提及", style = MaterialTheme.typography.labelSmall)
|
||||
mentionableUserIds.take(6).forEach { userId ->
|
||||
TextButton(onClick = { viewModel.appendMention(userId) }) {
|
||||
Text("@$userId")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
@ -144,6 +284,75 @@ fun ChatScreen(
|
||||
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 ->
|
||||
Column(
|
||||
@ -170,6 +379,8 @@ fun ChatScreen(
|
||||
MessageBubble(
|
||||
message = msg,
|
||||
isOwn = msg.fromId == viewModel.currentUserId,
|
||||
currentUserId = viewModel.currentUserId,
|
||||
onReply = viewModel::startReply,
|
||||
)
|
||||
}
|
||||
if (searchResults.isEmpty()) {
|
||||
@ -197,6 +408,8 @@ fun ChatScreen(
|
||||
MessageBubble(
|
||||
message = msg,
|
||||
isOwn = msg.fromId == viewModel.currentUserId,
|
||||
currentUserId = viewModel.currentUserId,
|
||||
onReply = viewModel::startReply,
|
||||
)
|
||||
}
|
||||
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
|
||||
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 bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
@ -244,19 +494,49 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
|
||||
color = bubbleColor,
|
||||
modifier = Modifier
|
||||
.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)) {
|
||||
Text(
|
||||
when (message.msgType.uppercase()) {
|
||||
"IMAGE" -> ImageBubble(media)
|
||||
"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) {
|
||||
Text(
|
||||
text = statusLabel(message.status),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
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()) {
|
||||
"SENDING" -> "发送中"
|
||||
"SENT" -> "已发送"
|
||||
@ -287,10 +683,18 @@ private fun parseContent(message: ImMessage): String {
|
||||
"TEXT" -> runCatching {
|
||||
org.json.JSONObject(message.content).getString("text")
|
||||
}.getOrDefault(message.content)
|
||||
"IMAGE" -> "[图片]"
|
||||
"IMAGE" -> message.previewText()
|
||||
"AUDIO" -> "[语音]"
|
||||
"VIDEO" -> "[视频]"
|
||||
"FILE" -> "[文件]"
|
||||
"VIDEO" -> message.previewText()
|
||||
"FILE" -> message.previewText()
|
||||
"LOCATION" -> "[位置]"
|
||||
"CUSTOM" -> "[自定义]"
|
||||
"RICH_TEXT" -> "[富文本]"
|
||||
"FORWARD" -> "[转发]"
|
||||
"QUOTE" -> "[引用]"
|
||||
"MERGE" -> "[合并转发]"
|
||||
"CALL_AUDIO" -> "[语音通话]"
|
||||
"CALL_VIDEO" -> "[视频通话]"
|
||||
"REVOKED" -> "[消息已撤回]"
|
||||
"NOTIFY" -> runCatching {
|
||||
org.json.JSONObject(message.content).getString("content")
|
||||
@ -298,3 +702,20 @@ private fun parseContent(message: ImMessage): String {
|
||||
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
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.ImMessage
|
||||
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.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@ -35,6 +38,15 @@ class ChatViewModel : ViewModel() {
|
||||
private val _draftText = MutableStateFlow("")
|
||||
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
|
||||
|
||||
private lateinit var targetId: String
|
||||
@ -51,7 +63,7 @@ class ChatViewModel : ViewModel() {
|
||||
ConversationData(
|
||||
targetId = targetId,
|
||||
chatType = chatType,
|
||||
lastMsgContent = message.content,
|
||||
lastMsgContent = message.previewText(),
|
||||
lastMsgType = message.msgType,
|
||||
lastMsgTime = message.createdAt,
|
||||
unreadCount = 0,
|
||||
@ -63,6 +75,11 @@ class ChatViewModel : ViewModel() {
|
||||
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
||||
}
|
||||
requestScrollToBottom()
|
||||
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
|
||||
viewModelScope.launch {
|
||||
runCatching { ImSDK.markRead(targetId, chatType) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,7 +91,7 @@ class ChatViewModel : ViewModel() {
|
||||
ConversationData(
|
||||
targetId = targetId,
|
||||
chatType = chatType,
|
||||
lastMsgContent = message.content,
|
||||
lastMsgContent = message.previewText(),
|
||||
lastMsgType = message.msgType,
|
||||
lastMsgTime = message.createdAt,
|
||||
unreadCount = 0,
|
||||
@ -86,12 +103,20 @@ class ChatViewModel : ViewModel() {
|
||||
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
||||
}
|
||||
requestScrollToBottom()
|
||||
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
|
||||
viewModelScope.launch {
|
||||
runCatching { ImSDK.markRead(targetId, chatType) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun init(targetId: String, chatType: String) {
|
||||
if (initialized && this.targetId == targetId && this.chatType == chatType) return
|
||||
if (initialized && this.chatType == "GROUP") {
|
||||
ImSDK.unsubscribeGroup(this.targetId)
|
||||
}
|
||||
this.targetId = targetId
|
||||
this.chatType = chatType
|
||||
nextHistoryPage = 0
|
||||
@ -102,8 +127,16 @@ class ChatViewModel : ViewModel() {
|
||||
_searchQuery.value = ""
|
||||
_searchResults.value = emptyList()
|
||||
_draftText.value = cache.loadDraft(targetId, chatType)
|
||||
_mentionableUserIds.value = emptyList()
|
||||
_replyTargetMessage.value = null
|
||||
ImSDK.addListener(listener)
|
||||
if (chatType == "GROUP") {
|
||||
ImSDK.subscribeGroup(targetId)
|
||||
}
|
||||
loadInitialHistory()
|
||||
if (chatType == "GROUP") {
|
||||
loadMentionableUsers()
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { ImSDK.markRead(targetId, chatType) }
|
||||
}
|
||||
@ -140,7 +173,7 @@ class ChatViewModel : ViewModel() {
|
||||
ConversationData(
|
||||
targetId = targetId,
|
||||
chatType = chatType,
|
||||
lastMsgContent = last.content,
|
||||
lastMsgContent = last.previewText(),
|
||||
lastMsgType = last.msgType,
|
||||
lastMsgTime = last.createdAt,
|
||||
unreadCount = 0,
|
||||
@ -176,7 +209,19 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
fun sendText(content: String) {
|
||||
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 = ""
|
||||
cache.clearDraft(targetId, chatType)
|
||||
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() {
|
||||
_searchQuery.value = ""
|
||||
_searchResults.value = emptyList()
|
||||
}
|
||||
|
||||
private fun prependMessage(message: ImMessage) {
|
||||
if (_messages.value.any { it.id == message.id }) return
|
||||
_messages.value = listOf(message) + _messages.value
|
||||
val updated = _messages.value.toMutableList()
|
||||
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> =
|
||||
@ -229,6 +333,85 @@ class ChatViewModel : ViewModel() {
|
||||
_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 {
|
||||
return if (chatType == "GROUP") {
|
||||
message.chatType == "GROUP" && message.toId == targetId
|
||||
@ -238,6 +421,9 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
if (initialized && chatType == "GROUP") {
|
||||
ImSDK.unsubscribeGroup(targetId)
|
||||
}
|
||||
ImSDK.removeListener(listener)
|
||||
initialized = false
|
||||
}
|
||||
|
||||
@ -25,6 +25,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.local.LocalContactCache
|
||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||
@ -47,9 +49,17 @@ class ContactViewModel(
|
||||
private val _searchResults = MutableStateFlow<List<UserData>>(emptyList())
|
||||
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 {
|
||||
_friends.value = cache.resolveFriends(cache.loadFriendIds())
|
||||
loadFriends()
|
||||
loadFriendRequests()
|
||||
loadBlacklist()
|
||||
}
|
||||
|
||||
fun loadFriends() {
|
||||
@ -84,7 +94,7 @@ class ContactViewModel(
|
||||
|
||||
fun addFriend(userId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { ImSDK.addFriend(userId) }
|
||||
runCatching { ImSDK.sendFriendRequest(userId) }
|
||||
.onSuccess { loadFriends() }
|
||||
}
|
||||
}
|
||||
@ -95,6 +105,48 @@ class ContactViewModel(
|
||||
.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
|
||||
@ -108,6 +160,8 @@ fun ContactScreen(
|
||||
) {
|
||||
val friends by viewModel.friends.collectAsStateWithLifecycle()
|
||||
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
|
||||
val friendRequests by viewModel.friendRequests.collectAsStateWithLifecycle()
|
||||
val blacklist by viewModel.blacklist.collectAsStateWithLifecycle()
|
||||
var keyword by remember { mutableStateOf("") }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@ -120,6 +174,32 @@ fun ContactScreen(
|
||||
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()) {
|
||||
Text(
|
||||
"联系人(${friends.size})",
|
||||
@ -152,14 +232,37 @@ fun ContactScreen(
|
||||
color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
if (!isFriend) {
|
||||
TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("添加好友") }
|
||||
TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("申请好友") }
|
||||
} else {
|
||||
TextButton(onClick = { onOpenChat(user.userId) }) { Text("发消息") }
|
||||
}
|
||||
TextButton(onClick = { viewModel.addToBlacklist(user.userId) }) { Text("拉黑") }
|
||||
}
|
||||
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 ->
|
||||
if (query.isBlank()) return@filter true
|
||||
val keyword = query.trim()
|
||||
val preview = conversationPreview(conversation)
|
||||
conversation.targetId.contains(keyword, ignoreCase = true) ||
|
||||
(conversation.lastMsgContent ?: "").contains(keyword, ignoreCase = true) ||
|
||||
preview.contains(keyword, ignoreCase = true) ||
|
||||
conversation.chatType.contains(keyword, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@ -148,7 +149,7 @@ private fun ConversationItem(
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
conversation.lastMsgContent ?: "",
|
||||
conversationPreview(conversation),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
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.compose.collectAsStateWithLifecycle
|
||||
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.GroupJoinRequest
|
||||
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -51,6 +53,8 @@ fun GroupListScreen(
|
||||
viewModel: GroupViewModel = viewModel(),
|
||||
) {
|
||||
val groups by viewModel.groups.collectAsStateWithLifecycle()
|
||||
val publicGroups by viewModel.publicGroups.collectAsStateWithLifecycle()
|
||||
val publicGroupQuery by viewModel.publicGroupQuery.collectAsStateWithLifecycle()
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
@ -63,6 +67,21 @@ fun GroupListScreen(
|
||||
LazyColumn(
|
||||
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 ->
|
||||
GroupItem(
|
||||
group = group,
|
||||
@ -71,15 +90,32 @@ fun GroupListScreen(
|
||||
)
|
||||
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) {
|
||||
CreateGroupDialog(
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onCreate = { name, memberIds ->
|
||||
onCreate = { name, memberIds, groupType ->
|
||||
showCreateDialog = false
|
||||
viewModel.createGroup(name, memberIds) { group ->
|
||||
viewModel.createGroup(name, memberIds, groupType) { group ->
|
||||
onOpenGroupChat(group.id, group.name)
|
||||
}
|
||||
},
|
||||
@ -103,15 +139,42 @@ private fun GroupItem(group: ImGroup, onClick: () -> Unit, onSettings: () -> Uni
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
Text(
|
||||
text = group.groupType?.let { "类型:$it" } ?: "类型:WORK",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onSettings) { Text("设置") }
|
||||
}
|
||||
}
|
||||
|
||||
@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 memberInput by remember { mutableStateOf("") }
|
||||
var isPublicGroup by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@ -130,6 +193,9 @@ private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<Str
|
||||
label = { Text("成员 ID(逗号分隔)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
TextButton(onClick = { isPublicGroup = !isPublicGroup }) {
|
||||
Text(if (isPublicGroup) "群类型:公开群" else "群类型:普通群")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@ -137,7 +203,7 @@ private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<Str
|
||||
onClick = {
|
||||
val members = memberInput.split(",").map { it.trim() }.filter { it.isNotBlank() }
|
||||
val allMembers = (members + listOf(ImSDK.currentUserId)).distinct()
|
||||
onCreate(name.trim(), allMembers)
|
||||
onCreate(name.trim(), allMembers, if (isPublicGroup) "PUBLIC" else "WORK")
|
||||
},
|
||||
enabled = name.isNotBlank(),
|
||||
) { Text("创建") }
|
||||
@ -154,6 +220,7 @@ fun GroupSettingsScreen(
|
||||
viewModel: GroupViewModel = viewModel(),
|
||||
) {
|
||||
val group by viewModel.currentGroup.collectAsStateWithLifecycle()
|
||||
val joinRequests by viewModel.joinRequests.collectAsStateWithLifecycle()
|
||||
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
||||
var showEditDialog by remember { mutableStateOf(false) }
|
||||
var editName by remember { mutableStateOf("") }
|
||||
@ -163,6 +230,11 @@ fun GroupSettingsScreen(
|
||||
LaunchedEffect(group) {
|
||||
editName = group?.name.orEmpty()
|
||||
editAnnouncement = group?.announcement.orEmpty()
|
||||
if (group?.groupType?.equals("PUBLIC", ignoreCase = true) == true) {
|
||||
viewModel.loadJoinRequests(groupId)
|
||||
} else {
|
||||
viewModel.clearJoinRequests()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@ -191,6 +263,8 @@ fun GroupSettingsScreen(
|
||||
Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline)
|
||||
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)
|
||||
if (isOwner) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@ -199,6 +273,24 @@ fun GroupSettingsScreen(
|
||||
}
|
||||
}
|
||||
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)
|
||||
val memberIds = g.memberIds.split(",").filter { it.isNotBlank() }
|
||||
memberIds.forEach { memberId ->
|
||||
@ -208,6 +300,12 @@ fun GroupSettingsScreen(
|
||||
) {
|
||||
Text(memberId, modifier = Modifier.weight(1f))
|
||||
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) }) {
|
||||
Text("移除", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
@ -215,6 +313,14 @@ fun GroupSettingsScreen(
|
||||
}
|
||||
}
|
||||
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(
|
||||
onClick = { viewModel.leaveGroup(g.id) { onNavigateBack() } },
|
||||
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.viewModelScope
|
||||
import com.xuqm.sdk.im.ImSDK
|
||||
import com.xuqm.sdk.im.model.GroupJoinRequest
|
||||
import com.xuqm.sdk.im.model.ImGroup
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -13,10 +14,22 @@ class GroupViewModel : ViewModel() {
|
||||
private val _groups = MutableStateFlow<List<ImGroup>>(emptyList())
|
||||
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)
|
||||
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() {
|
||||
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 {
|
||||
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() } }
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
viewModelScope.launch {
|
||||
runCatching { ImSDK.removeGroupMember(groupId, userId) }
|
||||
@ -59,4 +101,35 @@ class GroupViewModel : ViewModel() {
|
||||
.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.SystemUpdate
|
||||
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.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
@ -19,19 +22,26 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.conversation.ConversationScreen
|
||||
import com.xuqm.sdk.sample.ui.group.GroupListScreen
|
||||
import com.xuqm.sdk.sample.ui.profile.ProfileScreen
|
||||
import com.xuqm.sdk.sample.ui.update.UpdateScreen
|
||||
import kotlinx.coroutines.launch
|
||||
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
||||
|
||||
private data class BottomTab(val label: String, val icon: ImageVector)
|
||||
@ -54,6 +64,19 @@ fun MainScreen(
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
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(
|
||||
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 androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@ -15,17 +14,17 @@ import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.xuqm.sdk.update.UpdateSDK
|
||||
import com.xuqm.sdk.update.model.UpdateInfo
|
||||
import com.xuqm.sdk.sample.di.AppDependencies
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@ -69,6 +68,12 @@ fun UpdateScreen(viewModel: UpdateViewModel = viewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (state.appUpdate == null && !state.isChecking) {
|
||||
viewModel.checkAppUpdate(context)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@ -15,6 +15,9 @@ object XuqmSDK {
|
||||
lateinit var config: SDKConfig
|
||||
private set
|
||||
|
||||
lateinit var appContext: Context
|
||||
private set
|
||||
|
||||
lateinit var tokenStore: TokenStore
|
||||
private set
|
||||
|
||||
@ -28,6 +31,7 @@ object XuqmSDK {
|
||||
logLevel: LogLevel = LogLevel.WARN,
|
||||
) {
|
||||
config = SDKConfig(appId, logLevel)
|
||||
appContext = context.applicationContext
|
||||
tokenStore = TokenStore(context.applicationContext)
|
||||
ApiClient.init(config, tokenStore)
|
||||
initialized = true
|
||||
|
||||
@ -6,6 +6,7 @@ data class ServiceEndpoints(
|
||||
val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im",
|
||||
val pushBaseUrl: String = "https://dev.xuqinmin.com/",
|
||||
val updateBaseUrl: String = "https://update.dev.xuqinmin.com/",
|
||||
val fileBaseUrl: String = "https://file.dev.xuqinmin.com/",
|
||||
)
|
||||
|
||||
object ServiceEndpointRegistry {
|
||||
@ -19,6 +20,7 @@ object ServiceEndpointRegistry {
|
||||
val imWsUrl: String get() = current.imWsUrl
|
||||
val pushBaseUrl: String get() = current.pushBaseUrl
|
||||
val updateBaseUrl: String get() = current.updateBaseUrl
|
||||
val fileBaseUrl: String get() = current.fileBaseUrl
|
||||
|
||||
fun configure(endpoints: ServiceEndpoints) {
|
||||
current = endpoints
|
||||
@ -30,8 +32,9 @@ object ServiceEndpointRegistry {
|
||||
controlBaseUrl = "http://$normalizedHost:8081/",
|
||||
imApiBaseUrl = "http://$normalizedHost:8082/",
|
||||
imWsUrl = "ws://$normalizedHost:8082/ws/im",
|
||||
pushBaseUrl = "http://$normalizedHost:8081/",
|
||||
pushBaseUrl = "http://$normalizedHost:8083/",
|
||||
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.xuqm.sdk.im.listener.ImEventListener
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.net.URI
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ -21,8 +19,11 @@ class ImClient(
|
||||
) {
|
||||
private var webSocket: WebSocket? = null
|
||||
private val listeners = CopyOnWriteArrayList<ImEventListener>()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + Job())
|
||||
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()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
@ -30,19 +31,151 @@ class ImClient(
|
||||
.build()
|
||||
|
||||
fun connect() {
|
||||
disconnect(closeSocket = false)
|
||||
val request = Request.Builder()
|
||||
.url(wsUrl)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
webSocket = okhttp.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(ws: WebSocket, response: Response) {
|
||||
listeners.forEach { it.onConnected() }
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
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 {
|
||||
val msg = gson.fromJson(text, ImMessage::class.java)
|
||||
if (msg.chatType == "GROUP") {
|
||||
val msg = gson.fromJson(body, ImMessage::class.java)
|
||||
if (msg.chatType.uppercase() == "GROUP") {
|
||||
listeners.forEach { it.onGroupMessage(msg) }
|
||||
} else {
|
||||
listeners.forEach { it.onMessage(msg) }
|
||||
@ -51,31 +184,94 @@ class ImClient(
|
||||
listeners.forEach { it.onError("Parse error: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
|
||||
listeners.forEach { it.onDisconnected(t.message) }
|
||||
"ERROR" -> {
|
||||
val reason = body.ifBlank { headers["message"].orEmpty() }
|
||||
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) {
|
||||
val payload = mapOf(
|
||||
"appId" to appId, "toId" to toId,
|
||||
"chatType" to chatType, "msgType" to msgType,
|
||||
"content" to content,
|
||||
private fun sendSubscribe(destination: String, subscriptionId: String) {
|
||||
sendFrame(
|
||||
"SUBSCRIBE",
|
||||
mapOf(
|
||||
"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)
|
||||
fun removeListener(listener: ImEventListener) = listeners.remove(listener)
|
||||
private fun sendFrame(command: String, headers: Map<String, String>, body: String?) {
|
||||
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> {
|
||||
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
|
||||
}
|
||||
|
||||
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.CreateGroupRequest
|
||||
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.SetPinnedRequest
|
||||
import com.xuqm.sdk.im.api.SetGroupRoleRequest
|
||||
import com.xuqm.sdk.im.api.UpdateGroupRequest
|
||||
import com.xuqm.sdk.im.listener.ImEventListener
|
||||
import com.xuqm.sdk.im.model.ImConnectionState
|
||||
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.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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.time.LocalDateTime
|
||||
|
||||
object ImSDK {
|
||||
|
||||
@ -69,29 +78,324 @@ object ImSDK {
|
||||
suspend fun loginWithToken(userId: String, token: String) =
|
||||
loginWithUserSig(userId, token)
|
||||
|
||||
fun sendMessage(toId: String, chatType: String, msgType: String, content: String) {
|
||||
client?.sendMessage(toId, chatType, msgType, content)
|
||||
fun sendMessage(
|
||||
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> =
|
||||
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) {
|
||||
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> =
|
||||
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) {
|
||||
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> =
|
||||
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
|
||||
|
||||
suspend fun createGroup(name: String, memberIds: List<String>): ImGroup? =
|
||||
withContext(Dispatchers.IO) { api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds)).data }
|
||||
suspend fun createGroup(name: String, memberIds: List<String>, groupType: String = "WORK"): ImGroup? =
|
||||
withContext(Dispatchers.IO) {
|
||||
api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds, groupType)).data
|
||||
}
|
||||
|
||||
suspend fun getGroupInfo(groupId: String): ImGroup? =
|
||||
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) =
|
||||
withContext(Dispatchers.IO) { api.updateGroupInfo(groupId, UpdateGroupRequest(name, announcement)) }
|
||||
|
||||
@ -104,6 +408,27 @@ object ImSDK {
|
||||
suspend fun leaveGroup(groupId: String) =
|
||||
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> =
|
||||
withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() }
|
||||
|
||||
@ -113,6 +438,27 @@ object ImSDK {
|
||||
suspend fun removeFriend(friendId: String) =
|
||||
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> =
|
||||
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() }
|
||||
|
||||
@ -175,4 +521,42 @@ object ImSDK {
|
||||
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
|
||||
|
||||
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.GroupJoinRequest
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
@ -27,7 +30,7 @@ data class LoginRequest(
|
||||
|
||||
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)
|
||||
|
||||
@ -37,6 +40,10 @@ data class SetPinnedRequest(val pinned: 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 {
|
||||
|
||||
@POST("api/im/auth/login")
|
||||
@ -46,6 +53,10 @@ interface ImApi {
|
||||
suspend fun fetchHistory(
|
||||
@Path("toId") toId: 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("size") size: Int,
|
||||
): ApiResponse<List<ImMessage>>
|
||||
@ -54,6 +65,10 @@ interface ImApi {
|
||||
suspend fun fetchGroupHistory(
|
||||
@Path("groupId") groupId: 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("size") size: Int,
|
||||
): ApiResponse<List<ImMessage>>
|
||||
@ -61,6 +76,12 @@ interface ImApi {
|
||||
@GET("api/im/groups")
|
||||
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")
|
||||
suspend fun createGroup(
|
||||
@Query("appId") appId: String,
|
||||
@ -91,6 +112,48 @@ interface ImApi {
|
||||
@DELETE("api/im/groups/{groupId}/members/me")
|
||||
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")
|
||||
suspend fun listFriends(@Query("appId") appId: String): ApiResponse<List<String>>
|
||||
|
||||
@ -106,6 +169,46 @@ interface ImApi {
|
||||
@Query("appId") appId: String,
|
||||
): 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")
|
||||
suspend fun listConversations(@Query("appId") appId: String): ApiResponse<List<ConversationData>>
|
||||
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
package com.xuqm.sdk.im.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class ImMessage(
|
||||
val id: String,
|
||||
val appId: String,
|
||||
@SerializedName(value = "fromId", alternate = ["fromUserId"])
|
||||
val fromId: String,
|
||||
val toId: String,
|
||||
val chatType: String,
|
||||
@ -10,6 +13,7 @@ data class ImMessage(
|
||||
val content: String,
|
||||
val status: String,
|
||||
val mentionedUserIds: String? = null,
|
||||
val groupReadCount: Int? = null,
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
@ -27,6 +31,7 @@ data class ConversationData(
|
||||
data class ImGroup(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val groupType: String? = null,
|
||||
val creatorId: String,
|
||||
val memberIds: String,
|
||||
val adminIds: String,
|
||||
@ -40,11 +45,41 @@ data class UserProfile(
|
||||
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 MsgType {
|
||||
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 }
|
||||
|
||||
@ -9,11 +9,13 @@ import com.xuqm.sdk.utils.DeviceUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
object PushSDK {
|
||||
|
||||
private val api: PushApi get() = ApiClient.create(PushApi::class.java, ServiceEndpointRegistry.pushBaseUrl)
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
private val registeredUserId = AtomicReference<String?>(null)
|
||||
|
||||
fun registerDevice(context: Context, userId: String) {
|
||||
XuqmSDK.requireInit()
|
||||
@ -27,6 +29,7 @@ object PushSDK {
|
||||
vendor = vendor,
|
||||
token = deviceId,
|
||||
)
|
||||
registeredUserId.set(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,7 +37,21 @@ object PushSDK {
|
||||
fun unregisterDevice(userId: String) {
|
||||
XuqmSDK.requireInit()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户