feat(sample): 添加示例应用的核心功能模块

- 实现环境配置管理,支持外部和本地主机模式切换
- 集成Demo API接口,包含登录、注册、文件上传等功能
- 构建附件处理仓库,支持图片、视频、音频和文件发送
- 开发认证仓库,管理用户会话和IM令牌刷新机制
- 添加语音录制功能,支持实时音频消息录制
- 创建依赖注入容器,统一管理应用组件实例
- 实现登录界面,提供用户认证交互功能
- 开发聊天界面,集成消息收发和媒体处理功能
这个提交包含在:
XuqmGroup 2026-04-28 16:08:06 +08:00
父节点 bee82637f3
当前提交 b7ecf13908
共有 25 个文件被更改,包括 1656 次插入456 次删除

查看文件

@ -80,7 +80,7 @@ XuqmSDK.login(
默认使用外网域名。若要本地联调,可在 `Application.onCreate()` 里切换: 默认使用外网域名。若要本地联调,可在 `Application.onCreate()` 里切换:
```kotlin ```kotlin
XuqmSDK.useLocalServiceEndpoints("10.0.2.2") // Android Emulator XuqmSDK.useLocalServiceEndpoints("192.168.116.9")
// 物理设备可改成你的电脑局域网 IP // 物理设备可改成你的电脑局域网 IP
``` ```

查看文件

@ -10,7 +10,8 @@
android:name=".XuqmSampleApp" android:name=".XuqmSampleApp"
android:allowBackup="true" android:allowBackup="true"
android:label="XuqmGroup Demo" android:label="XuqmGroup Demo"
android:theme="@style/Theme.XuqmDemo"> android:theme="@style/Theme.XuqmDemo"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

查看文件

@ -13,12 +13,12 @@ object SampleEnvironmentConfig {
private set private set
fun external(): SampleEnvironment = SampleEnvironment( fun external(): SampleEnvironment = SampleEnvironment(
demoBaseUrl = "https://dev.xuqinmin.com/", demoBaseUrl = "http://192.168.116.9:8085/",
serviceHost = null, serviceHost = null,
) )
fun localhost(host: String): SampleEnvironment = SampleEnvironment( fun localhost(host: String): SampleEnvironment = SampleEnvironment(
demoBaseUrl = "http://$host:8081/", demoBaseUrl = "http://$host:8085/",
serviceHost = host, serviceHost = host,
) )

查看文件

@ -2,6 +2,8 @@ package com.xuqm.sdk.sample.data.api
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import com.xuqm.sdk.sample.BuildConfig
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
@ -81,6 +83,9 @@ interface DemoApi {
@GET("api/demo/user/profile") @GET("api/demo/user/profile")
suspend fun getProfile(@Query("appId") appId: String = DEMO_APP_ID): DemoResponse<UserData> suspend fun getProfile(@Query("appId") appId: String = DEMO_APP_ID): DemoResponse<UserData>
@GET("api/demo/users/members")
suspend fun listMembers(@Query("appId") appId: String = DEMO_APP_ID): DemoResponse<List<UserData>>
@PUT("api/demo/user/profile") @PUT("api/demo/user/profile")
suspend fun updateProfile(@Body request: UpdateProfileRequest): DemoResponse<UserData> suspend fun updateProfile(@Body request: UpdateProfileRequest): DemoResponse<UserData>
@ -106,8 +111,14 @@ object DemoApiFactory {
chain.proceed(request) chain.proceed(request)
} }
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
redactHeader("Authorization")
}
val okHttpClient = OkHttpClient.Builder() val okHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.build() .build()
return Retrofit.Builder() return Retrofit.Builder()

查看文件

@ -7,14 +7,24 @@ import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import com.xuqm.sdk.file.FileSDK import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.FileUploadResult
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImMessage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
data class PreparedAttachment(
val upload: FileUploadResult,
val message: ImMessage,
val width: Int? = null,
val height: Int? = null,
val durationMs: Long? = null,
)
class AttachmentRepository(private val context: Context) { class AttachmentRepository(private val context: Context) {
suspend fun sendImage(targetId: String, chatType: String, uri: Uri): Result<Unit> = suspend fun sendImage(targetId: String, chatType: String, uri: Uri): Result<PreparedAttachment> =
sendMedia(targetId, chatType, uri, MediaKind.IMAGE) sendMedia(targetId, chatType, uri, MediaKind.IMAGE)
suspend fun sendImageBytes( suspend fun sendImageBytes(
@ -24,7 +34,7 @@ class AttachmentRepository(private val context: Context) {
bytes: ByteArray, bytes: ByteArray,
width: Int? = null, width: Int? = null,
height: Int? = null, height: Int? = null,
): Result<Unit> = withContext(Dispatchers.IO) { ): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val upload = FileSDK.uploadBytes( val upload = FileSDK.uploadBytes(
fileName = fileName, fileName = fileName,
@ -37,17 +47,19 @@ class AttachmentRepository(private val context: Context) {
file = upload, file = upload,
width = width, width = width,
height = height, height = height,
) ).let { sent ->
PreparedAttachment(upload = upload, message = sent, width = width, height = height)
}
} }
} }
suspend fun sendVideo(targetId: String, chatType: String, uri: Uri): Result<Unit> = suspend fun sendVideo(targetId: String, chatType: String, uri: Uri): Result<PreparedAttachment> =
sendMedia(targetId, chatType, uri, MediaKind.VIDEO) sendMedia(targetId, chatType, uri, MediaKind.VIDEO)
suspend fun sendFile(targetId: String, chatType: String, uri: Uri): Result<Unit> = suspend fun sendFile(targetId: String, chatType: String, uri: Uri): Result<PreparedAttachment> =
sendMedia(targetId, chatType, uri, MediaKind.FILE) sendMedia(targetId, chatType, uri, MediaKind.FILE)
suspend fun sendAudio(targetId: String, chatType: String, uri: Uri): Result<Unit> = suspend fun sendAudio(targetId: String, chatType: String, uri: Uri): Result<PreparedAttachment> =
sendMedia(targetId, chatType, uri, MediaKind.AUDIO) sendMedia(targetId, chatType, uri, MediaKind.AUDIO)
suspend fun sendAudioBytes( suspend fun sendAudioBytes(
@ -56,7 +68,7 @@ class AttachmentRepository(private val context: Context) {
fileName: String, fileName: String,
bytes: ByteArray, bytes: ByteArray,
durationMs: Long? = null, durationMs: Long? = null,
): Result<Unit> = withContext(Dispatchers.IO) { ): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val upload = FileSDK.uploadBytes( val upload = FileSDK.uploadBytes(
fileName = fileName, fileName = fileName,
@ -68,7 +80,9 @@ class AttachmentRepository(private val context: Context) {
chatType = chatType, chatType = chatType,
file = upload, file = upload,
durationMs = durationMs, durationMs = durationMs,
) ).let { sent ->
PreparedAttachment(upload = upload, message = sent, durationMs = durationMs)
}
} }
} }
@ -77,7 +91,7 @@ class AttachmentRepository(private val context: Context) {
chatType: String, chatType: String,
uri: Uri, uri: Uri,
kind: MediaKind, kind: MediaKind,
): Result<Unit> = withContext(Dispatchers.IO) { ): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val meta = resolveMeta(uri) val meta = resolveMeta(uri)
val thumbnailBytes = when (kind) { val thumbnailBytes = when (kind) {
@ -93,7 +107,7 @@ class AttachmentRepository(private val context: Context) {
mimeType = meta.mimeType, mimeType = meta.mimeType,
thumbnailBytes = thumbnailBytes, thumbnailBytes = thumbnailBytes,
) )
when (kind) { val sent = when (kind) {
MediaKind.IMAGE -> ImSDK.sendImageMessage( MediaKind.IMAGE -> ImSDK.sendImageMessage(
toId = targetId, toId = targetId,
chatType = chatType, chatType = chatType,
@ -121,6 +135,13 @@ class AttachmentRepository(private val context: Context) {
file = upload, file = upload,
) )
} }
PreparedAttachment(
upload = upload,
message = sent,
width = meta.width,
height = meta.height,
durationMs = meta.durationMs,
)
} }
} }

查看文件

@ -23,6 +23,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.json.JSONObject
import retrofit2.HttpException
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
class AuthRepository(context: Context) { class AuthRepository(context: Context) {
@ -103,6 +105,7 @@ class AuthRepository(context: Context) {
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender) UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
} }
} }
.mapFailureMessage(::toChineseLoginMessage)
suspend fun register(userId: String, password: String, nickname: String): Result<UserData> = suspend fun register(userId: String, password: String, nickname: String): Result<UserData> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -122,12 +125,18 @@ class AuthRepository(context: Context) {
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender) UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
} }
} }
.mapFailureMessage(::toChineseLoginMessage)
suspend fun getProfile(): Result<UserData> = suspend fun getProfile(): Result<UserData> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { requireNotNull(api.getProfile().data) { "Failed to get profile" } } runCatching { requireNotNull(api.getProfile().data) { "Failed to get profile" } }
} }
suspend fun listMembers(): Result<List<UserData>> =
withContext(Dispatchers.IO) {
runCatching { api.listMembers().data ?: emptyList() }
}
suspend fun updateProfile(nickname: String, avatar: String?): Result<UserData> = suspend fun updateProfile(nickname: String, avatar: String?): Result<UserData> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { runCatching {
@ -215,4 +224,33 @@ class AuthRepository(context: Context) {
companion object { companion object {
private const val REFRESH_GRACE_MS = 5 * 60 * 1000L private const val REFRESH_GRACE_MS = 5 * 60 * 1000L
} }
private fun <T> Result<T>.mapFailureMessage(transform: (Throwable) -> String): Result<T> =
fold(
onSuccess = { Result.success(it) },
onFailure = { Result.failure(RuntimeException(transform(it), it)) },
)
private fun toChineseLoginMessage(error: Throwable): String {
val rawMessage = when (error) {
is HttpException -> error.response()?.errorBody()?.string()
else -> error.message
}.orEmpty()
val serverMessage = runCatching {
if (rawMessage.trimStart().startsWith("{")) {
JSONObject(rawMessage).optString("message")
} else {
rawMessage
}
}.getOrDefault(rawMessage)
return when {
serverMessage.contains("Invalid credentials", ignoreCase = true) -> "账号或密码错误"
serverMessage.contains("bad credentials", ignoreCase = true) -> "账号或密码错误"
serverMessage.contains("401") -> "账号或密码错误"
serverMessage.isNotBlank() -> serverMessage
else -> "登录失败,请检查账号或密码"
}
}
} }

查看文件

@ -10,7 +10,7 @@ enum class EnvironmentMode {
data class EnvironmentState( data class EnvironmentState(
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL, val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
val host: String = "10.0.2.2", val host: String = "192.168.116.9",
) )
class EnvironmentRepository(context: Context) { class EnvironmentRepository(context: Context) {
@ -25,7 +25,7 @@ class EnvironmentRepository(context: Context) {
} }
fun setLocalhost(host: String) { fun setLocalhost(host: String) {
val normalizedHost = host.trim().ifBlank { "10.0.2.2" } val normalizedHost = host.trim().ifBlank { "192.168.116.9" }
save(EnvironmentState(mode = EnvironmentMode.LOCALHOST, host = normalizedHost)) save(EnvironmentState(mode = EnvironmentMode.LOCALHOST, host = normalizedHost))
SampleEnvironmentConfig.useLocalhost(normalizedHost) SampleEnvironmentConfig.useLocalhost(normalizedHost)
} }
@ -34,7 +34,7 @@ class EnvironmentRepository(context: Context) {
val mode = runCatching { val mode = runCatching {
EnvironmentMode.valueOf(prefs.getString(KEY_MODE, EnvironmentMode.EXTERNAL.name)!!) EnvironmentMode.valueOf(prefs.getString(KEY_MODE, EnvironmentMode.EXTERNAL.name)!!)
}.getOrDefault(EnvironmentMode.EXTERNAL) }.getOrDefault(EnvironmentMode.EXTERNAL)
val host = prefs.getString(KEY_HOST, "10.0.2.2").orEmpty().ifBlank { "10.0.2.2" } val host = prefs.getString(KEY_HOST, "192.168.116.9").orEmpty().ifBlank { "192.168.116.9" }
return EnvironmentState(mode = mode, host = host) return EnvironmentState(mode = mode, host = host)
} }

查看文件

@ -1,6 +1,7 @@
package com.xuqm.sdk.sample.data.repo package com.xuqm.sdk.sample.data.repo
import android.content.Context import android.content.Context
import android.os.Build
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.SystemClock import android.os.SystemClock
import java.io.File import java.io.File
@ -24,7 +25,7 @@ class VoiceRecorder(private val context: Context) {
val file = File(dir, "voice_${System.currentTimeMillis()}.m4a") val file = File(dir, "voice_${System.currentTimeMillis()}.m4a")
outputFile = file outputFile = file
return runCatching { return runCatching {
recorder = MediaRecorder().apply { recorder = createMediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC) setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
@ -42,6 +43,15 @@ class VoiceRecorder(private val context: Context) {
} }
} }
private fun createMediaRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
fun stop(): RecordedVoice? { fun stop(): RecordedVoice? {
val current = recorder ?: return null val current = recorder ?: return null
val file = outputFile ?: return null val file = outputFile ?: return null

查看文件

@ -1,14 +1,19 @@
package com.xuqm.sdk.sample.di package com.xuqm.sdk.sample.di
import android.content.Context import android.content.Context
import android.util.Log
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.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
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
object AppDependencies { object AppDependencies {
private const val TAG = "XuqmAppDeps"
lateinit var authRepository: AuthRepository lateinit var authRepository: AuthRepository
private set private set
lateinit var environmentRepository: EnvironmentRepository lateinit var environmentRepository: EnvironmentRepository
@ -20,6 +25,9 @@ object AppDependencies {
lateinit var attachmentRepository: AttachmentRepository lateinit var attachmentRepository: AttachmentRepository
private set private set
private val _conversationRefreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val conversationRefreshRequests = _conversationRefreshRequests.asSharedFlow()
fun init(context: Context) { fun init(context: Context) {
environmentRepository = EnvironmentRepository(context) environmentRepository = EnvironmentRepository(context)
environmentRepository.current() environmentRepository.current()
@ -28,4 +36,9 @@ object AppDependencies {
attachmentRepository = AttachmentRepository(context.applicationContext) attachmentRepository = AttachmentRepository(context.applicationContext)
authRepository = AuthRepository(context) authRepository = AuthRepository(context)
} }
fun notifyConversationChanged() {
Log.d(TAG, "notifyConversationChanged()")
_conversationRefreshRequests.tryEmit(Unit)
}
} }

查看文件

@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@ -31,6 +32,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.xuqm.sdk.sample.di.AppDependencies import com.xuqm.sdk.sample.di.AppDependencies
import android.widget.Toast
@Composable @Composable
fun LoginScreen( fun LoginScreen(
@ -44,12 +46,16 @@ fun LoginScreen(
), ),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var userId by remember { mutableStateOf("") } var userId by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
LaunchedEffect(state) { LaunchedEffect(state) {
if (state is LoginState.Success) onLoginSuccess() if (state is LoginState.Success) onLoginSuccess()
if (state is LoginState.Error) {
Toast.makeText(context, (state as LoginState.Error).message, Toast.LENGTH_SHORT).show()
}
} }
Column( Column(

查看文件

@ -3,6 +3,7 @@ package com.xuqm.sdk.sample.ui.chat
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@ -18,7 +19,6 @@ 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
@ -49,9 +49,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale 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.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput 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.compose.foundation.layout.Box
import androidx.core.content.ContextCompat 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
@ -71,6 +72,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -86,7 +89,7 @@ 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 mentionableUsers by viewModel.mentionableUsers.collectAsStateWithLifecycle()
val replyTargetMessage by viewModel.replyTargetMessage.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()
@ -97,6 +100,7 @@ fun ChatScreen(
val context = LocalContext.current val context = LocalContext.current
val voiceRecorder = remember { VoiceRecorder(context.applicationContext) } val voiceRecorder = remember { VoiceRecorder(context.applicationContext) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var draftValue by remember { mutableStateOf(TextFieldValue(draftText)) }
var showSearchBar by remember { mutableStateOf(false) } var showSearchBar by remember { mutableStateOf(false) }
val replyTarget = replyTargetMessage val replyTarget = replyTargetMessage
var pendingCameraAction by remember { mutableStateOf<CameraAction?>(null) } var pendingCameraAction by remember { mutableStateOf<CameraAction?>(null) }
@ -175,6 +179,14 @@ fun ChatScreen(
} }
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) } LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
LaunchedEffect(Unit) {
viewModel.events.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
}
LaunchedEffect(draftText) {
if (draftValue.text != draftText) {
draftValue = TextFieldValue(draftText, selection = TextRange(draftText.length))
}
}
LaunchedEffect(scrollSignal) { LaunchedEffect(scrollSignal) {
if (messages.isNotEmpty() && searchQuery.isBlank()) { if (messages.isNotEmpty() && searchQuery.isBlank()) {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
@ -234,16 +246,38 @@ fun ChatScreen(
.padding(8.dp), .padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (chatType == "GROUP" && mentionableUserIds.isNotEmpty()) { if (chatType == "GROUP" && mentionableUsers.isNotEmpty()) {
val mentionQuery = remember(draftValue.text, draftValue.selection) {
mentionQueryAtCursor(draftValue)
}
val mentionCandidates = remember(mentionQuery, mentionableUsers) {
if (mentionQuery == null) {
emptyList()
} else {
mentionableUsers
.filter {
mentionQuery.isBlank() || it.nickname.contains(mentionQuery, ignoreCase = true)
}
.take(8)
}
}
if (mentionCandidates.isNotEmpty()) {
Box(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("提及", style = MaterialTheme.typography.labelSmall) Text("提及", style = MaterialTheme.typography.labelSmall)
mentionableUserIds.take(6).forEach { userId -> mentionCandidates.forEach { user ->
TextButton(onClick = { viewModel.appendMention(userId) }) { TextButton(
Text("@$userId") onClick = {
draftValue = insertMentionAtCursor(draftValue, user.nickname)
viewModel.updateDraft(draftValue.text)
},
) {
Text("@${user.nickname.ifBlank { "成员" }}")
}
}
} }
} }
} }
@ -270,16 +304,27 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedTextField( OutlinedTextField(
value = draftText, value = draftValue,
onValueChange = viewModel::updateDraft, onValueChange = { next ->
modifier = Modifier.weight(1f), draftValue = next
viewModel.updateDraft(next.text)
},
placeholder = { Text("输入消息…") }, placeholder = { Text("输入消息…") },
maxLines = 4, maxLines = 4,
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
modifier = Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
handleMentionBackspace(event, draftValue, mentionableUsers.map { it.nickname })?.let { edited ->
draftValue = edited
viewModel.updateDraft(edited.text)
true
} ?: false
},
) )
IconButton( IconButton(
onClick = { viewModel.sendText(draftText) }, onClick = { viewModel.sendText(draftValue.text) },
enabled = draftText.isNotBlank(), enabled = draftValue.text.isNotBlank(),
) { ) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
} }
@ -719,3 +764,55 @@ private fun formatDuration(ms: Long): String {
val seconds = totalSeconds % 60 val seconds = totalSeconds % 60
return if (minutes > 0) String.format("%d:%02d", minutes, seconds) else "${seconds}s" return if (minutes > 0) String.format("%d:%02d", minutes, seconds) else "${seconds}s"
} }
private fun mentionQueryAtCursor(value: TextFieldValue): String? {
if (!value.selection.collapsed) return null
val cursor = value.selection.end.coerceIn(0, value.text.length)
val prefix = value.text.substring(0, cursor)
val atIndex = prefix.lastIndexOf('@')
if (atIndex < 0) return null
val query = prefix.substring(atIndex + 1)
if (query.any { it.isWhitespace() }) return null
return query
}
private fun insertMentionAtCursor(value: TextFieldValue, nickname: String): TextFieldValue {
val cursor = value.selection.end.coerceIn(0, value.text.length)
val prefix = value.text.substring(0, cursor)
val suffix = value.text.substring(cursor)
val atIndex = prefix.lastIndexOf('@')
if (atIndex < 0) {
val insert = "@$nickname "
val next = prefix + insert + suffix
return TextFieldValue(next, selection = TextRange(prefix.length + insert.length))
}
val nextPrefix = prefix.substring(0, atIndex)
val insert = "@$nickname "
val next = nextPrefix + insert + suffix
val selection = nextPrefix.length + insert.length
return TextFieldValue(next, selection = TextRange(selection))
}
private fun handleMentionBackspace(
event: androidx.compose.ui.input.key.KeyEvent,
value: TextFieldValue,
nicknames: List<String>,
): TextFieldValue? {
if (event.nativeKeyEvent.action != android.view.KeyEvent.ACTION_DOWN ||
event.nativeKeyEvent.keyCode != android.view.KeyEvent.KEYCODE_DEL
) return null
if (!value.selection.collapsed) return null
val cursor = value.selection.end.coerceIn(0, value.text.length)
if (cursor <= 0) return null
val prefix = value.text.substring(0, cursor)
val suffix = value.text.substring(cursor)
val token = nicknames
.map { "@$it " }
.plus(nicknames.map { "@$it" })
.filter { prefix.endsWith(it) }
.maxByOrNull { it.length }
?: return null
val nextPrefix = prefix.dropLast(token.length)
val nextText = nextPrefix + suffix
return TextFieldValue(nextText, selection = TextRange(nextPrefix.length))
}

查看文件

@ -1,6 +1,7 @@
package com.xuqm.sdk.sample.ui.chat package com.xuqm.sdk.sample.ui.chat
import android.net.Uri import android.net.Uri
import android.util.Log
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
@ -8,10 +9,14 @@ 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.api.UserData
import com.xuqm.sdk.sample.data.repo.RecordedVoice import com.xuqm.sdk.sample.data.repo.RecordedVoice
import com.xuqm.sdk.sample.data.model.previewText import com.xuqm.sdk.sample.data.model.previewText
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ChatViewModel : ViewModel() { class ChatViewModel : ViewModel() {
@ -41,74 +46,30 @@ class ChatViewModel : ViewModel() {
private val _replyTargetMessage = MutableStateFlow<ImMessage?>(null) private val _replyTargetMessage = MutableStateFlow<ImMessage?>(null)
val replyTargetMessage: StateFlow<ImMessage?> = _replyTargetMessage val replyTargetMessage: StateFlow<ImMessage?> = _replyTargetMessage
private val _mentionableUserIds = MutableStateFlow<List<String>>(emptyList()) private val _mentionableUsers = MutableStateFlow<List<UserData>>(emptyList())
val mentionableUserIds: StateFlow<List<String>> = _mentionableUserIds val mentionableUsers: StateFlow<List<UserData>> = _mentionableUsers
private val _isSendingAttachment = MutableStateFlow(false) private val _isSendingAttachment = MutableStateFlow(false)
val isSendingAttachment: StateFlow<Boolean> = _isSendingAttachment val isSendingAttachment: StateFlow<Boolean> = _isSendingAttachment
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 1)
val events: SharedFlow<String> = _events.asSharedFlow()
val currentUserId: String get() = ImSDK.currentUserId val currentUserId: String get() = ImSDK.currentUserId
private lateinit var targetId: String private lateinit var targetId: String
private lateinit var chatType: String private lateinit var chatType: String
private var nextHistoryPage = 0 private var nextHistoryPage = 0
private var initialized = false private var initialized = false
private val pendingDeliveryTimeouts = mutableMapOf<String, kotlinx.coroutines.Job>()
private val listener = object : ImEventListener { private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) { override fun onMessage(message: ImMessage) {
if (isRelevant(message)) { handleIncomingMessage(message)
prependMessage(message)
cache.mergeHistory(targetId, chatType, _messages.value)
cache.upsertConversation(
ConversationData(
targetId = targetId,
chatType = chatType,
lastMsgContent = message.previewText(),
lastMsgType = message.msgType,
lastMsgTime = message.createdAt,
unreadCount = 0,
isMuted = false,
isPinned = false,
)
)
if (_searchQuery.value.isNotBlank()) {
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
}
requestScrollToBottom()
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
}
}
}
} }
override fun onGroupMessage(message: ImMessage) { override fun onGroupMessage(message: ImMessage) {
if (isRelevant(message)) { handleIncomingMessage(message)
prependMessage(message)
cache.mergeHistory(targetId, chatType, _messages.value)
cache.upsertConversation(
ConversationData(
targetId = targetId,
chatType = chatType,
lastMsgContent = message.previewText(),
lastMsgType = message.msgType,
lastMsgTime = message.createdAt,
unreadCount = 0,
isMuted = false,
isPinned = false,
)
)
if (_searchQuery.value.isNotBlank()) {
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
}
requestScrollToBottom()
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
}
}
}
} }
} }
@ -127,7 +88,7 @@ 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() _mentionableUsers.value = emptyList()
_replyTargetMessage.value = null _replyTargetMessage.value = null
ImSDK.addListener(listener) ImSDK.addListener(listener)
if (chatType == "GROUP") { if (chatType == "GROUP") {
@ -158,16 +119,16 @@ class ChatViewModel : ViewModel() {
val page = if (replace) 0 else nextHistoryPage val page = if (replace) 0 else nextHistoryPage
val history = fetchHistory(page) val history = fetchHistory(page)
if (replace) { if (replace) {
_messages.value = history _messages.value = mergeMessages(_messages.value, history)
nextHistoryPage = 1 nextHistoryPage = 1
requestScrollToBottom() requestScrollToBottom()
} else if (history.isNotEmpty()) { } else if (history.isNotEmpty()) {
_messages.value = (_messages.value + history).distinctBy { it.id } _messages.value = mergeMessages(_messages.value, history)
nextHistoryPage += 1 nextHistoryPage += 1
} }
_hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE _hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE
if (history.isNotEmpty()) { if (history.isNotEmpty()) {
cache.saveHistory(targetId, chatType, mergeHistory(_messages.value)) cache.mergeHistory(targetId, chatType, _messages.value)
history.firstOrNull()?.let { last -> history.firstOrNull()?.let { last ->
cache.upsertConversation( cache.upsertConversation(
ConversationData( ConversationData(
@ -198,7 +159,7 @@ class ChatViewModel : ViewModel() {
if (remote != null) { if (remote != null) {
val normalized = remote.sortedByDescending { it.createdAt } val normalized = remote.sortedByDescending { it.createdAt }
if (page == 0) { if (page == 0) {
cache.saveHistory(targetId, chatType, normalized) cache.mergeHistory(targetId, chatType, normalized)
} else { } else {
cache.mergeHistory(targetId, chatType, normalized) cache.mergeHistory(targetId, chatType, normalized)
} }
@ -210,7 +171,7 @@ class ChatViewModel : ViewModel() {
fun sendText(content: String) { fun sendText(content: String) {
if (content.isBlank()) return if (content.isBlank()) return
val replyTarget = _replyTargetMessage.value val replyTarget = _replyTargetMessage.value
if (replyTarget != null) { val sent = if (replyTarget != null) {
ImSDK.sendQuoteMessage( ImSDK.sendQuoteMessage(
toId = targetId, toId = targetId,
chatType = chatType, chatType = chatType,
@ -218,24 +179,18 @@ class ChatViewModel : ViewModel() {
quotedContent = replyTarget.previewText(), quotedContent = replyTarget.previewText(),
text = content, text = content,
) )
_replyTargetMessage.value = null
} else { } else {
ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content)) ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content))
} }
appendOutgoingMessage(sent)
if (sent.status.uppercase() == "FAILED") {
_events.tryEmit("消息发送失败,请检查网络后重试")
} else {
trackPendingDelivery(sent.id)
_draftText.value = "" _draftText.value = ""
cache.clearDraft(targetId, chatType) cache.clearDraft(targetId, chatType)
cache.upsertConversation( _replyTargetMessage.value = null
ConversationData( }
targetId = targetId,
chatType = chatType,
lastMsgContent = content,
lastMsgType = "TEXT",
lastMsgTime = System.currentTimeMillis(),
unreadCount = 0,
isMuted = false,
isPinned = false,
)
)
} }
fun searchCachedMessages(query: String) { fun searchCachedMessages(query: String) {
@ -251,9 +206,6 @@ class ChatViewModel : ViewModel() {
_draftText.value = text _draftText.value = text
if (initialized) { if (initialized) {
cache.saveDraft(targetId, chatType, text) cache.saveDraft(targetId, chatType, text)
viewModelScope.launch {
runCatching { ImSDK.setDraft(targetId, chatType, text) }
}
} }
} }
@ -288,19 +240,15 @@ class ChatViewModel : ViewModel() {
bytes = recording.bytes, bytes = recording.bytes,
durationMs = recording.durationMs, durationMs = recording.durationMs,
).getOrThrow() ).getOrThrow()
}.onSuccess { }.onSuccess { sent ->
cache.upsertConversation( appendOutgoingMessage(sent.message)
ConversationData( if (sent.message.status.uppercase() == "FAILED") {
targetId = targetId, _events.tryEmit("语音发送失败,请检查网络后重试")
chatType = chatType, } else {
lastMsgContent = "[语音]", trackPendingDelivery(sent.message.id)
lastMsgType = "AUDIO", }
lastMsgTime = System.currentTimeMillis(), }.onFailure {
unreadCount = 0, _events.tryEmit("语音发送失败,请检查网络后重试")
isMuted = false,
isPinned = false,
)
)
} }
} finally { } finally {
_isSendingAttachment.value = false _isSendingAttachment.value = false
@ -315,17 +263,6 @@ class ChatViewModel : ViewModel() {
_searchResults.value = emptyList() _searchResults.value = emptyList()
} }
private fun prependMessage(message: ImMessage) {
val updated = _messages.value.toMutableList()
val index = updated.indexOfFirst { it.id == message.id }
if (index >= 0) {
updated[index] = message
} else {
updated.add(0, message)
}
_messages.value = updated
}
private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> = private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> =
messages.distinctBy { it.id }.sortedByDescending { it.createdAt } messages.distinctBy { it.id }.sortedByDescending { it.createdAt }
@ -333,31 +270,35 @@ class ChatViewModel : ViewModel() {
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1 _scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
} }
fun appendMention(userId: String) { fun appendMention(user: UserData) {
val current = _draftText.value val current = _draftText.value
val next = if (current.isBlank()) "@$userId " else "$current @$userId " val label = user.nickname.ifBlank { "成员" }
val next = if (current.isBlank()) "@$label " else "$current @$label "
updateDraft(next) updateDraft(next)
} }
private fun loadMentionableUsers() { private fun loadMentionableUsers() {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.getGroupInfo(targetId) } runCatching {
.map { group -> val groupMemberIds = ImSDK.getGroupInfo(targetId)
group?.memberIds.orEmpty() ?.memberIds.orEmpty()
.split(",") .split(",")
.map { it.trim() } .map { it.trim() }
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .toSet()
} AppDependencies.authRepository.listMembers().getOrDefault(emptyList())
.onSuccess { _mentionableUserIds.value = it } .filter { it.userId in groupMemberIds && it.userId != currentUserId }
.filter { it.nickname.isNotBlank() }
.sortedBy { it.nickname.ifBlank { it.userId } }
}.onSuccess { _mentionableUsers.value = it }
} }
} }
private fun extractMentionedUserIds(content: String): String? { private fun extractMentionedUserIds(content: String): String? {
if (chatType != "GROUP") return null if (chatType != "GROUP") return null
val ids = _mentionableUserIds.value.filter { mentionId -> val ids = _mentionableUsers.value.filter { user ->
content.contains("@$mentionId") content.contains("@${user.nickname}")
} }.map { it.userId }
return ids.joinToString(",").takeIf { it.isNotBlank() } return ids.joinToString(",").takeIf { it.isNotBlank() }
} }
@ -373,6 +314,15 @@ class ChatViewModel : ViewModel() {
AttachmentKind.AUDIO -> AppDependencies.attachmentRepository.sendAudio(targetId, chatType, uri) AttachmentKind.AUDIO -> AppDependencies.attachmentRepository.sendAudio(targetId, chatType, uri)
AttachmentKind.FILE -> AppDependencies.attachmentRepository.sendFile(targetId, chatType, uri) AttachmentKind.FILE -> AppDependencies.attachmentRepository.sendFile(targetId, chatType, uri)
}.getOrThrow() }.getOrThrow()
}.onSuccess { sent ->
appendOutgoingMessage(sent.message)
if (sent.message.status.uppercase() == "FAILED") {
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
} else {
trackPendingDelivery(sent.message.id)
}
}.onFailure {
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
} }
} finally { } finally {
_isSendingAttachment.value = false _isSendingAttachment.value = false
@ -400,9 +350,18 @@ class ChatViewModel : ViewModel() {
bytes = bytes, bytes = bytes,
width = width, width = width,
height = height, height = height,
) ).getOrThrow()
else -> error("Unsupported bytes attachment kind: $kind") else -> error("Unsupported bytes attachment kind: $kind")
}.getOrThrow() }
}.onSuccess { sent ->
appendOutgoingMessage(sent.message)
if (sent.message.status.uppercase() == "FAILED") {
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
} else {
trackPendingDelivery(sent.message.id)
}
}.onFailure {
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
} }
} finally { } finally {
_isSendingAttachment.value = false _isSendingAttachment.value = false
@ -412,23 +371,147 @@ class ChatViewModel : ViewModel() {
private enum class AttachmentKind { IMAGE, VIDEO, AUDIO, FILE } private enum class AttachmentKind { IMAGE, VIDEO, AUDIO, FILE }
private fun AttachmentKind.previewLabel(): String = when (this) {
AttachmentKind.IMAGE -> "[图片]"
AttachmentKind.VIDEO -> "[视频]"
AttachmentKind.AUDIO -> "[语音]"
AttachmentKind.FILE -> "[文件]"
}
private fun isRelevant(message: ImMessage): Boolean { private fun isRelevant(message: ImMessage): Boolean {
return if (chatType == "GROUP") { val relevant = if (chatType == "GROUP") {
message.chatType == "GROUP" && message.toId == targetId message.chatType == "GROUP" && message.toId == targetId
} else { } else {
message.chatType != "GROUP" && (message.fromId == targetId || message.toId == targetId) message.chatType != "GROUP" && (message.fromId == targetId || message.toId == targetId)
} }
Log.d(
TAG,
"isRelevant=$relevant messageId=${message.id} messageChatType=${message.chatType} messageFrom=${message.fromId} messageTo=${message.toId} targetId=$targetId currentChatType=$chatType",
)
return relevant
} }
override fun onCleared() { override fun onCleared() {
if (initialized && chatType == "GROUP") { if (initialized && chatType == "GROUP") {
ImSDK.unsubscribeGroup(targetId) ImSDK.unsubscribeGroup(targetId)
} }
pendingDeliveryTimeouts.values.forEach { it.cancel() }
pendingDeliveryTimeouts.clear()
ImSDK.removeListener(listener) ImSDK.removeListener(listener)
initialized = false initialized = false
} }
private fun handleIncomingMessage(message: ImMessage) {
Log.d(
TAG,
"incoming message id=${message.id} chatType=${message.chatType} msgType=${message.msgType} from=${message.fromId} to=${message.toId} status=${message.status} targetId=$targetId currentChatType=$chatType",
)
if (!isRelevant(message)) return
if (message.fromId == currentUserId) {
pendingDeliveryTimeouts.remove(message.id)?.cancel()
}
Log.d(TAG, "incoming message matched targetId=$targetId currentChatType=$chatType")
_messages.value = mergeMessages(_messages.value, listOf(message))
cache.mergeHistory(targetId, chatType, _messages.value)
cache.upsertConversation(
ConversationData(
targetId = targetId,
chatType = chatType,
lastMsgContent = message.previewText(),
lastMsgType = message.msgType,
lastMsgTime = message.createdAt,
unreadCount = 0,
isMuted = false,
isPinned = false,
)
)
AppDependencies.notifyConversationChanged()
if (_searchQuery.value.isNotBlank()) {
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
}
requestScrollToBottom()
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
}
}
}
private fun appendOutgoingMessage(message: ImMessage) {
_messages.value = mergeMessages(_messages.value, listOf(message))
cache.mergeHistory(targetId, chatType, _messages.value)
updateConversationPreview(message.previewText(), message.msgType, message.createdAt)
requestScrollToBottom()
}
private fun updateConversationPreview(lastMsgContent: String, lastMsgType: String, lastMsgTime: Long = System.currentTimeMillis()) {
cache.upsertConversation(
ConversationData(
targetId = targetId,
chatType = chatType,
lastMsgContent = lastMsgContent,
lastMsgType = lastMsgType,
lastMsgTime = lastMsgTime,
unreadCount = 0,
isMuted = false,
isPinned = false,
)
)
AppDependencies.notifyConversationChanged()
}
private fun mergeMessages(existing: List<ImMessage>, incoming: List<ImMessage>): List<ImMessage> {
val merged = existing.toMutableList()
incoming.forEach { message ->
val exactIndex = merged.indexOfFirst { it.id == message.id }
if (exactIndex >= 0) {
merged[exactIndex] = mergeMessageRecord(merged[exactIndex], message)
return@forEach
}
merged.add(message)
}
return merged.distinctBy { it.id }.sortedByDescending { it.createdAt }
}
private fun mergeMessageRecord(existing: ImMessage, incoming: ImMessage): ImMessage {
return existing.copy(
appId = incoming.appId.ifBlank { existing.appId },
fromId = existing.fromId,
toId = existing.toId,
chatType = existing.chatType,
msgType = incoming.msgType.ifBlank { existing.msgType },
content = incoming.content.ifBlank { existing.content },
status = incoming.status.ifBlank { existing.status },
mentionedUserIds = incoming.mentionedUserIds ?: existing.mentionedUserIds,
groupReadCount = incoming.groupReadCount ?: existing.groupReadCount,
createdAt = existing.createdAt,
)
}
private fun updateMessageStatus(messageId: String, status: String) {
val updated = _messages.value.map { message ->
if (message.id == messageId) message.copy(status = status) else message
}
_messages.value = updated
cache.mergeHistory(targetId, chatType, updated)
}
private fun trackPendingDelivery(messageId: String) {
pendingDeliveryTimeouts.remove(messageId)?.cancel()
pendingDeliveryTimeouts[messageId] = viewModelScope.launch {
kotlinx.coroutines.delay(8_000)
val stillPending = _messages.value.firstOrNull { it.id == messageId && it.status.uppercase() == "SENDING" }
if (stillPending != null) {
updateMessageStatus(messageId, "FAILED")
_events.tryEmit("消息发送失败,请检查网络后重试")
Log.w(TAG, "delivery timeout messageId=$messageId")
}
pendingDeliveryTimeouts.remove(messageId)
}
}
private companion object { private companion object {
const val TAG = "XuqmChatViewModel"
const val HISTORY_PAGE_SIZE = 20 const val HISTORY_PAGE_SIZE = 20
} }
} }

查看文件

@ -1,6 +1,6 @@
package com.xuqm.sdk.sample.ui.contact package com.xuqm.sdk.sample.ui.contact
import androidx.compose.foundation.clickable import android.widget.Toast
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
@ -9,22 +9,30 @@ import androidx.compose.foundation.layout.padding
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.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
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.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.compose.viewModel
import androidx.lifecycle.ViewModel 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.listener.ImEventListener
import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.im.model.BlacklistEntry import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.sample.data.api.UserData import com.xuqm.sdk.sample.data.api.UserData
@ -32,8 +40,12 @@ import com.xuqm.sdk.sample.data.local.LocalContactCache
import com.xuqm.sdk.sample.data.repo.AuthRepository import com.xuqm.sdk.sample.data.repo.AuthRepository
import com.xuqm.sdk.sample.di.AppDependencies import com.xuqm.sdk.sample.di.AppDependencies
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.xuqm.sdk.ui.SearchBarField import com.xuqm.sdk.ui.SearchBarField
@ -43,9 +55,16 @@ class ContactViewModel(
) : ViewModel() { ) : ViewModel() {
private val cache: LocalContactCache = AppDependencies.localContactCache private val cache: LocalContactCache = AppDependencies.localContactCache
private val currentUserId: String? = authRepository.getCurrentUserId()
private var memberIndex: Map<String, UserData> = emptyMap()
private var friendIds: Set<String> = emptySet()
private val _friends = MutableStateFlow<List<UserData>>(emptyList()) private val _friends = MutableStateFlow<List<UserData>>(emptyList())
val friends: StateFlow<List<UserData>> = _friends val friends: StateFlow<List<UserData>> = _friends
private val _members = MutableStateFlow<List<UserData>>(emptyList())
val members: StateFlow<List<UserData>> = _members
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
@ -55,47 +74,74 @@ class ContactViewModel(
private val _blacklist = MutableStateFlow<List<BlacklistEntry>>(emptyList()) private val _blacklist = MutableStateFlow<List<BlacklistEntry>>(emptyList())
val blacklist: StateFlow<List<BlacklistEntry>> = _blacklist val blacklist: StateFlow<List<BlacklistEntry>> = _blacklist
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 1)
val events: SharedFlow<String> = _events.asSharedFlow()
private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) = handleNotification(message)
override fun onGroupMessage(message: ImMessage) = handleNotification(message)
}
init { init {
_friends.value = cache.resolveFriends(cache.loadFriendIds()) ImSDK.addListener(listener)
loadFriends() refresh()
loadFriendRequests() }
loadBlacklist()
fun loadMembers() {
viewModelScope.launch {
loadMembersInternal()
}
} }
fun loadFriends() { fun loadFriends() {
viewModelScope.launch { viewModelScope.launch {
runCatching { loadFriendsInternal()
ImSDK.listFriends().mapNotNull { friendId ->
authRepository.searchUsers(friendId).getOrDefault(emptyList())
.firstOrNull { it.userId == friendId }
}
}.onSuccess { list ->
_friends.value = list
cache.saveFriendIds(list.map { it.userId })
cache.saveProfiles(list)
}.onFailure {
val cached = cache.resolveFriends(cache.loadFriendIds())
if (cached.isNotEmpty()) _friends.value = cached
} }
} }
fun refresh() {
viewModelScope.launch {
_isRefreshing.value = true
try {
loadMembersInternal()
loadFriendsInternal()
loadFriendRequestsInternal()
loadBlacklistInternal()
} finally {
_isRefreshing.value = false
}
}
}
private fun rebuildFriendList() {
_friends.value = friendIds.mapNotNull { memberIndex[it] ?: cache.loadProfiles().firstOrNull { profile -> profile.userId == it } }
.distinctBy { it.userId }
.sortedBy { it.userId }
} }
fun search(keyword: String) { fun search(keyword: String) {
if (keyword.isBlank()) { _searchResults.value = emptyList(); return } if (keyword.isBlank()) {
_searchResults.value = cache.searchProfiles(keyword) _searchResults.value = emptyList()
viewModelScope.launch { return
authRepository.searchUsers(keyword)
.onSuccess { list ->
_searchResults.value = list
cache.saveProfiles(list)
} }
_searchResults.value = _members.value.filter {
it.userId.contains(keyword, ignoreCase = true) ||
it.nickname.contains(keyword, ignoreCase = true)
} }
} }
fun addFriend(userId: String) { fun addFriend(userId: String, remark: String? = null) {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.sendFriendRequest(userId) } runCatching { ImSDK.sendFriendRequest(userId, remark) }
.onSuccess { loadFriends() } .onSuccess {
_events.tryEmit("好友申请已发送")
}
.onFailure {
_events.tryEmit("好友申请发送失败,请检查网络后重试")
}
} }
} }
@ -108,29 +154,27 @@ class ContactViewModel(
fun loadFriendRequests() { fun loadFriendRequests() {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.listFriendRequests() } loadFriendRequestsInternal()
.onSuccess { _friendRequests.value = it }
} }
} }
fun acceptFriendRequest(requestId: String) { fun acceptFriendRequest(requestId: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.acceptFriendRequest(requestId) } runCatching { ImSDK.acceptFriendRequest(requestId) }
.onSuccess { loadFriends(); loadFriendRequests() } .onSuccess { refresh() }
} }
} }
fun rejectFriendRequest(requestId: String) { fun rejectFriendRequest(requestId: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.rejectFriendRequest(requestId) } runCatching { ImSDK.rejectFriendRequest(requestId) }
.onSuccess { loadFriendRequests() } .onSuccess { refresh() }
} }
} }
fun loadBlacklist() { fun loadBlacklist() {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.listBlacklist() } loadBlacklistInternal()
.onSuccess { _blacklist.value = it }
} }
} }
@ -147,8 +191,69 @@ class ContactViewModel(
.onSuccess { loadBlacklist() } .onSuccess { loadBlacklist() }
} }
} }
override fun onCleared() {
ImSDK.removeListener(listener)
} }
private fun handleNotification(message: ImMessage) {
if (message.msgType.uppercase() != "NOTIFY") return
val payload = runCatching { JSONObject(message.content) }.getOrNull() ?: return
val kind = payload.optString("type")
if (kind != "FRIEND_REQUEST" && kind != "FRIEND_REQUEST_STATUS") return
refresh()
}
private suspend fun loadMembersInternal() {
runCatching {
authRepository.listMembers().getOrDefault(emptyList())
.filter { it.userId != currentUserId }
.sortedBy { it.userId }
}.onSuccess { list ->
_members.value = list
memberIndex = list.associateBy { it.userId }
cache.saveProfiles(list)
rebuildFriendList()
}.onFailure {
val cached = cache.loadProfiles()
.filter { it.userId != currentUserId }
.sortedBy { it.userId }
if (cached.isNotEmpty()) {
_members.value = cached
memberIndex = cached.associateBy { it.userId }
rebuildFriendList()
}
}
}
private suspend fun loadFriendsInternal() {
runCatching { ImSDK.listFriends().toSet() }
.onSuccess { list ->
friendIds = list
cache.saveFriendIds(list.toList())
rebuildFriendList()
}.onFailure {
friendIds = cache.loadFriendIds().toSet()
rebuildFriendList()
}
}
private suspend fun loadFriendRequestsInternal() {
runCatching { ImSDK.listFriendRequests() }
.onSuccess {
_friendRequests.value = it.filter { request ->
request.status.equals("PENDING", ignoreCase = true)
}
}
}
private suspend fun loadBlacklistInternal() {
runCatching { ImSDK.listBlacklist() }
.onSuccess { _blacklist.value = it }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ContactScreen( fun ContactScreen(
onOpenChat: (userId: String) -> Unit, onOpenChat: (userId: String) -> Unit,
@ -159,11 +264,23 @@ fun ContactScreen(
), ),
) { ) {
val friends by viewModel.friends.collectAsStateWithLifecycle() val friends by viewModel.friends.collectAsStateWithLifecycle()
val members by viewModel.members.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
val friendRequests by viewModel.friendRequests.collectAsStateWithLifecycle() val friendRequests by viewModel.friendRequests.collectAsStateWithLifecycle()
val blacklist by viewModel.blacklist.collectAsStateWithLifecycle() val blacklist by viewModel.blacklist.collectAsStateWithLifecycle()
val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val context = LocalContext.current
var keyword by remember { mutableStateOf("") } var keyword by remember { mutableStateOf("") }
var selectedTab by remember { mutableStateOf(0) }
var pendingFriendRequestUser by remember { mutableStateOf<UserData?>(null) }
var friendRequestRemark by remember { mutableStateOf("") }
val visibleContacts = if (keyword.isBlank()) members else searchResults
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize(),
) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
SearchBarField( SearchBarField(
value = keyword, value = keyword,
@ -174,6 +291,21 @@ fun ContactScreen(
placeholder = "搜索用户 ID 或昵称", placeholder = "搜索用户 ID 或昵称",
) )
androidx.compose.material3.PrimaryTabRow(selectedTabIndex = selectedTab) {
androidx.compose.material3.Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text("好友") },
)
androidx.compose.material3.Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text("全部联系人") },
)
}
when (selectedTab) {
0 -> {
if (friendRequests.isNotEmpty()) { if (friendRequests.isNotEmpty()) {
Text( Text(
"好友申请(${friendRequests.size}", "好友申请(${friendRequests.size}",
@ -189,8 +321,11 @@ fun ContactScreen(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(request.fromUserId, style = MaterialTheme.typography.titleSmall) Text(request.fromUserId, style = MaterialTheme.typography.titleSmall)
Text(request.remark.orEmpty(), style = MaterialTheme.typography.bodySmall, Text(
color = MaterialTheme.colorScheme.outline) request.remark.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
} }
TextButton(onClick = { viewModel.acceptFriendRequest(request.id) }) { Text("接受") } TextButton(onClick = { viewModel.acceptFriendRequest(request.id) }) { Text("接受") }
TextButton(onClick = { viewModel.rejectFriendRequest(request.id) }) { Text("拒绝") } TextButton(onClick = { viewModel.rejectFriendRequest(request.id) }) { Text("拒绝") }
@ -199,48 +334,58 @@ fun ContactScreen(
} }
} }
} }
if (keyword.isBlank()) {
Text( Text(
"联系人(${friends.size}", "好友(${friends.size}",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline,
)
if (friends.isEmpty()) {
Text(
"暂无好友",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodySmall,
)
} else {
LazyColumn {
items(friends, key = { it.userId }) { user ->
MemberItem(
user = user,
isFriend = true,
onChat = { onOpenChat(user.userId) },
onAddFriend = { viewModel.addFriend(user.userId) },
onRemoveFriend = { viewModel.removeFriend(user.userId) },
onBlacklist = { viewModel.addToBlacklist(user.userId) },
)
HorizontalDivider()
}
}
}
}
else -> {
Text(
if (keyword.isBlank()) "全部联系人(${visibleContacts.size}" else "搜索结果(${visibleContacts.size}",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )
LazyColumn { LazyColumn {
items(friends, key = { it.userId }) { user -> items(visibleContacts, key = { it.userId }) { user ->
FriendItem( MemberItem(
userId = user.userId, user = user,
nickname = user.nickname, isFriend = friends.any { it.userId == user.userId },
onChat = { onOpenChat(user.userId) }, onChat = { onOpenChat(user.userId) },
onRemove = { viewModel.removeFriend(user.userId) }, onAddFriend = {
pendingFriendRequestUser = user
friendRequestRemark = ""
},
onRemoveFriend = { viewModel.removeFriend(user.userId) },
onBlacklist = { viewModel.addToBlacklist(user.userId) },
) )
HorizontalDivider() HorizontalDivider()
} }
} }
} else {
LazyColumn {
items(searchResults, key = { it.userId }) { user ->
val isFriend = friends.any { it.userId == user.userId }
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(user.nickname, style = MaterialTheme.typography.titleSmall)
Text(user.userId, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline)
}
if (!isFriend) {
TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("申请好友") }
} else {
TextButton(onClick = { onOpenChat(user.userId) }) { Text("发消息") }
}
TextButton(onClick = { viewModel.addToBlacklist(user.userId) }) { Text("拉黑") }
}
HorizontalDivider()
}
}
if (blacklist.isNotEmpty()) { if (blacklist.isNotEmpty()) {
Text( Text(
"黑名单(${blacklist.size}", "黑名单(${blacklist.size}",
@ -266,23 +411,89 @@ fun ContactScreen(
} }
} }
} }
}
if (pendingFriendRequestUser != null) {
AlertDialog(
onDismissRequest = {
pendingFriendRequestUser = null
friendRequestRemark = ""
},
title = { Text("申请好友") },
text = {
Column {
Text(
pendingFriendRequestUser?.nickname.orEmpty(),
style = MaterialTheme.typography.titleSmall,
)
Text(
pendingFriendRequestUser?.userId.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
OutlinedTextField(
value = friendRequestRemark,
onValueChange = { friendRequestRemark = it },
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
label = { Text("申请信息") },
placeholder = { Text("例如:我是某某,想和你加好友") },
minLines = 3,
)
}
},
confirmButton = {
TextButton(
onClick = {
pendingFriendRequestUser?.let { user ->
viewModel.addFriend(user.userId, friendRequestRemark.trim().takeIf { it.isNotBlank() })
}
pendingFriendRequestUser = null
friendRequestRemark = ""
},
) { Text("发送") }
},
dismissButton = {
TextButton(
onClick = {
pendingFriendRequestUser = null
friendRequestRemark = ""
},
) { Text("取消") }
},
)
}
LaunchedEffect(Unit) {
viewModel.events.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
}
}
@Composable @Composable
private fun FriendItem(userId: String, nickname: String, onChat: () -> Unit, onRemove: () -> Unit) { private fun MemberItem(
user: UserData,
isFriend: Boolean,
onChat: () -> Unit,
onAddFriend: () -> Unit,
onRemoveFriend: () -> Unit,
onBlacklist: () -> Unit,
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onChat)
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(nickname, style = MaterialTheme.typography.titleSmall) Text(user.nickname, style = MaterialTheme.typography.titleSmall)
Text(userId, style = MaterialTheme.typography.bodySmall, Text(user.userId, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline) color = MaterialTheme.colorScheme.outline)
} }
TextButton(onClick = onRemove) { if (isFriend) {
Text("删除", color = MaterialTheme.colorScheme.error) TextButton(onClick = onChat) { Text("发消息") }
} TextButton(onClick = onRemoveFriend) { Text("删除", color = MaterialTheme.colorScheme.error) }
} else {
TextButton(onClick = onAddFriend) { Text("申请好友") }
}
TextButton(onClick = onBlacklist) { Text("拉黑") }
} }
} }

查看文件

@ -13,12 +13,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -39,13 +41,16 @@ import com.xuqm.sdk.ui.SearchBarField
import com.xuqm.sdk.utils.TimeFormatters import com.xuqm.sdk.utils.TimeFormatters
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConversationScreen( fun ConversationScreen(
onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit, onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit,
viewModel: ConversationViewModel = viewModel(), viewModel: ConversationViewModel = viewModel(),
) { ) {
val conversations by viewModel.conversations.collectAsStateWithLifecycle() val conversations by viewModel.conversations.collectAsStateWithLifecycle()
val conversationTitles by viewModel.conversationTitles.collectAsStateWithLifecycle()
val totalUnreadCount by viewModel.totalUnreadCount.collectAsStateWithLifecycle() val totalUnreadCount by viewModel.totalUnreadCount.collectAsStateWithLifecycle()
val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val filtered = remember(conversations, query) { val filtered = remember(conversations, query) {
@ -59,6 +64,11 @@ fun ConversationScreen(
} }
} }
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize(),
) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Text( Text(
"总未读 $totalUnreadCount", "总未读 $totalUnreadCount",
@ -87,9 +97,11 @@ fun ConversationScreen(
.weight(1f), .weight(1f),
) { ) {
items(filtered, key = { "${it.chatType}_${it.targetId}" }) { conv -> items(filtered, key = { "${it.chatType}_${it.targetId}" }) { conv ->
val title = conversationTitle(conv, conversationTitles)
ConversationItem( ConversationItem(
conversation = conv, conversation = conv,
onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) }, title = title,
onClick = { onOpenChat(conv.targetId, conv.chatType, title) },
onPinToggle = { onPinToggle = {
scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) } scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) }
}, },
@ -106,11 +118,13 @@ fun ConversationScreen(
} }
} }
} }
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun ConversationItem( private fun ConversationItem(
conversation: ConversationData, conversation: ConversationData,
title: String,
onClick: () -> Unit, onClick: () -> Unit,
onPinToggle: () -> Unit, onPinToggle: () -> Unit,
onMuteToggle: () -> Unit, onMuteToggle: () -> Unit,
@ -126,14 +140,14 @@ private fun ConversationItem(
.padding(horizontal = 16.dp, vertical = 10.dp), .padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
InitialAvatar(text = conversation.targetId, modifier = Modifier.size(48.dp)) InitialAvatar(text = title, modifier = Modifier.size(48.dp))
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
conversation.targetId, title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@ -180,6 +194,17 @@ private fun ConversationItem(
} }
} }
private fun conversationTitle(
conversation: ConversationData,
titles: Map<String, String>,
): String {
return if (conversation.chatType.equals("GROUP", ignoreCase = true)) {
titles[conversation.targetId].orEmpty().ifBlank { conversation.targetId }
} else {
conversation.targetId
}
}
private fun conversationPreview(conversation: ConversationData): String { private fun conversationPreview(conversation: ConversationData): String {
return when (conversation.lastMsgType?.uppercase()) { return when (conversation.lastMsgType?.uppercase()) {
"TEXT" -> conversation.lastMsgContent.orEmpty() "TEXT" -> conversation.lastMsgContent.orEmpty()

查看文件

@ -1,5 +1,6 @@
package com.xuqm.sdk.sample.ui.conversation package com.xuqm.sdk.sample.ui.conversation
import android.util.Log
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,7 @@ 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.ImMessage import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.sample.di.AppDependencies 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
@ -14,16 +16,39 @@ import kotlinx.coroutines.launch
class ConversationViewModel : ViewModel() { class ConversationViewModel : ViewModel() {
companion object {
private const val TAG = "XuqmConversationVM"
}
private val cache = AppDependencies.localImCache private val cache = AppDependencies.localImCache
private val _conversations = MutableStateFlow<List<ConversationData>>(emptyList()) private val _conversations = MutableStateFlow<List<ConversationData>>(emptyList())
val conversations: StateFlow<List<ConversationData>> = _conversations val conversations: StateFlow<List<ConversationData>> = _conversations
private val _conversationTitles = MutableStateFlow<Map<String, String>>(emptyMap())
val conversationTitles: StateFlow<Map<String, String>> = _conversationTitles
private val _totalUnreadCount = MutableStateFlow(0) private val _totalUnreadCount = MutableStateFlow(0)
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount val totalUnreadCount: StateFlow<Int> = _totalUnreadCount
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing
private val listener = object : ImEventListener { private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) { refresh() } override fun onMessage(message: ImMessage) {
override fun onGroupMessage(message: ImMessage) { refresh() } Log.d(
TAG,
"incoming single message refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
)
refresh()
}
override fun onGroupMessage(message: ImMessage) {
Log.d(
TAG,
"incoming group message refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
)
refresh()
}
} }
init { init {
@ -31,6 +56,11 @@ class ConversationViewModel : ViewModel() {
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime } _conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount } _totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
refresh() refresh()
viewModelScope.launch {
AppDependencies.conversationRefreshRequests.collect {
refresh()
}
}
viewModelScope.launch { viewModelScope.launch {
ImSDK.connectionState.collect { state -> ImSDK.connectionState.collect { state ->
if (state is ImConnectionState.Connected) { if (state is ImConnectionState.Connected) {
@ -41,20 +71,28 @@ class ConversationViewModel : ViewModel() {
} }
fun refresh() { fun refresh() {
Log.d(TAG, "refresh() called")
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.listConversations() } _isRefreshing.value = true
.onSuccess { list -> try {
val sorted = list.sortedByDescending { it.lastMsgTime } val conversations = runCatching { ImSDK.listConversations() }.getOrDefault(emptyList())
cache.saveConversations(sorted) val groups = runCatching { ImSDK.listGroups() }.getOrDefault(emptyList())
_conversations.value = sorted val groupMap = groups.associateBy({ it.id }, { it.name })
_totalUnreadCount.value = sorted.sumOf { it.unreadCount } val merged = mergeGroupConversations(conversations, groups).sortedByDescending { it.lastMsgTime }
} Log.d(TAG, "refresh success conversationCount=${merged.size} groupCount=${groups.size}")
.onFailure { cache.saveConversations(merged)
_conversations.value = merged
_conversationTitles.value = groupMap
_totalUnreadCount.value = merged.sumOf { it.unreadCount }
} catch (t: Throwable) {
Log.w(TAG, "refresh failed, falling back to cache", t)
val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime } val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime }
if (cached.isNotEmpty()) { if (cached.isNotEmpty()) {
_conversations.value = cached _conversations.value = cached
_totalUnreadCount.value = cached.sumOf { it.unreadCount } _totalUnreadCount.value = cached.sumOf { it.unreadCount }
} }
} finally {
_isRefreshing.value = false
} }
} }
} }
@ -81,4 +119,27 @@ class ConversationViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
ImSDK.removeListener(listener) ImSDK.removeListener(listener)
} }
private fun mergeGroupConversations(
conversations: List<ConversationData>,
groups: List<ImGroup>,
): List<ConversationData> {
val existing = conversations.associateBy { "${it.chatType}_${it.targetId}" }.toMutableMap()
groups.forEach { group ->
val key = "GROUP_${group.id}"
if (key !in existing) {
existing[key] = ConversationData(
targetId = group.id,
chatType = "GROUP",
lastMsgContent = "暂无消息",
lastMsgType = null,
lastMsgTime = group.createdAt,
unreadCount = 0,
isMuted = false,
isPinned = false,
)
}
}
return existing.values.toList()
}
} }

查看文件

@ -45,7 +45,7 @@ import kotlinx.coroutines.flow.StateFlow
data class EnvironmentUiState( data class EnvironmentUiState(
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL, val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
val host: String = "10.0.2.2", val host: String = "192.168.116.9",
val message: String? = null, val message: String? = null,
) )
@ -133,15 +133,15 @@ fun EnvironmentScreen(
Text( Text(
text = if (state.mode == EnvironmentMode.EXTERNAL) { text = if (state.mode == EnvironmentMode.EXTERNAL) {
"当前使用外网服务dev.xuqinmin.com" "当前使用开发服务http://192.168.116.9:8085"
} else { } else {
"当前使用本地服务http://${state.host}:8081" "当前使用本地服务http://${state.host}:8085"
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
EnvironmentModeRow( EnvironmentModeRow(
title = "外网服务", title = "开发服务",
selected = state.mode == EnvironmentMode.EXTERNAL, selected = state.mode == EnvironmentMode.EXTERNAL,
description = "走线上 / 开发服务器,适合正常联调。", description = "走线上 / 开发服务器,适合正常联调。",
onClick = viewModel::selectExternal, onClick = viewModel::selectExternal,
@ -162,7 +162,7 @@ fun EnvironmentScreen(
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("本地 Host") }, label = { Text("本地 Host") },
placeholder = { Text("10.0.2.2 或你的电脑局域网 IP") }, placeholder = { Text("192.168.116.9 或你的电脑局域网 IP") },
singleLine = true, singleLine = true,
enabled = state.mode == EnvironmentMode.LOCALHOST, enabled = state.mode == EnvironmentMode.LOCALHOST,
) )

查看文件

@ -3,6 +3,7 @@ package com.xuqm.sdk.sample.ui.group
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
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.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -11,9 +12,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@ -27,6 +32,9 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
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
@ -34,16 +42,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp 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 coil3.compose.AsyncImage
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.ui.SearchBarField 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.im.model.GroupJoinRequest
import com.xuqm.sdk.sample.data.api.UserData
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
import com.xuqm.sdk.ui.InitialAvatar
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -53,8 +67,10 @@ fun GroupListScreen(
viewModel: GroupViewModel = viewModel(), viewModel: GroupViewModel = viewModel(),
) { ) {
val groups by viewModel.groups.collectAsStateWithLifecycle() val groups by viewModel.groups.collectAsStateWithLifecycle()
val members by viewModel.members.collectAsStateWithLifecycle()
val publicGroups by viewModel.publicGroups.collectAsStateWithLifecycle() val publicGroups by viewModel.publicGroups.collectAsStateWithLifecycle()
val publicGroupQuery by viewModel.publicGroupQuery.collectAsStateWithLifecycle() val publicGroupQuery by viewModel.publicGroupQuery.collectAsStateWithLifecycle()
val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
@ -64,8 +80,13 @@ fun GroupListScreen(
} }
}, },
) { padding -> ) { padding ->
LazyColumn( PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) { ) {
item { item {
SearchBarField( SearchBarField(
@ -109,9 +130,11 @@ fun GroupListScreen(
} }
} }
} }
}
if (showCreateDialog) { if (showCreateDialog) {
CreateGroupDialog( CreateGroupDialog(
members = members,
onDismiss = { showCreateDialog = false }, onDismiss = { showCreateDialog = false },
onCreate = { name, memberIds, groupType -> onCreate = { name, memberIds, groupType ->
showCreateDialog = false showCreateDialog = false
@ -171,39 +194,108 @@ private fun PublicGroupItem(group: ImGroup, onOpen: () -> Unit, onJoin: () -> Un
} }
@Composable @Composable
private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List<String>, String) -> Unit) { private fun CreateGroupDialog(
members: List<UserData>,
onDismiss: () -> Unit,
onCreate: (String, List<String>, String) -> Unit,
) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var memberInput by remember { mutableStateOf("") } var keyword by remember { mutableStateOf("") }
var isPublicGroup by remember { mutableStateOf(false) } var isPublicGroup by remember { mutableStateOf(false) }
val selectedIds = remember { mutableStateListOf<String>() }
val selectableMembers = remember(members) {
members.filter { it.userId != ImSDK.currentUserId }
}
val filteredMembers = remember(keyword, selectableMembers, selectedIds.size) {
val normalized = keyword.trim()
selectableMembers
.filter { it.userId !in selectedIds }
.filter {
normalized.isBlank() ||
it.userId.contains(normalized, ignoreCase = true) ||
it.nickname.contains(normalized, ignoreCase = true)
}
.sortedBy { it.userId }
}
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("创建群组") }, title = { Text("创建群组") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("群名称") }, label = { Text("群名称") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) singleLine = true,
OutlinedTextField(
value = memberInput,
onValueChange = { memberInput = it },
label = { Text("成员 ID逗号分隔") },
modifier = Modifier.fillMaxWidth(),
) )
TextButton(onClick = { isPublicGroup = !isPublicGroup }) { TextButton(onClick = { isPublicGroup = !isPublicGroup }) {
Text(if (isPublicGroup) "群类型:公开群" else "群类型:普通群") Text(if (isPublicGroup) "群类型:公开群" else "群类型:普通群")
} }
OutlinedTextField(
value = keyword,
onValueChange = { keyword = it },
label = { Text("搜索成员") },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("输入昵称或用户ID") },
singleLine = true,
)
if (selectedIds.isNotEmpty()) {
Text("已选成员", style = MaterialTheme.typography.labelMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
selectedIds.forEach { userId ->
val user = members.firstOrNull { it.userId == userId }
TextButton(onClick = { selectedIds.remove(userId) }) {
Text(user?.nickname ?: userId)
}
}
}
}
LazyColumn(
modifier = Modifier.height(240.dp),
) {
items(filteredMembers, key = { it.userId }) { user ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(user.nickname, style = MaterialTheme.typography.titleSmall)
Text(
user.userId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
TextButton(onClick = {
if (user.userId !in selectedIds) selectedIds.add(user.userId)
}) {
Text("添加")
}
}
HorizontalDivider()
}
}
if (filteredMembers.isEmpty()) {
Text(
"未找到匹配成员",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
} }
}, },
confirmButton = { confirmButton = {
Button( Button(
onClick = { onClick = {
val members = memberInput.split(",").map { it.trim() }.filter { it.isNotBlank() } onCreate(
val allMembers = (members + listOf(ImSDK.currentUserId)).distinct() name.trim(),
onCreate(name.trim(), allMembers, if (isPublicGroup) "PUBLIC" else "WORK") selectedIds.distinct(),
if (isPublicGroup) "PUBLIC" else "WORK",
)
}, },
enabled = name.isNotBlank(), enabled = name.isNotBlank(),
) { Text("创建") } ) { Text("创建") }
@ -220,11 +312,27 @@ fun GroupSettingsScreen(
viewModel: GroupViewModel = viewModel(), viewModel: GroupViewModel = viewModel(),
) { ) {
val group by viewModel.currentGroup.collectAsStateWithLifecycle() val group by viewModel.currentGroup.collectAsStateWithLifecycle()
val members by viewModel.members.collectAsStateWithLifecycle()
val joinRequests by viewModel.joinRequests.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 showAddMemberDialog by remember { mutableStateOf(false) }
var showRemoveMemberDialog by remember { mutableStateOf(false) }
var editName by remember { mutableStateOf("") } var editName by remember { mutableStateOf("") }
var editAnnouncement by remember { mutableStateOf("") } var editAnnouncement by remember { mutableStateOf("") }
val memberProfiles = remember(group, members) {
val ids = group?.memberIds
?.split(",")
?.map { it.trim() }
?.filter { it.isNotBlank() }
.orEmpty()
ids.mapNotNull { id ->
members.firstOrNull { it.userId == id } ?: UserData(id, "")
}
}
val memberNameById = remember(members) {
members.associateBy { it.userId }
}
LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) } LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) }
LaunchedEffect(group) { LaunchedEffect(group) {
@ -241,7 +349,7 @@ fun GroupSettingsScreen(
topBar = { topBar = {
androidx.compose.foundation.layout.Column { androidx.compose.foundation.layout.Column {
TopAppBar( TopAppBar(
title = { Text(group?.name ?: "群设置") }, title = { Text("聊天信息(${memberProfiles.size})") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@ -260,6 +368,37 @@ fun GroupSettingsScreen(
) { ) {
group?.let { g -> group?.let { g ->
val isOwner = g.creatorId == ImSDK.currentUserId val isOwner = g.creatorId == ImSDK.currentUserId
Text("群名称", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
Text(g.name, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(5),
modifier = Modifier.fillMaxWidth().height(180.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(memberProfiles, key = { it.userId }) { user ->
MemberAvatarTile(user = user)
}
if (isOwner) {
item {
ActionTile(
icon = Icons.Default.Add,
label = "添加",
onClick = { showAddMemberDialog = true },
)
}
item {
ActionTile(
icon = Icons.Default.Remove,
label = "移除",
onClick = { showRemoveMemberDialog = true },
)
}
}
}
Spacer(Modifier.height(16.dp))
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))
@ -278,6 +417,8 @@ fun GroupSettingsScreen(
joinRequests.filter { it.status.equals("PENDING", ignoreCase = true) }.forEach { request -> joinRequests.filter { it.status.equals("PENDING", ignoreCase = true) }.forEach { request ->
JoinRequestRow( JoinRequestRow(
request = request, request = request,
requesterName = memberNameById[request.requesterId]?.nickname?.ifBlank { "未命名成员" }
?: "未命名成员",
onAccept = { viewModel.acceptJoinRequest(g.id, request.id) }, onAccept = { viewModel.acceptJoinRequest(g.id, request.id) },
onReject = { viewModel.rejectJoinRequest(g.id, request.id) }, onReject = { viewModel.rejectJoinRequest(g.id, request.id) },
) )
@ -291,27 +432,6 @@ fun GroupSettingsScreen(
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
Text("成员", style = MaterialTheme.typography.titleSmall)
val memberIds = g.memberIds.split(",").filter { it.isNotBlank() }
memberIds.forEach { memberId ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(memberId, modifier = Modifier.weight(1f))
if (g.creatorId == ImSDK.currentUserId && memberId != ImSDK.currentUserId) {
TextButton(onClick = { viewModel.setRole(g.id, memberId, "ADMIN") }) {
Text("设管")
}
TextButton(onClick = { viewModel.muteMember(g.id, memberId, 60) }) {
Text("禁言1h")
}
TextButton(onClick = { viewModel.removeMember(g.id, memberId) }) {
Text("移除", color = MaterialTheme.colorScheme.error)
}
}
}
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
if (isOwner) { if (isOwner) {
Button( Button(
@ -370,11 +490,195 @@ fun GroupSettingsScreen(
}, },
) )
} }
if (showAddMemberDialog && group != null) {
AddMemberDialog(
groupId = groupId,
group = group!!,
members = members,
onDismiss = { showAddMemberDialog = false },
onAdd = { userId ->
showAddMemberDialog = false
viewModel.addMember(groupId, userId)
},
)
}
if (showRemoveMemberDialog && group != null) {
RemoveMemberDialog(
groupId = groupId,
group = group!!,
members = members,
onDismiss = { showRemoveMemberDialog = false },
onRemove = { userId ->
showRemoveMemberDialog = false
viewModel.removeMember(groupId, userId)
},
)
}
}
@Composable
private fun MemberAvatarTile(user: UserData) {
val displayName = user.nickname.ifBlank { "未命名成员" }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!user.avatar.isNullOrBlank()) {
AsyncImage(
model = user.avatar,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clip(RoundedCornerShape(12.dp)),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
)
} else {
InitialAvatar(
text = displayName,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
)
}
Text(
text = displayName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
@Composable
private fun ActionTile(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) {
androidx.compose.material3.Surface(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
tonalElevation = 1.dp,
modifier = Modifier.fillMaxWidth().height(72.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(icon, contentDescription = label)
Text(label, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun AddMemberDialog(
groupId: String,
group: ImGroup,
members: List<UserData>,
onDismiss: () -> Unit,
onAdd: (String) -> Unit,
) {
var keyword by remember { mutableStateOf("") }
val groupMemberIds = remember(group.memberIds) {
group.memberIds.split(",").map { it.trim() }.filter { it.isNotBlank() }.toSet()
}
val candidates = remember(keyword, members, groupMemberIds) {
val normalized = keyword.trim()
members
.filter { it.userId != ImSDK.currentUserId }
.filter { it.userId !in groupMemberIds }
.filter {
normalized.isBlank() ||
it.userId.contains(normalized, ignoreCase = true) ||
it.nickname.contains(normalized, ignoreCase = true)
}
.sortedBy { it.userId }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("添加群成员") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = keyword,
onValueChange = { keyword = it },
label = { Text("搜索成员") },
placeholder = { Text("输入昵称或用户ID") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
LazyColumn(modifier = Modifier.height(280.dp)) {
items(candidates, key = { it.userId }) { user ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(user.nickname.ifBlank { "未命名成员" }, style = MaterialTheme.typography.titleSmall)
}
TextButton(onClick = { onAdd(user.userId) }) { Text("添加") }
}
HorizontalDivider()
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("关闭") }
},
)
}
@Composable
private fun RemoveMemberDialog(
groupId: String,
group: ImGroup,
members: List<UserData>,
onDismiss: () -> Unit,
onRemove: (String) -> Unit,
) {
val groupMemberIds = remember(group.memberIds) {
group.memberIds.split(",").map { it.trim() }.filter { it.isNotBlank() }.toSet()
}
val removable = remember(members, groupMemberIds) {
members
.filter { it.userId != ImSDK.currentUserId }
.filter { it.userId in groupMemberIds }
.sortedBy { it.userId }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("移除群成员") },
text = {
LazyColumn(modifier = Modifier.height(280.dp)) {
items(removable, key = { it.userId }) { user ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(user.nickname.ifBlank { "未命名成员" }, style = MaterialTheme.typography.titleSmall)
}
TextButton(onClick = { onRemove(user.userId) }) {
Text("移除", color = MaterialTheme.colorScheme.error)
}
}
HorizontalDivider()
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("关闭") }
},
)
} }
@Composable @Composable
private fun JoinRequestRow( private fun JoinRequestRow(
request: GroupJoinRequest, request: GroupJoinRequest,
requesterName: String,
onAccept: () -> Unit, onAccept: () -> Unit,
onReject: () -> Unit, onReject: () -> Unit,
) { ) {
@ -383,7 +687,7 @@ private fun JoinRequestRow(
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
Text(request.requesterId, style = MaterialTheme.typography.bodyMedium) Text(requesterName, style = MaterialTheme.typography.bodyMedium)
if (!request.remark.isNullOrBlank()) { if (!request.remark.isNullOrBlank()) {
Text( Text(
text = request.remark!!, text = request.remark!!,

查看文件

@ -5,15 +5,23 @@ 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.GroupJoinRequest
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.sample.data.api.UserData
import com.xuqm.sdk.sample.data.repo.AuthRepository
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
class GroupViewModel : ViewModel() { class GroupViewModel : ViewModel() {
private val authRepository: AuthRepository = AppDependencies.authRepository
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 _members = MutableStateFlow<List<UserData>>(emptyList())
val members: StateFlow<List<UserData>> = _members
private val _publicGroups = MutableStateFlow<List<ImGroup>>(emptyList()) private val _publicGroups = MutableStateFlow<List<ImGroup>>(emptyList())
val publicGroups: StateFlow<List<ImGroup>> = _publicGroups val publicGroups: StateFlow<List<ImGroup>> = _publicGroups
@ -26,15 +34,24 @@ class GroupViewModel : ViewModel() {
private val _joinRequests = MutableStateFlow<List<GroupJoinRequest>>(emptyList()) private val _joinRequests = MutableStateFlow<List<GroupJoinRequest>>(emptyList())
val joinRequests: StateFlow<List<GroupJoinRequest>> = _joinRequests val joinRequests: StateFlow<List<GroupJoinRequest>> = _joinRequests
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing
init { init {
loadGroups() loadGroups()
loadMembers()
searchPublicGroups("") searchPublicGroups("")
} }
fun loadMembers() {
viewModelScope.launch {
loadMembersInternal()
}
}
fun loadGroups() { fun loadGroups() {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.listGroups() } loadGroupsInternal()
.onSuccess { _groups.value = it }
} }
} }
@ -48,8 +65,20 @@ class GroupViewModel : ViewModel() {
fun searchPublicGroups(keyword: String) { fun searchPublicGroups(keyword: String) {
_publicGroupQuery.value = keyword _publicGroupQuery.value = keyword
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) } loadPublicGroupsInternal(keyword)
.onSuccess { _publicGroups.value = it } }
}
fun refresh() {
viewModelScope.launch {
_isRefreshing.value = true
try {
loadGroupsInternal()
loadMembersInternal()
loadPublicGroupsInternal(_publicGroupQuery.value)
} finally {
_isRefreshing.value = false
}
} }
} }
@ -74,6 +103,13 @@ class GroupViewModel : ViewModel() {
} }
} }
fun addMember(groupId: String, userId: String) {
viewModelScope.launch {
runCatching { ImSDK.addGroupMember(groupId, userId) }
.onSuccess { loadGroupInfo(groupId) }
}
}
fun muteMember(groupId: String, userId: String, minutes: Long) { fun muteMember(groupId: String, userId: String, minutes: Long) {
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) } runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) }
@ -132,4 +168,22 @@ class GroupViewModel : ViewModel() {
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) } .onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
} }
} }
private suspend fun loadMembersInternal() {
runCatching {
authRepository.listMembers().getOrDefault(emptyList())
}.onSuccess {
_members.value = it
}
}
private suspend fun loadGroupsInternal() {
runCatching { ImSDK.listGroups() }
.onSuccess { _groups.value = it }
}
private suspend fun loadPublicGroupsInternal(keyword: String) {
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
.onSuccess { _publicGroups.value = it }
}
} }

查看文件

@ -1,12 +1,12 @@
package com.xuqm.sdk.core package com.xuqm.sdk.core
data class ServiceEndpoints( data class ServiceEndpoints(
val controlBaseUrl: String = "https://dev.xuqinmin.com/", val controlBaseUrl: String = "http://192.168.116.9:8081/",
val imApiBaseUrl: String = "https://im.dev.xuqinmin.com/", val imApiBaseUrl: String = "http://192.168.116.9:8082/",
val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im", val imWsUrl: String = "ws://192.168.116.9:8082/ws/im",
val pushBaseUrl: String = "https://dev.xuqinmin.com/", val pushBaseUrl: String = "http://192.168.116.9:8083/",
val updateBaseUrl: String = "https://update.dev.xuqinmin.com/", val updateBaseUrl: String = "http://192.168.116.9:8084/",
val fileBaseUrl: String = "https://file.dev.xuqinmin.com/", val fileBaseUrl: String = "http://192.168.116.9:8086/",
) )
object ServiceEndpointRegistry { object ServiceEndpointRegistry {

查看文件

@ -2,19 +2,14 @@ package com.xuqm.sdk.file
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import okio.BufferedSink
import okio.source
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import java.util.Locale
data class FileUploadResult( data class FileUploadResult(
val url: String, val url: String,
@ -54,12 +49,12 @@ object FileSDK {
mimeType: String? = null, mimeType: String? = null,
thumbnailBytes: ByteArray? = null, thumbnailBytes: ByteArray? = null,
): FileUploadResult { ): FileUploadResult {
val resolvedName = displayName?.takeIf { it.isNotBlank() } ?: resolveDisplayName(context, uri) val resolvedName = displayName?.takeIf { it.isNotBlank() } ?: FileTransfer.resolveDisplayName(context, uri)
val resolvedMimeType = mimeType?.takeIf { it.isNotBlank() } ?: context.contentResolver.getType(uri) val resolvedMimeType = mimeType?.takeIf { it.isNotBlank() } ?: context.contentResolver.getType(uri)
val filePart = MultipartBody.Part.createFormData( val filePart = MultipartBody.Part.createFormData(
"file", "file",
resolvedName, resolvedName,
UriRequestBody(context, uri, resolvedMimeType), FileTransfer.createUriRequestBody(context, uri, resolvedMimeType),
) )
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { bytes -> val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { bytes ->
MultipartBody.Part.createFormData( MultipartBody.Part.createFormData(
@ -95,33 +90,4 @@ object FileSDK {
"File upload failed" "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")
}
}
} }

查看文件

@ -0,0 +1,67 @@
package com.xuqm.sdk.file
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import okio.BufferedSink
import okio.source
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import java.io.File
import java.net.URL
import java.util.Locale
object FileTransfer {
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())
}
fun createUriRequestBody(
context: Context,
uri: Uri,
mimeType: String?,
): RequestBody = object : 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")
}
}
fun downloadToFile(
downloadUrl: String,
targetFile: File,
onProgress: (Int) -> Unit = {},
) {
val connection = URL(downloadUrl).openConnection()
connection.connect()
val totalSize = connection.contentLengthLong
connection.getInputStream().use { input ->
targetFile.outputStream().use { output ->
val buffer = ByteArray(8192)
var downloaded = 0L
var read: Int
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
downloaded += read
if (totalSize > 0) {
onProgress((downloaded * 100 / totalSize).toInt())
}
}
}
}
}
}

查看文件

@ -1,18 +1,25 @@
package com.xuqm.sdk.network package com.xuqm.sdk.network
import android.util.Log
import com.xuqm.sdk.auth.TokenStore import com.xuqm.sdk.auth.TokenStore
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.core.LogLevel import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.SDKConfig import com.xuqm.sdk.core.SDKConfig
import okhttp3.Interceptor
import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.Response
import okio.Buffer
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object ApiClient { object ApiClient {
private const val TAG = "XuqmApi"
private const val MAX_LOG_BODY_BYTES = 1024 * 1024L
private var tokenStore: TokenStore? = null private var tokenStore: TokenStore? = null
private var okHttpClient: OkHttpClient? = null private var okHttpClient: OkHttpClient? = null
private val retrofitCache = mutableMapOf<String, Retrofit>() private val retrofitCache = mutableMapOf<String, Retrofit>()
@ -20,15 +27,14 @@ object ApiClient {
fun init(cfg: SDKConfig, store: TokenStore) { fun init(cfg: SDKConfig, store: TokenStore) {
tokenStore = store tokenStore = store
val logging = HttpLoggingInterceptor().apply {
level = if (cfg.logLevel == LogLevel.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
}
okHttpClient = OkHttpClient.Builder() okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(logging) .apply {
if (cfg.logLevel == LogLevel.DEBUG) {
addInterceptor(DebugLoggingInterceptor())
}
}
.addInterceptor { chain -> .addInterceptor { chain ->
val token = store.getToken() val token = store.getToken()
val req: Request = if (token != null) { val req: Request = if (token != null) {
@ -56,4 +62,89 @@ object ApiClient {
} }
return retrofit.create(service) return retrofit.create(service)
} }
private class DebugLoggingInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val startedNs = System.nanoTime()
logRequest(request)
val response = chain.proceed(request)
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNs)
Log.d(TAG, "<-- ${response.code} ${request.method} ${request.url} (${tookMs}ms)")
logResponseBody(response)
return response
}
private fun logRequest(request: Request) {
val body = request.body
if (body is MultipartBody) {
Log.d(TAG, "--> ${request.method} ${request.url}")
body.parts.forEachIndexed { index, part ->
Log.d(TAG, " ${describePart(index, part)}")
}
return
}
val bodyText = requestBodyAsString(body)
if (bodyText.isNullOrBlank()) {
Log.d(TAG, "--> ${request.method} ${request.url}")
} else {
Log.d(TAG, "--> ${request.method} ${request.url}\n$bodyText")
}
}
private fun requestBodyAsString(body: okhttp3.RequestBody?): String? {
if (body == null) return null
val contentType = body.contentType()?.toString().orEmpty()
if (contentType.startsWith("multipart/", ignoreCase = true)) return null
if (!isTextual(contentType)) return null
return runCatching {
val buffer = Buffer()
body.writeTo(buffer)
val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
buffer.readString(charset)
}.getOrNull()
}
private fun describePart(index: Int, part: MultipartBody.Part): String {
val headers = part.headers
val disposition = headers?.get("Content-Disposition").orEmpty()
val name = parseDispositionValue(disposition, "name").orEmpty().ifBlank { "-" }
val filename = parseDispositionValue(disposition, "filename").orEmpty().ifBlank { "-" }
val mimeType = part.body.contentType()?.toString().orEmpty().ifBlank { "-" }
val size = runCatching { part.body.contentLength() }.getOrDefault(-1L)
val sizeText = if (size >= 0) size.toString() else "unknown"
return "part[$index] name=$name filename=$filename contentType=$mimeType size=$sizeText"
}
private fun parseDispositionValue(disposition: String, key: String): String? {
val token = "$key=\""
val start = disposition.indexOf(token)
if (start < 0) return null
val from = start + token.length
val end = disposition.indexOf('"', from)
if (end < 0) return null
return disposition.substring(from, end)
}
private fun isTextual(contentType: String): Boolean {
return contentType.startsWith("application/json", ignoreCase = true) ||
contentType.startsWith("application/xml", ignoreCase = true) ||
contentType.startsWith("text/", ignoreCase = true) ||
contentType.contains("x-www-form-urlencoded", ignoreCase = true) ||
contentType.contains("json", ignoreCase = true)
}
private fun logResponseBody(response: Response) {
val body = response.body
val contentType = body.contentType()?.toString().orEmpty()
if (!isTextual(contentType)) return
val bodyText = runCatching {
response.peekBody(MAX_LOG_BODY_BYTES).string()
}.getOrNull()?.trim()
if (!bodyText.isNullOrBlank()) {
Log.d(TAG, " ${bodyText.replace('\n', ' ')}")
}
}
}
} }

查看文件

@ -3,13 +3,14 @@ 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 android.util.Log
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.net.URI
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class ImClient( class ImClient(
@ -17,8 +18,12 @@ class ImClient(
private val token: String, private val token: String,
private val appId: String, private val appId: String,
) { ) {
companion object {
private const val TAG = "XuqmImClient"
}
private var webSocket: WebSocket? = null private var webSocket: WebSocket? = null
private val listeners = CopyOnWriteArrayList<ImEventListener>() private val listeners = CopyOnWriteArraySet<ImEventListener>()
private val gson = Gson() private val gson = Gson()
private val subscriptions = mutableMapOf<String, String>() private val subscriptions = mutableMapOf<String, String>()
private var subscriptionSeed = 0 private var subscriptionSeed = 0
@ -31,26 +36,31 @@ class ImClient(
.build() .build()
fun connect() { fun connect() {
Log.d(TAG, "connect() wsUrl=$wsUrl appId=$appId")
disconnect(closeSocket = false) disconnect(closeSocket = false)
val request = Request.Builder() val request = Request.Builder()
.url(wsUrl) .url(wsUrl)
.build() .build()
webSocket = okhttp.newWebSocket(request, object : WebSocketListener() { webSocket = okhttp.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "websocket onOpen code=${response.code}")
sendConnectFrame() sendConnectFrame()
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "websocket raw frame received length=${text.length}")
handleIncoming(text) handleIncoming(text)
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
connected = false connected = false
Log.e(TAG, "websocket onFailure connected=false reason=${t.message}", t)
listeners.forEach { it.onDisconnected(t.message) } listeners.forEach { it.onDisconnected(t.message) }
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
connected = false connected = false
Log.d(TAG, "websocket onClosed code=$code reason=$reason")
listeners.forEach { it.onDisconnected(reason) } listeners.forEach { it.onDisconnected(reason) }
} }
}) })
@ -76,14 +86,17 @@ class ImClient(
} }
fun sendMessage( fun sendMessage(
messageId: String,
toId: String, toId: String,
chatType: String, chatType: String,
msgType: String, msgType: String,
content: String, content: String,
mentionedUserIds: String? = null, mentionedUserIds: String? = null,
) { ): Boolean {
Log.d(TAG, "sendMessage messageId=$messageId toId=$toId chatType=$chatType msgType=$msgType contentLength=${content.length} mentioned=${mentionedUserIds.orEmpty()}")
val payload = linkedMapOf( val payload = linkedMapOf(
"appId" to appId, "appId" to appId,
"messageId" to messageId,
"toId" to toId, "toId" to toId,
"chatType" to chatType, "chatType" to chatType,
"msgType" to msgType, "msgType" to msgType,
@ -92,7 +105,7 @@ class ImClient(
if (!mentionedUserIds.isNullOrBlank()) { if (!mentionedUserIds.isNullOrBlank()) {
payload["mentionedUserIds"] = mentionedUserIds payload["mentionedUserIds"] = mentionedUserIds
} }
sendFrame( return sendFrame(
"SEND", "SEND",
mapOf( mapOf(
"destination" to "/app/chat.send", "destination" to "/app/chat.send",
@ -127,6 +140,7 @@ class ImClient(
private fun sendConnectFrame() { private fun sendConnectFrame() {
connected = false connected = false
Log.d(TAG, "send CONNECT frame")
sendFrame( sendFrame(
"CONNECT", "CONNECT",
mapOf( mapOf(
@ -148,6 +162,7 @@ class ImClient(
val frame = inboundBuffer.substring(0, terminator) val frame = inboundBuffer.substring(0, terminator)
inboundBuffer = StringBuilder(inboundBuffer.substring(terminator + 1)) inboundBuffer = StringBuilder(inboundBuffer.substring(terminator + 1))
if (frame.isNotBlank()) { if (frame.isNotBlank()) {
Log.d(TAG, "stomp frame completed size=${frame.length}")
handleFrame(frame) handleFrame(frame)
} }
} }
@ -163,6 +178,7 @@ class ImClient(
when (command.uppercase()) { when (command.uppercase()) {
"CONNECTED" -> { "CONNECTED" -> {
connected = true connected = true
Log.d(TAG, "stomp CONNECTED subscriptionCount=${subscriptions.size}")
listeners.forEach { it.onConnected() } listeners.forEach { it.onConnected() }
sendSubscribe("/user/queue/messages", nextSubscriptionId(prefix = "user")) sendSubscribe("/user/queue/messages", nextSubscriptionId(prefix = "user"))
val pendingSubscriptions = synchronized(subscriptions) { subscriptions.toMap() } val pendingSubscriptions = synchronized(subscriptions) { subscriptions.toMap() }
@ -175,17 +191,31 @@ class ImClient(
"MESSAGE" -> { "MESSAGE" -> {
runCatching { runCatching {
val msg = gson.fromJson(body, ImMessage::class.java) val msg = gson.fromJson(body, ImMessage::class.java)
Log.d(
TAG,
buildString {
append("stomp MESSAGE destination=").append(headers["destination"].orEmpty())
append(" id=").append(msg.id)
append(" chatType=").append(msg.chatType)
append(" msgType=").append(msg.msgType)
append(" from=").append(msg.fromId)
append(" to=").append(msg.toId)
append(" status=").append(msg.status)
},
)
if (msg.chatType.uppercase() == "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) }
} }
}.onFailure { e -> }.onFailure { e ->
Log.e(TAG, "failed to parse MESSAGE frame body length=${body.length}", e)
listeners.forEach { it.onError("Parse error: ${e.message}") } listeners.forEach { it.onError("Parse error: ${e.message}") }
} }
} }
"ERROR" -> { "ERROR" -> {
val reason = body.ifBlank { headers["message"].orEmpty() } val reason = body.ifBlank { headers["message"].orEmpty() }
Log.e(TAG, "stomp ERROR reason=$reason")
listeners.forEach { it.onError(reason.ifBlank { "STOMP error" }) } listeners.forEach { it.onError(reason.ifBlank { "STOMP error" }) }
} }
} }
@ -202,8 +232,8 @@ class ImClient(
) )
} }
private fun sendFrame(command: String, headers: Map<String, String>, body: String?) { private fun sendFrame(command: String, headers: Map<String, String>, body: String?): Boolean {
val socket = webSocket ?: return val socket = webSocket ?: return false
val frame = buildString { val frame = buildString {
append(command).append('\n') append(command).append('\n')
headers.forEach { (key, value) -> headers.forEach { (key, value) ->
@ -215,7 +245,7 @@ class ImClient(
} }
append('\u0000') append('\u0000')
} }
socket.send(frame) return socket.send(frame)
} }
private fun parseHeaders(lines: List<String>): Map<String, String> { private fun parseHeaders(lines: List<String>): Map<String, String> {

查看文件

@ -21,29 +21,55 @@ import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.file.FileUploadResult import com.xuqm.sdk.file.FileUploadResult
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import android.util.Log
import kotlinx.coroutines.CoroutineScope
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.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.concurrent.CopyOnWriteArraySet
import java.util.UUID
object ImSDK { object ImSDK {
private const val TAG = "XuqmImSDK"
private var client: ImClient? = null private var client: ImClient? = null
private val api: ImApi get() = ApiClient.create(ImApi::class.java, ServiceEndpointRegistry.imApiBaseUrl) private val api: ImApi get() = ApiClient.create(ImApi::class.java, ServiceEndpointRegistry.imApiBaseUrl)
private const val MAX_RECONNECT_ATTEMPTS = 5
private val RECONNECT_BACKOFF_MS = longArrayOf(1_000L, 2_000L, 5_000L, 10_000L, 30_000L)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activeGroupSubscriptions = linkedSetOf<String>()
private val listeners = CopyOnWriteArraySet<ImEventListener>()
private var reconnectJob: Job? = null
private var reconnectAttempts = 0
@Volatile private var reconnectEnabled = false
@Volatile private var currentToken: String = ""
private val connectionListener = object : ImEventListener { private val connectionListener = object : ImEventListener {
override fun onConnected() { override fun onConnected() {
reconnectAttempts = 0
reconnectJob?.cancel()
reconnectJob = null
_connectionState.value = ImConnectionState.Connected _connectionState.value = ImConnectionState.Connected
resubscribeActiveGroups()
} }
override fun onDisconnected(reason: String?) { override fun onDisconnected(reason: String?) {
_connectionState.value = ImConnectionState.Disconnected(reason) _connectionState.value = ImConnectionState.Disconnected(reason)
scheduleReconnect(reason)
} }
override fun onError(error: String) { override fun onError(error: String) {
if (error.startsWith("Parse error", ignoreCase = true)) return
_connectionState.value = ImConnectionState.Disconnected(error) _connectionState.value = ImConnectionState.Disconnected(error)
scheduleReconnect(error)
} }
} }
private val _connectionState = MutableStateFlow<ImConnectionState>(ImConnectionState.Disconnected("未连接")) private val _connectionState = MutableStateFlow<ImConnectionState>(ImConnectionState.Disconnected("未连接"))
@ -84,8 +110,25 @@ object ImSDK {
msgType: String, msgType: String,
content: String, content: String,
mentionedUserIds: String? = null, mentionedUserIds: String? = null,
) { ): ImMessage {
client?.sendMessage(toId, chatType, msgType, content, mentionedUserIds) val message = buildOutgoingMessage(
messageId = UUID.randomUUID().toString(),
toId = toId,
chatType = chatType,
msgType = msgType,
content = content,
mentionedUserIds = mentionedUserIds,
)
val sent = client?.sendMessage(
messageId = message.id,
toId = toId,
chatType = chatType,
msgType = msgType,
content = content,
mentionedUserIds = mentionedUserIds,
) == true
Log.d(TAG, "sendMessage id=${message.id} toId=$toId chatType=$chatType msgType=$msgType contentLength=${content.length} mentioned=${mentionedUserIds.orEmpty()} sent=$sent")
return if (sent) message else message.copy(status = "FAILED")
} }
fun sendTextMessage( fun sendTextMessage(
@ -93,8 +136,8 @@ object ImSDK {
chatType: String, chatType: String,
content: String, content: String,
mentionedUserIds: String? = null, mentionedUserIds: String? = null,
) { ): ImMessage {
sendMessage(toId, chatType, "TEXT", content, mentionedUserIds) return sendMessage(toId, chatType, "TEXT", content, mentionedUserIds)
} }
fun sendImageMessage( fun sendImageMessage(
@ -103,8 +146,8 @@ object ImSDK {
file: FileUploadResult, file: FileUploadResult,
width: Int? = null, width: Int? = null,
height: Int? = null, height: Int? = null,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "IMAGE", msgType = "IMAGE",
@ -128,8 +171,8 @@ object ImSDK {
width: Int? = null, width: Int? = null,
height: Int? = null, height: Int? = null,
durationMs: Long? = null, durationMs: Long? = null,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "VIDEO", msgType = "VIDEO",
@ -151,8 +194,8 @@ object ImSDK {
toId: String, toId: String,
chatType: String, chatType: String,
file: FileUploadResult, file: FileUploadResult,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "FILE", msgType = "FILE",
@ -171,8 +214,8 @@ object ImSDK {
chatType: String, chatType: String,
file: FileUploadResult, file: FileUploadResult,
durationMs: Long? = null, durationMs: Long? = null,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "AUDIO", msgType = "AUDIO",
@ -194,8 +237,8 @@ object ImSDK {
longitude: Double, longitude: Double,
title: String? = null, title: String? = null,
address: String? = null, address: String? = null,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "LOCATION", msgType = "LOCATION",
@ -212,8 +255,8 @@ object ImSDK {
toId: String, toId: String,
chatType: String, chatType: String,
data: String, data: String,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "CUSTOM", msgType = "CUSTOM",
@ -227,8 +270,8 @@ object ImSDK {
toId: String, toId: String,
chatType: String, chatType: String,
html: String, html: String,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "RICH_TEXT", msgType = "RICH_TEXT",
@ -243,8 +286,8 @@ object ImSDK {
chatType: String, chatType: String,
originalSender: String, originalSender: String,
originalContent: String, originalContent: String,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "FORWARD", msgType = "FORWARD",
@ -259,16 +302,16 @@ object ImSDK {
toId: String, toId: String,
chatType: String, chatType: String,
action: String, action: String,
) { ): ImMessage {
sendCallSignalMessage(toId, chatType, "CALL_AUDIO", action) return sendCallSignalMessage(toId, chatType, "CALL_AUDIO", action)
} }
fun sendCallVideoMessage( fun sendCallVideoMessage(
toId: String, toId: String,
chatType: String, chatType: String,
action: String, action: String,
) { ): ImMessage {
sendCallSignalMessage(toId, chatType, "CALL_VIDEO", action) return sendCallSignalMessage(toId, chatType, "CALL_VIDEO", action)
} }
fun sendNotifyMessage( fun sendNotifyMessage(
@ -276,8 +319,8 @@ object ImSDK {
chatType: String, chatType: String,
title: String, title: String,
content: String, content: String,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "NOTIFY", msgType = "NOTIFY",
@ -294,8 +337,8 @@ object ImSDK {
quotedMsgId: String, quotedMsgId: String,
quotedContent: String, quotedContent: String,
text: String, text: String,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "QUOTE", msgType = "QUOTE",
@ -312,8 +355,8 @@ object ImSDK {
chatType: String, chatType: String,
title: String, title: String,
msgList: List<String>, msgList: List<String>,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = "MERGE", msgType = "MERGE",
@ -325,10 +368,18 @@ object ImSDK {
} }
fun subscribeGroup(groupId: String) { fun subscribeGroup(groupId: String) {
Log.d(TAG, "subscribeGroup groupId=$groupId")
synchronized(activeGroupSubscriptions) {
activeGroupSubscriptions.add(groupId)
}
client?.subscribe("/topic/group/$groupId") client?.subscribe("/topic/group/$groupId")
} }
fun unsubscribeGroup(groupId: String) { fun unsubscribeGroup(groupId: String) {
Log.d(TAG, "unsubscribeGroup groupId=$groupId")
synchronized(activeGroupSubscriptions) {
activeGroupSubscriptions.remove(groupId)
}
client?.unsubscribe("/topic/group/$groupId") client?.unsubscribe("/topic/group/$groupId")
} }
@ -486,8 +537,17 @@ object ImSDK {
suspend fun deleteConversation(targetId: String, chatType: String) = suspend fun deleteConversation(targetId: String, chatType: String) =
withContext(Dispatchers.IO) { api.deleteConversation(targetId, XuqmSDK.appId, chatType) } withContext(Dispatchers.IO) { api.deleteConversation(targetId, XuqmSDK.appId, chatType) }
fun addListener(listener: ImEventListener) = client?.addListener(listener) fun addListener(listener: ImEventListener) {
fun removeListener(listener: ImEventListener) = client?.removeListener(listener) Log.d(TAG, "addListener listener=${listener.javaClass.name}")
listeners.add(listener)
client?.addListener(listener)
}
fun removeListener(listener: ImEventListener) {
Log.d(TAG, "removeListener listener=${listener.javaClass.name}")
listeners.remove(listener)
client?.removeListener(listener)
}
fun disconnect() { fun disconnect() {
disconnectInternal(clearTokenStore = true) disconnectInternal(clearTokenStore = true)
@ -504,18 +564,33 @@ object ImSDK {
} }
private fun connectWithToken(token: String) { private fun connectWithToken(token: String) {
reconnectEnabled = false
reconnectJob?.cancel()
reconnectJob = null
XuqmSDK.tokenStore.saveToken(token) XuqmSDK.tokenStore.saveToken(token)
client?.disconnect() client?.disconnect()
_connectionState.value = ImConnectionState.Connecting _connectionState.value = ImConnectionState.Connecting
currentToken = token
Log.d(TAG, "connectWithToken userId=$currentUserId activeGroups=${activeGroupSubscriptions.size}")
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId) client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId)
client?.addListener(connectionListener) client?.addListener(connectionListener)
listeners.forEach { client?.addListener(it) }
reconnectEnabled = true
client?.connect() client?.connect()
} }
private fun disconnectInternal(clearTokenStore: Boolean) { private fun disconnectInternal(clearTokenStore: Boolean) {
reconnectEnabled = false
reconnectJob?.cancel()
reconnectJob = null
reconnectAttempts = 0
client?.disconnect() client?.disconnect()
client = null client = null
currentUserId = "" currentUserId = ""
currentToken = ""
synchronized(activeGroupSubscriptions) {
activeGroupSubscriptions.clear()
}
_connectionState.value = ImConnectionState.Disconnected("已断开") _connectionState.value = ImConnectionState.Disconnected("已断开")
if (clearTokenStore) { if (clearTokenStore) {
XuqmSDK.tokenStore.clear() XuqmSDK.tokenStore.clear()
@ -549,8 +624,8 @@ object ImSDK {
chatType: String, chatType: String,
msgType: String, msgType: String,
action: String, action: String,
) { ): ImMessage {
sendMessage( return sendMessage(
toId = toId, toId = toId,
chatType = chatType, chatType = chatType,
msgType = msgType, msgType = msgType,
@ -559,4 +634,56 @@ object ImSDK {
}.toString(), }.toString(),
) )
} }
private fun buildOutgoingMessage(
messageId: String,
toId: String,
chatType: String,
msgType: String,
content: String,
mentionedUserIds: String? = null,
): ImMessage {
return ImMessage(
id = messageId,
appId = XuqmSDK.appId,
fromId = currentUserId,
toId = toId,
chatType = chatType,
msgType = msgType,
content = content,
status = "SENDING",
mentionedUserIds = mentionedUserIds?.takeIf { it.isNotBlank() },
createdAt = System.currentTimeMillis(),
)
}
private fun scheduleReconnect(reason: String?) {
if (!reconnectEnabled || currentToken.isBlank()) return
if (reconnectJob?.isActive == true) return
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Log.w(TAG, "scheduleReconnect stop attempts=$reconnectAttempts reason=$reason")
return
}
val delayMs = RECONNECT_BACKOFF_MS[reconnectAttempts.coerceAtMost(RECONNECT_BACKOFF_MS.lastIndex)]
Log.w(TAG, "scheduleReconnect attempts=${reconnectAttempts + 1} delayMs=$delayMs reason=$reason")
reconnectJob = scope.launch {
delay(delayMs)
if (!reconnectEnabled || currentToken.isBlank()) return@launch
reconnectAttempts += 1
_connectionState.value = ImConnectionState.Connecting
Log.d(TAG, "reconnect attempt=$reconnectAttempts")
client = ImClient(ServiceEndpointRegistry.imWsUrl, currentToken, XuqmSDK.appId)
client?.addListener(connectionListener)
listeners.forEach { client?.addListener(it) }
client?.connect()
}
}
private fun resubscribeActiveGroups() {
val groups = synchronized(activeGroupSubscriptions) { activeGroupSubscriptions.toList() }
groups.forEach { groupId ->
client?.subscribe("/topic/group/$groupId")
}
}
} }

查看文件

@ -5,6 +5,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.file.FileTransfer
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.update.api.UpdateApi import com.xuqm.sdk.update.api.UpdateApi
@ -13,7 +14,6 @@ import com.xuqm.sdk.update.model.UpdateInfo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.net.URL
object UpdateSDK { object UpdateSDK {
@ -53,23 +53,7 @@ object UpdateSDK {
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val apkFile = File(context.getExternalFilesDir(null), "update.apk") val apkFile = File(context.getExternalFilesDir(null), "update.apk")
val url = URL(downloadUrl) FileTransfer.downloadToFile(downloadUrl, apkFile, onProgress)
val connection = url.openConnection()
connection.connect()
val totalSize = connection.contentLengthLong
connection.getInputStream().use { input ->
apkFile.outputStream().use { output ->
val buffer = ByteArray(8192)
var downloaded = 0L
var read: Int
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
downloaded += read
if (totalSize > 0) onProgress((downloaded * 100 / totalSize).toInt())
}
}
}
withContext(Dispatchers.Main) { installApk(context, apkFile) } withContext(Dispatchers.Main) { installApk(context, apkFile) }
} }