From bee82637f31dcef52605951664814c018ea0711f Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Apr 2026 09:45:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=B7=BB=E5=8A=A0=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E5=8A=9F=E8=83=BD=E7=9B=B8=E5=85=B3API=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E3=80=81=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98=E5=92=8C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加DemoApi接口定义用户认证和资料管理API - 实现LocalImCache用于本地存储IM对话和消息历史 - 添加MessageContent模型处理多媒体消息内容 - 创建AttachmentRepository处理图片视频音频文件发送 - 实现AuthRepository管理用户登录注册和会话 - 添加VoiceRecorder支持语音录制功能 - 创建AppDependencies依赖注入容器 - 添加ChatScreen界面组件实现聊天UI逻辑 --- README.md | 45 +- sample-app/src/main/AndroidManifest.xml | 2 + .../com/xuqm/sdk/sample/data/api/DemoApi.kt | 11 + .../sdk/sample/data/local/LocalImCache.kt | 14 +- .../sdk/sample/data/model/MessageContent.kt | 104 ++++ .../sample/data/repo/AttachmentRepository.kt | 216 ++++++++ .../sdk/sample/data/repo/AuthRepository.kt | 84 +++- .../sdk/sample/data/repo/VoiceRecorder.kt | 80 +++ .../com/xuqm/sdk/sample/di/AppDependencies.kt | 4 + .../com/xuqm/sdk/sample/ui/chat/ChatScreen.kt | 473 +++++++++++++++++- .../xuqm/sdk/sample/ui/chat/ChatViewModel.kt | 198 +++++++- .../sdk/sample/ui/contact/ContactScreen.kt | 107 +++- .../ui/conversation/ConversationScreen.kt | 26 +- .../xuqm/sdk/sample/ui/group/GroupScreen.kt | 140 +++++- .../sdk/sample/ui/group/GroupViewModel.kt | 79 ++- .../com/xuqm/sdk/sample/ui/main/MainScreen.kt | 67 +++ .../xuqm/sdk/sample/ui/update/UpdateScreen.kt | 11 +- .../src/main/java/com/xuqm/sdk/XuqmSDK.kt | 4 + .../com/xuqm/sdk/core/ServiceEndpoints.kt | 5 +- .../main/java/com/xuqm/sdk/file/FileSDK.kt | 127 +++++ .../src/main/java/com/xuqm/sdk/im/ImClient.kt | 252 ++++++++-- sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt | 396 ++++++++++++++- .../main/java/com/xuqm/sdk/im/api/ImApi.kt | 105 +++- .../java/com/xuqm/sdk/im/model/ImMessage.kt | 37 +- .../main/java/com/xuqm/sdk/push/PushSDK.kt | 19 +- 25 files changed, 2490 insertions(+), 116 deletions(-) create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/data/model/MessageContent.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/VoiceRecorder.kt create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt diff --git a/README.md b/README.md index 8043a89..cb0493c 100644 --- a/README.md +++ b/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 -} -``` - --- ## 发版 diff --git a/sample-app/src/main/AndroidManifest.xml b/sample-app/src/main/AndroidManifest.xml index 9e6d2e5..fd0b0eb 100644 --- a/sample-app/src/main/AndroidManifest.xml +++ b/sample-app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + + @POST("api/demo/auth/refresh-im") + suspend fun refreshImToken(@Query("appId") appId: String = DEMO_APP_ID): DemoResponse + @POST("api/demo/auth/reset-password") suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt index 462823e..194e81b 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt @@ -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) { 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" diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/model/MessageContent.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/model/MessageContent.kt new file mode 100644 index 0000000..587f567 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/model/MessageContent.kt @@ -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) diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt new file mode 100644 index 0000000..772552e --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt @@ -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 = + sendMedia(targetId, chatType, uri, MediaKind.IMAGE) + + suspend fun sendImageBytes( + targetId: String, + chatType: String, + fileName: String, + bytes: ByteArray, + width: Int? = null, + height: Int? = null, + ): Result = 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 = + sendMedia(targetId, chatType, uri, MediaKind.VIDEO) + + suspend fun sendFile(targetId: String, chatType: String, uri: Uri): Result = + sendMedia(targetId, chatType, uri, MediaKind.FILE) + + suspend fun sendAudio(targetId: String, chatType: String, uri: Uri): Result = + sendMedia(targetId, chatType, uri, MediaKind.AUDIO) + + suspend fun sendAudioBytes( + targetId: String, + chatType: String, + fileName: String, + bytes: ByteArray, + durationMs: Long? = null, + ): Result = 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 = 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? { + 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? { + 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, + ) +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt index 8260f66..0b496a9 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt @@ -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 = @@ -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 + } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/VoiceRecorder.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/VoiceRecorder.kt new file mode 100644 index 0000000..07a97ac --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/VoiceRecorder.kt @@ -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 + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt index aa1076e..796a31b 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt @@ -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) } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt index 1de4a32..af90c3a 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt @@ -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(null) } + var pendingCaptureUri by remember { mutableStateOf(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,29 +227,133 @@ fun ChatScreen( } }, bottomBar = { - Row( + Column( modifier = Modifier .fillMaxWidth() .imePadding() .padding(8.dp), - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedTextField( - value = draftText, - onValueChange = viewModel::updateDraft, - modifier = Modifier.weight(1f), - placeholder = { Text("输入消息…") }, - maxLines = 4, - shape = RoundedCornerShape(24.dp), - ) - IconButton( - onClick = { viewModel.sendText(draftText) }, - enabled = draftText.isNotBlank(), - ) { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) + 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( + value = draftText, + onValueChange = viewModel::updateDraft, + modifier = Modifier.weight(1f), + placeholder = { Text("输入消息…") }, + maxLines = 4, + shape = RoundedCornerShape(24.dp), + ) + IconButton( + onClick = { viewModel.sendText(draftText) }, + enabled = draftText.isNotBlank(), + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { pickImage.launch("image/*") }) { Text("相册图片") } + TextButton(onClick = { requestCameraAction(CameraAction.PHOTO) }) { Text("拍照") } + TextButton(onClick = { pickVideo.launch("video/*") }) { Text("相册视频") } + TextButton(onClick = { requestCameraAction(CameraAction.VIDEO) }) { Text("摄像") } + TextButton(onClick = { pickFile.launch(arrayOf("*/*")) }) { Text("文件管理器") } + } + Surface( + shape = RoundedCornerShape(16.dp), + color = if (isRecordingVoice) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + if (!hasAudioPermission(context)) { + voiceHint = "请先授权麦克风权限" + recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + return@detectTapGestures + } + if (!voiceRecorder.start()) { + voiceHint = "录音启动失败" + return@detectTapGestures + } + isRecordingVoice = true + voiceHint = "松开发送" + try { + val released = tryAwaitRelease() + if (released) { + val recorded = voiceRecorder.stop() + if (recorded != null) { + coroutineScope.launch { + viewModel.sendAudioRecording(recorded) + } + } else { + voiceHint = "录音失败" + } + } else { + voiceRecorder.cancel() + } + } finally { + isRecordingVoice = false + if (!voiceHint.contains("权限") && voiceHint != "录音失败") { + voiceHint = "按住说话" + } + } + } + ) + } + ) { + Text( + text = voiceHint, + style = MaterialTheme.typography.labelSmall, + color = if (isRecordingVoice) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + if (isSendingAttachment) { + Text( + text = "附件上传中…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + }, ) { padding -> Column( modifier = Modifier @@ -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( - text = parseContent(message), - style = MaterialTheme.typography.bodyMedium, - ) + 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" +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt index ffb2394..f61822f 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt @@ -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 = _draftText + private val _replyTargetMessage = MutableStateFlow(null) + val replyTargetMessage: StateFlow = _replyTargetMessage + + private val _mentionableUserIds = MutableStateFlow>(emptyList()) + val mentionableUserIds: StateFlow> = _mentionableUserIds + + private val _isSendingAttachment = MutableStateFlow(false) + val isSendingAttachment: StateFlow = _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): List = @@ -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 } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt index a8f3a32..ff7edff 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt @@ -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>(emptyList()) val searchResults: StateFlow> = _searchResults + private val _friendRequests = MutableStateFlow>(emptyList()) + val friendRequests: StateFlow> = _friendRequests + + private val _blacklist = MutableStateFlow>(emptyList()) + val blacklist: StateFlow> = _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() + } + } + } } } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt index fc42033..f72e3ec 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt @@ -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() + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt index c693eb7..87b68fe 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt @@ -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) -> 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) -> 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 Unit, onCreate: (String, List + 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("拒绝") } + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt index 81b6e21..36f7252 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt @@ -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>(emptyList()) val groups: StateFlow> = _groups + private val _publicGroups = MutableStateFlow>(emptyList()) + val publicGroups: StateFlow> = _publicGroups + + private val _publicGroupQuery = MutableStateFlow("") + val publicGroupQuery: StateFlow = _publicGroupQuery + private val _currentGroup = MutableStateFlow(null) val currentGroup: StateFlow = _currentGroup - init { loadGroups() } + private val _joinRequests = MutableStateFlow>(emptyList()) + val joinRequests: StateFlow> = _joinRequests + + init { + loadGroups() + searchPublicGroups("") + } fun loadGroups() { viewModelScope.launch { @@ -32,9 +45,17 @@ class GroupViewModel : ViewModel() { } } - fun createGroup(name: String, memberIds: List, 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, 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) } + } + } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt index 67d92bf..477e276 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt @@ -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(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("稍后") + } + } + } + ) + } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt index 585237c..22367ee 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt @@ -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() diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt index 4be1dbb..a1e6968 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt @@ -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 diff --git a/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt b/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt index 899d55d..7c9a0fa 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt @@ -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/", ) } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt new file mode 100644 index 0000000..1578955 --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt @@ -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( + 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 +} + +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") + } + } +} diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt index f842287..b65c3e1 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt @@ -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() - private val scope = CoroutineScope(Dispatchers.IO + Job()) private val gson = Gson() + private val subscriptions = mutableMapOf() + 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, 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() { - webSocket?.close(1000, "User disconnect") + private fun parseHeaders(lines: List): Map { + val headers = linkedMapOf() + 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() + } } diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt index b77728b..2db290d 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt @@ -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, + ) { + 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 = + 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 = 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 = + 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 = 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 = withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() } - suspend fun createGroup(name: String, memberIds: List): ImGroup? = - withContext(Dispatchers.IO) { api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds)).data } + suspend fun createGroup(name: String, memberIds: List, 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 = + 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 = + 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 = 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 = + 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 = + 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 = 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(), + ) + } } diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt index 72a79a7..00f4780 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt @@ -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) +data class CreateGroupRequest(val name: String, val memberIds: List, 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> @@ -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> @@ -61,6 +76,12 @@ interface ImApi { @GET("api/im/groups") suspend fun listGroups(@Query("appId") appId: String): ApiResponse> + @GET("api/im/groups/public") + suspend fun listPublicGroups( + @Query("appId") appId: String, + @Query("keyword") keyword: String? = null, + ): ApiResponse> + @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 + @POST("api/im/groups/{groupId}/roles") + suspend fun setGroupRole( + @Path("groupId") groupId: String, + @Body request: SetGroupRoleRequest, + ): ApiResponse + + @POST("api/im/groups/{groupId}/mute") + suspend fun muteGroupMember( + @Path("groupId") groupId: String, + @Body request: MuteGroupMemberRequest, + ): ApiResponse + + @DELETE("api/im/groups/{groupId}") + suspend fun dismissGroup(@Path("groupId") groupId: String): ApiResponse + + @POST("api/im/groups/{groupId}/join-requests") + suspend fun sendGroupJoinRequest( + @Path("groupId") groupId: String, + @Query("appId") appId: String, + @Query("remark") remark: String? = null, + ): ApiResponse + + @GET("api/im/groups/{groupId}/join-requests") + suspend fun listGroupJoinRequests( + @Path("groupId") groupId: String, + @Query("appId") appId: String, + ): ApiResponse> + + @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 + + @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 + @GET("api/im/friends") suspend fun listFriends(@Query("appId") appId: String): ApiResponse> @@ -106,6 +169,46 @@ interface ImApi { @Query("appId") appId: String, ): ApiResponse + @GET("api/im/friend-requests") + suspend fun listFriendRequests( + @Query("appId") appId: String, + @Query("direction") direction: String = "incoming", + ): ApiResponse> + + @POST("api/im/friend-requests") + suspend fun sendFriendRequest( + @Query("appId") appId: String, + @Query("toUserId") toUserId: String, + @Query("remark") remark: String? = null, + ): ApiResponse + + @POST("api/im/friend-requests/{requestId}/accept") + suspend fun acceptFriendRequest( + @Path("requestId") requestId: String, + @Query("appId") appId: String, + ): ApiResponse + + @POST("api/im/friend-requests/{requestId}/reject") + suspend fun rejectFriendRequest( + @Path("requestId") requestId: String, + @Query("appId") appId: String, + ): ApiResponse + + @GET("api/im/blacklist") + suspend fun listBlacklist(@Query("appId") appId: String): ApiResponse> + + @POST("api/im/blacklist") + suspend fun addToBlacklist( + @Query("appId") appId: String, + @Query("blockedUserId") blockedUserId: String, + ): ApiResponse + + @DELETE("api/im/blacklist") + suspend fun removeFromBlacklist( + @Query("appId") appId: String, + @Query("blockedUserId") blockedUserId: String, + ): ApiResponse + @GET("api/im/conversations") suspend fun listConversations(@Query("appId") appId: String): ApiResponse> diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt index e6d6ac8..4d443fb 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt @@ -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 } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt index f93441a..5bfd4e3 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt @@ -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(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) + } }