diff --git a/README.md b/README.md index cb0493c..e364e6f 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ XuqmSDK.login( 默认使用外网域名。若要本地联调,可在 `Application.onCreate()` 里切换: ```kotlin -XuqmSDK.useLocalServiceEndpoints("10.0.2.2") // Android Emulator +XuqmSDK.useLocalServiceEndpoints("192.168.116.9") // 物理设备可改成你的电脑局域网 IP ``` diff --git a/sample-app/src/main/AndroidManifest.xml b/sample-app/src/main/AndroidManifest.xml index fd0b0eb..0220f94 100644 --- a/sample-app/src/main/AndroidManifest.xml +++ b/sample-app/src/main/AndroidManifest.xml @@ -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"> + @GET("api/demo/users/members") + suspend fun listMembers(@Query("appId") appId: String = DEMO_APP_ID): DemoResponse> + @PUT("api/demo/user/profile") suspend fun updateProfile(@Body request: UpdateProfileRequest): DemoResponse @@ -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() diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt index 772552e..63b104d 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AttachmentRepository.kt @@ -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 = + suspend fun sendImage(targetId: String, chatType: String, uri: Uri): Result = 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 = withContext(Dispatchers.IO) { + ): Result = 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 = + suspend fun sendVideo(targetId: String, chatType: String, uri: Uri): Result = sendMedia(targetId, chatType, uri, MediaKind.VIDEO) - suspend fun sendFile(targetId: String, chatType: String, uri: Uri): Result = + suspend fun sendFile(targetId: String, chatType: String, uri: Uri): Result = sendMedia(targetId, chatType, uri, MediaKind.FILE) - suspend fun sendAudio(targetId: String, chatType: String, uri: Uri): Result = + suspend fun sendAudio(targetId: String, chatType: String, uri: Uri): Result = 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 = withContext(Dispatchers.IO) { + ): Result = 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 = withContext(Dispatchers.IO) { + ): Result = 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, + ) } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt index 0b496a9..446be42 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt @@ -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 = 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 = withContext(Dispatchers.IO) { runCatching { requireNotNull(api.getProfile().data) { "Failed to get profile" } } } + suspend fun listMembers(): Result> = + withContext(Dispatchers.IO) { + runCatching { api.listMembers().data ?: emptyList() } + } + suspend fun updateProfile(nickname: String, avatar: String?): Result = 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 Result.mapFailureMessage(transform: (Throwable) -> String): Result = + 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 -> "登录失败,请检查账号或密码" + } + } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt index 7fbc52d..e038c51 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt @@ -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) } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/VoiceRecorder.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/VoiceRecorder.kt index 07a97ac..3b96441 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/VoiceRecorder.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/VoiceRecorder.kt @@ -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 diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt index 796a31b..fdcea72 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt @@ -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(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) + } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt index d7e31c5..9180ae1 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt @@ -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( diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt index af90c3a..3fd43c3 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt @@ -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(null) } @@ -175,6 +179,14 @@ fun ChatScreen( } LaunchedEffect(Unit) { viewModel.init(targetId, chatType) } + LaunchedEffect(Unit) { + viewModel.events.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } + } + LaunchedEffect(draftText) { + if (draftValue.text != draftText) { + draftValue = TextFieldValue(draftText, selection = TextRange(draftText.length)) + } + } LaunchedEffect(scrollSignal) { if (messages.isNotEmpty() && searchQuery.isBlank()) { listState.animateScrollToItem(0) @@ -234,16 +246,38 @@ fun ChatScreen( .padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (chatType == "GROUP" && mentionableUserIds.isNotEmpty()) { - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text("提及", style = MaterialTheme.typography.labelSmall) - mentionableUserIds.take(6).forEach { userId -> - TextButton(onClick = { viewModel.appendMention(userId) }) { - Text("@$userId") + if (chatType == "GROUP" && mentionableUsers.isNotEmpty()) { + val mentionQuery = remember(draftValue.text, draftValue.selection) { + mentionQueryAtCursor(draftValue) + } + val mentionCandidates = remember(mentionQuery, mentionableUsers) { + if (mentionQuery == null) { + emptyList() + } else { + mentionableUsers + .filter { + mentionQuery.isBlank() || it.nickname.contains(mentionQuery, ignoreCase = true) + } + .take(8) + } + } + if (mentionCandidates.isNotEmpty()) { + Box(modifier = Modifier.fillMaxWidth()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("提及", style = MaterialTheme.typography.labelSmall) + mentionCandidates.forEach { user -> + TextButton( + onClick = { + draftValue = insertMentionAtCursor(draftValue, user.nickname) + viewModel.updateDraft(draftValue.text) + }, + ) { + Text("@${user.nickname.ifBlank { "成员" }}") + } + } } } } @@ -270,16 +304,27 @@ fun ChatScreen( verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( - value = draftText, - onValueChange = viewModel::updateDraft, - modifier = Modifier.weight(1f), + value = draftValue, + onValueChange = { next -> + draftValue = next + viewModel.updateDraft(next.text) + }, placeholder = { Text("输入消息…") }, maxLines = 4, shape = RoundedCornerShape(24.dp), + modifier = Modifier + .weight(1f) + .onPreviewKeyEvent { event -> + handleMentionBackspace(event, draftValue, mentionableUsers.map { it.nickname })?.let { edited -> + draftValue = edited + viewModel.updateDraft(edited.text) + true + } ?: false + }, ) IconButton( - onClick = { viewModel.sendText(draftText) }, - enabled = draftText.isNotBlank(), + onClick = { viewModel.sendText(draftValue.text) }, + enabled = draftValue.text.isNotBlank(), ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) } @@ -719,3 +764,55 @@ private fun formatDuration(ms: Long): String { val seconds = totalSeconds % 60 return if (minutes > 0) String.format("%d:%02d", minutes, seconds) else "${seconds}s" } + +private fun mentionQueryAtCursor(value: TextFieldValue): String? { + if (!value.selection.collapsed) return null + val cursor = value.selection.end.coerceIn(0, value.text.length) + val prefix = value.text.substring(0, cursor) + val atIndex = prefix.lastIndexOf('@') + if (atIndex < 0) return null + val query = prefix.substring(atIndex + 1) + if (query.any { it.isWhitespace() }) return null + return query +} + +private fun insertMentionAtCursor(value: TextFieldValue, nickname: String): TextFieldValue { + val cursor = value.selection.end.coerceIn(0, value.text.length) + val prefix = value.text.substring(0, cursor) + val suffix = value.text.substring(cursor) + val atIndex = prefix.lastIndexOf('@') + if (atIndex < 0) { + val insert = "@$nickname " + val next = prefix + insert + suffix + return TextFieldValue(next, selection = TextRange(prefix.length + insert.length)) + } + val nextPrefix = prefix.substring(0, atIndex) + val insert = "@$nickname " + val next = nextPrefix + insert + suffix + val selection = nextPrefix.length + insert.length + return TextFieldValue(next, selection = TextRange(selection)) +} + +private fun handleMentionBackspace( + event: androidx.compose.ui.input.key.KeyEvent, + value: TextFieldValue, + nicknames: List, +): 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)) +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt index f61822f..c13c553 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt @@ -1,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(null) val replyTargetMessage: StateFlow = _replyTargetMessage - private val _mentionableUserIds = MutableStateFlow>(emptyList()) - val mentionableUserIds: StateFlow> = _mentionableUserIds + private val _mentionableUsers = MutableStateFlow>(emptyList()) + val mentionableUsers: StateFlow> = _mentionableUsers private val _isSendingAttachment = MutableStateFlow(false) val isSendingAttachment: StateFlow = _isSendingAttachment + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _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() private val listener = object : ImEventListener { override fun onMessage(message: ImMessage) { - if (isRelevant(message)) { - prependMessage(message) - cache.mergeHistory(targetId, chatType, _messages.value) - cache.upsertConversation( - ConversationData( - targetId = targetId, - chatType = chatType, - lastMsgContent = message.previewText(), - lastMsgType = message.msgType, - lastMsgTime = message.createdAt, - unreadCount = 0, - isMuted = false, - isPinned = false, - ) - ) - if (_searchQuery.value.isNotBlank()) { - _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) - } - requestScrollToBottom() - if (message.status.uppercase() != "READ" && message.fromId != currentUserId) { - viewModelScope.launch { - runCatching { ImSDK.markRead(targetId, chatType) } - } - } - } + handleIncomingMessage(message) } override fun onGroupMessage(message: ImMessage) { - if (isRelevant(message)) { - prependMessage(message) - cache.mergeHistory(targetId, chatType, _messages.value) - cache.upsertConversation( - ConversationData( - targetId = targetId, - chatType = chatType, - lastMsgContent = message.previewText(), - lastMsgType = message.msgType, - lastMsgTime = message.createdAt, - unreadCount = 0, - isMuted = false, - isPinned = false, - ) - ) - if (_searchQuery.value.isNotBlank()) { - _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) - } - requestScrollToBottom() - if (message.status.uppercase() != "READ" && message.fromId != currentUserId) { - viewModelScope.launch { - runCatching { ImSDK.markRead(targetId, chatType) } - } - } - } + handleIncomingMessage(message) } } @@ -127,7 +88,7 @@ class ChatViewModel : ViewModel() { _searchQuery.value = "" _searchResults.value = emptyList() _draftText.value = cache.loadDraft(targetId, chatType) - _mentionableUserIds.value = emptyList() + _mentionableUsers.value = emptyList() _replyTargetMessage.value = null ImSDK.addListener(listener) if (chatType == "GROUP") { @@ -158,16 +119,16 @@ class ChatViewModel : ViewModel() { val page = if (replace) 0 else nextHistoryPage val history = fetchHistory(page) if (replace) { - _messages.value = history + _messages.value = mergeMessages(_messages.value, history) nextHistoryPage = 1 requestScrollToBottom() } else if (history.isNotEmpty()) { - _messages.value = (_messages.value + history).distinctBy { it.id } + _messages.value = mergeMessages(_messages.value, history) nextHistoryPage += 1 } _hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE if (history.isNotEmpty()) { - cache.saveHistory(targetId, chatType, mergeHistory(_messages.value)) + cache.mergeHistory(targetId, chatType, _messages.value) history.firstOrNull()?.let { last -> cache.upsertConversation( ConversationData( @@ -198,7 +159,7 @@ class ChatViewModel : ViewModel() { if (remote != null) { val normalized = remote.sortedByDescending { it.createdAt } if (page == 0) { - cache.saveHistory(targetId, chatType, normalized) + cache.mergeHistory(targetId, chatType, normalized) } else { cache.mergeHistory(targetId, chatType, normalized) } @@ -210,7 +171,7 @@ class ChatViewModel : ViewModel() { fun sendText(content: String) { if (content.isBlank()) return val replyTarget = _replyTargetMessage.value - if (replyTarget != null) { + val sent = if (replyTarget != null) { ImSDK.sendQuoteMessage( toId = targetId, chatType = chatType, @@ -218,24 +179,18 @@ class ChatViewModel : ViewModel() { quotedContent = replyTarget.previewText(), text = content, ) - _replyTargetMessage.value = null } else { ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content)) } - _draftText.value = "" - cache.clearDraft(targetId, chatType) - cache.upsertConversation( - ConversationData( - targetId = targetId, - chatType = chatType, - lastMsgContent = content, - lastMsgType = "TEXT", - lastMsgTime = System.currentTimeMillis(), - unreadCount = 0, - isMuted = false, - isPinned = false, - ) - ) + appendOutgoingMessage(sent) + if (sent.status.uppercase() == "FAILED") { + _events.tryEmit("消息发送失败,请检查网络后重试") + } else { + trackPendingDelivery(sent.id) + _draftText.value = "" + cache.clearDraft(targetId, chatType) + _replyTargetMessage.value = null + } } fun searchCachedMessages(query: String) { @@ -251,9 +206,6 @@ class ChatViewModel : ViewModel() { _draftText.value = text if (initialized) { cache.saveDraft(targetId, chatType, text) - viewModelScope.launch { - runCatching { ImSDK.setDraft(targetId, chatType, text) } - } } } @@ -288,19 +240,15 @@ class ChatViewModel : ViewModel() { bytes = recording.bytes, durationMs = recording.durationMs, ).getOrThrow() - }.onSuccess { - cache.upsertConversation( - ConversationData( - targetId = targetId, - chatType = chatType, - lastMsgContent = "[语音]", - lastMsgType = "AUDIO", - lastMsgTime = System.currentTimeMillis(), - unreadCount = 0, - isMuted = false, - isPinned = false, - ) - ) + }.onSuccess { sent -> + appendOutgoingMessage(sent.message) + if (sent.message.status.uppercase() == "FAILED") { + _events.tryEmit("语音发送失败,请检查网络后重试") + } else { + trackPendingDelivery(sent.message.id) + } + }.onFailure { + _events.tryEmit("语音发送失败,请检查网络后重试") } } finally { _isSendingAttachment.value = false @@ -315,17 +263,6 @@ class ChatViewModel : ViewModel() { _searchResults.value = emptyList() } - private fun prependMessage(message: ImMessage) { - val updated = _messages.value.toMutableList() - val index = updated.indexOfFirst { it.id == message.id } - if (index >= 0) { - updated[index] = message - } else { - updated.add(0, message) - } - _messages.value = updated - } - private fun mergeHistory(messages: List): List = messages.distinctBy { it.id }.sortedByDescending { it.createdAt } @@ -333,31 +270,35 @@ class ChatViewModel : ViewModel() { _scrollToBottomSignal.value = _scrollToBottomSignal.value + 1 } - fun appendMention(userId: String) { + fun appendMention(user: UserData) { val current = _draftText.value - val next = if (current.isBlank()) "@$userId " else "$current @$userId " + val label = user.nickname.ifBlank { "成员" } + val next = if (current.isBlank()) "@$label " else "$current @$label " updateDraft(next) } private fun loadMentionableUsers() { viewModelScope.launch { - runCatching { ImSDK.getGroupInfo(targetId) } - .map { group -> - group?.memberIds.orEmpty() - .split(",") - .map { it.trim() } - .filter { it.isNotBlank() } - .distinct() - } - .onSuccess { _mentionableUserIds.value = it } + runCatching { + val groupMemberIds = ImSDK.getGroupInfo(targetId) + ?.memberIds.orEmpty() + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .toSet() + AppDependencies.authRepository.listMembers().getOrDefault(emptyList()) + .filter { it.userId in groupMemberIds && it.userId != currentUserId } + .filter { it.nickname.isNotBlank() } + .sortedBy { it.nickname.ifBlank { it.userId } } + }.onSuccess { _mentionableUsers.value = it } } } private fun extractMentionedUserIds(content: String): String? { if (chatType != "GROUP") return null - val ids = _mentionableUserIds.value.filter { mentionId -> - content.contains("@$mentionId") - } + val ids = _mentionableUsers.value.filter { user -> + content.contains("@${user.nickname}") + }.map { it.userId } return ids.joinToString(",").takeIf { it.isNotBlank() } } @@ -373,6 +314,15 @@ class ChatViewModel : ViewModel() { AttachmentKind.AUDIO -> AppDependencies.attachmentRepository.sendAudio(targetId, chatType, uri) AttachmentKind.FILE -> AppDependencies.attachmentRepository.sendFile(targetId, chatType, uri) }.getOrThrow() + }.onSuccess { sent -> + appendOutgoingMessage(sent.message) + if (sent.message.status.uppercase() == "FAILED") { + _events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试") + } else { + trackPendingDelivery(sent.message.id) + } + }.onFailure { + _events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试") } } finally { _isSendingAttachment.value = false @@ -400,9 +350,18 @@ class ChatViewModel : ViewModel() { bytes = bytes, width = width, height = height, - ) + ).getOrThrow() else -> error("Unsupported bytes attachment kind: $kind") - }.getOrThrow() + } + }.onSuccess { sent -> + appendOutgoingMessage(sent.message) + if (sent.message.status.uppercase() == "FAILED") { + _events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试") + } else { + trackPendingDelivery(sent.message.id) + } + }.onFailure { + _events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试") } } finally { _isSendingAttachment.value = false @@ -412,23 +371,147 @@ class ChatViewModel : ViewModel() { private enum class AttachmentKind { IMAGE, VIDEO, AUDIO, FILE } + private fun AttachmentKind.previewLabel(): String = when (this) { + AttachmentKind.IMAGE -> "[图片]" + AttachmentKind.VIDEO -> "[视频]" + AttachmentKind.AUDIO -> "[语音]" + AttachmentKind.FILE -> "[文件]" + } + private fun isRelevant(message: ImMessage): Boolean { - return if (chatType == "GROUP") { + val relevant = if (chatType == "GROUP") { message.chatType == "GROUP" && message.toId == targetId } else { message.chatType != "GROUP" && (message.fromId == targetId || message.toId == targetId) } + Log.d( + TAG, + "isRelevant=$relevant messageId=${message.id} messageChatType=${message.chatType} messageFrom=${message.fromId} messageTo=${message.toId} targetId=$targetId currentChatType=$chatType", + ) + return relevant } override fun onCleared() { if (initialized && chatType == "GROUP") { ImSDK.unsubscribeGroup(targetId) } + pendingDeliveryTimeouts.values.forEach { it.cancel() } + pendingDeliveryTimeouts.clear() ImSDK.removeListener(listener) initialized = false } + private fun handleIncomingMessage(message: ImMessage) { + Log.d( + TAG, + "incoming message id=${message.id} chatType=${message.chatType} msgType=${message.msgType} from=${message.fromId} to=${message.toId} status=${message.status} targetId=$targetId currentChatType=$chatType", + ) + if (!isRelevant(message)) return + if (message.fromId == currentUserId) { + pendingDeliveryTimeouts.remove(message.id)?.cancel() + } + Log.d(TAG, "incoming message matched targetId=$targetId currentChatType=$chatType") + _messages.value = mergeMessages(_messages.value, listOf(message)) + cache.mergeHistory(targetId, chatType, _messages.value) + cache.upsertConversation( + ConversationData( + targetId = targetId, + chatType = chatType, + lastMsgContent = message.previewText(), + lastMsgType = message.msgType, + lastMsgTime = message.createdAt, + unreadCount = 0, + isMuted = false, + isPinned = false, + ) + ) + AppDependencies.notifyConversationChanged() + if (_searchQuery.value.isNotBlank()) { + _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) + } + requestScrollToBottom() + if (message.status.uppercase() != "READ" && message.fromId != currentUserId) { + viewModelScope.launch { + runCatching { ImSDK.markRead(targetId, chatType) } + } + } + } + + private fun appendOutgoingMessage(message: ImMessage) { + _messages.value = mergeMessages(_messages.value, listOf(message)) + cache.mergeHistory(targetId, chatType, _messages.value) + updateConversationPreview(message.previewText(), message.msgType, message.createdAt) + requestScrollToBottom() + } + + private fun updateConversationPreview(lastMsgContent: String, lastMsgType: String, lastMsgTime: Long = System.currentTimeMillis()) { + cache.upsertConversation( + ConversationData( + targetId = targetId, + chatType = chatType, + lastMsgContent = lastMsgContent, + lastMsgType = lastMsgType, + lastMsgTime = lastMsgTime, + unreadCount = 0, + isMuted = false, + isPinned = false, + ) + ) + AppDependencies.notifyConversationChanged() + } + + private fun mergeMessages(existing: List, incoming: List): List { + 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 } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt index ff7edff..b1a9bb2 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt @@ -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 = emptyMap() + private var friendIds: Set = emptySet() + private val _friends = MutableStateFlow>(emptyList()) val friends: StateFlow> = _friends + private val _members = MutableStateFlow>(emptyList()) + val members: StateFlow> = _members + private val _searchResults = MutableStateFlow>(emptyList()) val searchResults: StateFlow> = _searchResults @@ -55,47 +74,74 @@ class ContactViewModel( private val _blacklist = MutableStateFlow>(emptyList()) val blacklist: StateFlow> = _blacklist + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + private val listener = object : ImEventListener { + override fun onMessage(message: ImMessage) = handleNotification(message) + override fun onGroupMessage(message: ImMessage) = handleNotification(message) + } + init { - _friends.value = cache.resolveFriends(cache.loadFriendIds()) - loadFriends() - loadFriendRequests() - loadBlacklist() + ImSDK.addListener(listener) + refresh() + } + + fun loadMembers() { + viewModelScope.launch { + loadMembersInternal() + } } fun loadFriends() { viewModelScope.launch { - runCatching { - ImSDK.listFriends().mapNotNull { friendId -> - authRepository.searchUsers(friendId).getOrDefault(emptyList()) - .firstOrNull { it.userId == friendId } - } - }.onSuccess { list -> - _friends.value = list - cache.saveFriendIds(list.map { it.userId }) - cache.saveProfiles(list) - }.onFailure { - val cached = cache.resolveFriends(cache.loadFriendIds()) - if (cached.isNotEmpty()) _friends.value = cached + loadFriendsInternal() + } + } + + fun refresh() { + viewModelScope.launch { + _isRefreshing.value = true + try { + loadMembersInternal() + loadFriendsInternal() + loadFriendRequestsInternal() + loadBlacklistInternal() + } finally { + _isRefreshing.value = false } } } + private fun rebuildFriendList() { + _friends.value = friendIds.mapNotNull { memberIndex[it] ?: cache.loadProfiles().firstOrNull { profile -> profile.userId == it } } + .distinctBy { it.userId } + .sortedBy { it.userId } + } + fun search(keyword: String) { - if (keyword.isBlank()) { _searchResults.value = emptyList(); return } - _searchResults.value = cache.searchProfiles(keyword) - viewModelScope.launch { - authRepository.searchUsers(keyword) - .onSuccess { list -> - _searchResults.value = list - cache.saveProfiles(list) - } + if (keyword.isBlank()) { + _searchResults.value = emptyList() + return + } + _searchResults.value = _members.value.filter { + it.userId.contains(keyword, ignoreCase = true) || + it.nickname.contains(keyword, ignoreCase = true) } } - fun addFriend(userId: String) { + fun addFriend(userId: String, remark: String? = null) { viewModelScope.launch { - runCatching { ImSDK.sendFriendRequest(userId) } - .onSuccess { loadFriends() } + runCatching { ImSDK.sendFriendRequest(userId, remark) } + .onSuccess { + _events.tryEmit("好友申请已发送") + } + .onFailure { + _events.tryEmit("好友申请发送失败,请检查网络后重试") + } } } @@ -108,29 +154,27 @@ class ContactViewModel( fun loadFriendRequests() { viewModelScope.launch { - runCatching { ImSDK.listFriendRequests() } - .onSuccess { _friendRequests.value = it } + loadFriendRequestsInternal() } } fun acceptFriendRequest(requestId: String) { viewModelScope.launch { runCatching { ImSDK.acceptFriendRequest(requestId) } - .onSuccess { loadFriends(); loadFriendRequests() } + .onSuccess { refresh() } } } fun rejectFriendRequest(requestId: String) { viewModelScope.launch { runCatching { ImSDK.rejectFriendRequest(requestId) } - .onSuccess { loadFriendRequests() } + .onSuccess { refresh() } } } fun loadBlacklist() { viewModelScope.launch { - runCatching { ImSDK.listBlacklist() } - .onSuccess { _blacklist.value = it } + loadBlacklistInternal() } } @@ -147,8 +191,69 @@ class ContactViewModel( .onSuccess { loadBlacklist() } } } + + override fun onCleared() { + ImSDK.removeListener(listener) + } + + private fun handleNotification(message: ImMessage) { + if (message.msgType.uppercase() != "NOTIFY") return + val payload = runCatching { JSONObject(message.content) }.getOrNull() ?: return + val kind = payload.optString("type") + if (kind != "FRIEND_REQUEST" && kind != "FRIEND_REQUEST_STATUS") return + refresh() + } + + private suspend fun loadMembersInternal() { + runCatching { + authRepository.listMembers().getOrDefault(emptyList()) + .filter { it.userId != currentUserId } + .sortedBy { it.userId } + }.onSuccess { list -> + _members.value = list + memberIndex = list.associateBy { it.userId } + cache.saveProfiles(list) + rebuildFriendList() + }.onFailure { + val cached = cache.loadProfiles() + .filter { it.userId != currentUserId } + .sortedBy { it.userId } + if (cached.isNotEmpty()) { + _members.value = cached + memberIndex = cached.associateBy { it.userId } + rebuildFriendList() + } + } + } + + private suspend fun loadFriendsInternal() { + runCatching { ImSDK.listFriends().toSet() } + .onSuccess { list -> + friendIds = list + cache.saveFriendIds(list.toList()) + rebuildFriendList() + }.onFailure { + friendIds = cache.loadFriendIds().toSet() + rebuildFriendList() + } + } + + private suspend fun loadFriendRequestsInternal() { + runCatching { ImSDK.listFriendRequests() } + .onSuccess { + _friendRequests.value = it.filter { request -> + request.status.equals("PENDING", ignoreCase = true) + } + } + } + + private suspend fun loadBlacklistInternal() { + runCatching { ImSDK.listBlacklist() } + .onSuccess { _blacklist.value = it } + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ContactScreen( onOpenChat: (userId: String) -> Unit, @@ -159,130 +264,236 @@ fun ContactScreen( ), ) { val friends by viewModel.friends.collectAsStateWithLifecycle() + val members by viewModel.members.collectAsStateWithLifecycle() val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() val friendRequests by viewModel.friendRequests.collectAsStateWithLifecycle() val blacklist by viewModel.blacklist.collectAsStateWithLifecycle() + val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val context = LocalContext.current var keyword by remember { mutableStateOf("") } + var selectedTab by remember { mutableStateOf(0) } + var pendingFriendRequestUser by remember { mutableStateOf(null) } + var friendRequestRemark by remember { mutableStateOf("") } + val visibleContacts = if (keyword.isBlank()) members else searchResults - Column(modifier = Modifier.fillMaxSize()) { - SearchBarField( - value = keyword, - onValueChange = { keyword = it; viewModel.search(it) }, - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - placeholder = "搜索用户 ID 或昵称", - ) - - if (friendRequests.isNotEmpty()) { - Text( - "好友申请(${friendRequests.size})", - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - color = MaterialTheme.colorScheme.outline, + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) { + Column(modifier = Modifier.fillMaxSize()) { + SearchBarField( + value = keyword, + onValueChange = { keyword = it; viewModel.search(it) }, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + placeholder = "搜索用户 ID 或昵称", ) - LazyColumn { - items(friendRequests, key = { it.id }) { request -> - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(request.fromUserId, style = MaterialTheme.typography.titleSmall) - Text(request.remark.orEmpty(), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline) - } - TextButton(onClick = { viewModel.acceptFriendRequest(request.id) }) { Text("接受") } - TextButton(onClick = { viewModel.rejectFriendRequest(request.id) }) { Text("拒绝") } - } - HorizontalDivider() - } - } - } - if (keyword.isBlank()) { - Text( - "联系人(${friends.size})", - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - color = MaterialTheme.colorScheme.outline, - ) - LazyColumn { - items(friends, key = { it.userId }) { user -> - FriendItem( - userId = user.userId, - nickname = user.nickname, - onChat = { onOpenChat(user.userId) }, - onRemove = { viewModel.removeFriend(user.userId) }, - ) - HorizontalDivider() - } - } - } else { - LazyColumn { - items(searchResults, key = { it.userId }) { user -> - val isFriend = friends.any { it.userId == user.userId } - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(user.nickname, style = MaterialTheme.typography.titleSmall) - Text(user.userId, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline) - } - if (!isFriend) { - TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("申请好友") } - } else { - TextButton(onClick = { onOpenChat(user.userId) }) { Text("发消息") } - } - TextButton(onClick = { viewModel.addToBlacklist(user.userId) }) { Text("拉黑") } - } - HorizontalDivider() - } - } - if (blacklist.isNotEmpty()) { - Text( - "黑名单(${blacklist.size})", - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - color = MaterialTheme.colorScheme.outline, + androidx.compose.material3.PrimaryTabRow(selectedTabIndex = selectedTab) { + androidx.compose.material3.Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text("好友") }, ) - LazyColumn { - items(blacklist, key = { it.id }) { entry -> - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(entry.blockedUserId, modifier = Modifier.weight(1f)) - TextButton(onClick = { viewModel.removeFromBlacklist(entry.blockedUserId) }) { - Text("移除") + androidx.compose.material3.Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { Text("全部联系人") }, + ) + } + + when (selectedTab) { + 0 -> { + if (friendRequests.isNotEmpty()) { + Text( + "好友申请(${friendRequests.size})", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.outline, + ) + LazyColumn { + items(friendRequests, key = { it.id }) { request -> + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(request.fromUserId, style = MaterialTheme.typography.titleSmall) + Text( + request.remark.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + TextButton(onClick = { viewModel.acceptFriendRequest(request.id) }) { Text("接受") } + TextButton(onClick = { viewModel.rejectFriendRequest(request.id) }) { Text("拒绝") } + } + HorizontalDivider() + } + } + } + Text( + "好友(${friends.size})", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.outline, + ) + if (friends.isEmpty()) { + Text( + "暂无好友", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.bodySmall, + ) + } else { + LazyColumn { + items(friends, key = { it.userId }) { user -> + MemberItem( + user = user, + isFriend = true, + onChat = { onOpenChat(user.userId) }, + onAddFriend = { viewModel.addFriend(user.userId) }, + onRemoveFriend = { viewModel.removeFriend(user.userId) }, + onBlacklist = { viewModel.addToBlacklist(user.userId) }, + ) + HorizontalDivider() + } + } + } + } + else -> { + Text( + if (keyword.isBlank()) "全部联系人(${visibleContacts.size})" else "搜索结果(${visibleContacts.size})", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.outline, + ) + LazyColumn { + items(visibleContacts, key = { it.userId }) { user -> + MemberItem( + user = user, + isFriend = friends.any { it.userId == user.userId }, + onChat = { onOpenChat(user.userId) }, + onAddFriend = { + pendingFriendRequestUser = user + friendRequestRemark = "" + }, + onRemoveFriend = { viewModel.removeFriend(user.userId) }, + onBlacklist = { viewModel.addToBlacklist(user.userId) }, + ) + HorizontalDivider() + } + } + if (blacklist.isNotEmpty()) { + Text( + "黑名单(${blacklist.size})", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.outline, + ) + LazyColumn { + items(blacklist, key = { it.id }) { entry -> + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(entry.blockedUserId, modifier = Modifier.weight(1f)) + TextButton(onClick = { viewModel.removeFromBlacklist(entry.blockedUserId) }) { + Text("移除") + } + } + HorizontalDivider() } } - HorizontalDivider() } } } } } + + if (pendingFriendRequestUser != null) { + AlertDialog( + onDismissRequest = { + pendingFriendRequestUser = null + friendRequestRemark = "" + }, + title = { Text("申请好友") }, + text = { + Column { + Text( + pendingFriendRequestUser?.nickname.orEmpty(), + style = MaterialTheme.typography.titleSmall, + ) + Text( + pendingFriendRequestUser?.userId.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + OutlinedTextField( + value = friendRequestRemark, + onValueChange = { friendRequestRemark = it }, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + label = { Text("申请信息") }, + placeholder = { Text("例如:我是某某,想和你加好友") }, + minLines = 3, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + pendingFriendRequestUser?.let { user -> + viewModel.addFriend(user.userId, friendRequestRemark.trim().takeIf { it.isNotBlank() }) + } + pendingFriendRequestUser = null + friendRequestRemark = "" + }, + ) { Text("发送") } + }, + dismissButton = { + TextButton( + onClick = { + pendingFriendRequestUser = null + friendRequestRemark = "" + }, + ) { Text("取消") } + }, + ) + } + + LaunchedEffect(Unit) { + viewModel.events.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } + } } @Composable -private fun FriendItem(userId: String, nickname: String, onChat: () -> Unit, onRemove: () -> Unit) { +private fun MemberItem( + user: UserData, + isFriend: Boolean, + onChat: () -> Unit, + onAddFriend: () -> Unit, + onRemoveFriend: () -> Unit, + onBlacklist: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onChat) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - Text(nickname, style = MaterialTheme.typography.titleSmall) - Text(userId, style = MaterialTheme.typography.bodySmall, + Text(user.nickname, style = MaterialTheme.typography.titleSmall) + Text(user.userId, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) } - TextButton(onClick = onRemove) { - Text("删除", color = MaterialTheme.colorScheme.error) + if (isFriend) { + TextButton(onClick = onChat) { Text("发消息") } + TextButton(onClick = onRemoveFriend) { Text("删除", color = MaterialTheme.colorScheme.error) } + } else { + TextButton(onClick = onAddFriend) { Text("申请好友") } } + TextButton(onClick = onBlacklist) { Text("拉黑") } } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt index f72e3ec..70defc6 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt @@ -13,12 +13,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Badge import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -39,13 +41,16 @@ import com.xuqm.sdk.ui.SearchBarField import com.xuqm.sdk.utils.TimeFormatters import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConversationScreen( onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit, viewModel: ConversationViewModel = viewModel(), ) { val conversations by viewModel.conversations.collectAsStateWithLifecycle() + val conversationTitles by viewModel.conversationTitles.collectAsStateWithLifecycle() val totalUnreadCount by viewModel.totalUnreadCount.collectAsStateWithLifecycle() + val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var query by remember { mutableStateOf("") } val filtered = remember(conversations, query) { @@ -59,7 +64,12 @@ fun ConversationScreen( } } - Column(modifier = Modifier.fillMaxSize()) { + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) { + Column(modifier = Modifier.fillMaxSize()) { Text( "总未读 $totalUnreadCount", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -87,9 +97,11 @@ fun ConversationScreen( .weight(1f), ) { items(filtered, key = { "${it.chatType}_${it.targetId}" }) { conv -> + val title = conversationTitle(conv, conversationTitles) ConversationItem( conversation = conv, - onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) }, + title = title, + onClick = { onOpenChat(conv.targetId, conv.chatType, title) }, onPinToggle = { scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) } }, @@ -104,6 +116,7 @@ fun ConversationScreen( } } } + } } } @@ -111,6 +124,7 @@ fun ConversationScreen( @Composable private fun ConversationItem( conversation: ConversationData, + title: String, onClick: () -> Unit, onPinToggle: () -> Unit, onMuteToggle: () -> Unit, @@ -126,14 +140,14 @@ private fun ConversationItem( .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - InitialAvatar(text = conversation.targetId, modifier = Modifier.size(48.dp)) + InitialAvatar(text = title, modifier = Modifier.size(48.dp)) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - conversation.targetId, + title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f), ) @@ -180,6 +194,17 @@ private fun ConversationItem( } } +private fun conversationTitle( + conversation: ConversationData, + titles: Map, +): String { + 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() diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt index 0fe27be..0df402f 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt @@ -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>(emptyList()) val conversations: StateFlow> = _conversations + private val _conversationTitles = MutableStateFlow>(emptyMap()) + val conversationTitles: StateFlow> = _conversationTitles + private val _totalUnreadCount = MutableStateFlow(0) val totalUnreadCount: StateFlow = _totalUnreadCount + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing + private val listener = object : ImEventListener { - override fun onMessage(message: ImMessage) { refresh() } - override fun onGroupMessage(message: ImMessage) { refresh() } + override fun onMessage(message: ImMessage) { + Log.d( + TAG, + "incoming single message refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}", + ) + refresh() + } + + override fun onGroupMessage(message: ImMessage) { + Log.d( + TAG, + "incoming group message refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}", + ) + refresh() + } } init { @@ -31,6 +56,11 @@ class ConversationViewModel : ViewModel() { _conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime } _totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount } refresh() + viewModelScope.launch { + AppDependencies.conversationRefreshRequests.collect { + refresh() + } + } viewModelScope.launch { ImSDK.connectionState.collect { state -> if (state is ImConnectionState.Connected) { @@ -41,21 +71,29 @@ class ConversationViewModel : ViewModel() { } fun refresh() { + Log.d(TAG, "refresh() called") viewModelScope.launch { - runCatching { ImSDK.listConversations() } - .onSuccess { list -> - val sorted = list.sortedByDescending { it.lastMsgTime } - cache.saveConversations(sorted) - _conversations.value = sorted - _totalUnreadCount.value = sorted.sumOf { it.unreadCount } - } - .onFailure { - val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime } - if (cached.isNotEmpty()) { - _conversations.value = cached - _totalUnreadCount.value = cached.sumOf { it.unreadCount } - } + _isRefreshing.value = true + try { + val conversations = runCatching { ImSDK.listConversations() }.getOrDefault(emptyList()) + val groups = runCatching { ImSDK.listGroups() }.getOrDefault(emptyList()) + val groupMap = groups.associateBy({ it.id }, { it.name }) + val merged = mergeGroupConversations(conversations, groups).sortedByDescending { it.lastMsgTime } + Log.d(TAG, "refresh success conversationCount=${merged.size} groupCount=${groups.size}") + cache.saveConversations(merged) + _conversations.value = merged + _conversationTitles.value = groupMap + _totalUnreadCount.value = merged.sumOf { it.unreadCount } + } catch (t: Throwable) { + Log.w(TAG, "refresh failed, falling back to cache", t) + val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime } + if (cached.isNotEmpty()) { + _conversations.value = cached + _totalUnreadCount.value = cached.sumOf { it.unreadCount } } + } finally { + _isRefreshing.value = false + } } } @@ -81,4 +119,27 @@ class ConversationViewModel : ViewModel() { override fun onCleared() { ImSDK.removeListener(listener) } + + private fun mergeGroupConversations( + conversations: List, + groups: List, + ): List { + 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() + } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt index 281f043..cbcff6a 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt @@ -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, ) diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt index 87b68fe..05557fc 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt @@ -3,6 +3,7 @@ package com.xuqm.sdk.sample.ui.group import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -11,9 +12,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -27,6 +32,9 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,16 +42,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.ui.SearchBarField import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.GroupJoinRequest +import com.xuqm.sdk.sample.data.api.UserData import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner +import com.xuqm.sdk.ui.InitialAvatar @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -53,8 +67,10 @@ fun GroupListScreen( viewModel: GroupViewModel = viewModel(), ) { val groups by viewModel.groups.collectAsStateWithLifecycle() + val members by viewModel.members.collectAsStateWithLifecycle() val publicGroups by viewModel.publicGroups.collectAsStateWithLifecycle() val publicGroupQuery by viewModel.publicGroupQuery.collectAsStateWithLifecycle() + val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() var showCreateDialog by remember { mutableStateOf(false) } Scaffold( @@ -64,9 +80,14 @@ fun GroupListScreen( } }, ) { padding -> - LazyColumn( + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = viewModel::refresh, modifier = Modifier.fillMaxSize().padding(padding), ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { item { SearchBarField( value = publicGroupQuery, @@ -108,10 +129,12 @@ fun GroupListScreen( } } } + } } if (showCreateDialog) { CreateGroupDialog( + members = members, onDismiss = { showCreateDialog = false }, onCreate = { name, memberIds, groupType -> showCreateDialog = false @@ -171,39 +194,108 @@ private fun PublicGroupItem(group: ImGroup, onOpen: () -> Unit, onJoin: () -> Un } @Composable -private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List, String) -> Unit) { +private fun CreateGroupDialog( + members: List, + onDismiss: () -> Unit, + onCreate: (String, List, 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() } + 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, + 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, + 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!!, diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt index 36f7252..69e7db0 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt @@ -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>(emptyList()) val groups: StateFlow> = _groups + private val _members = MutableStateFlow>(emptyList()) + val members: StateFlow> = _members + private val _publicGroups = MutableStateFlow>(emptyList()) val publicGroups: StateFlow> = _publicGroups @@ -26,15 +34,24 @@ class GroupViewModel : ViewModel() { private val _joinRequests = MutableStateFlow>(emptyList()) val joinRequests: StateFlow> = _joinRequests + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _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 } + } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt b/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt index 7c9a0fa..8a642d6 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/core/ServiceEndpoints.kt @@ -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 { diff --git a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt index 1578955..f642375 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt @@ -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") - } - } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/file/FileTransfer.kt b/sdk-core/src/main/java/com/xuqm/sdk/file/FileTransfer.kt new file mode 100644 index 0000000..9a3d79e --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileTransfer.kt @@ -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()) + } + } + } + } + } +} diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt index 05a4226..6501cf6 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/network/ApiClient.kt @@ -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() @@ -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', ' ')}") + } + } + } + } diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt index b65c3e1..7e4de82 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImClient.kt @@ -3,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() + private val listeners = CopyOnWriteArraySet() private val gson = Gson() private val subscriptions = mutableMapOf() 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, body: String?) { - val socket = webSocket ?: return + private fun sendFrame(command: String, headers: Map, 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): Map { diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt index 2db290d..d918e35 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt @@ -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() + private val listeners = CopyOnWriteArraySet() + 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.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, - ) { - 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") + } + } + } diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt index 76486b5..b2552c0 100644 --- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt @@ -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) } }