feat(chat): 添加聊天功能相关API接口、本地缓存和数据仓库

- 添加DemoApi接口定义用户认证和资料管理API
- 实现LocalImCache用于本地存储IM对话和消息历史
- 添加MessageContent模型处理多媒体消息内容
- 创建AttachmentRepository处理图片视频音频文件发送
- 实现AuthRepository管理用户登录注册和会话
- 添加VoiceRecorder支持语音录制功能
- 创建AppDependencies依赖注入容器
- 添加ChatScreen界面组件实现聊天UI逻辑
这个提交包含在:
XuqmGroup 2026-04-28 09:45:20 +08:00
父节点 59611de3c1
当前提交 bee82637f3
共有 25 个文件被更改,包括 2490 次插入116 次删除

查看文件

@ -6,10 +6,10 @@
``` ```
XuqmGroup-AndroidSDK/ XuqmGroup-AndroidSDK/
├── sdk-core/ # 核心初始化、HTTP、Token 存储、通用工具/组件 ├── sdk-core/ # 核心初始化、HTTP、Token 存储、文件上传、通用工具/组件
├── sdk-im/ # IMWebSocket 实时通信 ├── sdk-im/ # IMWebSocket 实时通信
├── sdk-push/ # 推送:设备 Token 注册 ├── sdk-push/ # 推送:设备 Token 注册
├── sdk-update/ # 版本管理:检查更新、下载安装、RN 热更新 ├── sdk-update/ # 版本管理:检查更新、下载安装
└── sample-app/ # 示例 AppJetpack Compose └── sample-app/ # 示例 AppJetpack Compose
``` ```
@ -141,12 +141,23 @@ val service = RetrofitFactory.create(MyApiService::class.java)
- 当前会话本地搜索 - 当前会话本地搜索
- 输入草稿自动保存 - 输入草稿自动保存
- 群设置支持编辑群名和群公告 - 群设置支持编辑群名和群公告
- 群组扩展 API 已补齐:管理员设置、禁言、解散群
- 公开群支持搜索、创建和加群审批
- 会话支持本地删除 - 会话支持本地删除
- 显示总未读数 - 显示总未读数
- 消息状态直接展示 - 消息状态直接展示
- 会话置顶/免打扰/已读/草稿/删除同步服务端 - 会话置顶/免打扰/已读/草稿/删除同步服务端
- IM 连接状态提示 - IM 连接状态提示
- SDK 登录态恢复后自动重连 - SDK 登录态恢复后自动重连
- IM token 自动续签,过期前会静默刷新并重连
- 群聊支持 `@userId` 提及,并写入 `mentionedUserIds`
- 关系链支持好友申请、接受/拒绝、黑名单
- 支持图片 / 视频 / 音频 / 文件消息,文件通过独立文件服务上传后再发 IM
- 图片支持拍照、摄像、图库选择,文件支持文件管理器选择
- 语音支持长按录音、抬手发送
- 支持引用回复、群聊已读人数展示
- 支持 `LOCATION` / `CUSTOM` / `RICH_TEXT` / `FORWARD` / `QUOTE` / `MERGE` / `CALL_AUDIO` / `CALL_VIDEO` 等通用消息类型发送
- 单聊支持已读回执,服务端会把 `READ` 状态推回发送者
### ImClient ### ImClient
@ -173,6 +184,15 @@ imClient.send(SendMessageParams(
content = "Hello!" content = "Hello!"
)) ))
// 群聊提及
imClient.send(SendMessageParams(
toId = "group_001",
chatType = ChatType.GROUP,
msgType = MsgType.TEXT,
content = "@user_002 你好",
mentionedUserIds = "user_002",
))
// 撤回消息 // 撤回消息
imClient.revoke(msgId = "uuid") imClient.revoke(msgId = "uuid")
@ -182,7 +202,7 @@ imClient.disconnect()
### 消息类型MsgType ### 消息类型MsgType
`TEXT` / `IMAGE` / `VIDEO` / `AUDIO` / `FILE` / `CUSTOM` / `LOCATION` / `NOTIFY` / `RICH_TEXT` / `CALL_AUDIO` / `CALL_VIDEO` / `FORWARD` `TEXT` / `IMAGE` / `VIDEO` / `AUDIO` / `FILE` / `CUSTOM` / `LOCATION` / `NOTIFY` / `RICH_TEXT` / `CALL_AUDIO` / `CALL_VIDEO` / `FORWARD` / `QUOTE` / `MERGE`
### ImMessage 结构 ### ImMessage 结构
@ -210,7 +230,7 @@ data class ImMessage(
### 推送接入 ### 推送接入
`PushSDK.initialize()` 后由 SDK 自动完成厂商初始化、设备注册与 token 上传,不再要求业务侧单独调用注册接口。 `XuqmSDK.login()` 成功后,SDK 会自动完成厂商初始化、设备注册与 token 上传,不再要求业务侧单独调用注册接口。`logout()` 时会自动注销当前设备绑定。
### 与 IM 联动 ### 与 IM 联动
@ -238,23 +258,6 @@ if (result.needsUpdate) {
`downloadAndInstall` 会将 APK 下载到 `getExternalFilesDir(null)`,通过 `FileProvider` 触发系统安装。 `downloadAndInstall` 会将 APK 下载到 `getExternalFilesDir(null)`,通过 `FileProvider` 触发系统安装。
AndroidManifest 中已配置 `@xml/file_paths``external-files-path`)。 AndroidManifest 中已配置 `@xml/file_paths``external-files-path`)。
### 检查 RN Bundle 更新
```kotlin
val rnResult = UpdateSDK.checkRnUpdate(
appId = "ak_xxx",
moduleId = "main",
platform = "android",
currentVersion = "1.0.0"
)
if (rnResult.needsUpdate) {
val filePath = UpdateSDK.downloadBundle(context, rnResult.downloadUrl, "main.android.bundle")
// 校验 MD5
// 通知 RN 引擎加载新 bundle
}
```
--- ---
## 发版 ## 发版

查看文件

@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:name=".XuqmSampleApp" android:name=".XuqmSampleApp"

查看文件

@ -36,11 +36,19 @@ data class AuthProfile(
data class AuthResult( data class AuthResult(
val demoToken: String, val demoToken: String,
val demoTokenExpiresAt: Long? = null,
@SerializedName(value = "imToken", alternate = ["userSig"]) @SerializedName(value = "imToken", alternate = ["userSig"])
val userSig: String? = null, val userSig: String? = null,
val imTokenExpiresAt: Long? = null,
val profile: AuthProfile, val profile: AuthProfile,
) )
data class ImRefreshResult(
@SerializedName(value = "imToken", alternate = ["userSig"])
val userSig: String,
val imTokenExpiresAt: Long? = null,
)
data class UserData( data class UserData(
val userId: String, val userId: String,
val nickname: String, val nickname: String,
@ -64,6 +72,9 @@ interface DemoApi {
@POST("api/demo/auth/register") @POST("api/demo/auth/register")
suspend fun register(@Body request: RegisterRequest): DemoResponse<AuthResult> suspend fun register(@Body request: RegisterRequest): DemoResponse<AuthResult>
@POST("api/demo/auth/refresh-im")
suspend fun refreshImToken(@Query("appId") appId: String = DEMO_APP_ID): DemoResponse<ImRefreshResult>
@POST("api/demo/auth/reset-password") @POST("api/demo/auth/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse<Unit> suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse<Unit>

查看文件

@ -3,6 +3,7 @@ package com.xuqm.sdk.sample.data.local
import android.content.Context import android.content.Context
import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.im.model.ConversationData
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.sample.data.model.searchableText
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -61,7 +62,8 @@ class LocalImCache(context: Context) {
fun mergeHistory(targetId: String, chatType: String, messages: List<ImMessage>) { fun mergeHistory(targetId: String, chatType: String, messages: List<ImMessage>) {
val merged = (loadHistory(targetId, chatType) + messages) val merged = (loadHistory(targetId, chatType) + messages)
.distinctBy { it.id } .associateBy { it.id }
.values
.sortedByDescending { it.createdAt } .sortedByDescending { it.createdAt }
saveHistory(targetId, chatType, merged) saveHistory(targetId, chatType, merged)
} }
@ -180,15 +182,7 @@ class LocalImCache(context: Context) {
} }
private fun ImMessage.matches(keyword: String): Boolean { private fun ImMessage.matches(keyword: String): Boolean {
val plainText = when (msgType.uppercase()) { return searchableText().contains(keyword, ignoreCase = true)
"TEXT" -> runCatching { JSONObject(content).optString("text") }.getOrDefault(content)
"NOTIFY" -> runCatching { JSONObject(content).optString("content") }.getOrDefault(content)
else -> content
}
return plainText.contains(keyword, ignoreCase = true) ||
fromId.contains(keyword, ignoreCase = true) ||
toId.contains(keyword, ignoreCase = true) ||
msgType.contains(keyword, ignoreCase = true)
} }
private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId" private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId"

查看文件

@ -0,0 +1,104 @@
package com.xuqm.sdk.sample.data.model
import com.xuqm.sdk.im.model.ImMessage
import org.json.JSONObject
data class MediaMessageContent(
val url: String,
val thumbnailUrl: String? = null,
val width: Int? = null,
val height: Int? = null,
val size: Long? = null,
val name: String? = null,
val mimeType: String? = null,
val durationMs: Long? = null,
val hash: String? = null,
)
data class QuoteMessageContent(
val quotedMsgId: String? = null,
val quotedContent: String? = null,
val text: String? = null,
)
data class MergeMessageContent(
val title: String? = null,
)
fun ImMessage.previewText(): String = when (msgType.uppercase()) {
"TEXT" -> textContent().ifBlank { content }
"IMAGE" -> "[图片]"
"VIDEO" -> "[视频]"
"AUDIO" -> "[语音]"
"FILE" -> "[文件]"
"LOCATION" -> "[位置]"
"CUSTOM" -> "[自定义]"
"RICH_TEXT" -> "[富文本]"
"FORWARD" -> "[转发]"
"QUOTE" -> quoteContent()?.text?.takeIf { it.isNotBlank() } ?: "[引用]"
"MERGE" -> mergeContent()?.title?.takeIf { it.isNotBlank() } ?: "[合并转发]"
"CALL_AUDIO" -> "[语音通话]"
"CALL_VIDEO" -> "[视频通话]"
"REVOKED" -> "[消息已撤回]"
"NOTIFY" -> notifyContent().ifBlank { "[通知]" }
else -> content
}
fun ImMessage.searchableText(): String {
val media = mediaContent()
return buildString {
append(previewText())
append(' ')
append(fromId)
append(' ')
append(toId)
append(' ')
append(msgType)
append(' ')
append(content)
media?.name?.let { append(' ').append(it) }
media?.url?.let { append(' ').append(it) }
media?.thumbnailUrl?.let { append(' ').append(it) }
quoteContent()?.quotedContent?.let { append(' ').append(it) }
quoteContent()?.text?.let { append(' ').append(it) }
mergeContent()?.title?.let { append(' ').append(it) }
}
}
fun ImMessage.mediaContent(): MediaMessageContent? = runCatching {
val obj = JSONObject(content)
val url = obj.optString("url").takeIf { it.isNotBlank() } ?: return null
MediaMessageContent(
url = url,
thumbnailUrl = obj.optString("thumbnailUrl").takeIf { it.isNotBlank() },
width = obj.optInt("width").takeIf { it > 0 },
height = obj.optInt("height").takeIf { it > 0 },
size = obj.optLong("size").takeIf { it > 0 },
name = obj.optString("name").takeIf { it.isNotBlank() },
mimeType = obj.optString("mimeType").takeIf { it.isNotBlank() },
durationMs = obj.optLong("durationMs").takeIf { it > 0 },
hash = obj.optString("hash").takeIf { it.isNotBlank() },
)
}.getOrNull()
fun ImMessage.quoteContent(): QuoteMessageContent? = runCatching {
val obj = JSONObject(content)
QuoteMessageContent(
quotedMsgId = obj.optString("quotedMsgId").takeIf { it.isNotBlank() },
quotedContent = obj.optString("quotedContent").takeIf { it.isNotBlank() },
text = obj.optString("text").takeIf { it.isNotBlank() },
)
}.getOrNull()
fun ImMessage.mergeContent(): MergeMessageContent? = runCatching {
val obj = JSONObject(content)
MergeMessageContent(
title = obj.optString("title").takeIf { it.isNotBlank() },
)
}.getOrNull()
private fun ImMessage.textContent(): String =
runCatching { JSONObject(content).optString("text") }.getOrDefault(content)
private fun ImMessage.notifyContent(): String =
runCatching { JSONObject(content).optString("content") }.getOrDefault(content)

查看文件

@ -0,0 +1,216 @@
package com.xuqm.sdk.sample.data.repo
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.OpenableColumns
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.im.ImSDK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
class AttachmentRepository(private val context: Context) {
suspend fun sendImage(targetId: String, chatType: String, uri: Uri): Result<Unit> =
sendMedia(targetId, chatType, uri, MediaKind.IMAGE)
suspend fun sendImageBytes(
targetId: String,
chatType: String,
fileName: String,
bytes: ByteArray,
width: Int? = null,
height: Int? = null,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val upload = FileSDK.uploadBytes(
fileName = fileName,
mimeType = "image/jpeg",
bytes = bytes,
)
ImSDK.sendImageMessage(
toId = targetId,
chatType = chatType,
file = upload,
width = width,
height = height,
)
}
}
suspend fun sendVideo(targetId: String, chatType: String, uri: Uri): Result<Unit> =
sendMedia(targetId, chatType, uri, MediaKind.VIDEO)
suspend fun sendFile(targetId: String, chatType: String, uri: Uri): Result<Unit> =
sendMedia(targetId, chatType, uri, MediaKind.FILE)
suspend fun sendAudio(targetId: String, chatType: String, uri: Uri): Result<Unit> =
sendMedia(targetId, chatType, uri, MediaKind.AUDIO)
suspend fun sendAudioBytes(
targetId: String,
chatType: String,
fileName: String,
bytes: ByteArray,
durationMs: Long? = null,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val upload = FileSDK.uploadBytes(
fileName = fileName,
mimeType = "audio/mp4",
bytes = bytes,
)
ImSDK.sendAudioMessage(
toId = targetId,
chatType = chatType,
file = upload,
durationMs = durationMs,
)
}
}
private suspend fun sendMedia(
targetId: String,
chatType: String,
uri: Uri,
kind: MediaKind,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val meta = resolveMeta(uri)
val thumbnailBytes = when (kind) {
MediaKind.IMAGE -> null
MediaKind.VIDEO -> extractVideoThumbnail(uri)
MediaKind.AUDIO -> null
MediaKind.FILE -> null
}
val upload = FileSDK.upload(
context = context,
uri = uri,
displayName = meta.displayName,
mimeType = meta.mimeType,
thumbnailBytes = thumbnailBytes,
)
when (kind) {
MediaKind.IMAGE -> ImSDK.sendImageMessage(
toId = targetId,
chatType = chatType,
file = upload,
width = meta.width,
height = meta.height,
)
MediaKind.VIDEO -> ImSDK.sendVideoMessage(
toId = targetId,
chatType = chatType,
file = upload,
width = meta.width,
height = meta.height,
durationMs = meta.durationMs,
)
MediaKind.AUDIO -> ImSDK.sendAudioMessage(
toId = targetId,
chatType = chatType,
file = upload,
durationMs = meta.durationMs,
)
MediaKind.FILE -> ImSDK.sendFileMessage(
toId = targetId,
chatType = chatType,
file = upload,
)
}
}
}
private fun resolveMeta(uri: Uri): AttachmentMeta {
val resolver = context.contentResolver
val displayName = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null
} ?: uri.lastPathSegment?.substringAfterLast('/')
val mimeType = resolver.getType(uri)
val size = when {
mimeType?.startsWith("image/") == true -> readImageSize(uri)
mimeType?.startsWith("video/") == true -> readVideoSize(uri)
else -> null
}
val durationMs = if (mimeType?.startsWith("video/") == true || mimeType?.startsWith("audio/") == true) {
readMediaDuration(uri)
} else {
null
}
return AttachmentMeta(
displayName = displayName?.takeIf { it.isNotBlank() } ?: "attachment",
mimeType = mimeType,
width = size?.first,
height = size?.second,
durationMs = durationMs,
)
}
private fun readImageSize(uri: Uri): Pair<Int, Int>? {
return context.contentResolver.openInputStream(uri)?.use { input ->
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(input, null, options)
if (options.outWidth > 0 && options.outHeight > 0) {
options.outWidth to options.outHeight
} else null
}
}
private fun readVideoSize(uri: Uri): Pair<Int, Int>? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
if (width > 0 && height > 0) width to height else null
} catch (_: Throwable) {
null
} finally {
runCatching { retriever.release() }
}
}
private fun readMediaDuration(uri: Uri): Long? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
} catch (_: Throwable) {
null
} finally {
runCatching { retriever.release() }
}
}
private fun extractVideoThumbnail(uri: Uri): ByteArray? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
val bitmap: Bitmap = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
?: return null
ByteArrayOutputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, output)
output.toByteArray()
}
} catch (_: Throwable) {
null
} finally {
runCatching { retriever.release() }
}
}
private enum class MediaKind { IMAGE, VIDEO, AUDIO, FILE }
private data class AttachmentMeta(
val displayName: String,
val mimeType: String?,
val width: Int? = null,
val height: Int? = null,
val durationMs: Long? = null,
)
}

查看文件

@ -5,6 +5,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys import androidx.security.crypto.MasterKeys
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.sample.data.api.AuthResult import com.xuqm.sdk.sample.data.api.AuthResult
import com.xuqm.sdk.sample.data.api.ImRefreshResult
import com.xuqm.sdk.sample.data.api.ChangePasswordRequest import com.xuqm.sdk.sample.data.api.ChangePasswordRequest
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
import com.xuqm.sdk.sample.data.api.DemoApi import com.xuqm.sdk.sample.data.api.DemoApi
@ -16,10 +17,20 @@ import com.xuqm.sdk.sample.data.api.UpdateProfileRequest
import com.xuqm.sdk.sample.data.api.UserData import com.xuqm.sdk.sample.data.api.UserData
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.atomic.AtomicBoolean
class AuthRepository(context: Context) { class AuthRepository(context: Context) {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var refreshJob: Job? = null
private val refreshing = AtomicBoolean(false)
private val prefs = EncryptedSharedPreferences.create( private val prefs = EncryptedSharedPreferences.create(
"xuqm_demo_auth", "xuqm_demo_auth",
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
@ -39,17 +50,39 @@ class AuthRepository(context: Context) {
fun getCurrentNickname(): String? = prefs.getString("nickname", null) fun getCurrentNickname(): String? = prefs.getString("nickname", null)
fun getCurrentAvatar(): String? = prefs.getString("avatar", null) fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null) fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
fun getCurrentUserSigExpiresAt(): Long = prefs.getLong("user_sig_expires_at", 0L)
fun isLoggedIn(): Boolean = getDemoToken() != null fun isLoggedIn(): Boolean = getDemoToken() != null
private fun saveSession(result: AuthResult) { private fun saveSession(result: AuthResult) {
val profile = result.profile val profile = result.profile
prefs.edit() prefs.edit()
.putString("demo_token", result.demoToken) .putString("demo_token", result.demoToken)
.putLong("demo_token_expires_at", result.demoTokenExpiresAt ?: 0L)
.putString("user_id", profile.userId) .putString("user_id", profile.userId)
.putString("nickname", profile.nickname) .putString("nickname", profile.nickname)
.putString("avatar", profile.avatar) .putString("avatar", profile.avatar)
.putString("user_sig", result.userSig) .putString("user_sig", result.userSig)
.putLong("user_sig_expires_at", result.imTokenExpiresAt ?: 0L)
.apply() .apply()
scheduleImTokenRefresh(result.imTokenExpiresAt)
}
private fun saveImCredential(result: ImRefreshResult) {
prefs.edit()
.putString("user_sig", result.userSig)
.putLong("user_sig_expires_at", result.imTokenExpiresAt ?: 0L)
.apply()
}
private fun scheduleImTokenRefresh(expiresAt: Long?) {
refreshJob?.cancel()
val tokenExpiresAt = expiresAt ?: 0L
if (tokenExpiresAt <= 0L) return
val delayMs = (tokenExpiresAt - System.currentTimeMillis() - REFRESH_GRACE_MS).coerceAtLeast(0L)
refreshJob = appScope.launch {
delay(delayMs)
refreshImToken()
}
} }
suspend fun login(userId: String, password: String): Result<UserData> = suspend fun login(userId: String, password: String): Result<UserData> =
@ -120,17 +153,66 @@ class AuthRepository(context: Context) {
val userId = getCurrentUserId() val userId = getCurrentUserId()
val userSig = getCurrentUserSig() val userSig = getCurrentUserSig()
if (userId.isNullOrBlank() || userSig.isNullOrBlank()) return@runCatching if (userId.isNullOrBlank() || userSig.isNullOrBlank()) return@runCatching
val refreshed = if (shouldRefreshImToken()) {
refreshImTokenInternal()
} else {
null
}
XuqmSDK.login( XuqmSDK.login(
userId = userId, userId = userId,
userSig = userSig, userSig = refreshed?.userSig ?: userSig,
nickname = getCurrentNickname(), nickname = getCurrentNickname(),
avatar = getCurrentAvatar(), avatar = getCurrentAvatar(),
) )
if (refreshed != null) {
saveImCredential(refreshed)
scheduleImTokenRefresh(refreshed.imTokenExpiresAt)
} else {
scheduleImTokenRefresh(getCurrentUserSigExpiresAt().takeIf { it > 0L })
}
} }
} }
fun logout() { fun logout() {
refreshJob?.cancel()
refreshJob = null
XuqmSDK.logout() XuqmSDK.logout()
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }
private suspend fun refreshImToken() {
if (!refreshing.compareAndSet(false, true)) return
try {
withContext(Dispatchers.IO) {
val refreshed = refreshImTokenInternal() ?: return@withContext
saveImCredential(refreshed)
XuqmSDK.login(
userId = getCurrentUserId() ?: return@withContext,
userSig = refreshed.userSig,
nickname = getCurrentNickname(),
avatar = getCurrentAvatar(),
)
scheduleImTokenRefresh(refreshed.imTokenExpiresAt)
}
} finally {
refreshing.set(false)
}
}
private suspend fun refreshImTokenInternal(): ImRefreshResult? {
val appId = DEMO_APP_ID
return runCatching {
val response = api.refreshImToken(appId)
requireNotNull(response.data) { response.message ?: "Refresh IM token failed" }
}.getOrNull()
}
private fun shouldRefreshImToken(): Boolean {
val expiresAt = getCurrentUserSigExpiresAt()
return expiresAt <= 0L || expiresAt - System.currentTimeMillis() <= REFRESH_GRACE_MS
}
companion object {
private const val REFRESH_GRACE_MS = 5 * 60 * 1000L
}
} }

查看文件

@ -0,0 +1,80 @@
package com.xuqm.sdk.sample.data.repo
import android.content.Context
import android.media.MediaRecorder
import android.os.SystemClock
import java.io.File
data class RecordedVoice(
val fileName: String,
val mimeType: String,
val bytes: ByteArray,
val durationMs: Long,
)
class VoiceRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
private var outputFile: File? = null
private var startedAtMs: Long = 0L
fun start(): Boolean {
if (recorder != null) return true
val dir = File(context.cacheDir, "voice-recordings").apply { mkdirs() }
val file = File(dir, "voice_${System.currentTimeMillis()}.m4a")
outputFile = file
return runCatching {
recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioEncodingBitRate(128_000)
setAudioSamplingRate(44_100)
setOutputFile(file.absolutePath)
prepare()
start()
}
startedAtMs = SystemClock.elapsedRealtime()
true
}.getOrElse {
releaseAndCleanup()
false
}
}
fun stop(): RecordedVoice? {
val current = recorder ?: return null
val file = outputFile ?: return null
return try {
current.stop()
val durationMs = (SystemClock.elapsedRealtime() - startedAtMs).coerceAtLeast(0L)
if (!file.exists() || file.length() <= 0L) {
releaseAndCleanup()
return null
}
RecordedVoice(
fileName = file.name,
mimeType = "audio/mp4",
bytes = file.readBytes(),
durationMs = durationMs,
)
} catch (_: Throwable) {
null
} finally {
releaseAndCleanup()
}
}
fun cancel() {
runCatching { recorder?.stop() }
releaseAndCleanup()
}
private fun releaseAndCleanup() {
runCatching { recorder?.release() }
recorder = null
outputFile?.delete()
outputFile = null
startedAtMs = 0L
}
}

查看文件

@ -2,6 +2,7 @@ package com.xuqm.sdk.sample.di
import android.content.Context import android.content.Context
import com.xuqm.sdk.sample.data.repo.AuthRepository import com.xuqm.sdk.sample.data.repo.AuthRepository
import com.xuqm.sdk.sample.data.repo.AttachmentRepository
import com.xuqm.sdk.sample.data.local.LocalContactCache import com.xuqm.sdk.sample.data.local.LocalContactCache
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
import com.xuqm.sdk.sample.data.local.LocalImCache import com.xuqm.sdk.sample.data.local.LocalImCache
@ -16,12 +17,15 @@ object AppDependencies {
private set private set
lateinit var localContactCache: LocalContactCache lateinit var localContactCache: LocalContactCache
private set private set
lateinit var attachmentRepository: AttachmentRepository
private set
fun init(context: Context) { fun init(context: Context) {
environmentRepository = EnvironmentRepository(context) environmentRepository = EnvironmentRepository(context)
environmentRepository.current() environmentRepository.current()
localImCache = LocalImCache(context) localImCache = LocalImCache(context)
localContactCache = LocalContactCache(context) localContactCache = LocalContactCache(context)
attachmentRepository = AttachmentRepository(context.applicationContext)
authRepository = AuthRepository(context) authRepository = AuthRepository(context)
} }
} }

查看文件

@ -1,15 +1,24 @@
package com.xuqm.sdk.sample.ui.chat package com.xuqm.sdk.sample.ui.chat
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -27,6 +36,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -36,17 +46,31 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.rememberScrollState
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.sample.data.model.mediaContent
import com.xuqm.sdk.sample.data.model.quoteContent
import com.xuqm.sdk.sample.data.model.previewText
import com.xuqm.sdk.sample.data.repo.VoiceRecorder
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
import com.xuqm.sdk.ui.InitialAvatar import com.xuqm.sdk.ui.InitialAvatar
import com.xuqm.sdk.ui.SearchBarField import com.xuqm.sdk.ui.SearchBarField
import coil3.compose.AsyncImage
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.io.File
import androidx.compose.runtime.rememberCoroutineScope
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -62,12 +86,93 @@ fun ChatScreen(
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
val draftText by viewModel.draftText.collectAsStateWithLifecycle() val draftText by viewModel.draftText.collectAsStateWithLifecycle()
val mentionableUserIds by viewModel.mentionableUserIds.collectAsStateWithLifecycle()
val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle()
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle() val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle() val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle() val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
val isSendingAttachment by viewModel.isSendingAttachment.collectAsStateWithLifecycle()
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val context = LocalContext.current
val voiceRecorder = remember { VoiceRecorder(context.applicationContext) }
val coroutineScope = rememberCoroutineScope()
var showSearchBar by remember { mutableStateOf(false) } var showSearchBar by remember { mutableStateOf(false) }
val replyTarget = replyTargetMessage
var pendingCameraAction by remember { mutableStateOf<CameraAction?>(null) }
var pendingCaptureUri by remember { mutableStateOf<Uri?>(null) }
var isRecordingVoice by remember { mutableStateOf(false) }
var voiceHint by remember { mutableStateOf("按住说话") }
var requestCameraAction: (CameraAction) -> Unit = {}
val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) viewModel.sendImage(uri)
}
val pickVideo = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) viewModel.sendVideo(uri)
}
val pickFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) viewModel.sendFile(uri)
}
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val uri = pendingCaptureUri
pendingCaptureUri = null
if (success && uri != null) {
viewModel.sendImage(uri)
}
}
val captureVideo = rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo()) { success ->
val uri = pendingCaptureUri
pendingCaptureUri = null
if (success && uri != null) {
viewModel.sendVideo(uri)
}
}
val startCameraAction: (CameraAction) -> Unit = { action ->
when (action) {
CameraAction.PHOTO -> {
val uri = createCaptureUri(
context = context,
directoryType = "Pictures",
prefix = "photo_${System.currentTimeMillis()}",
suffix = ".jpg",
)
pendingCaptureUri = uri
takePicture.launch(uri)
}
CameraAction.VIDEO -> {
val uri = createCaptureUri(
context = context,
directoryType = "Movies",
prefix = "video_${System.currentTimeMillis()}",
suffix = ".mp4",
)
pendingCaptureUri = uri
captureVideo.launch(uri)
}
}
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!granted) {
pendingCameraAction = null
return@rememberLauncherForActivityResult
}
pendingCameraAction?.let(startCameraAction)
pendingCameraAction = null
}
requestCameraAction = { action ->
if (hasCameraPermission(context)) {
startCameraAction(action)
} else {
pendingCameraAction = action
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
val recordAudioPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!granted) {
voiceHint = "需要麦克风权限"
}
}
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) } LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
LaunchedEffect(scrollSignal) { LaunchedEffect(scrollSignal) {
@ -122,11 +227,46 @@ fun ChatScreen(
} }
}, },
bottomBar = { bottomBar = {
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.imePadding() .imePadding()
.padding(8.dp), .padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (chatType == "GROUP" && mentionableUserIds.isNotEmpty()) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("提及", style = MaterialTheme.typography.labelSmall)
mentionableUserIds.take(6).forEach { userId ->
TextButton(onClick = { viewModel.appendMention(userId) }) {
Text("@$userId")
}
}
}
}
if (replyTarget != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "回复: ${replyTarget.previewText()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.weight(1f),
)
TextButton(onClick = viewModel::clearReply) {
Text("取消")
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedTextField( OutlinedTextField(
@ -144,6 +284,75 @@ fun ChatScreen(
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { pickImage.launch("image/*") }) { Text("相册图片") }
TextButton(onClick = { requestCameraAction(CameraAction.PHOTO) }) { Text("拍照") }
TextButton(onClick = { pickVideo.launch("video/*") }) { Text("相册视频") }
TextButton(onClick = { requestCameraAction(CameraAction.VIDEO) }) { Text("摄像") }
TextButton(onClick = { pickFile.launch(arrayOf("*/*")) }) { Text("文件管理器") }
}
Surface(
shape = RoundedCornerShape(16.dp),
color = if (isRecordingVoice) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (!hasAudioPermission(context)) {
voiceHint = "请先授权麦克风权限"
recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
return@detectTapGestures
}
if (!voiceRecorder.start()) {
voiceHint = "录音启动失败"
return@detectTapGestures
}
isRecordingVoice = true
voiceHint = "松开发送"
try {
val released = tryAwaitRelease()
if (released) {
val recorded = voiceRecorder.stop()
if (recorded != null) {
coroutineScope.launch {
viewModel.sendAudioRecording(recorded)
}
} else {
voiceHint = "录音失败"
}
} else {
voiceRecorder.cancel()
}
} finally {
isRecordingVoice = false
if (!voiceHint.contains("权限") && voiceHint != "录音失败") {
voiceHint = "按住说话"
}
}
}
)
}
) {
Text(
text = voiceHint,
style = MaterialTheme.typography.labelSmall,
color = if (isRecordingVoice) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
if (isSendingAttachment) {
Text(
text = "附件上传中…",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}, },
) { padding -> ) { padding ->
Column( Column(
@ -170,6 +379,8 @@ fun ChatScreen(
MessageBubble( MessageBubble(
message = msg, message = msg,
isOwn = msg.fromId == viewModel.currentUserId, isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId,
onReply = viewModel::startReply,
) )
} }
if (searchResults.isEmpty()) { if (searchResults.isEmpty()) {
@ -197,6 +408,8 @@ fun ChatScreen(
MessageBubble( MessageBubble(
message = msg, message = msg,
isOwn = msg.fromId == viewModel.currentUserId, isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId,
onReply = viewModel::startReply,
) )
} }
if (isLoadingMore) { if (isLoadingMore) {
@ -217,8 +430,45 @@ fun ChatScreen(
} }
} }
private enum class CameraAction { PHOTO, VIDEO }
private fun hasAudioPermission(context: android.content.Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
}
private fun hasCameraPermission(context: android.content.Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
}
private fun createCaptureUri(
context: android.content.Context,
directoryType: String,
prefix: String,
suffix: String,
): Uri {
val dir = context.getExternalFilesDir(directoryType) ?: context.filesDir
val file = File(dir, "$prefix$suffix")
return FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
}
@Composable @Composable
private fun MessageBubble(message: ImMessage, isOwn: Boolean) { private fun MessageBubble(
message: ImMessage,
isOwn: Boolean,
currentUserId: String,
onReply: (ImMessage) -> Unit,
) {
val media = message.mediaContent()
val arrangement = if (isOwn) Arrangement.End else Arrangement.Start val arrangement = if (isOwn) Arrangement.End else Arrangement.Start
val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surfaceVariant
@ -244,19 +494,49 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
color = bubbleColor, color = bubbleColor,
modifier = Modifier modifier = Modifier
.widthIn(max = 280.dp) .widthIn(max = 280.dp)
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp)
.combinedClickable(
onClick = {},
onLongClick = { onReply(message) },
),
) { ) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text( when (message.msgType.uppercase()) {
"IMAGE" -> ImageBubble(media)
"VIDEO" -> VideoBubble(media)
"AUDIO" -> AudioBubble(media)
"FILE" -> FileBubble(media)
"QUOTE" -> QuoteBubble(message.quoteContent())
else -> Text(
text = parseContent(message), text = parseContent(message),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
}
val mentionedUserIds = message.mentionedUserIds.orEmpty()
if (mentionedUserIds.isNotBlank() &&
mentionedUserIds.split(",").map { it.trim() }.contains(currentUserId)
) {
Text(
text = "@我",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
if (isOwn) { if (isOwn) {
Text( Text(
text = statusLabel(message.status), text = statusLabel(message.status),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )
if (message.chatType.uppercase() == "GROUP") {
message.groupReadCount?.takeIf { it > 0 }?.let {
Text(
text = "${it}人已读",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
} }
} }
} }
@ -267,6 +547,122 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
} }
} }
@Composable
private fun ImageBubble(media: com.xuqm.sdk.sample.data.model.MediaMessageContent?) {
if (media == null) {
Text(text = "[图片]", style = MaterialTheme.typography.bodyMedium)
return
}
val ratio = media.width?.takeIf { it > 0 }?.toFloat()
?.div(media.height?.takeIf { it > 0 } ?: 1)
?: 1f
AsyncImage(
model = media.thumbnailUrl ?: media.url,
contentDescription = media.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.widthIn(min = 140.dp, max = 260.dp)
.fillMaxWidth()
.aspectRatio(ratio)
.padding(bottom = 4.dp)
.clip(RoundedCornerShape(12.dp))
)
if (media.name.isNullOrBlank().not()) {
Text(
text = media.name,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
@Composable
private fun VideoBubble(media: com.xuqm.sdk.sample.data.model.MediaMessageContent?) {
if (media == null) {
Text(text = "[视频]", style = MaterialTheme.typography.bodyMedium)
return
}
val ratio = media.width?.takeIf { it > 0 }?.toFloat()
?.div(media.height?.takeIf { it > 0 } ?: 1)
?: 1f
AsyncImage(
model = media.thumbnailUrl ?: media.url,
contentDescription = media.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.widthIn(min = 160.dp, max = 260.dp)
.fillMaxWidth()
.aspectRatio(ratio)
.padding(bottom = 4.dp)
.clip(RoundedCornerShape(12.dp))
)
Text(
text = media.name?.takeIf { it.isNotBlank() } ?: "[视频]",
style = MaterialTheme.typography.bodyMedium,
)
media.durationMs?.takeIf { it > 0 }?.let {
Text(
text = formatDuration(it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
@Composable
private fun AudioBubble(media: com.xuqm.sdk.sample.data.model.MediaMessageContent?) {
if (media == null) {
Text(text = "[语音]", style = MaterialTheme.typography.bodyMedium)
return
}
Text(
text = media.name?.takeIf { it.isNotBlank() } ?: "[语音]",
style = MaterialTheme.typography.bodyMedium,
)
media.durationMs?.takeIf { it > 0 }?.let {
Text(
text = formatDuration(it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
@Composable
private fun FileBubble(media: com.xuqm.sdk.sample.data.model.MediaMessageContent?) {
val title = media?.name?.takeIf { it.isNotBlank() } ?: "文件"
Text(
text = "[$title]",
style = MaterialTheme.typography.bodyMedium,
)
media?.size?.takeIf { it > 0 }?.let {
Text(
text = formatSize(it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
@Composable
private fun QuoteBubble(content: com.xuqm.sdk.sample.data.model.QuoteMessageContent?) {
if (content == null) {
Text(text = "[引用]", style = MaterialTheme.typography.bodyMedium)
return
}
if (!content.quotedContent.isNullOrBlank()) {
Text(
text = "引用: ${content.quotedContent}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
Text(
text = content.text?.takeIf { it.isNotBlank() } ?: "[引用]",
style = MaterialTheme.typography.bodyMedium,
)
}
private fun statusLabel(status: String): String = when (status.uppercase()) { private fun statusLabel(status: String): String = when (status.uppercase()) {
"SENDING" -> "发送中" "SENDING" -> "发送中"
"SENT" -> "已发送" "SENT" -> "已发送"
@ -287,10 +683,18 @@ private fun parseContent(message: ImMessage): String {
"TEXT" -> runCatching { "TEXT" -> runCatching {
org.json.JSONObject(message.content).getString("text") org.json.JSONObject(message.content).getString("text")
}.getOrDefault(message.content) }.getOrDefault(message.content)
"IMAGE" -> "[图片]" "IMAGE" -> message.previewText()
"AUDIO" -> "[语音]" "AUDIO" -> "[语音]"
"VIDEO" -> "[视频]" "VIDEO" -> message.previewText()
"FILE" -> "[文件]" "FILE" -> message.previewText()
"LOCATION" -> "[位置]"
"CUSTOM" -> "[自定义]"
"RICH_TEXT" -> "[富文本]"
"FORWARD" -> "[转发]"
"QUOTE" -> "[引用]"
"MERGE" -> "[合并转发]"
"CALL_AUDIO" -> "[语音通话]"
"CALL_VIDEO" -> "[视频通话]"
"REVOKED" -> "[消息已撤回]" "REVOKED" -> "[消息已撤回]"
"NOTIFY" -> runCatching { "NOTIFY" -> runCatching {
org.json.JSONObject(message.content).getString("content") org.json.JSONObject(message.content).getString("content")
@ -298,3 +702,20 @@ private fun parseContent(message: ImMessage): String {
else -> message.content else -> message.content
} }
} }
private fun formatSize(bytes: Long): String {
val kb = bytes / 1024.0
val mb = kb / 1024.0
return when {
mb >= 1 -> String.format("%.1f MB", mb)
kb >= 1 -> String.format("%.1f KB", kb)
else -> "$bytes B"
}
}
private fun formatDuration(ms: Long): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return if (minutes > 0) String.format("%d:%02d", minutes, seconds) else "${seconds}s"
}

查看文件

@ -1,5 +1,6 @@
package com.xuqm.sdk.sample.ui.chat package com.xuqm.sdk.sample.ui.chat
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
@ -7,6 +8,8 @@ import com.xuqm.sdk.im.listener.ImEventListener
import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.im.model.ConversationData
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.sample.di.AppDependencies import com.xuqm.sdk.sample.di.AppDependencies
import com.xuqm.sdk.sample.data.repo.RecordedVoice
import com.xuqm.sdk.sample.data.model.previewText
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -35,6 +38,15 @@ class ChatViewModel : ViewModel() {
private val _draftText = MutableStateFlow("") private val _draftText = MutableStateFlow("")
val draftText: StateFlow<String> = _draftText val draftText: StateFlow<String> = _draftText
private val _replyTargetMessage = MutableStateFlow<ImMessage?>(null)
val replyTargetMessage: StateFlow<ImMessage?> = _replyTargetMessage
private val _mentionableUserIds = MutableStateFlow<List<String>>(emptyList())
val mentionableUserIds: StateFlow<List<String>> = _mentionableUserIds
private val _isSendingAttachment = MutableStateFlow(false)
val isSendingAttachment: StateFlow<Boolean> = _isSendingAttachment
val currentUserId: String get() = ImSDK.currentUserId val currentUserId: String get() = ImSDK.currentUserId
private lateinit var targetId: String private lateinit var targetId: String
@ -51,7 +63,7 @@ class ChatViewModel : ViewModel() {
ConversationData( ConversationData(
targetId = targetId, targetId = targetId,
chatType = chatType, chatType = chatType,
lastMsgContent = message.content, lastMsgContent = message.previewText(),
lastMsgType = message.msgType, lastMsgType = message.msgType,
lastMsgTime = message.createdAt, lastMsgTime = message.createdAt,
unreadCount = 0, unreadCount = 0,
@ -63,6 +75,11 @@ class ChatViewModel : ViewModel() {
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
} }
requestScrollToBottom() requestScrollToBottom()
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
}
}
} }
} }
@ -74,7 +91,7 @@ class ChatViewModel : ViewModel() {
ConversationData( ConversationData(
targetId = targetId, targetId = targetId,
chatType = chatType, chatType = chatType,
lastMsgContent = message.content, lastMsgContent = message.previewText(),
lastMsgType = message.msgType, lastMsgType = message.msgType,
lastMsgTime = message.createdAt, lastMsgTime = message.createdAt,
unreadCount = 0, unreadCount = 0,
@ -86,12 +103,20 @@ class ChatViewModel : ViewModel() {
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
} }
requestScrollToBottom() requestScrollToBottom()
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
}
}
} }
} }
} }
fun init(targetId: String, chatType: String) { fun init(targetId: String, chatType: String) {
if (initialized && this.targetId == targetId && this.chatType == chatType) return if (initialized && this.targetId == targetId && this.chatType == chatType) return
if (initialized && this.chatType == "GROUP") {
ImSDK.unsubscribeGroup(this.targetId)
}
this.targetId = targetId this.targetId = targetId
this.chatType = chatType this.chatType = chatType
nextHistoryPage = 0 nextHistoryPage = 0
@ -102,8 +127,16 @@ class ChatViewModel : ViewModel() {
_searchQuery.value = "" _searchQuery.value = ""
_searchResults.value = emptyList() _searchResults.value = emptyList()
_draftText.value = cache.loadDraft(targetId, chatType) _draftText.value = cache.loadDraft(targetId, chatType)
_mentionableUserIds.value = emptyList()
_replyTargetMessage.value = null
ImSDK.addListener(listener) ImSDK.addListener(listener)
if (chatType == "GROUP") {
ImSDK.subscribeGroup(targetId)
}
loadInitialHistory() loadInitialHistory()
if (chatType == "GROUP") {
loadMentionableUsers()
}
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) } runCatching { ImSDK.markRead(targetId, chatType) }
} }
@ -140,7 +173,7 @@ class ChatViewModel : ViewModel() {
ConversationData( ConversationData(
targetId = targetId, targetId = targetId,
chatType = chatType, chatType = chatType,
lastMsgContent = last.content, lastMsgContent = last.previewText(),
lastMsgType = last.msgType, lastMsgType = last.msgType,
lastMsgTime = last.createdAt, lastMsgTime = last.createdAt,
unreadCount = 0, unreadCount = 0,
@ -176,7 +209,19 @@ class ChatViewModel : ViewModel() {
fun sendText(content: String) { fun sendText(content: String) {
if (content.isBlank()) return if (content.isBlank()) return
ImSDK.sendMessage(targetId, chatType, "TEXT", content) val replyTarget = _replyTargetMessage.value
if (replyTarget != null) {
ImSDK.sendQuoteMessage(
toId = targetId,
chatType = chatType,
quotedMsgId = replyTarget.id,
quotedContent = replyTarget.previewText(),
text = content,
)
_replyTargetMessage.value = null
} else {
ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content))
}
_draftText.value = "" _draftText.value = ""
cache.clearDraft(targetId, chatType) cache.clearDraft(targetId, chatType)
cache.upsertConversation( cache.upsertConversation(
@ -212,14 +257,73 @@ class ChatViewModel : ViewModel() {
} }
} }
fun startReply(message: ImMessage) {
_replyTargetMessage.value = message
}
fun clearReply() {
_replyTargetMessage.value = null
}
fun sendImage(uri: Uri) = sendAttachment(uri, AttachmentKind.IMAGE)
fun sendImageBytes(fileName: String, bytes: ByteArray, width: Int? = null, height: Int? = null) {
sendAttachmentBytes(fileName, bytes, AttachmentKind.IMAGE, width, height)
}
fun sendVideo(uri: Uri) = sendAttachment(uri, AttachmentKind.VIDEO)
fun sendAudio(uri: Uri) = sendAttachment(uri, AttachmentKind.AUDIO)
fun sendAudioRecording(recording: RecordedVoice) {
if (!initialized) return
viewModelScope.launch {
_isSendingAttachment.value = true
try {
runCatching {
AppDependencies.attachmentRepository.sendAudioBytes(
targetId = targetId,
chatType = chatType,
fileName = recording.fileName,
bytes = recording.bytes,
durationMs = recording.durationMs,
).getOrThrow()
}.onSuccess {
cache.upsertConversation(
ConversationData(
targetId = targetId,
chatType = chatType,
lastMsgContent = "[语音]",
lastMsgType = "AUDIO",
lastMsgTime = System.currentTimeMillis(),
unreadCount = 0,
isMuted = false,
isPinned = false,
)
)
}
} finally {
_isSendingAttachment.value = false
}
}
}
fun sendFile(uri: Uri) = sendAttachment(uri, AttachmentKind.FILE)
fun clearSearch() { fun clearSearch() {
_searchQuery.value = "" _searchQuery.value = ""
_searchResults.value = emptyList() _searchResults.value = emptyList()
} }
private fun prependMessage(message: ImMessage) { private fun prependMessage(message: ImMessage) {
if (_messages.value.any { it.id == message.id }) return val updated = _messages.value.toMutableList()
_messages.value = listOf(message) + _messages.value val index = updated.indexOfFirst { it.id == message.id }
if (index >= 0) {
updated[index] = message
} else {
updated.add(0, message)
}
_messages.value = updated
} }
private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> = private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> =
@ -229,6 +333,85 @@ class ChatViewModel : ViewModel() {
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1 _scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
} }
fun appendMention(userId: String) {
val current = _draftText.value
val next = if (current.isBlank()) "@$userId " else "$current @$userId "
updateDraft(next)
}
private fun loadMentionableUsers() {
viewModelScope.launch {
runCatching { ImSDK.getGroupInfo(targetId) }
.map { group ->
group?.memberIds.orEmpty()
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
}
.onSuccess { _mentionableUserIds.value = it }
}
}
private fun extractMentionedUserIds(content: String): String? {
if (chatType != "GROUP") return null
val ids = _mentionableUserIds.value.filter { mentionId ->
content.contains("@$mentionId")
}
return ids.joinToString(",").takeIf { it.isNotBlank() }
}
private fun sendAttachment(uri: Uri, kind: AttachmentKind) {
if (!initialized) return
viewModelScope.launch {
_isSendingAttachment.value = true
try {
runCatching {
when (kind) {
AttachmentKind.IMAGE -> AppDependencies.attachmentRepository.sendImage(targetId, chatType, uri)
AttachmentKind.VIDEO -> AppDependencies.attachmentRepository.sendVideo(targetId, chatType, uri)
AttachmentKind.AUDIO -> AppDependencies.attachmentRepository.sendAudio(targetId, chatType, uri)
AttachmentKind.FILE -> AppDependencies.attachmentRepository.sendFile(targetId, chatType, uri)
}.getOrThrow()
}
} finally {
_isSendingAttachment.value = false
}
}
}
private fun sendAttachmentBytes(
fileName: String,
bytes: ByteArray,
kind: AttachmentKind,
width: Int? = null,
height: Int? = null,
) {
if (!initialized) return
viewModelScope.launch {
_isSendingAttachment.value = true
try {
runCatching {
when (kind) {
AttachmentKind.IMAGE -> AppDependencies.attachmentRepository.sendImageBytes(
targetId = targetId,
chatType = chatType,
fileName = fileName,
bytes = bytes,
width = width,
height = height,
)
else -> error("Unsupported bytes attachment kind: $kind")
}.getOrThrow()
}
} finally {
_isSendingAttachment.value = false
}
}
}
private enum class AttachmentKind { IMAGE, VIDEO, AUDIO, FILE }
private fun isRelevant(message: ImMessage): Boolean { private fun isRelevant(message: ImMessage): Boolean {
return if (chatType == "GROUP") { return if (chatType == "GROUP") {
message.chatType == "GROUP" && message.toId == targetId message.chatType == "GROUP" && message.toId == targetId
@ -238,6 +421,9 @@ class ChatViewModel : ViewModel() {
} }
override fun onCleared() { override fun onCleared() {
if (initialized && chatType == "GROUP") {
ImSDK.unsubscribeGroup(targetId)
}
ImSDK.removeListener(listener) ImSDK.removeListener(listener)
initialized = false initialized = false
} }

查看文件

@ -25,6 +25,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.sample.data.api.UserData import com.xuqm.sdk.sample.data.api.UserData
import com.xuqm.sdk.sample.data.local.LocalContactCache import com.xuqm.sdk.sample.data.local.LocalContactCache
import com.xuqm.sdk.sample.data.repo.AuthRepository import com.xuqm.sdk.sample.data.repo.AuthRepository
@ -47,9 +49,17 @@ class ContactViewModel(
private val _searchResults = MutableStateFlow<List<UserData>>(emptyList()) private val _searchResults = MutableStateFlow<List<UserData>>(emptyList())
val searchResults: StateFlow<List<UserData>> = _searchResults val searchResults: StateFlow<List<UserData>> = _searchResults
private val _friendRequests = MutableStateFlow<List<FriendRequest>>(emptyList())
val friendRequests: StateFlow<List<FriendRequest>> = _friendRequests
private val _blacklist = MutableStateFlow<List<BlacklistEntry>>(emptyList())
val blacklist: StateFlow<List<BlacklistEntry>> = _blacklist
init { init {
_friends.value = cache.resolveFriends(cache.loadFriendIds()) _friends.value = cache.resolveFriends(cache.loadFriendIds())
loadFriends() loadFriends()
loadFriendRequests()
loadBlacklist()
} }
fun loadFriends() { fun loadFriends() {
@ -84,7 +94,7 @@ class ContactViewModel(
fun addFriend(userId: String) { fun addFriend(userId: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.addFriend(userId) } runCatching { ImSDK.sendFriendRequest(userId) }
.onSuccess { loadFriends() } .onSuccess { loadFriends() }
} }
} }
@ -95,6 +105,48 @@ class ContactViewModel(
.onSuccess { loadFriends() } .onSuccess { loadFriends() }
} }
} }
fun loadFriendRequests() {
viewModelScope.launch {
runCatching { ImSDK.listFriendRequests() }
.onSuccess { _friendRequests.value = it }
}
}
fun acceptFriendRequest(requestId: String) {
viewModelScope.launch {
runCatching { ImSDK.acceptFriendRequest(requestId) }
.onSuccess { loadFriends(); loadFriendRequests() }
}
}
fun rejectFriendRequest(requestId: String) {
viewModelScope.launch {
runCatching { ImSDK.rejectFriendRequest(requestId) }
.onSuccess { loadFriendRequests() }
}
}
fun loadBlacklist() {
viewModelScope.launch {
runCatching { ImSDK.listBlacklist() }
.onSuccess { _blacklist.value = it }
}
}
fun addToBlacklist(userId: String) {
viewModelScope.launch {
runCatching { ImSDK.addToBlacklist(userId) }
.onSuccess { loadBlacklist() }
}
}
fun removeFromBlacklist(userId: String) {
viewModelScope.launch {
runCatching { ImSDK.removeFromBlacklist(userId) }
.onSuccess { loadBlacklist() }
}
}
} }
@Composable @Composable
@ -108,6 +160,8 @@ fun ContactScreen(
) { ) {
val friends by viewModel.friends.collectAsStateWithLifecycle() val friends by viewModel.friends.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
val friendRequests by viewModel.friendRequests.collectAsStateWithLifecycle()
val blacklist by viewModel.blacklist.collectAsStateWithLifecycle()
var keyword by remember { mutableStateOf("") } var keyword by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@ -120,6 +174,32 @@ fun ContactScreen(
placeholder = "搜索用户 ID 或昵称", placeholder = "搜索用户 ID 或昵称",
) )
if (friendRequests.isNotEmpty()) {
Text(
"好友申请(${friendRequests.size}",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline,
)
LazyColumn {
items(friendRequests, key = { it.id }) { request ->
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(request.fromUserId, style = MaterialTheme.typography.titleSmall)
Text(request.remark.orEmpty(), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline)
}
TextButton(onClick = { viewModel.acceptFriendRequest(request.id) }) { Text("接受") }
TextButton(onClick = { viewModel.rejectFriendRequest(request.id) }) { Text("拒绝") }
}
HorizontalDivider()
}
}
}
if (keyword.isBlank()) { if (keyword.isBlank()) {
Text( Text(
"联系人(${friends.size}", "联系人(${friends.size}",
@ -152,14 +232,37 @@ fun ContactScreen(
color = MaterialTheme.colorScheme.outline) color = MaterialTheme.colorScheme.outline)
} }
if (!isFriend) { if (!isFriend) {
TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("添加好友") } TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("申请好友") }
} else { } else {
TextButton(onClick = { onOpenChat(user.userId) }) { Text("发消息") } TextButton(onClick = { onOpenChat(user.userId) }) { Text("发消息") }
} }
TextButton(onClick = { viewModel.addToBlacklist(user.userId) }) { Text("拉黑") }
} }
HorizontalDivider() HorizontalDivider()
} }
} }
if (blacklist.isNotEmpty()) {
Text(
"黑名单(${blacklist.size}",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline,
)
LazyColumn {
items(blacklist, key = { it.id }) { entry ->
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(entry.blockedUserId, modifier = Modifier.weight(1f))
TextButton(onClick = { viewModel.removeFromBlacklist(entry.blockedUserId) }) {
Text("移除")
}
}
HorizontalDivider()
}
}
}
} }
} }
} }

查看文件

@ -52,8 +52,9 @@ fun ConversationScreen(
conversations.filter { conversation -> conversations.filter { conversation ->
if (query.isBlank()) return@filter true if (query.isBlank()) return@filter true
val keyword = query.trim() val keyword = query.trim()
val preview = conversationPreview(conversation)
conversation.targetId.contains(keyword, ignoreCase = true) || conversation.targetId.contains(keyword, ignoreCase = true) ||
(conversation.lastMsgContent ?: "").contains(keyword, ignoreCase = true) || preview.contains(keyword, ignoreCase = true) ||
conversation.chatType.contains(keyword, ignoreCase = true) conversation.chatType.contains(keyword, ignoreCase = true)
} }
} }
@ -148,7 +149,7 @@ private fun ConversationItem(
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
conversation.lastMsgContent ?: "", conversationPreview(conversation),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
maxLines = 1, maxLines = 1,
@ -178,3 +179,24 @@ private fun ConversationItem(
} }
} }
} }
private fun conversationPreview(conversation: ConversationData): String {
return when (conversation.lastMsgType?.uppercase()) {
"TEXT" -> conversation.lastMsgContent.orEmpty()
"IMAGE" -> "[图片]"
"VIDEO" -> "[视频]"
"AUDIO" -> "[语音]"
"FILE" -> "[文件]"
"LOCATION" -> "[位置]"
"CUSTOM" -> "[自定义]"
"RICH_TEXT" -> "[富文本]"
"FORWARD" -> "[转发]"
"QUOTE" -> "[引用]"
"MERGE" -> "[合并转发]"
"CALL_AUDIO" -> "[语音通话]"
"CALL_VIDEO" -> "[视频通话]"
"REVOKED" -> "[消息已撤回]"
"NOTIFY" -> conversation.lastMsgContent.orEmpty().takeIf { it.isNotBlank() } ?: "[通知]"
else -> conversation.lastMsgContent.orEmpty()
}
}

查看文件

@ -40,7 +40,9 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.ui.SearchBarField
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.GroupJoinRequest
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -51,6 +53,8 @@ fun GroupListScreen(
viewModel: GroupViewModel = viewModel(), viewModel: GroupViewModel = viewModel(),
) { ) {
val groups by viewModel.groups.collectAsStateWithLifecycle() val groups by viewModel.groups.collectAsStateWithLifecycle()
val publicGroups by viewModel.publicGroups.collectAsStateWithLifecycle()
val publicGroupQuery by viewModel.publicGroupQuery.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
@ -63,6 +67,21 @@ fun GroupListScreen(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
) { ) {
item {
SearchBarField(
value = publicGroupQuery,
onValueChange = viewModel::searchPublicGroups,
placeholder = "搜索公开群",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
item {
Text(
text = "我的群聊",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
items(groups, key = { it.id }) { group -> items(groups, key = { it.id }) { group ->
GroupItem( GroupItem(
group = group, group = group,
@ -71,15 +90,32 @@ fun GroupListScreen(
) )
HorizontalDivider() HorizontalDivider()
} }
if (publicGroups.isNotEmpty()) {
item {
Text(
text = "公开群",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
items(publicGroups, key = { "public-${it.id}" }) { group ->
PublicGroupItem(
group = group,
onOpen = { onOpenGroupChat(group.id, group.name) },
onJoin = { viewModel.requestJoinGroup(group.id) },
)
HorizontalDivider()
}
}
} }
} }
if (showCreateDialog) { if (showCreateDialog) {
CreateGroupDialog( CreateGroupDialog(
onDismiss = { showCreateDialog = false }, onDismiss = { showCreateDialog = false },
onCreate = { name, memberIds -> onCreate = { name, memberIds, groupType ->
showCreateDialog = false showCreateDialog = false
viewModel.createGroup(name, memberIds) { group -> viewModel.createGroup(name, memberIds, groupType) { group ->
onOpenGroupChat(group.id, group.name) onOpenGroupChat(group.id, group.name)
} }
}, },
@ -103,15 +139,42 @@ private fun GroupItem(group: ImGroup, onClick: () -> Unit, onSettings: () -> Uni
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )
Text(
text = group.groupType?.let { "类型:$it" } ?: "类型WORK",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
} }
TextButton(onClick = onSettings) { Text("设置") } TextButton(onClick = onSettings) { Text("设置") }
} }
} }
@Composable @Composable
private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<String>) -> Unit) { private fun PublicGroupItem(group: ImGroup, onOpen: () -> Unit, onJoin: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(group.name, style = MaterialTheme.typography.titleSmall)
Text(
"群 ID: ${group.id}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
TextButton(onClick = onOpen) { Text("查看") }
TextButton(onClick = onJoin) { Text("申请加入") }
}
}
@Composable
private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<String>, String) -> Unit) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var memberInput by remember { mutableStateOf("") } var memberInput by remember { mutableStateOf("") }
var isPublicGroup by remember { mutableStateOf(false) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@ -130,6 +193,9 @@ private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<Str
label = { Text("成员 ID逗号分隔") }, label = { Text("成员 ID逗号分隔") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
TextButton(onClick = { isPublicGroup = !isPublicGroup }) {
Text(if (isPublicGroup) "群类型:公开群" else "群类型:普通群")
}
} }
}, },
confirmButton = { confirmButton = {
@ -137,7 +203,7 @@ private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<Str
onClick = { onClick = {
val members = memberInput.split(",").map { it.trim() }.filter { it.isNotBlank() } val members = memberInput.split(",").map { it.trim() }.filter { it.isNotBlank() }
val allMembers = (members + listOf(ImSDK.currentUserId)).distinct() val allMembers = (members + listOf(ImSDK.currentUserId)).distinct()
onCreate(name.trim(), allMembers) onCreate(name.trim(), allMembers, if (isPublicGroup) "PUBLIC" else "WORK")
}, },
enabled = name.isNotBlank(), enabled = name.isNotBlank(),
) { Text("创建") } ) { Text("创建") }
@ -154,6 +220,7 @@ fun GroupSettingsScreen(
viewModel: GroupViewModel = viewModel(), viewModel: GroupViewModel = viewModel(),
) { ) {
val group by viewModel.currentGroup.collectAsStateWithLifecycle() val group by viewModel.currentGroup.collectAsStateWithLifecycle()
val joinRequests by viewModel.joinRequests.collectAsStateWithLifecycle()
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
var showEditDialog by remember { mutableStateOf(false) } var showEditDialog by remember { mutableStateOf(false) }
var editName by remember { mutableStateOf("") } var editName by remember { mutableStateOf("") }
@ -163,6 +230,11 @@ fun GroupSettingsScreen(
LaunchedEffect(group) { LaunchedEffect(group) {
editName = group?.name.orEmpty() editName = group?.name.orEmpty()
editAnnouncement = group?.announcement.orEmpty() editAnnouncement = group?.announcement.orEmpty()
if (group?.groupType?.equals("PUBLIC", ignoreCase = true) == true) {
viewModel.loadJoinRequests(groupId)
} else {
viewModel.clearJoinRequests()
}
} }
Scaffold( Scaffold(
@ -191,6 +263,8 @@ fun GroupSettingsScreen(
Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall, Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline) color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("群类型: ${g.groupType ?: "WORK"}", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium) Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium)
if (isOwner) { if (isOwner) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@ -199,6 +273,24 @@ fun GroupSettingsScreen(
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
if (g.groupType?.equals("PUBLIC", ignoreCase = true) == true && isOwner) {
Text("加群申请", style = MaterialTheme.typography.titleSmall)
joinRequests.filter { it.status.equals("PENDING", ignoreCase = true) }.forEach { request ->
JoinRequestRow(
request = request,
onAccept = { viewModel.acceptJoinRequest(g.id, request.id) },
onReject = { viewModel.rejectJoinRequest(g.id, request.id) },
)
}
if (joinRequests.none { it.status.equals("PENDING", ignoreCase = true) }) {
Text(
"暂无待处理申请",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
Spacer(Modifier.height(16.dp))
}
Text("成员", style = MaterialTheme.typography.titleSmall) Text("成员", style = MaterialTheme.typography.titleSmall)
val memberIds = g.memberIds.split(",").filter { it.isNotBlank() } val memberIds = g.memberIds.split(",").filter { it.isNotBlank() }
memberIds.forEach { memberId -> memberIds.forEach { memberId ->
@ -208,6 +300,12 @@ fun GroupSettingsScreen(
) { ) {
Text(memberId, modifier = Modifier.weight(1f)) Text(memberId, modifier = Modifier.weight(1f))
if (g.creatorId == ImSDK.currentUserId && memberId != ImSDK.currentUserId) { if (g.creatorId == ImSDK.currentUserId && memberId != ImSDK.currentUserId) {
TextButton(onClick = { viewModel.setRole(g.id, memberId, "ADMIN") }) {
Text("设管")
}
TextButton(onClick = { viewModel.muteMember(g.id, memberId, 60) }) {
Text("禁言1h")
}
TextButton(onClick = { viewModel.removeMember(g.id, memberId) }) { TextButton(onClick = { viewModel.removeMember(g.id, memberId) }) {
Text("移除", color = MaterialTheme.colorScheme.error) Text("移除", color = MaterialTheme.colorScheme.error)
} }
@ -215,6 +313,14 @@ fun GroupSettingsScreen(
} }
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
if (isOwner) {
Button(
onClick = { viewModel.dismissGroup(g.id) { onNavigateBack() } },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(),
) { Text("解散群聊") }
Spacer(Modifier.height(8.dp))
}
Button( Button(
onClick = { viewModel.leaveGroup(g.id) { onNavigateBack() } }, onClick = { viewModel.leaveGroup(g.id) { onNavigateBack() } },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
@ -265,3 +371,29 @@ fun GroupSettingsScreen(
) )
} }
} }
@Composable
private fun JoinRequestRow(
request: GroupJoinRequest,
onAccept: () -> Unit,
onReject: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Text(request.requesterId, style = MaterialTheme.typography.bodyMedium)
if (!request.remark.isNullOrBlank()) {
Text(
text = request.remark!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onAccept) { Text("通过") }
TextButton(onClick = onReject) { Text("拒绝") }
}
}
}

查看文件

@ -3,6 +3,7 @@ package com.xuqm.sdk.sample.ui.group
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.GroupJoinRequest
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -13,10 +14,22 @@ class GroupViewModel : ViewModel() {
private val _groups = MutableStateFlow<List<ImGroup>>(emptyList()) private val _groups = MutableStateFlow<List<ImGroup>>(emptyList())
val groups: StateFlow<List<ImGroup>> = _groups val groups: StateFlow<List<ImGroup>> = _groups
private val _publicGroups = MutableStateFlow<List<ImGroup>>(emptyList())
val publicGroups: StateFlow<List<ImGroup>> = _publicGroups
private val _publicGroupQuery = MutableStateFlow("")
val publicGroupQuery: StateFlow<String> = _publicGroupQuery
private val _currentGroup = MutableStateFlow<ImGroup?>(null) private val _currentGroup = MutableStateFlow<ImGroup?>(null)
val currentGroup: StateFlow<ImGroup?> = _currentGroup val currentGroup: StateFlow<ImGroup?> = _currentGroup
init { loadGroups() } private val _joinRequests = MutableStateFlow<List<GroupJoinRequest>>(emptyList())
val joinRequests: StateFlow<List<GroupJoinRequest>> = _joinRequests
init {
loadGroups()
searchPublicGroups("")
}
fun loadGroups() { fun loadGroups() {
viewModelScope.launch { viewModelScope.launch {
@ -32,9 +45,17 @@ class GroupViewModel : ViewModel() {
} }
} }
fun createGroup(name: String, memberIds: List<String>, onSuccess: (ImGroup) -> Unit) { fun searchPublicGroups(keyword: String) {
_publicGroupQuery.value = keyword
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.createGroup(name, memberIds) } runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
.onSuccess { _publicGroups.value = it }
}
}
fun createGroup(name: String, memberIds: List<String>, groupType: String, onSuccess: (ImGroup) -> Unit) {
viewModelScope.launch {
runCatching { ImSDK.createGroup(name, memberIds, groupType) }
.onSuccess { group -> group?.let { onSuccess(it); loadGroups() } } .onSuccess { group -> group?.let { onSuccess(it); loadGroups() } }
} }
} }
@ -46,6 +67,27 @@ class GroupViewModel : ViewModel() {
} }
} }
fun setRole(groupId: String, userId: String, role: String) {
viewModelScope.launch {
runCatching { ImSDK.setGroupRole(groupId, userId, role) }
.onSuccess { loadGroupInfo(groupId) }
}
}
fun muteMember(groupId: String, userId: String, minutes: Long) {
viewModelScope.launch {
runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) }
.onSuccess { loadGroupInfo(groupId) }
}
}
fun dismissGroup(groupId: String, onSuccess: () -> Unit) {
viewModelScope.launch {
runCatching { ImSDK.dismissGroup(groupId) }
.onSuccess { loadGroups(); onSuccess() }
}
}
fun removeMember(groupId: String, userId: String) { fun removeMember(groupId: String, userId: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.removeGroupMember(groupId, userId) } runCatching { ImSDK.removeGroupMember(groupId, userId) }
@ -59,4 +101,35 @@ class GroupViewModel : ViewModel() {
.onSuccess { loadGroups(); onSuccess() } .onSuccess { loadGroups(); onSuccess() }
} }
} }
fun requestJoinGroup(groupId: String, remark: String? = null) {
viewModelScope.launch {
runCatching { ImSDK.sendGroupJoinRequest(groupId, remark) }
}
}
fun loadJoinRequests(groupId: String) {
viewModelScope.launch {
runCatching { ImSDK.listGroupJoinRequests(groupId) }
.onSuccess { _joinRequests.value = it }
}
}
fun clearJoinRequests() {
_joinRequests.value = emptyList()
}
fun acceptJoinRequest(groupId: String, requestId: String) {
viewModelScope.launch {
runCatching { ImSDK.acceptGroupJoinRequest(groupId, requestId) }
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
}
}
fun rejectJoinRequest(groupId: String, requestId: String) {
viewModelScope.launch {
runCatching { ImSDK.rejectGroupJoinRequest(groupId, requestId) }
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
}
}
} }

查看文件

@ -11,6 +11,9 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
@ -19,19 +22,26 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.update.UpdateSDK
import com.xuqm.sdk.update.model.UpdateInfo
import com.xuqm.sdk.sample.ui.contact.ContactScreen import com.xuqm.sdk.sample.ui.contact.ContactScreen
import com.xuqm.sdk.sample.ui.conversation.ConversationScreen import com.xuqm.sdk.sample.ui.conversation.ConversationScreen
import com.xuqm.sdk.sample.ui.group.GroupListScreen import com.xuqm.sdk.sample.ui.group.GroupListScreen
import com.xuqm.sdk.sample.ui.profile.ProfileScreen import com.xuqm.sdk.sample.ui.profile.ProfileScreen
import com.xuqm.sdk.sample.ui.update.UpdateScreen import com.xuqm.sdk.sample.ui.update.UpdateScreen
import kotlinx.coroutines.launch
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
private data class BottomTab(val label: String, val icon: ImageVector) private data class BottomTab(val label: String, val icon: ImageVector)
@ -54,6 +64,19 @@ fun MainScreen(
) { ) {
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
var updateChecked by remember { mutableStateOf(false) }
var pendingUpdate by remember { mutableStateOf<UpdateInfo?>(null) }
var downloadProgress by remember { mutableStateOf(-1) }
var isDownloading by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (!updateChecked) {
updateChecked = true
pendingUpdate = UpdateSDK.checkAppUpdate(context)?.takeIf { it.needsUpdate }
}
}
Scaffold( Scaffold(
topBar = { topBar = {
@ -97,4 +120,48 @@ fun MainScreen(
} }
} }
} }
pendingUpdate?.let { update ->
AlertDialog(
onDismissRequest = {
if (!update.forceUpdate) pendingUpdate = null
},
title = { Text("发现新版本 ${update.versionName}") },
text = {
androidx.compose.foundation.layout.Column {
Text(update.changeLog.ifBlank { "无更新说明" })
if (downloadProgress in 0..99) {
CircularProgressIndicator()
Text("下载中 $downloadProgress%")
}
}
},
confirmButton = {
Button(
onClick = {
if (update.downloadUrl.isNotBlank() && !isDownloading) {
isDownloading = true
scope.launch {
UpdateSDK.downloadAndInstall(context, update.downloadUrl) { progress ->
downloadProgress = progress
}
isDownloading = false
pendingUpdate = null
downloadProgress = -1
}
}
},
) {
Text(if (isDownloading) "处理中" else "立即更新")
}
},
dismissButton = {
if (!update.forceUpdate) {
Button(onClick = { pendingUpdate = null }) {
Text("稍后")
}
}
}
)
}
} }

查看文件

@ -3,7 +3,6 @@ package com.xuqm.sdk.sample.ui.update
import android.content.Context import android.content.Context
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -15,17 +14,17 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.update.UpdateSDK import com.xuqm.sdk.update.UpdateSDK
import com.xuqm.sdk.update.model.UpdateInfo import com.xuqm.sdk.update.model.UpdateInfo
import com.xuqm.sdk.sample.di.AppDependencies
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -69,6 +68,12 @@ fun UpdateScreen(viewModel: UpdateViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) {
if (state.appUpdate == null && !state.isChecking) {
viewModel.checkAppUpdate(context)
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

查看文件

@ -15,6 +15,9 @@ object XuqmSDK {
lateinit var config: SDKConfig lateinit var config: SDKConfig
private set private set
lateinit var appContext: Context
private set
lateinit var tokenStore: TokenStore lateinit var tokenStore: TokenStore
private set private set
@ -28,6 +31,7 @@ object XuqmSDK {
logLevel: LogLevel = LogLevel.WARN, logLevel: LogLevel = LogLevel.WARN,
) { ) {
config = SDKConfig(appId, logLevel) config = SDKConfig(appId, logLevel)
appContext = context.applicationContext
tokenStore = TokenStore(context.applicationContext) tokenStore = TokenStore(context.applicationContext)
ApiClient.init(config, tokenStore) ApiClient.init(config, tokenStore)
initialized = true initialized = true

查看文件

@ -6,6 +6,7 @@ data class ServiceEndpoints(
val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im", val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im",
val pushBaseUrl: String = "https://dev.xuqinmin.com/", val pushBaseUrl: String = "https://dev.xuqinmin.com/",
val updateBaseUrl: String = "https://update.dev.xuqinmin.com/", val updateBaseUrl: String = "https://update.dev.xuqinmin.com/",
val fileBaseUrl: String = "https://file.dev.xuqinmin.com/",
) )
object ServiceEndpointRegistry { object ServiceEndpointRegistry {
@ -19,6 +20,7 @@ object ServiceEndpointRegistry {
val imWsUrl: String get() = current.imWsUrl val imWsUrl: String get() = current.imWsUrl
val pushBaseUrl: String get() = current.pushBaseUrl val pushBaseUrl: String get() = current.pushBaseUrl
val updateBaseUrl: String get() = current.updateBaseUrl val updateBaseUrl: String get() = current.updateBaseUrl
val fileBaseUrl: String get() = current.fileBaseUrl
fun configure(endpoints: ServiceEndpoints) { fun configure(endpoints: ServiceEndpoints) {
current = endpoints current = endpoints
@ -30,8 +32,9 @@ object ServiceEndpointRegistry {
controlBaseUrl = "http://$normalizedHost:8081/", controlBaseUrl = "http://$normalizedHost:8081/",
imApiBaseUrl = "http://$normalizedHost:8082/", imApiBaseUrl = "http://$normalizedHost:8082/",
imWsUrl = "ws://$normalizedHost:8082/ws/im", imWsUrl = "ws://$normalizedHost:8082/ws/im",
pushBaseUrl = "http://$normalizedHost:8081/", pushBaseUrl = "http://$normalizedHost:8083/",
updateBaseUrl = "http://$normalizedHost:8084/", updateBaseUrl = "http://$normalizedHost:8084/",
fileBaseUrl = "http://$normalizedHost:8086/",
) )
} }

查看文件

@ -0,0 +1,127 @@
package com.xuqm.sdk.file
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
import okio.BufferedSink
import okio.source
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import java.util.Locale
data class FileUploadResult(
val url: String,
val thumbnailUrl: String? = null,
val hash: String,
val size: Long,
val originalName: String? = null,
val mimeType: String? = null,
val ext: String? = null,
)
data class ApiResponse<T>(
val code: Int,
val status: String,
val data: T?,
val message: String? = null,
)
private interface FileApi {
@Multipart
@POST("api/file/upload")
suspend fun upload(
@Part file: MultipartBody.Part,
@Part thumbnail: MultipartBody.Part? = null,
): ApiResponse<FileUploadResult>
}
object FileSDK {
private val api: FileApi
get() = ApiClient.create(FileApi::class.java, ServiceEndpointRegistry.fileBaseUrl)
suspend fun upload(
context: Context,
uri: Uri,
displayName: String? = null,
mimeType: String? = null,
thumbnailBytes: ByteArray? = null,
): FileUploadResult {
val resolvedName = displayName?.takeIf { it.isNotBlank() } ?: resolveDisplayName(context, uri)
val resolvedMimeType = mimeType?.takeIf { it.isNotBlank() } ?: context.contentResolver.getType(uri)
val filePart = MultipartBody.Part.createFormData(
"file",
resolvedName,
UriRequestBody(context, uri, resolvedMimeType),
)
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { bytes ->
MultipartBody.Part.createFormData(
"thumbnail",
"${resolvedName.substringBeforeLast('.', resolvedName)}_thumb.jpg",
bytes.toRequestBody("image/jpeg".toMediaTypeOrNull()),
)
}
return requireNotNull(api.upload(filePart, thumbnailPart).data) {
"File upload failed"
}
}
suspend fun uploadBytes(
fileName: String,
mimeType: String?,
bytes: ByteArray,
thumbnailBytes: ByteArray? = null,
): FileUploadResult {
val filePart = MultipartBody.Part.createFormData(
"file",
fileName,
bytes.toRequestBody(mimeType?.toMediaTypeOrNull()),
)
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { thumbBytes ->
MultipartBody.Part.createFormData(
"thumbnail",
"${fileName.substringBeforeLast('.', fileName)}_thumb.jpg",
thumbBytes.toRequestBody("image/jpeg".toMediaTypeOrNull()),
)
}
return requireNotNull(api.upload(filePart, thumbnailPart).data) {
"File upload failed"
}
}
private fun resolveDisplayName(context: Context, uri: Uri): String {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) {
val value = cursor.getString(nameIndex)
if (!value.isNullOrBlank()) return value
}
}
val fallback = uri.lastPathSegment?.substringAfterLast('/')
return fallback?.takeIf { it.isNotBlank() }
?: String.format(Locale.US, "upload_%d", System.currentTimeMillis())
}
private class UriRequestBody(
private val context: Context,
private val uri: Uri,
private val mimeType: String?,
) : RequestBody() {
override fun contentType() = mimeType?.toMediaTypeOrNull()
override fun writeTo(sink: BufferedSink) {
context.contentResolver.openInputStream(uri)?.use { input ->
sink.writeAll(input.source())
} ?: error("Failed to open input stream for $uri")
}
}
}

查看文件

@ -3,14 +3,12 @@ package com.xuqm.sdk.im
import com.google.gson.Gson import com.google.gson.Gson
import com.xuqm.sdk.im.listener.ImEventListener import com.xuqm.sdk.im.listener.ImEventListener
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import java.net.URI
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -21,8 +19,11 @@ class ImClient(
) { ) {
private var webSocket: WebSocket? = null private var webSocket: WebSocket? = null
private val listeners = CopyOnWriteArrayList<ImEventListener>() private val listeners = CopyOnWriteArrayList<ImEventListener>()
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val gson = Gson() private val gson = Gson()
private val subscriptions = mutableMapOf<String, String>()
private var subscriptionSeed = 0
private var connected = false
private var inboundBuffer = StringBuilder()
private val okhttp = OkHttpClient.Builder() private val okhttp = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
@ -30,19 +31,151 @@ class ImClient(
.build() .build()
fun connect() { fun connect() {
disconnect(closeSocket = false)
val request = Request.Builder() val request = Request.Builder()
.url(wsUrl) .url(wsUrl)
.header("Authorization", "Bearer $token")
.build() .build()
webSocket = okhttp.newWebSocket(request, object : WebSocketListener() { webSocket = okhttp.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
listeners.forEach { it.onConnected() } sendConnectFrame()
} }
override fun onMessage(ws: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
handleIncoming(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
connected = false
listeners.forEach { it.onDisconnected(t.message) }
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
connected = false
listeners.forEach { it.onDisconnected(reason) }
}
})
}
fun subscribe(destination: String) {
val subscriptionId = synchronized(subscriptions) {
if (subscriptions.containsKey(destination)) return
val id = nextSubscriptionId()
subscriptions[destination] = id
id
}
if (connected) {
sendSubscribe(destination, subscriptionId)
}
}
fun unsubscribe(destination: String) {
val subscriptionId = synchronized(subscriptions) { subscriptions.remove(destination) } ?: return
if (connected) {
sendFrame("UNSUBSCRIBE", mapOf("id" to subscriptionId), null)
}
}
fun sendMessage(
toId: String,
chatType: String,
msgType: String,
content: String,
mentionedUserIds: String? = null,
) {
val payload = linkedMapOf(
"appId" to appId,
"toId" to toId,
"chatType" to chatType,
"msgType" to msgType,
"content" to content,
)
if (!mentionedUserIds.isNullOrBlank()) {
payload["mentionedUserIds"] = mentionedUserIds
}
sendFrame(
"SEND",
mapOf(
"destination" to "/app/chat.send",
"content-type" to "application/json",
),
gson.toJson(payload),
)
}
fun revokeMessage(messageId: String) {
sendFrame(
"SEND",
mapOf(
"destination" to "/app/chat.revoke",
"content-type" to "application/json",
),
gson.toJson(
mapOf(
"appId" to appId,
"messageId" to messageId,
)
),
)
}
fun addListener(listener: ImEventListener) = listeners.add(listener)
fun removeListener(listener: ImEventListener) = listeners.remove(listener)
fun disconnect() {
disconnect(closeSocket = true)
}
private fun sendConnectFrame() {
connected = false
sendFrame(
"CONNECT",
mapOf(
"accept-version" to "1.2",
"heart-beat" to "0,0",
"host" to URI.create(wsUrl).host.orEmpty(),
"Authorization" to "Bearer $token",
),
null,
)
}
private fun handleIncoming(chunk: String) {
if (chunk.isBlank()) return
inboundBuffer.append(chunk)
while (true) {
val terminator = inboundBuffer.indexOf("\u0000")
if (terminator < 0) return
val frame = inboundBuffer.substring(0, terminator)
inboundBuffer = StringBuilder(inboundBuffer.substring(terminator + 1))
if (frame.isNotBlank()) {
handleFrame(frame)
}
}
}
private fun handleFrame(frame: String) {
val parts = frame.split("\n\n", limit = 2)
val headerLines = parts.firstOrNull().orEmpty().split("\n").filter { it.isNotBlank() }
val command = headerLines.firstOrNull()?.trim().orEmpty()
val headers = parseHeaders(headerLines.drop(1))
val body = parts.getOrNull(1).orEmpty()
when (command.uppercase()) {
"CONNECTED" -> {
connected = true
listeners.forEach { it.onConnected() }
sendSubscribe("/user/queue/messages", nextSubscriptionId(prefix = "user"))
val pendingSubscriptions = synchronized(subscriptions) { subscriptions.toMap() }
pendingSubscriptions.forEach { (destination, id) ->
if (destination != "/user/queue/messages") {
sendSubscribe(destination, id)
}
}
}
"MESSAGE" -> {
runCatching { runCatching {
val msg = gson.fromJson(text, ImMessage::class.java) val msg = gson.fromJson(body, ImMessage::class.java)
if (msg.chatType == "GROUP") { if (msg.chatType.uppercase() == "GROUP") {
listeners.forEach { it.onGroupMessage(msg) } listeners.forEach { it.onGroupMessage(msg) }
} else { } else {
listeners.forEach { it.onMessage(msg) } listeners.forEach { it.onMessage(msg) }
@ -51,31 +184,94 @@ class ImClient(
listeners.forEach { it.onError("Parse error: ${e.message}") } listeners.forEach { it.onError("Parse error: ${e.message}") }
} }
} }
"ERROR" -> {
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { val reason = body.ifBlank { headers["message"].orEmpty() }
listeners.forEach { it.onDisconnected(t.message) } listeners.forEach { it.onError(reason.ifBlank { "STOMP error" }) }
}
}
} }
override fun onClosed(ws: WebSocket, code: Int, reason: String) { private fun sendSubscribe(destination: String, subscriptionId: String) {
listeners.forEach { it.onDisconnected(reason) } sendFrame(
} "SUBSCRIBE",
}) mapOf(
} "id" to subscriptionId,
"destination" to destination,
fun sendMessage(toId: String, chatType: String, msgType: String, content: String) { ),
val payload = mapOf( null,
"appId" to appId, "toId" to toId,
"chatType" to chatType, "msgType" to msgType,
"content" to content,
) )
webSocket?.send(gson.toJson(mapOf("type" to "chat.send", "data" to payload)))
} }
fun addListener(listener: ImEventListener) = listeners.add(listener) private fun sendFrame(command: String, headers: Map<String, String>, body: String?) {
fun removeListener(listener: ImEventListener) = listeners.remove(listener) val socket = webSocket ?: return
val frame = buildString {
append(command).append('\n')
headers.forEach { (key, value) ->
append(escapeHeader(key)).append(':').append(escapeHeader(value)).append('\n')
}
append('\n')
if (body != null) {
append(body)
}
append('\u0000')
}
socket.send(frame)
}
fun disconnect() { private fun parseHeaders(lines: List<String>): Map<String, String> {
val headers = linkedMapOf<String, String>()
lines.forEach { line ->
val index = line.indexOf(':')
if (index <= 0) return@forEach
val key = unescapeHeader(line.substring(0, index))
val value = unescapeHeader(line.substring(index + 1))
headers[key] = value
}
return headers
}
private fun nextSubscriptionId(prefix: String = "sub"): String {
subscriptionSeed += 1
return "$prefix-$subscriptionSeed"
}
private fun disconnect(closeSocket: Boolean) {
connected = false
synchronized(subscriptions) { subscriptions.clear() }
inboundBuffer = StringBuilder()
if (closeSocket) {
webSocket?.close(1000, "User disconnect") webSocket?.close(1000, "User disconnect")
}
webSocket = null webSocket = null
} }
private fun escapeHeader(value: String): String =
value.replace("\\", "\\\\")
.replace("\r", "\\r")
.replace("\n", "\\n")
.replace(":", "\\c")
private fun unescapeHeader(value: String): String {
val builder = StringBuilder()
var index = 0
while (index < value.length) {
val ch = value[index]
if (ch == '\\' && index + 1 < value.length) {
when (value[index + 1]) {
'r' -> builder.append('\r')
'n' -> builder.append('\n')
'c' -> builder.append(':')
'\\' -> builder.append('\\')
else -> {
builder.append(value[index + 1])
}
}
index += 2
} else {
builder.append(ch)
index += 1
}
}
return builder.toString()
}
} }

查看文件

@ -6,19 +6,28 @@ import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.im.api.AddMemberRequest import com.xuqm.sdk.im.api.AddMemberRequest
import com.xuqm.sdk.im.api.CreateGroupRequest import com.xuqm.sdk.im.api.CreateGroupRequest
import com.xuqm.sdk.im.api.ImApi import com.xuqm.sdk.im.api.ImApi
import com.xuqm.sdk.im.api.MuteGroupMemberRequest
import com.xuqm.sdk.im.api.SetMutedRequest import com.xuqm.sdk.im.api.SetMutedRequest
import com.xuqm.sdk.im.api.SetPinnedRequest import com.xuqm.sdk.im.api.SetPinnedRequest
import com.xuqm.sdk.im.api.SetGroupRoleRequest
import com.xuqm.sdk.im.api.UpdateGroupRequest import com.xuqm.sdk.im.api.UpdateGroupRequest
import com.xuqm.sdk.im.listener.ImEventListener import com.xuqm.sdk.im.listener.ImEventListener
import com.xuqm.sdk.im.model.ImConnectionState import com.xuqm.sdk.im.model.ImConnectionState
import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.im.model.ConversationData
import com.xuqm.sdk.im.model.GroupJoinRequest
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.file.FileUploadResult
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray
import org.json.JSONObject
import java.time.LocalDateTime
object ImSDK { object ImSDK {
@ -69,29 +78,324 @@ object ImSDK {
suspend fun loginWithToken(userId: String, token: String) = suspend fun loginWithToken(userId: String, token: String) =
loginWithUserSig(userId, token) loginWithUserSig(userId, token)
fun sendMessage(toId: String, chatType: String, msgType: String, content: String) { fun sendMessage(
client?.sendMessage(toId, chatType, msgType, content) toId: String,
chatType: String,
msgType: String,
content: String,
mentionedUserIds: String? = null,
) {
client?.sendMessage(toId, chatType, msgType, content, mentionedUserIds)
}
fun sendTextMessage(
toId: String,
chatType: String,
content: String,
mentionedUserIds: String? = null,
) {
sendMessage(toId, chatType, "TEXT", content, mentionedUserIds)
}
fun sendImageMessage(
toId: String,
chatType: String,
file: FileUploadResult,
width: Int? = null,
height: Int? = null,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "IMAGE",
content = buildMediaPayload(
url = file.url,
thumbnailUrl = file.thumbnailUrl,
name = file.originalName,
mimeType = file.mimeType,
size = file.size,
hash = file.hash,
width = width,
height = height,
),
)
}
fun sendVideoMessage(
toId: String,
chatType: String,
file: FileUploadResult,
width: Int? = null,
height: Int? = null,
durationMs: Long? = null,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "VIDEO",
content = buildMediaPayload(
url = file.url,
thumbnailUrl = file.thumbnailUrl,
name = file.originalName,
mimeType = file.mimeType,
size = file.size,
hash = file.hash,
width = width,
height = height,
durationMs = durationMs,
),
)
}
fun sendFileMessage(
toId: String,
chatType: String,
file: FileUploadResult,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "FILE",
content = buildMediaPayload(
url = file.url,
name = file.originalName,
mimeType = file.mimeType,
size = file.size,
hash = file.hash,
),
)
}
fun sendAudioMessage(
toId: String,
chatType: String,
file: FileUploadResult,
durationMs: Long? = null,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "AUDIO",
content = buildMediaPayload(
url = file.url,
name = file.originalName,
mimeType = file.mimeType,
size = file.size,
hash = file.hash,
durationMs = durationMs,
),
)
}
fun sendLocationMessage(
toId: String,
chatType: String,
latitude: Double,
longitude: Double,
title: String? = null,
address: String? = null,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "LOCATION",
content = JSONObject().apply {
put("lat", latitude)
put("lng", longitude)
title?.takeIf { it.isNotBlank() }?.let { put("title", it) }
address?.takeIf { it.isNotBlank() }?.let { put("address", it) }
}.toString(),
)
}
fun sendCustomMessage(
toId: String,
chatType: String,
data: String,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "CUSTOM",
content = JSONObject().apply {
put("data", data)
}.toString(),
)
}
fun sendRichTextMessage(
toId: String,
chatType: String,
html: String,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "RICH_TEXT",
content = JSONObject().apply {
put("html", html)
}.toString(),
)
}
fun sendForwardMessage(
toId: String,
chatType: String,
originalSender: String,
originalContent: String,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "FORWARD",
content = JSONObject().apply {
put("originalSender", originalSender)
put("originalContent", originalContent)
}.toString(),
)
}
fun sendCallAudioMessage(
toId: String,
chatType: String,
action: String,
) {
sendCallSignalMessage(toId, chatType, "CALL_AUDIO", action)
}
fun sendCallVideoMessage(
toId: String,
chatType: String,
action: String,
) {
sendCallSignalMessage(toId, chatType, "CALL_VIDEO", action)
}
fun sendNotifyMessage(
toId: String,
chatType: String,
title: String,
content: String,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "NOTIFY",
content = JSONObject().apply {
put("title", title)
put("content", content)
}.toString(),
)
}
fun sendQuoteMessage(
toId: String,
chatType: String,
quotedMsgId: String,
quotedContent: String,
text: String,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "QUOTE",
content = JSONObject().apply {
put("quotedMsgId", quotedMsgId)
put("quotedContent", quotedContent)
put("text", text)
}.toString(),
)
}
fun sendMergeMessage(
toId: String,
chatType: String,
title: String,
msgList: List<String>,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = "MERGE",
content = JSONObject().apply {
put("title", title)
put("msgList", JSONArray(msgList))
}.toString(),
)
}
fun subscribeGroup(groupId: String) {
client?.subscribe("/topic/group/$groupId")
}
fun unsubscribeGroup(groupId: String) {
client?.unsubscribe("/topic/group/$groupId")
} }
suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<ImMessage> = suspend fun fetchHistory(toId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
fetchHistoryWithFilters(toId, page, size)
suspend fun fetchHistoryWithFilters(
toId: String,
page: Int = 0,
size: Int = 20,
msgType: String? = null,
keyword: String? = null,
startTime: LocalDateTime? = null,
endTime: LocalDateTime? = null,
): List<ImMessage> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
api.fetchHistory(toId, XuqmSDK.appId, page, size).data ?: emptyList() api.fetchHistory(
toId,
XuqmSDK.appId,
msgType,
keyword,
startTime?.toString(),
endTime?.toString(),
page,
size,
).data ?: emptyList()
} }
suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List<ImMessage> = suspend fun fetchGroupHistory(groupId: String, page: Int = 0, size: Int = 20): List<ImMessage> =
fetchGroupHistoryWithFilters(groupId, page, size)
suspend fun fetchGroupHistoryWithFilters(
groupId: String,
page: Int = 0,
size: Int = 20,
msgType: String? = null,
keyword: String? = null,
startTime: LocalDateTime? = null,
endTime: LocalDateTime? = null,
): List<ImMessage> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
api.fetchGroupHistory(groupId, XuqmSDK.appId, page, size).data ?: emptyList() api.fetchGroupHistory(
groupId,
XuqmSDK.appId,
msgType,
keyword,
startTime?.toString(),
endTime?.toString(),
page,
size,
).data ?: emptyList()
} }
suspend fun listGroups(): List<ImGroup> = suspend fun listGroups(): List<ImGroup> =
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() } withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
suspend fun createGroup(name: String, memberIds: List<String>): ImGroup? = suspend fun createGroup(name: String, memberIds: List<String>, groupType: String = "WORK"): ImGroup? =
withContext(Dispatchers.IO) { api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds)).data } withContext(Dispatchers.IO) {
api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds, groupType)).data
}
suspend fun getGroupInfo(groupId: String): ImGroup? = suspend fun getGroupInfo(groupId: String): ImGroup? =
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data } withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
suspend fun listPublicGroups(keyword: String? = null): List<ImGroup> =
withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appId, keyword).data ?: emptyList() }
suspend fun updateGroupInfo(groupId: String, name: String? = null, announcement: String? = null) = suspend fun updateGroupInfo(groupId: String, name: String? = null, announcement: String? = null) =
withContext(Dispatchers.IO) { api.updateGroupInfo(groupId, UpdateGroupRequest(name, announcement)) } withContext(Dispatchers.IO) { api.updateGroupInfo(groupId, UpdateGroupRequest(name, announcement)) }
@ -104,6 +408,27 @@ object ImSDK {
suspend fun leaveGroup(groupId: String) = suspend fun leaveGroup(groupId: String) =
withContext(Dispatchers.IO) { api.removeGroupMember(groupId, currentUserId) } withContext(Dispatchers.IO) { api.removeGroupMember(groupId, currentUserId) }
suspend fun setGroupRole(groupId: String, userId: String, role: String) =
withContext(Dispatchers.IO) { api.setGroupRole(groupId, SetGroupRoleRequest(userId, role)) }
suspend fun muteGroupMember(groupId: String, userId: String, minutes: Long) =
withContext(Dispatchers.IO) { api.muteGroupMember(groupId, MuteGroupMemberRequest(userId, minutes)) }
suspend fun dismissGroup(groupId: String) =
withContext(Dispatchers.IO) { api.dismissGroup(groupId) }
suspend fun sendGroupJoinRequest(groupId: String, remark: String? = null): GroupJoinRequest? =
withContext(Dispatchers.IO) { api.sendGroupJoinRequest(groupId, XuqmSDK.appId, remark).data }
suspend fun listGroupJoinRequests(groupId: String): List<GroupJoinRequest> =
withContext(Dispatchers.IO) { api.listGroupJoinRequests(groupId, XuqmSDK.appId).data ?: emptyList() }
suspend fun acceptGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? =
withContext(Dispatchers.IO) { api.acceptGroupJoinRequest(groupId, requestId, XuqmSDK.appId).data }
suspend fun rejectGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? =
withContext(Dispatchers.IO) { api.rejectGroupJoinRequest(groupId, requestId, XuqmSDK.appId).data }
suspend fun listFriends(): List<String> = suspend fun listFriends(): List<String> =
withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() } withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() }
@ -113,6 +438,27 @@ object ImSDK {
suspend fun removeFriend(friendId: String) = suspend fun removeFriend(friendId: String) =
withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appId) } withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appId) }
suspend fun listFriendRequests(direction: String = "incoming"): List<FriendRequest> =
withContext(Dispatchers.IO) { api.listFriendRequests(XuqmSDK.appId, direction).data ?: emptyList() }
suspend fun sendFriendRequest(friendId: String, remark: String? = null): FriendRequest? =
withContext(Dispatchers.IO) { api.sendFriendRequest(XuqmSDK.appId, friendId, remark).data }
suspend fun acceptFriendRequest(requestId: String): FriendRequest? =
withContext(Dispatchers.IO) { api.acceptFriendRequest(requestId, XuqmSDK.appId).data }
suspend fun rejectFriendRequest(requestId: String): FriendRequest? =
withContext(Dispatchers.IO) { api.rejectFriendRequest(requestId, XuqmSDK.appId).data }
suspend fun listBlacklist(): List<BlacklistEntry> =
withContext(Dispatchers.IO) { api.listBlacklist(XuqmSDK.appId).data ?: emptyList() }
suspend fun addToBlacklist(blockedUserId: String): BlacklistEntry? =
withContext(Dispatchers.IO) { api.addToBlacklist(XuqmSDK.appId, blockedUserId).data }
suspend fun removeFromBlacklist(blockedUserId: String) =
withContext(Dispatchers.IO) { api.removeFromBlacklist(XuqmSDK.appId, blockedUserId) }
suspend fun listConversations(): List<ConversationData> = suspend fun listConversations(): List<ConversationData> =
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() } withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() }
@ -175,4 +521,42 @@ object ImSDK {
XuqmSDK.tokenStore.clear() XuqmSDK.tokenStore.clear()
} }
} }
private fun buildMediaPayload(
url: String,
thumbnailUrl: String? = null,
name: String? = null,
mimeType: String? = null,
size: Long? = null,
hash: String? = null,
width: Int? = null,
height: Int? = null,
durationMs: Long? = null,
): String = JSONObject().apply {
put("url", url)
thumbnailUrl?.takeIf { it.isNotBlank() }?.let { put("thumbnailUrl", it) }
name?.takeIf { it.isNotBlank() }?.let { put("name", it) }
mimeType?.takeIf { it.isNotBlank() }?.let { put("mimeType", it) }
size?.takeIf { it > 0 }?.let { put("size", it) }
hash?.takeIf { it.isNotBlank() }?.let { put("hash", it) }
width?.takeIf { it > 0 }?.let { put("width", it) }
height?.takeIf { it > 0 }?.let { put("height", it) }
durationMs?.takeIf { it > 0 }?.let { put("durationMs", it) }
}.toString()
private fun sendCallSignalMessage(
toId: String,
chatType: String,
msgType: String,
action: String,
) {
sendMessage(
toId = toId,
chatType = chatType,
msgType = msgType,
content = JSONObject().apply {
put("action", action)
}.toString(),
)
}
} }

查看文件

@ -1,7 +1,10 @@
package com.xuqm.sdk.im.api package com.xuqm.sdk.im.api
import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.im.model.ConversationData
import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.GroupJoinRequest
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
@ -27,7 +30,7 @@ data class LoginRequest(
data class LoginResponse(val token: String) data class LoginResponse(val token: String)
data class CreateGroupRequest(val name: String, val memberIds: List<String>) data class CreateGroupRequest(val name: String, val memberIds: List<String>, val groupType: String? = null)
data class UpdateGroupRequest(val name: String? = null, val announcement: String? = null) data class UpdateGroupRequest(val name: String? = null, val announcement: String? = null)
@ -37,6 +40,10 @@ data class SetPinnedRequest(val pinned: Boolean)
data class SetMutedRequest(val muted: Boolean) data class SetMutedRequest(val muted: Boolean)
data class SetGroupRoleRequest(val userId: String, val role: String)
data class MuteGroupMemberRequest(val userId: String, val minutes: Long)
interface ImApi { interface ImApi {
@POST("api/im/auth/login") @POST("api/im/auth/login")
@ -46,6 +53,10 @@ interface ImApi {
suspend fun fetchHistory( suspend fun fetchHistory(
@Path("toId") toId: String, @Path("toId") toId: String,
@Query("appId") appId: String, @Query("appId") appId: String,
@Query("msgType") msgType: String? = null,
@Query("keyword") keyword: String? = null,
@Query("startTime") startTime: String? = null,
@Query("endTime") endTime: String? = null,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
): ApiResponse<List<ImMessage>> ): ApiResponse<List<ImMessage>>
@ -54,6 +65,10 @@ interface ImApi {
suspend fun fetchGroupHistory( suspend fun fetchGroupHistory(
@Path("groupId") groupId: String, @Path("groupId") groupId: String,
@Query("appId") appId: String, @Query("appId") appId: String,
@Query("msgType") msgType: String? = null,
@Query("keyword") keyword: String? = null,
@Query("startTime") startTime: String? = null,
@Query("endTime") endTime: String? = null,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
): ApiResponse<List<ImMessage>> ): ApiResponse<List<ImMessage>>
@ -61,6 +76,12 @@ interface ImApi {
@GET("api/im/groups") @GET("api/im/groups")
suspend fun listGroups(@Query("appId") appId: String): ApiResponse<List<ImGroup>> suspend fun listGroups(@Query("appId") appId: String): ApiResponse<List<ImGroup>>
@GET("api/im/groups/public")
suspend fun listPublicGroups(
@Query("appId") appId: String,
@Query("keyword") keyword: String? = null,
): ApiResponse<List<ImGroup>>
@POST("api/im/groups") @POST("api/im/groups")
suspend fun createGroup( suspend fun createGroup(
@Query("appId") appId: String, @Query("appId") appId: String,
@ -91,6 +112,48 @@ interface ImApi {
@DELETE("api/im/groups/{groupId}/members/me") @DELETE("api/im/groups/{groupId}/members/me")
suspend fun leaveGroup(@Path("groupId") groupId: String): ApiResponse<Unit> suspend fun leaveGroup(@Path("groupId") groupId: String): ApiResponse<Unit>
@POST("api/im/groups/{groupId}/roles")
suspend fun setGroupRole(
@Path("groupId") groupId: String,
@Body request: SetGroupRoleRequest,
): ApiResponse<ImGroup>
@POST("api/im/groups/{groupId}/mute")
suspend fun muteGroupMember(
@Path("groupId") groupId: String,
@Body request: MuteGroupMemberRequest,
): ApiResponse<ImGroup>
@DELETE("api/im/groups/{groupId}")
suspend fun dismissGroup(@Path("groupId") groupId: String): ApiResponse<Unit>
@POST("api/im/groups/{groupId}/join-requests")
suspend fun sendGroupJoinRequest(
@Path("groupId") groupId: String,
@Query("appId") appId: String,
@Query("remark") remark: String? = null,
): ApiResponse<GroupJoinRequest>
@GET("api/im/groups/{groupId}/join-requests")
suspend fun listGroupJoinRequests(
@Path("groupId") groupId: String,
@Query("appId") appId: String,
): ApiResponse<List<GroupJoinRequest>>
@POST("api/im/groups/{groupId}/join-requests/{requestId}/accept")
suspend fun acceptGroupJoinRequest(
@Path("groupId") groupId: String,
@Path("requestId") requestId: String,
@Query("appId") appId: String,
): ApiResponse<GroupJoinRequest>
@POST("api/im/groups/{groupId}/join-requests/{requestId}/reject")
suspend fun rejectGroupJoinRequest(
@Path("groupId") groupId: String,
@Path("requestId") requestId: String,
@Query("appId") appId: String,
): ApiResponse<GroupJoinRequest>
@GET("api/im/friends") @GET("api/im/friends")
suspend fun listFriends(@Query("appId") appId: String): ApiResponse<List<String>> suspend fun listFriends(@Query("appId") appId: String): ApiResponse<List<String>>
@ -106,6 +169,46 @@ interface ImApi {
@Query("appId") appId: String, @Query("appId") appId: String,
): ApiResponse<Unit> ): ApiResponse<Unit>
@GET("api/im/friend-requests")
suspend fun listFriendRequests(
@Query("appId") appId: String,
@Query("direction") direction: String = "incoming",
): ApiResponse<List<FriendRequest>>
@POST("api/im/friend-requests")
suspend fun sendFriendRequest(
@Query("appId") appId: String,
@Query("toUserId") toUserId: String,
@Query("remark") remark: String? = null,
): ApiResponse<FriendRequest>
@POST("api/im/friend-requests/{requestId}/accept")
suspend fun acceptFriendRequest(
@Path("requestId") requestId: String,
@Query("appId") appId: String,
): ApiResponse<FriendRequest>
@POST("api/im/friend-requests/{requestId}/reject")
suspend fun rejectFriendRequest(
@Path("requestId") requestId: String,
@Query("appId") appId: String,
): ApiResponse<FriendRequest>
@GET("api/im/blacklist")
suspend fun listBlacklist(@Query("appId") appId: String): ApiResponse<List<BlacklistEntry>>
@POST("api/im/blacklist")
suspend fun addToBlacklist(
@Query("appId") appId: String,
@Query("blockedUserId") blockedUserId: String,
): ApiResponse<BlacklistEntry>
@DELETE("api/im/blacklist")
suspend fun removeFromBlacklist(
@Query("appId") appId: String,
@Query("blockedUserId") blockedUserId: String,
): ApiResponse<Unit>
@GET("api/im/conversations") @GET("api/im/conversations")
suspend fun listConversations(@Query("appId") appId: String): ApiResponse<List<ConversationData>> suspend fun listConversations(@Query("appId") appId: String): ApiResponse<List<ConversationData>>

查看文件

@ -1,8 +1,11 @@
package com.xuqm.sdk.im.model package com.xuqm.sdk.im.model
import com.google.gson.annotations.SerializedName
data class ImMessage( data class ImMessage(
val id: String, val id: String,
val appId: String, val appId: String,
@SerializedName(value = "fromId", alternate = ["fromUserId"])
val fromId: String, val fromId: String,
val toId: String, val toId: String,
val chatType: String, val chatType: String,
@ -10,6 +13,7 @@ data class ImMessage(
val content: String, val content: String,
val status: String, val status: String,
val mentionedUserIds: String? = null, val mentionedUserIds: String? = null,
val groupReadCount: Int? = null,
val createdAt: Long, val createdAt: Long,
) )
@ -27,6 +31,7 @@ data class ConversationData(
data class ImGroup( data class ImGroup(
val id: String, val id: String,
val name: String, val name: String,
val groupType: String? = null,
val creatorId: String, val creatorId: String,
val memberIds: String, val memberIds: String,
val adminIds: String, val adminIds: String,
@ -40,11 +45,41 @@ data class UserProfile(
val avatar: String? = null, val avatar: String? = null,
) )
data class FriendRequest(
val id: String,
val appId: String,
val fromUserId: String,
val toUserId: String,
val remark: String? = null,
val status: String,
val createdAt: Long,
val reviewedAt: Long? = null,
)
data class GroupJoinRequest(
val id: String,
val appId: String,
val groupId: String,
val requesterId: String,
val remark: String? = null,
val status: String,
val createdAt: Long,
val reviewedAt: Long? = null,
)
data class BlacklistEntry(
val id: String,
val appId: String,
val userId: String,
val blockedUserId: String,
val createdAt: Long,
)
enum class ChatType { SINGLE, GROUP } enum class ChatType { SINGLE, GROUP }
enum class MsgType { enum class MsgType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY, TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY,
RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD RICH_TEXT, CALL_AUDIO, CALL_VIDEO, FORWARD, QUOTE, MERGE, REVOKED
} }
enum class MsgStatus { SENDING, SENT, DELIVERED, READ, FAILED, REVOKED } enum class MsgStatus { SENDING, SENT, DELIVERED, READ, FAILED, REVOKED }

查看文件

@ -9,11 +9,13 @@ import com.xuqm.sdk.utils.DeviceUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicReference
object PushSDK { object PushSDK {
private val api: PushApi get() = ApiClient.create(PushApi::class.java, ServiceEndpointRegistry.pushBaseUrl) private val api: PushApi get() = ApiClient.create(PushApi::class.java, ServiceEndpointRegistry.pushBaseUrl)
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private val registeredUserId = AtomicReference<String?>(null)
fun registerDevice(context: Context, userId: String) { fun registerDevice(context: Context, userId: String) {
XuqmSDK.requireInit() XuqmSDK.requireInit()
@ -27,6 +29,7 @@ object PushSDK {
vendor = vendor, vendor = vendor,
token = deviceId, token = deviceId,
) )
registeredUserId.set(userId)
} }
} }
} }
@ -34,7 +37,21 @@ object PushSDK {
fun unregisterDevice(userId: String) { fun unregisterDevice(userId: String) {
XuqmSDK.requireInit() XuqmSDK.requireInit()
scope.launch { scope.launch {
runCatching { api.unregisterDevice(XuqmSDK.appId, userId) } runCatching {
api.unregisterDevice(XuqmSDK.appId, userId)
registeredUserId.compareAndSet(userId, null)
} }
} }
}
fun onSdkLogin(session: com.xuqm.sdk.XuqmLoginSession) {
val context = runCatching { XuqmSDK.appContext }.getOrNull() ?: return
if (registeredUserId.get() == session.userId) return
registerDevice(context, session.userId)
}
fun onSdkLogout() {
val userId = registeredUserId.getAndSet(null) ?: return
unregisterDevice(userId)
}
} }