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)
+ }
}