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()` 里切换:
```kotlin
XuqmSDK.useLocalServiceEndpoints("10.0.2.2") // Android Emulator
XuqmSDK.useLocalServiceEndpoints("192.168.116.9")
// 物理设备可改成你的电脑局域网 IP
```

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -7,14 +7,24 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.OpenableColumns
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.FileUploadResult
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImMessage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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) {
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)
suspend fun sendImageBytes(
@ -24,7 +34,7 @@ class AttachmentRepository(private val context: Context) {
bytes: ByteArray,
width: Int? = null,
height: Int? = null,
): Result<Unit> = withContext(Dispatchers.IO) {
): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
runCatching {
val upload = FileSDK.uploadBytes(
fileName = fileName,
@ -37,17 +47,19 @@ class AttachmentRepository(private val context: Context) {
file = upload,
width = width,
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)
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)
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)
suspend fun sendAudioBytes(
@ -56,7 +68,7 @@ class AttachmentRepository(private val context: Context) {
fileName: String,
bytes: ByteArray,
durationMs: Long? = null,
): Result<Unit> = withContext(Dispatchers.IO) {
): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
runCatching {
val upload = FileSDK.uploadBytes(
fileName = fileName,
@ -68,7 +80,9 @@ class AttachmentRepository(private val context: Context) {
chatType = chatType,
file = upload,
durationMs = durationMs,
)
).let { sent ->
PreparedAttachment(upload = upload, message = sent, durationMs = durationMs)
}
}
}
@ -77,7 +91,7 @@ class AttachmentRepository(private val context: Context) {
chatType: String,
uri: Uri,
kind: MediaKind,
): Result<Unit> = withContext(Dispatchers.IO) {
): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
runCatching {
val meta = resolveMeta(uri)
val thumbnailBytes = when (kind) {
@ -93,7 +107,7 @@ class AttachmentRepository(private val context: Context) {
mimeType = meta.mimeType,
thumbnailBytes = thumbnailBytes,
)
when (kind) {
val sent = when (kind) {
MediaKind.IMAGE -> ImSDK.sendImageMessage(
toId = targetId,
chatType = chatType,
@ -121,6 +135,13 @@ class AttachmentRepository(private val context: Context) {
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.withContext
import kotlinx.coroutines.CoroutineScope
import org.json.JSONObject
import retrofit2.HttpException
import java.util.concurrent.atomic.AtomicBoolean
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)
}
}
.mapFailureMessage(::toChineseLoginMessage)
suspend fun register(userId: String, password: String, nickname: String): Result<UserData> =
withContext(Dispatchers.IO) {
@ -122,12 +125,18 @@ class AuthRepository(context: Context) {
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
}
}
.mapFailureMessage(::toChineseLoginMessage)
suspend fun getProfile(): Result<UserData> =
withContext(Dispatchers.IO) {
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> =
withContext(Dispatchers.IO) {
runCatching {
@ -215,4 +224,33 @@ class AuthRepository(context: Context) {
companion object {
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(
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
val host: String = "10.0.2.2",
val host: String = "192.168.116.9",
)
class EnvironmentRepository(context: Context) {
@ -25,7 +25,7 @@ class EnvironmentRepository(context: Context) {
}
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))
SampleEnvironmentConfig.useLocalhost(normalizedHost)
}
@ -34,7 +34,7 @@ class EnvironmentRepository(context: Context) {
val mode = runCatching {
EnvironmentMode.valueOf(prefs.getString(KEY_MODE, EnvironmentMode.EXTERNAL.name)!!)
}.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)
}

查看文件

@ -1,6 +1,7 @@
package com.xuqm.sdk.sample.data.repo
import android.content.Context
import android.os.Build
import android.media.MediaRecorder
import android.os.SystemClock
import java.io.File
@ -24,7 +25,7 @@ class VoiceRecorder(private val context: Context) {
val file = File(dir, "voice_${System.currentTimeMillis()}.m4a")
outputFile = file
return runCatching {
recorder = MediaRecorder().apply {
recorder = createMediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
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? {
val current = recorder ?: return null
val file = outputFile ?: return null

查看文件

@ -1,14 +1,19 @@
package com.xuqm.sdk.sample.di
import android.content.Context
import android.util.Log
import com.xuqm.sdk.sample.data.repo.AuthRepository
import com.xuqm.sdk.sample.data.repo.AttachmentRepository
import com.xuqm.sdk.sample.data.local.LocalContactCache
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
import com.xuqm.sdk.sample.data.local.LocalImCache
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
object AppDependencies {
private const val TAG = "XuqmAppDeps"
lateinit var authRepository: AuthRepository
private set
lateinit var environmentRepository: EnvironmentRepository
@ -20,6 +25,9 @@ object AppDependencies {
lateinit var attachmentRepository: AttachmentRepository
private set
private val _conversationRefreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val conversationRefreshRequests = _conversationRefreshRequests.asSharedFlow()
fun init(context: Context) {
environmentRepository = EnvironmentRepository(context)
environmentRepository.current()
@ -28,4 +36,9 @@ object AppDependencies {
attachmentRepository = AttachmentRepository(context.applicationContext)
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.viewModelFactory
import com.xuqm.sdk.sample.di.AppDependencies
import android.widget.Toast
@Composable
fun LoginScreen(
@ -44,12 +46,16 @@ fun LoginScreen(
),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var userId by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
LaunchedEffect(state) {
if (state is LoginState.Success) onLoginSuccess()
if (state is LoginState.Error) {
Toast.makeText(context, (state as LoginState.Error).message, Toast.LENGTH_SHORT).show()
}
}
Column(

查看文件

@ -3,6 +3,7 @@ package com.xuqm.sdk.sample.ui.chat
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -49,9 +49,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Box
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@ -71,6 +72,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.io.File
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -86,7 +89,7 @@ fun ChatScreen(
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
val draftText by viewModel.draftText.collectAsStateWithLifecycle()
val mentionableUserIds by viewModel.mentionableUserIds.collectAsStateWithLifecycle()
val mentionableUsers by viewModel.mentionableUsers.collectAsStateWithLifecycle()
val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle()
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
@ -97,6 +100,7 @@ fun ChatScreen(
val context = LocalContext.current
val voiceRecorder = remember { VoiceRecorder(context.applicationContext) }
val coroutineScope = rememberCoroutineScope()
var draftValue by remember { mutableStateOf(TextFieldValue(draftText)) }
var showSearchBar by remember { mutableStateOf(false) }
val replyTarget = replyTargetMessage
var pendingCameraAction by remember { mutableStateOf<CameraAction?>(null) }
@ -175,6 +179,14 @@ fun ChatScreen(
}
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) {
if (messages.isNotEmpty() && searchQuery.isBlank()) {
listState.animateScrollToItem(0)
@ -234,16 +246,38 @@ fun ChatScreen(
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (chatType == "GROUP" && mentionableUserIds.isNotEmpty()) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("提及", style = MaterialTheme.typography.labelSmall)
mentionableUserIds.take(6).forEach { userId ->
TextButton(onClick = { viewModel.appendMention(userId) }) {
Text("@$userId")
if (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(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("提及", style = MaterialTheme.typography.labelSmall)
mentionCandidates.forEach { user ->
TextButton(
onClick = {
draftValue = insertMentionAtCursor(draftValue, user.nickname)
viewModel.updateDraft(draftValue.text)
},
) {
Text("@${user.nickname.ifBlank { "成员" }}")
}
}
}
}
}
@ -270,16 +304,27 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draftText,
onValueChange = viewModel::updateDraft,
modifier = Modifier.weight(1f),
value = draftValue,
onValueChange = { next ->
draftValue = next
viewModel.updateDraft(next.text)
},
placeholder = { Text("输入消息…") },
maxLines = 4,
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(
onClick = { viewModel.sendText(draftText) },
enabled = draftText.isNotBlank(),
onClick = { viewModel.sendText(draftValue.text) },
enabled = draftValue.text.isNotBlank(),
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
}
@ -719,3 +764,55 @@ private fun formatDuration(ms: Long): String {
val seconds = totalSeconds % 60
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
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.ImMessage
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.model.previewText
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
class ChatViewModel : ViewModel() {
@ -41,74 +46,30 @@ class ChatViewModel : ViewModel() {
private val _replyTargetMessage = MutableStateFlow<ImMessage?>(null)
val replyTargetMessage: StateFlow<ImMessage?> = _replyTargetMessage
private val _mentionableUserIds = MutableStateFlow<List<String>>(emptyList())
val mentionableUserIds: StateFlow<List<String>> = _mentionableUserIds
private val _mentionableUsers = MutableStateFlow<List<UserData>>(emptyList())
val mentionableUsers: StateFlow<List<UserData>> = _mentionableUsers
private val _isSendingAttachment = MutableStateFlow(false)
val isSendingAttachment: StateFlow<Boolean> = _isSendingAttachment
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 1)
val events: SharedFlow<String> = _events.asSharedFlow()
val currentUserId: String get() = ImSDK.currentUserId
private lateinit var targetId: String
private lateinit var chatType: String
private var nextHistoryPage = 0
private var initialized = false
private val pendingDeliveryTimeouts = mutableMapOf<String, kotlinx.coroutines.Job>()
private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) {
if (isRelevant(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) }
}
}
}
handleIncomingMessage(message)
}
override fun onGroupMessage(message: ImMessage) {
if (isRelevant(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) }
}
}
}
handleIncomingMessage(message)
}
}
@ -127,7 +88,7 @@ class ChatViewModel : ViewModel() {
_searchQuery.value = ""
_searchResults.value = emptyList()
_draftText.value = cache.loadDraft(targetId, chatType)
_mentionableUserIds.value = emptyList()
_mentionableUsers.value = emptyList()
_replyTargetMessage.value = null
ImSDK.addListener(listener)
if (chatType == "GROUP") {
@ -158,16 +119,16 @@ class ChatViewModel : ViewModel() {
val page = if (replace) 0 else nextHistoryPage
val history = fetchHistory(page)
if (replace) {
_messages.value = history
_messages.value = mergeMessages(_messages.value, history)
nextHistoryPage = 1
requestScrollToBottom()
} else if (history.isNotEmpty()) {
_messages.value = (_messages.value + history).distinctBy { it.id }
_messages.value = mergeMessages(_messages.value, history)
nextHistoryPage += 1
}
_hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE
if (history.isNotEmpty()) {
cache.saveHistory(targetId, chatType, mergeHistory(_messages.value))
cache.mergeHistory(targetId, chatType, _messages.value)
history.firstOrNull()?.let { last ->
cache.upsertConversation(
ConversationData(
@ -198,7 +159,7 @@ class ChatViewModel : ViewModel() {
if (remote != null) {
val normalized = remote.sortedByDescending { it.createdAt }
if (page == 0) {
cache.saveHistory(targetId, chatType, normalized)
cache.mergeHistory(targetId, chatType, normalized)
} else {
cache.mergeHistory(targetId, chatType, normalized)
}
@ -210,7 +171,7 @@ class ChatViewModel : ViewModel() {
fun sendText(content: String) {
if (content.isBlank()) return
val replyTarget = _replyTargetMessage.value
if (replyTarget != null) {
val sent = if (replyTarget != null) {
ImSDK.sendQuoteMessage(
toId = targetId,
chatType = chatType,
@ -218,24 +179,18 @@ class ChatViewModel : ViewModel() {
quotedContent = replyTarget.previewText(),
text = content,
)
_replyTargetMessage.value = null
} else {
ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content))
}
_draftText.value = ""
cache.clearDraft(targetId, chatType)
cache.upsertConversation(
ConversationData(
targetId = targetId,
chatType = chatType,
lastMsgContent = content,
lastMsgType = "TEXT",
lastMsgTime = System.currentTimeMillis(),
unreadCount = 0,
isMuted = false,
isPinned = false,
)
)
appendOutgoingMessage(sent)
if (sent.status.uppercase() == "FAILED") {
_events.tryEmit("消息发送失败,请检查网络后重试")
} else {
trackPendingDelivery(sent.id)
_draftText.value = ""
cache.clearDraft(targetId, chatType)
_replyTargetMessage.value = null
}
}
fun searchCachedMessages(query: String) {
@ -251,9 +206,6 @@ class ChatViewModel : ViewModel() {
_draftText.value = text
if (initialized) {
cache.saveDraft(targetId, chatType, text)
viewModelScope.launch {
runCatching { ImSDK.setDraft(targetId, chatType, text) }
}
}
}
@ -288,19 +240,15 @@ class ChatViewModel : ViewModel() {
bytes = recording.bytes,
durationMs = recording.durationMs,
).getOrThrow()
}.onSuccess {
cache.upsertConversation(
ConversationData(
targetId = targetId,
chatType = chatType,
lastMsgContent = "[语音]",
lastMsgType = "AUDIO",
lastMsgTime = System.currentTimeMillis(),
unreadCount = 0,
isMuted = false,
isPinned = false,
)
)
}.onSuccess { sent ->
appendOutgoingMessage(sent.message)
if (sent.message.status.uppercase() == "FAILED") {
_events.tryEmit("语音发送失败,请检查网络后重试")
} else {
trackPendingDelivery(sent.message.id)
}
}.onFailure {
_events.tryEmit("语音发送失败,请检查网络后重试")
}
} finally {
_isSendingAttachment.value = false
@ -315,17 +263,6 @@ class ChatViewModel : ViewModel() {
_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> =
messages.distinctBy { it.id }.sortedByDescending { it.createdAt }
@ -333,31 +270,35 @@ class ChatViewModel : ViewModel() {
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
}
fun appendMention(userId: String) {
fun appendMention(user: UserData) {
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)
}
private fun loadMentionableUsers() {
viewModelScope.launch {
runCatching { ImSDK.getGroupInfo(targetId) }
.map { group ->
group?.memberIds.orEmpty()
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
}
.onSuccess { _mentionableUserIds.value = it }
runCatching {
val groupMemberIds = ImSDK.getGroupInfo(targetId)
?.memberIds.orEmpty()
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
.toSet()
AppDependencies.authRepository.listMembers().getOrDefault(emptyList())
.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? {
if (chatType != "GROUP") return null
val ids = _mentionableUserIds.value.filter { mentionId ->
content.contains("@$mentionId")
}
val ids = _mentionableUsers.value.filter { user ->
content.contains("@${user.nickname}")
}.map { it.userId }
return ids.joinToString(",").takeIf { it.isNotBlank() }
}
@ -373,6 +314,15 @@ class ChatViewModel : ViewModel() {
AttachmentKind.AUDIO -> AppDependencies.attachmentRepository.sendAudio(targetId, chatType, uri)
AttachmentKind.FILE -> AppDependencies.attachmentRepository.sendFile(targetId, chatType, uri)
}.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 {
_isSendingAttachment.value = false
@ -400,9 +350,18 @@ class ChatViewModel : ViewModel() {
bytes = bytes,
width = width,
height = height,
)
).getOrThrow()
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 {
_isSendingAttachment.value = false
@ -412,23 +371,147 @@ class ChatViewModel : ViewModel() {
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 {
return if (chatType == "GROUP") {
val relevant = if (chatType == "GROUP") {
message.chatType == "GROUP" && message.toId == targetId
} else {
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() {
if (initialized && chatType == "GROUP") {
ImSDK.unsubscribeGroup(targetId)
}
pendingDeliveryTimeouts.values.forEach { it.cancel() }
pendingDeliveryTimeouts.clear()
ImSDK.removeListener(listener)
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 {
const val TAG = "XuqmChatViewModel"
const val HISTORY_PAGE_SIZE = 20
}
}

查看文件

@ -1,6 +1,6 @@
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.Row
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.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
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.FriendRequest
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.di.AppDependencies
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import org.json.JSONObject
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.xuqm.sdk.ui.SearchBarField
@ -43,9 +55,16 @@ class ContactViewModel(
) : ViewModel() {
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())
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())
val searchResults: StateFlow<List<UserData>> = _searchResults
@ -55,47 +74,74 @@ class ContactViewModel(
private val _blacklist = MutableStateFlow<List<BlacklistEntry>>(emptyList())
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 {
_friends.value = cache.resolveFriends(cache.loadFriendIds())
loadFriends()
loadFriendRequests()
loadBlacklist()
ImSDK.addListener(listener)
refresh()
}
fun loadMembers() {
viewModelScope.launch {
loadMembersInternal()
}
}
fun loadFriends() {
viewModelScope.launch {
runCatching {
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
loadFriendsInternal()
}
}
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) {
if (keyword.isBlank()) { _searchResults.value = emptyList(); return }
_searchResults.value = cache.searchProfiles(keyword)
viewModelScope.launch {
authRepository.searchUsers(keyword)
.onSuccess { list ->
_searchResults.value = list
cache.saveProfiles(list)
}
if (keyword.isBlank()) {
_searchResults.value = emptyList()
return
}
_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 {
runCatching { ImSDK.sendFriendRequest(userId) }
.onSuccess { loadFriends() }
runCatching { ImSDK.sendFriendRequest(userId, remark) }
.onSuccess {
_events.tryEmit("好友申请已发送")
}
.onFailure {
_events.tryEmit("好友申请发送失败,请检查网络后重试")
}
}
}
@ -108,29 +154,27 @@ class ContactViewModel(
fun loadFriendRequests() {
viewModelScope.launch {
runCatching { ImSDK.listFriendRequests() }
.onSuccess { _friendRequests.value = it }
loadFriendRequestsInternal()
}
}
fun acceptFriendRequest(requestId: String) {
viewModelScope.launch {
runCatching { ImSDK.acceptFriendRequest(requestId) }
.onSuccess { loadFriends(); loadFriendRequests() }
.onSuccess { refresh() }
}
}
fun rejectFriendRequest(requestId: String) {
viewModelScope.launch {
runCatching { ImSDK.rejectFriendRequest(requestId) }
.onSuccess { loadFriendRequests() }
.onSuccess { refresh() }
}
}
fun loadBlacklist() {
viewModelScope.launch {
runCatching { ImSDK.listBlacklist() }
.onSuccess { _blacklist.value = it }
loadBlacklistInternal()
}
}
@ -147,8 +191,69 @@ class ContactViewModel(
.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
fun ContactScreen(
onOpenChat: (userId: String) -> Unit,
@ -159,130 +264,236 @@ fun ContactScreen(
),
) {
val friends by viewModel.friends.collectAsStateWithLifecycle()
val members by viewModel.members.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
val friendRequests by viewModel.friendRequests.collectAsStateWithLifecycle()
val blacklist by viewModel.blacklist.collectAsStateWithLifecycle()
val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val context = LocalContext.current
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
Column(modifier = Modifier.fillMaxSize()) {
SearchBarField(
value = keyword,
onValueChange = { keyword = it; viewModel.search(it) },
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
placeholder = "搜索用户 ID 或昵称",
)
if (friendRequests.isNotEmpty()) {
Text(
"好友申请(${friendRequests.size}",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline,
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize(),
) {
Column(modifier = Modifier.fillMaxSize()) {
SearchBarField(
value = keyword,
onValueChange = { keyword = it; viewModel.search(it) },
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
placeholder = "搜索用户 ID 或昵称",
)
LazyColumn {
items(friendRequests, key = { it.id }) { request ->
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(request.fromUserId, style = MaterialTheme.typography.titleSmall)
Text(request.remark.orEmpty(), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline)
}
TextButton(onClick = { viewModel.acceptFriendRequest(request.id) }) { Text("接受") }
TextButton(onClick = { viewModel.rejectFriendRequest(request.id) }) { Text("拒绝") }
}
HorizontalDivider()
}
}
}
if (keyword.isBlank()) {
Text(
"联系人(${friends.size}",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline,
)
LazyColumn {
items(friends, key = { it.userId }) { user ->
FriendItem(
userId = user.userId,
nickname = user.nickname,
onChat = { onOpenChat(user.userId) },
onRemove = { viewModel.removeFriend(user.userId) },
)
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()) {
Text(
"黑名单(${blacklist.size}",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline,
androidx.compose.material3.PrimaryTabRow(selectedTabIndex = selectedTab) {
androidx.compose.material3.Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text("好友") },
)
LazyColumn {
items(blacklist, key = { it.id }) { entry ->
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(entry.blockedUserId, modifier = Modifier.weight(1f))
TextButton(onClick = { viewModel.removeFromBlacklist(entry.blockedUserId) }) {
Text("移除")
androidx.compose.material3.Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text("全部联系人") },
)
}
when (selectedTab) {
0 -> {
if (friendRequests.isNotEmpty()) {
Text(
"好友申请(${friendRequests.size}",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline,
)
LazyColumn {
items(friendRequests, key = { it.id }) { request ->
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(request.fromUserId, style = MaterialTheme.typography.titleSmall)
Text(
request.remark.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
TextButton(onClick = { viewModel.acceptFriendRequest(request.id) }) { Text("接受") }
TextButton(onClick = { viewModel.rejectFriendRequest(request.id) }) { Text("拒绝") }
}
HorizontalDivider()
}
}
}
Text(
"好友(${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,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline,
)
LazyColumn {
items(visibleContacts, key = { it.userId }) { user ->
MemberItem(
user = user,
isFriend = friends.any { it.userId == user.userId },
onChat = { onOpenChat(user.userId) },
onAddFriend = {
pendingFriendRequestUser = user
friendRequestRemark = ""
},
onRemoveFriend = { viewModel.removeFriend(user.userId) },
onBlacklist = { viewModel.addToBlacklist(user.userId) },
)
HorizontalDivider()
}
}
if (blacklist.isNotEmpty()) {
Text(
"黑名单(${blacklist.size}",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outline,
)
LazyColumn {
items(blacklist, key = { it.id }) { entry ->
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(entry.blockedUserId, modifier = Modifier.weight(1f))
TextButton(onClick = { viewModel.removeFromBlacklist(entry.blockedUserId) }) {
Text("移除")
}
}
HorizontalDivider()
}
}
HorizontalDivider()
}
}
}
}
}
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
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(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onChat)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(nickname, style = MaterialTheme.typography.titleSmall)
Text(userId, style = MaterialTheme.typography.bodySmall,
Text(user.nickname, style = MaterialTheme.typography.titleSmall)
Text(user.userId, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline)
}
TextButton(onClick = onRemove) {
Text("删除", color = MaterialTheme.colorScheme.error)
if (isFriend) {
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Badge
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -39,13 +41,16 @@ import com.xuqm.sdk.ui.SearchBarField
import com.xuqm.sdk.utils.TimeFormatters
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConversationScreen(
onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit,
viewModel: ConversationViewModel = viewModel(),
) {
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
val conversationTitles by viewModel.conversationTitles.collectAsStateWithLifecycle()
val totalUnreadCount by viewModel.totalUnreadCount.collectAsStateWithLifecycle()
val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
val filtered = remember(conversations, query) {
@ -59,7 +64,12 @@ fun ConversationScreen(
}
}
Column(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize(),
) {
Column(modifier = Modifier.fillMaxSize()) {
Text(
"总未读 $totalUnreadCount",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
@ -87,9 +97,11 @@ fun ConversationScreen(
.weight(1f),
) {
items(filtered, key = { "${it.chatType}_${it.targetId}" }) { conv ->
val title = conversationTitle(conv, conversationTitles)
ConversationItem(
conversation = conv,
onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) },
title = title,
onClick = { onOpenChat(conv.targetId, conv.chatType, title) },
onPinToggle = {
scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) }
},
@ -104,6 +116,7 @@ fun ConversationScreen(
}
}
}
}
}
}
@ -111,6 +124,7 @@ fun ConversationScreen(
@Composable
private fun ConversationItem(
conversation: ConversationData,
title: String,
onClick: () -> Unit,
onPinToggle: () -> Unit,
onMuteToggle: () -> Unit,
@ -126,14 +140,14 @@ private fun ConversationItem(
.padding(horizontal = 16.dp, vertical = 10.dp),
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))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
conversation.targetId,
title,
style = MaterialTheme.typography.titleSmall,
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 {
return when (conversation.lastMsgType?.uppercase()) {
"TEXT" -> conversation.lastMsgContent.orEmpty()

查看文件

@ -1,5 +1,6 @@
package com.xuqm.sdk.sample.ui.conversation
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.ConversationData
import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.sample.di.AppDependencies
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -14,16 +16,39 @@ import kotlinx.coroutines.launch
class ConversationViewModel : ViewModel() {
companion object {
private const val TAG = "XuqmConversationVM"
}
private val cache = AppDependencies.localImCache
private val _conversations = MutableStateFlow<List<ConversationData>>(emptyList())
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)
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing
private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) { refresh() }
override fun onGroupMessage(message: ImMessage) { refresh() }
override fun onMessage(message: ImMessage) {
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 {
@ -31,6 +56,11 @@ class ConversationViewModel : ViewModel() {
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
refresh()
viewModelScope.launch {
AppDependencies.conversationRefreshRequests.collect {
refresh()
}
}
viewModelScope.launch {
ImSDK.connectionState.collect { state ->
if (state is ImConnectionState.Connected) {
@ -41,21 +71,29 @@ class ConversationViewModel : ViewModel() {
}
fun refresh() {
Log.d(TAG, "refresh() called")
viewModelScope.launch {
runCatching { ImSDK.listConversations() }
.onSuccess { list ->
val sorted = list.sortedByDescending { it.lastMsgTime }
cache.saveConversations(sorted)
_conversations.value = sorted
_totalUnreadCount.value = sorted.sumOf { it.unreadCount }
}
.onFailure {
val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime }
if (cached.isNotEmpty()) {
_conversations.value = cached
_totalUnreadCount.value = cached.sumOf { it.unreadCount }
}
_isRefreshing.value = true
try {
val conversations = runCatching { ImSDK.listConversations() }.getOrDefault(emptyList())
val groups = runCatching { ImSDK.listGroups() }.getOrDefault(emptyList())
val groupMap = groups.associateBy({ it.id }, { it.name })
val merged = mergeGroupConversations(conversations, groups).sortedByDescending { it.lastMsgTime }
Log.d(TAG, "refresh success conversationCount=${merged.size} groupCount=${groups.size}")
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 }
if (cached.isNotEmpty()) {
_conversations.value = cached
_totalUnreadCount.value = cached.sumOf { it.unreadCount }
}
} finally {
_isRefreshing.value = false
}
}
}
@ -81,4 +119,27 @@ class ConversationViewModel : ViewModel() {
override fun onCleared() {
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(
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
val host: String = "10.0.2.2",
val host: String = "192.168.116.9",
val message: String? = null,
)
@ -133,15 +133,15 @@ fun EnvironmentScreen(
Text(
text = if (state.mode == EnvironmentMode.EXTERNAL) {
"当前使用外网服务dev.xuqinmin.com"
"当前使用开发服务http://192.168.116.9:8085"
} else {
"当前使用本地服务http://${state.host}:8081"
"当前使用本地服务http://${state.host}:8085"
},
style = MaterialTheme.typography.bodyMedium,
)
EnvironmentModeRow(
title = "外网服务",
title = "开发服务",
selected = state.mode == EnvironmentMode.EXTERNAL,
description = "走线上 / 开发服务器,适合正常联调。",
onClick = viewModel::selectExternal,
@ -162,7 +162,7 @@ fun EnvironmentScreen(
},
modifier = Modifier.fillMaxWidth(),
label = { Text("本地 Host") },
placeholder = { Text("10.0.2.2 或你的电脑局域网 IP") },
placeholder = { Text("192.168.116.9 或你的电脑局域网 IP") },
singleLine = true,
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -27,6 +32,9 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -34,16 +42,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Alignment
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.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.ui.SearchBarField
import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.GroupJoinRequest
import com.xuqm.sdk.sample.data.api.UserData
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
import com.xuqm.sdk.ui.InitialAvatar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -53,8 +67,10 @@ fun GroupListScreen(
viewModel: GroupViewModel = viewModel(),
) {
val groups by viewModel.groups.collectAsStateWithLifecycle()
val members by viewModel.members.collectAsStateWithLifecycle()
val publicGroups by viewModel.publicGroups.collectAsStateWithLifecycle()
val publicGroupQuery by viewModel.publicGroupQuery.collectAsStateWithLifecycle()
val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) }
Scaffold(
@ -64,9 +80,14 @@ fun GroupListScreen(
}
},
) { padding ->
LazyColumn(
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize().padding(padding),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
item {
SearchBarField(
value = publicGroupQuery,
@ -108,10 +129,12 @@ fun GroupListScreen(
}
}
}
}
}
if (showCreateDialog) {
CreateGroupDialog(
members = members,
onDismiss = { showCreateDialog = false },
onCreate = { name, memberIds, groupType ->
showCreateDialog = false
@ -171,39 +194,108 @@ private fun PublicGroupItem(group: ImGroup, onOpen: () -> Unit, onJoin: () -> Un
}
@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 memberInput by remember { mutableStateOf("") }
var keyword by remember { mutableStateOf("") }
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(
onDismissRequest = onDismiss,
title = { Text("创建群组") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("群名称") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = memberInput,
onValueChange = { memberInput = it },
label = { Text("成员 ID逗号分隔") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
TextButton(onClick = { isPublicGroup = !isPublicGroup }) {
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 = {
Button(
onClick = {
val members = memberInput.split(",").map { it.trim() }.filter { it.isNotBlank() }
val allMembers = (members + listOf(ImSDK.currentUserId)).distinct()
onCreate(name.trim(), allMembers, if (isPublicGroup) "PUBLIC" else "WORK")
onCreate(
name.trim(),
selectedIds.distinct(),
if (isPublicGroup) "PUBLIC" else "WORK",
)
},
enabled = name.isNotBlank(),
) { Text("创建") }
@ -220,11 +312,27 @@ fun GroupSettingsScreen(
viewModel: GroupViewModel = viewModel(),
) {
val group by viewModel.currentGroup.collectAsStateWithLifecycle()
val members by viewModel.members.collectAsStateWithLifecycle()
val joinRequests by viewModel.joinRequests.collectAsStateWithLifecycle()
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
var showEditDialog by remember { mutableStateOf(false) }
var showAddMemberDialog by remember { mutableStateOf(false) }
var showRemoveMemberDialog by remember { mutableStateOf(false) }
var editName 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(group) {
@ -241,7 +349,7 @@ fun GroupSettingsScreen(
topBar = {
androidx.compose.foundation.layout.Column {
TopAppBar(
title = { Text(group?.name ?: "群设置") },
title = { Text("聊天信息(${memberProfiles.size})") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@ -260,6 +368,37 @@ fun GroupSettingsScreen(
) {
group?.let { g ->
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,
color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(8.dp))
@ -278,6 +417,8 @@ fun GroupSettingsScreen(
joinRequests.filter { it.status.equals("PENDING", ignoreCase = true) }.forEach { request ->
JoinRequestRow(
request = request,
requesterName = memberNameById[request.requesterId]?.nickname?.ifBlank { "未命名成员" }
?: "未命名成员",
onAccept = { viewModel.acceptJoinRequest(g.id, request.id) },
onReject = { viewModel.rejectJoinRequest(g.id, request.id) },
)
@ -291,27 +432,6 @@ fun GroupSettingsScreen(
}
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))
if (isOwner) {
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
private fun JoinRequestRow(
request: GroupJoinRequest,
requesterName: String,
onAccept: () -> Unit,
onReject: () -> Unit,
) {
@ -383,7 +687,7 @@ private fun JoinRequestRow(
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Text(request.requesterId, style = MaterialTheme.typography.bodyMedium)
Text(requesterName, style = MaterialTheme.typography.bodyMedium)
if (!request.remark.isNullOrBlank()) {
Text(
text = request.remark!!,

查看文件

@ -5,15 +5,23 @@ import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.GroupJoinRequest
import com.xuqm.sdk.im.model.ImGroup
import 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.StateFlow
import kotlinx.coroutines.launch
class GroupViewModel : ViewModel() {
private val authRepository: AuthRepository = AppDependencies.authRepository
private val _groups = MutableStateFlow<List<ImGroup>>(emptyList())
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())
val publicGroups: StateFlow<List<ImGroup>> = _publicGroups
@ -26,15 +34,24 @@ class GroupViewModel : ViewModel() {
private val _joinRequests = MutableStateFlow<List<GroupJoinRequest>>(emptyList())
val joinRequests: StateFlow<List<GroupJoinRequest>> = _joinRequests
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing
init {
loadGroups()
loadMembers()
searchPublicGroups("")
}
fun loadMembers() {
viewModelScope.launch {
loadMembersInternal()
}
}
fun loadGroups() {
viewModelScope.launch {
runCatching { ImSDK.listGroups() }
.onSuccess { _groups.value = it }
loadGroupsInternal()
}
}
@ -48,8 +65,20 @@ class GroupViewModel : ViewModel() {
fun searchPublicGroups(keyword: String) {
_publicGroupQuery.value = keyword
viewModelScope.launch {
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
.onSuccess { _publicGroups.value = it }
loadPublicGroupsInternal(keyword)
}
}
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) {
viewModelScope.launch {
runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) }
@ -132,4 +168,22 @@ class GroupViewModel : ViewModel() {
.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
data class ServiceEndpoints(
val controlBaseUrl: String = "https://dev.xuqinmin.com/",
val imApiBaseUrl: String = "https://im.dev.xuqinmin.com/",
val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im",
val pushBaseUrl: String = "https://dev.xuqinmin.com/",
val updateBaseUrl: String = "https://update.dev.xuqinmin.com/",
val fileBaseUrl: String = "https://file.dev.xuqinmin.com/",
val controlBaseUrl: String = "http://192.168.116.9:8081/",
val imApiBaseUrl: String = "http://192.168.116.9:8082/",
val imWsUrl: String = "ws://192.168.116.9:8082/ws/im",
val pushBaseUrl: String = "http://192.168.116.9:8083/",
val updateBaseUrl: String = "http://192.168.116.9:8084/",
val fileBaseUrl: String = "http://192.168.116.9:8086/",
)
object ServiceEndpointRegistry {

查看文件

@ -2,19 +2,14 @@ package com.xuqm.sdk.file
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
import okio.BufferedSink
import okio.source
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import java.util.Locale
data class FileUploadResult(
val url: String,
@ -54,12 +49,12 @@ object FileSDK {
mimeType: String? = null,
thumbnailBytes: ByteArray? = null,
): 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 filePart = MultipartBody.Part.createFormData(
"file",
resolvedName,
UriRequestBody(context, uri, resolvedMimeType),
FileTransfer.createUriRequestBody(context, uri, resolvedMimeType),
)
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { bytes ->
MultipartBody.Part.createFormData(
@ -95,33 +90,4 @@ object FileSDK {
"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
import android.util.Log
import com.xuqm.sdk.auth.TokenStore
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.SDKConfig
import okhttp3.Interceptor
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.Response
import okio.Buffer
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
private const val TAG = "XuqmApi"
private const val MAX_LOG_BODY_BYTES = 1024 * 1024L
private var tokenStore: TokenStore? = null
private var okHttpClient: OkHttpClient? = null
private val retrofitCache = mutableMapOf<String, Retrofit>()
@ -20,15 +27,14 @@ object ApiClient {
fun init(cfg: SDKConfig, store: TokenStore) {
tokenStore = store
val logging = HttpLoggingInterceptor().apply {
level = if (cfg.logLevel == LogLevel.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
}
okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(logging)
.apply {
if (cfg.logLevel == LogLevel.DEBUG) {
addInterceptor(DebugLoggingInterceptor())
}
}
.addInterceptor { chain ->
val token = store.getToken()
val req: Request = if (token != null) {
@ -56,4 +62,89 @@ object ApiClient {
}
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.xuqm.sdk.im.listener.ImEventListener
import com.xuqm.sdk.im.model.ImMessage
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.net.URI
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.TimeUnit
class ImClient(
@ -17,8 +18,12 @@ class ImClient(
private val token: String,
private val appId: String,
) {
companion object {
private const val TAG = "XuqmImClient"
}
private var webSocket: WebSocket? = null
private val listeners = CopyOnWriteArrayList<ImEventListener>()
private val listeners = CopyOnWriteArraySet<ImEventListener>()
private val gson = Gson()
private val subscriptions = mutableMapOf<String, String>()
private var subscriptionSeed = 0
@ -31,26 +36,31 @@ class ImClient(
.build()
fun connect() {
Log.d(TAG, "connect() wsUrl=$wsUrl appId=$appId")
disconnect(closeSocket = false)
val request = Request.Builder()
.url(wsUrl)
.build()
webSocket = okhttp.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "websocket onOpen code=${response.code}")
sendConnectFrame()
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "websocket raw frame received length=${text.length}")
handleIncoming(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
connected = false
Log.e(TAG, "websocket onFailure connected=false reason=${t.message}", t)
listeners.forEach { it.onDisconnected(t.message) }
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
connected = false
Log.d(TAG, "websocket onClosed code=$code reason=$reason")
listeners.forEach { it.onDisconnected(reason) }
}
})
@ -76,14 +86,17 @@ class ImClient(
}
fun sendMessage(
messageId: String,
toId: String,
chatType: String,
msgType: String,
content: String,
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(
"appId" to appId,
"messageId" to messageId,
"toId" to toId,
"chatType" to chatType,
"msgType" to msgType,
@ -92,7 +105,7 @@ class ImClient(
if (!mentionedUserIds.isNullOrBlank()) {
payload["mentionedUserIds"] = mentionedUserIds
}
sendFrame(
return sendFrame(
"SEND",
mapOf(
"destination" to "/app/chat.send",
@ -127,6 +140,7 @@ class ImClient(
private fun sendConnectFrame() {
connected = false
Log.d(TAG, "send CONNECT frame")
sendFrame(
"CONNECT",
mapOf(
@ -148,6 +162,7 @@ class ImClient(
val frame = inboundBuffer.substring(0, terminator)
inboundBuffer = StringBuilder(inboundBuffer.substring(terminator + 1))
if (frame.isNotBlank()) {
Log.d(TAG, "stomp frame completed size=${frame.length}")
handleFrame(frame)
}
}
@ -163,6 +178,7 @@ class ImClient(
when (command.uppercase()) {
"CONNECTED" -> {
connected = true
Log.d(TAG, "stomp CONNECTED subscriptionCount=${subscriptions.size}")
listeners.forEach { it.onConnected() }
sendSubscribe("/user/queue/messages", nextSubscriptionId(prefix = "user"))
val pendingSubscriptions = synchronized(subscriptions) { subscriptions.toMap() }
@ -175,17 +191,31 @@ class ImClient(
"MESSAGE" -> {
runCatching {
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") {
listeners.forEach { it.onGroupMessage(msg) }
} else {
listeners.forEach { it.onMessage(msg) }
}
}.onFailure { e ->
Log.e(TAG, "failed to parse MESSAGE frame body length=${body.length}", e)
listeners.forEach { it.onError("Parse error: ${e.message}") }
}
}
"ERROR" -> {
val reason = body.ifBlank { headers["message"].orEmpty() }
Log.e(TAG, "stomp ERROR reason=$reason")
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?) {
val socket = webSocket ?: return
private fun sendFrame(command: String, headers: Map<String, String>, body: String?): Boolean {
val socket = webSocket ?: return false
val frame = buildString {
append(command).append('\n')
headers.forEach { (key, value) ->
@ -215,7 +245,7 @@ class ImClient(
}
append('\u0000')
}
socket.send(frame)
return socket.send(frame)
}
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.file.FileUploadResult
import com.xuqm.sdk.network.ApiClient
import android.util.Log
import kotlinx.coroutines.CoroutineScope
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray
import org.json.JSONObject
import java.time.LocalDateTime
import java.util.concurrent.CopyOnWriteArraySet
import java.util.UUID
object ImSDK {
private const val TAG = "XuqmImSDK"
private var client: ImClient? = null
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 {
override fun onConnected() {
reconnectAttempts = 0
reconnectJob?.cancel()
reconnectJob = null
_connectionState.value = ImConnectionState.Connected
resubscribeActiveGroups()
}
override fun onDisconnected(reason: String?) {
_connectionState.value = ImConnectionState.Disconnected(reason)
scheduleReconnect(reason)
}
override fun onError(error: String) {
if (error.startsWith("Parse error", ignoreCase = true)) return
_connectionState.value = ImConnectionState.Disconnected(error)
scheduleReconnect(error)
}
}
private val _connectionState = MutableStateFlow<ImConnectionState>(ImConnectionState.Disconnected("未连接"))
@ -84,8 +110,25 @@ object ImSDK {
msgType: String,
content: String,
mentionedUserIds: String? = null,
) {
client?.sendMessage(toId, chatType, msgType, content, mentionedUserIds)
): ImMessage {
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(
@ -93,8 +136,8 @@ object ImSDK {
chatType: String,
content: String,
mentionedUserIds: String? = null,
) {
sendMessage(toId, chatType, "TEXT", content, mentionedUserIds)
): ImMessage {
return sendMessage(toId, chatType, "TEXT", content, mentionedUserIds)
}
fun sendImageMessage(
@ -103,8 +146,8 @@ object ImSDK {
file: FileUploadResult,
width: Int? = null,
height: Int? = null,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "IMAGE",
@ -128,8 +171,8 @@ object ImSDK {
width: Int? = null,
height: Int? = null,
durationMs: Long? = null,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "VIDEO",
@ -151,8 +194,8 @@ object ImSDK {
toId: String,
chatType: String,
file: FileUploadResult,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "FILE",
@ -171,8 +214,8 @@ object ImSDK {
chatType: String,
file: FileUploadResult,
durationMs: Long? = null,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "AUDIO",
@ -194,8 +237,8 @@ object ImSDK {
longitude: Double,
title: String? = null,
address: String? = null,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "LOCATION",
@ -212,8 +255,8 @@ object ImSDK {
toId: String,
chatType: String,
data: String,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "CUSTOM",
@ -227,8 +270,8 @@ object ImSDK {
toId: String,
chatType: String,
html: String,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "RICH_TEXT",
@ -243,8 +286,8 @@ object ImSDK {
chatType: String,
originalSender: String,
originalContent: String,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "FORWARD",
@ -259,16 +302,16 @@ object ImSDK {
toId: String,
chatType: String,
action: String,
) {
sendCallSignalMessage(toId, chatType, "CALL_AUDIO", action)
): ImMessage {
return sendCallSignalMessage(toId, chatType, "CALL_AUDIO", action)
}
fun sendCallVideoMessage(
toId: String,
chatType: String,
action: String,
) {
sendCallSignalMessage(toId, chatType, "CALL_VIDEO", action)
): ImMessage {
return sendCallSignalMessage(toId, chatType, "CALL_VIDEO", action)
}
fun sendNotifyMessage(
@ -276,8 +319,8 @@ object ImSDK {
chatType: String,
title: String,
content: String,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "NOTIFY",
@ -294,8 +337,8 @@ object ImSDK {
quotedMsgId: String,
quotedContent: String,
text: String,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "QUOTE",
@ -312,8 +355,8 @@ object ImSDK {
chatType: String,
title: String,
msgList: List<String>,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = "MERGE",
@ -325,10 +368,18 @@ object ImSDK {
}
fun subscribeGroup(groupId: String) {
Log.d(TAG, "subscribeGroup groupId=$groupId")
synchronized(activeGroupSubscriptions) {
activeGroupSubscriptions.add(groupId)
}
client?.subscribe("/topic/group/$groupId")
}
fun unsubscribeGroup(groupId: String) {
Log.d(TAG, "unsubscribeGroup groupId=$groupId")
synchronized(activeGroupSubscriptions) {
activeGroupSubscriptions.remove(groupId)
}
client?.unsubscribe("/topic/group/$groupId")
}
@ -486,8 +537,17 @@ object ImSDK {
suspend fun deleteConversation(targetId: String, chatType: String) =
withContext(Dispatchers.IO) { api.deleteConversation(targetId, XuqmSDK.appId, chatType) }
fun addListener(listener: ImEventListener) = client?.addListener(listener)
fun removeListener(listener: ImEventListener) = client?.removeListener(listener)
fun addListener(listener: ImEventListener) {
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() {
disconnectInternal(clearTokenStore = true)
@ -504,18 +564,33 @@ object ImSDK {
}
private fun connectWithToken(token: String) {
reconnectEnabled = false
reconnectJob?.cancel()
reconnectJob = null
XuqmSDK.tokenStore.saveToken(token)
client?.disconnect()
_connectionState.value = ImConnectionState.Connecting
currentToken = token
Log.d(TAG, "connectWithToken userId=$currentUserId activeGroups=${activeGroupSubscriptions.size}")
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId)
client?.addListener(connectionListener)
listeners.forEach { client?.addListener(it) }
reconnectEnabled = true
client?.connect()
}
private fun disconnectInternal(clearTokenStore: Boolean) {
reconnectEnabled = false
reconnectJob?.cancel()
reconnectJob = null
reconnectAttempts = 0
client?.disconnect()
client = null
currentUserId = ""
currentToken = ""
synchronized(activeGroupSubscriptions) {
activeGroupSubscriptions.clear()
}
_connectionState.value = ImConnectionState.Disconnected("已断开")
if (clearTokenStore) {
XuqmSDK.tokenStore.clear()
@ -549,8 +624,8 @@ object ImSDK {
chatType: String,
msgType: String,
action: String,
) {
sendMessage(
): ImMessage {
return sendMessage(
toId = toId,
chatType = chatType,
msgType = msgType,
@ -559,4 +634,56 @@ object ImSDK {
}.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 androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.file.FileTransfer
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
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.withContext
import java.io.File
import java.net.URL
object UpdateSDK {
@ -53,23 +53,7 @@ object UpdateSDK {
onProgress: (Int) -> Unit = {},
) = withContext(Dispatchers.IO) {
val apkFile = File(context.getExternalFilesDir(null), "update.apk")
val url = URL(downloadUrl)
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())
}
}
}
FileTransfer.downloadToFile(downloadUrl, apkFile, onProgress)
withContext(Dispatchers.Main) { installApk(context, apkFile) }
}