feat(sample): 添加示例应用的核心功能模块
- 实现环境配置管理,支持外部和本地主机模式切换 - 集成Demo API接口,包含登录、注册、文件上传等功能 - 构建附件处理仓库,支持图片、视频、音频和文件发送 - 开发认证仓库,管理用户会话和IM令牌刷新机制 - 添加语音录制功能,支持实时音频消息录制 - 创建依赖注入容器,统一管理应用组件实例 - 实现登录界面,提供用户认证交互功能 - 开发聊天界面,集成消息收发和媒体处理功能
这个提交包含在:
父节点
bee82637f3
当前提交
b7ecf13908
@ -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()) {
|
||||
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(
|
||||
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")
|
||||
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))
|
||||
}
|
||||
appendOutgoingMessage(sent)
|
||||
if (sent.status.uppercase() == "FAILED") {
|
||||
_events.tryEmit("消息发送失败,请检查网络后重试")
|
||||
} else {
|
||||
trackPendingDelivery(sent.id)
|
||||
_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,
|
||||
)
|
||||
)
|
||||
_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()
|
||||
runCatching {
|
||||
val groupMemberIds = ImSDK.getGroupInfo(targetId)
|
||||
?.memberIds.orEmpty()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
}
|
||||
.onSuccess { _mentionableUserIds.value = it }
|
||||
.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,11 +264,23 @@ 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
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
SearchBarField(
|
||||
value = keyword,
|
||||
@ -174,6 +291,21 @@ fun ContactScreen(
|
||||
placeholder = "搜索用户 ID 或昵称",
|
||||
)
|
||||
|
||||
androidx.compose.material3.PrimaryTabRow(selectedTabIndex = selectedTab) {
|
||||
androidx.compose.material3.Tab(
|
||||
selected = selectedTab == 0,
|
||||
onClick = { selectedTab = 0 },
|
||||
text = { Text("好友") },
|
||||
)
|
||||
androidx.compose.material3.Tab(
|
||||
selected = selectedTab == 1,
|
||||
onClick = { selectedTab = 1 },
|
||||
text = { Text("全部联系人") },
|
||||
)
|
||||
}
|
||||
|
||||
when (selectedTab) {
|
||||
0 -> {
|
||||
if (friendRequests.isNotEmpty()) {
|
||||
Text(
|
||||
"好友申请(${friendRequests.size})",
|
||||
@ -189,8 +321,11 @@ fun ContactScreen(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(request.fromUserId, style = MaterialTheme.typography.titleSmall)
|
||||
Text(request.remark.orEmpty(), style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline)
|
||||
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("拒绝") }
|
||||
@ -199,48 +334,58 @@ fun ContactScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (keyword.isBlank()) {
|
||||
Text(
|
||||
"联系人(${friends.size})",
|
||||
"好友(${friends.size})",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
if (friends.isEmpty()) {
|
||||
Text(
|
||||
"暂无好友",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(friends, key = { it.userId }) { user ->
|
||||
MemberItem(
|
||||
user = user,
|
||||
isFriend = true,
|
||||
onChat = { onOpenChat(user.userId) },
|
||||
onAddFriend = { viewModel.addFriend(user.userId) },
|
||||
onRemoveFriend = { viewModel.removeFriend(user.userId) },
|
||||
onBlacklist = { viewModel.addToBlacklist(user.userId) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
if (keyword.isBlank()) "全部联系人(${visibleContacts.size})" else "搜索结果(${visibleContacts.size})",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
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,
|
||||
items(visibleContacts, key = { it.userId }) { user ->
|
||||
MemberItem(
|
||||
user = user,
|
||||
isFriend = friends.any { it.userId == user.userId },
|
||||
onChat = { onOpenChat(user.userId) },
|
||||
onRemove = { viewModel.removeFriend(user.userId) },
|
||||
onAddFriend = {
|
||||
pendingFriendRequestUser = user
|
||||
friendRequestRemark = ""
|
||||
},
|
||||
onRemoveFriend = { viewModel.removeFriend(user.userId) },
|
||||
onBlacklist = { viewModel.addToBlacklist(user.userId) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
} 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})",
|
||||
@ -266,23 +411,89 @@ fun ContactScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingFriendRequestUser != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
pendingFriendRequestUser = null
|
||||
friendRequestRemark = ""
|
||||
},
|
||||
title = { Text("申请好友") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
pendingFriendRequestUser?.nickname.orEmpty(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
pendingFriendRequestUser?.userId.orEmpty(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = friendRequestRemark,
|
||||
onValueChange = { friendRequestRemark = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
label = { Text("申请信息") },
|
||||
placeholder = { Text("例如:我是某某,想和你加好友") },
|
||||
minLines = 3,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
pendingFriendRequestUser?.let { user ->
|
||||
viewModel.addFriend(user.userId, friendRequestRemark.trim().takeIf { it.isNotBlank() })
|
||||
}
|
||||
pendingFriendRequestUser = null
|
||||
friendRequestRemark = ""
|
||||
},
|
||||
) { Text("发送") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
pendingFriendRequestUser = null
|
||||
friendRequestRemark = ""
|
||||
},
|
||||
) { Text("取消") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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,6 +64,11 @@ fun ConversationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
"总未读 $totalUnreadCount",
|
||||
@ -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) }
|
||||
},
|
||||
@ -106,11 +118,13 @@ fun ConversationScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@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,20 +71,28 @@ 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 {
|
||||
_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,8 +80,13 @@ fun GroupListScreen(
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
SearchBarField(
|
||||
@ -109,9 +130,11 @@ 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) }
|
||||
}
|
||||
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户