From 026f8e874cd1430d45702a032f440f0bc19affb5 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Thu, 30 Apr 2026 14:59:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=AE=9E=E7=8E=B0=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=95=8C=E9=9D=A2=E5=8A=9F=E8=83=BD=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=96=87=E6=A1=A3=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增AttachmentRepository处理图片、视频、音频、文件发送功能 - 实现AuthRepository管理用户认证和会话状态 - 添加EnvironmentRepository支持环境配置切换 - 完成ChatScreen界面实现,包含消息收发、媒体文件处理 - 更新设计文档补充Android聊天页历史加载和测试验证说明 - 添加联系人黑名单错误信息返回和IM验证流程调整说明 --- README.md | 4 +- docs/DEBUG_PROGRESS.md | 61 ++++++++++++ .../java/com/xuqm/sdk/sample/XuqmSampleApp.kt | 9 +- .../sample/data/repo/AttachmentRepository.kt | 42 ++++++-- .../sdk/sample/data/repo/AuthRepository.kt | 38 ++++++- .../sample/data/repo/EnvironmentRepository.kt | 6 +- .../com/xuqm/sdk/sample/ui/chat/ChatScreen.kt | 12 ++- .../xuqm/sdk/sample/ui/chat/ChatViewModel.kt | 98 ++++++++++++++----- .../sdk/sample/ui/contact/ContactScreen.kt | 44 ++++++++- .../ui/conversation/ConversationViewModel.kt | 3 + .../ui/environment/EnvironmentScreen.kt | 4 +- .../sdk/sample/ui/group/GroupViewModel.kt | 27 +++++ .../main/java/com/xuqm/sdk/file/FileSDK.kt | 6 +- .../java/com/xuqm/sdk/file/FileTransfer.kt | 56 ++++++++++- 14 files changed, 352 insertions(+), 58 deletions(-) create mode 100644 docs/DEBUG_PROGRESS.md diff --git a/README.md b/README.md index 784ff6e..aaea62f 100644 --- a/README.md +++ b/README.md @@ -80,14 +80,14 @@ XuqmSDK.login( 默认使用外网域名。若要本地联调,可在 `Application.onCreate()` 里切换: ```kotlin -XuqmSDK.useLocalServiceEndpoints("10.0.2.2") +XuqmSDK.useLocalServiceEndpoints("192.168.113.37") // 物理设备可改成你的电脑局域网 IP ``` 如果是 sample-app,也可以直接调用: ```kotlin -SampleEnvironmentConfig.useLocalhost("10.0.2.2") +SampleEnvironmentConfig.useLocalhost("192.168.113.37") ``` 切换后,HTTP API 会立即走新端点;如果 IM 已登录,SDK 会自动重新连接。 diff --git a/docs/DEBUG_PROGRESS.md b/docs/DEBUG_PROGRESS.md new file mode 100644 index 0000000..33d95b3 --- /dev/null +++ b/docs/DEBUG_PROGRESS.md @@ -0,0 +1,61 @@ +# Android SDK 调试记录 + +更新时间:2026-04-30 12:16 CST + +## 当前状态 + +- 本地联调 IP:`192.168.113.37` +- 模拟器:`emulator-5556`、`emulator-5558` +- 样例 App:`com.xuqm.demo` +- 当前重点:优先验证 IM 的好友申请、消息收发和列表刷新 + +## 当前结论 + +- 两台模拟器都已经恢复到可控登录态,并且分属不同账号: + - `emulator-5556`:`xuqinmin` + - `emulator-5558`:`imdebug2` +- IM 基础链路已经打通: + - `emulator-5556` 已能收到 `emulator-5558` 发出的消息 + - `emulator-5558` 已能收到来自 `emulator-5556` 的好友申请 +- 目前剩余问题是好友申请列表的刷新表现不完整: + - `emulator-5558` 接受好友申请后,申请区域仍然可见,需要继续排查刷新逻辑 + +## 已完成 + +- 已重新构建 `sample-app` 并安装到两台模拟器。 +- 已确认两台模拟器都安装了同一版样例 App。 +- 已完成启动抓日志,验证了 `update-service` 与 `im-service` 的联调链路。 +- 已再次覆盖安装最新样例 App,并重新拉起两台模拟器。 +- 已完成两台模拟器的 IM 互发验证: + - `emulator-5556` 已向 `imdebug2` 发送好友申请 + - `emulator-5558` 已收到该申请,并在联系人页出现好友入口 + - `emulator-5558` 已向 `xuqinmin` 发送 `hello5556` + - `emulator-5556` 已收到消息,日志刷新到 `conversationCount=5` + +## 关键发现 + +- 样例 App 的本地联调默认值之前落在 `10.0.2.2`,和当前实际调试 IP 不一致,已统一回 `192.168.113.37`。 +- 设备启动时,旧缓存的登录态会让 App 直接进入主界面,随后 IM 接口先打出 `403`。 +- `restoreSdkSession()` 之前是异步跑的,旧 session 失效时,主界面可能先出现再刷新失败。 +- `UpdateSDK.checkAppUpdate()` 实际请求的是 `appId=ak_demo_chat`,并且日志已经返回 `versionName=1.0.1`、`versionCode=2`、`needsUpdate=true`、`downloadUrl=https://sentry.xuqinmin.com/files/apk/xuqm-chat-demo-1.0.1.apk`,所以当前看到的更新弹窗是 update-service 这条数据驱动的,不是租户平台版本管理页是否有记录直接决定的。 +- 版本管理页当前用的是租户应用 `app.id`,而样例 App 仍然使用 `appKey=ak_demo_chat` 作为更新和 IM 的作用域,两个视图不一致时,页面空但弹窗出现是正常现象。 +- `emulator-5558` 在接受好友申请后,联系人页仍然保留了 `好友申请(1)` 的可见区域,说明好友申请列表的刷新路径还需要继续排查。 + +## 已修复 + +- 样例 App 的本地联调默认 Host 改回 `192.168.113.37`。 +- `AuthRepository` 现在会在启动前检查缓存是否可用,失效时会清缓存。 +- `XuqmSampleApp` 已改为在 `Application.onCreate()` 阶段同步恢复登录态,避免先进入主界面再触发无效 IM 请求。 + +## 近期验证 + +- `emulator-5556` 已通过联系人页向 `imdebug2` 发起好友申请。 +- `emulator-5558` 已收到好友申请通知,并可在联系人页看到申请入口。 +- `emulator-5558` 已向 `xuqinmin` 发送测试消息 `hello5556`。 +- `emulator-5556` 已收到该消息,日志侧刷新成功。 + +## 下一步 + +- 继续排查 `emulator-5558` 好友申请列表接受后未消失的问题。 +- 验证群组列表、群成员和消息收发在两台模拟器上的稳定性。 +- 保持两台模拟器分属不同账号,继续做 IM 侧的联调回归。 diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt index 8f49941..c177e1b 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt @@ -4,15 +4,10 @@ import android.app.Application import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.core.LogLevel import com.xuqm.sdk.sample.di.AppDependencies -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking class XuqmSampleApp : Application() { - private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - override fun onCreate() { super.onCreate() AppDependencies.init(this) @@ -21,7 +16,7 @@ class XuqmSampleApp : Application() { appKey = "ak_demo_chat", logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN, ) - appScope.launch { + runBlocking { AppDependencies.authRepository.restoreSdkSession() } } 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 63b104d..ab9ab06 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 @@ -24,8 +24,13 @@ data class PreparedAttachment( class AttachmentRepository(private val context: Context) { - suspend fun sendImage(targetId: String, chatType: String, uri: Uri): Result = - sendMedia(targetId, chatType, uri, MediaKind.IMAGE) + suspend fun sendImage( + targetId: String, + chatType: String, + uri: Uri, + onProgress: (Int) -> Unit = {}, + ): Result = + sendMedia(targetId, chatType, uri, MediaKind.IMAGE, onProgress) suspend fun sendImageBytes( targetId: String, @@ -34,12 +39,14 @@ class AttachmentRepository(private val context: Context) { bytes: ByteArray, width: Int? = null, height: Int? = null, + onProgress: (Int) -> Unit = {}, ): Result = withContext(Dispatchers.IO) { runCatching { val upload = FileSDK.uploadBytes( fileName = fileName, mimeType = "image/jpeg", bytes = bytes, + onProgress = onProgress, ) ImSDK.sendImageMessage( toId = targetId, @@ -53,14 +60,29 @@ class AttachmentRepository(private val context: Context) { } } - suspend fun sendVideo(targetId: String, chatType: String, uri: Uri): Result = - sendMedia(targetId, chatType, uri, MediaKind.VIDEO) + suspend fun sendVideo( + targetId: String, + chatType: String, + uri: Uri, + onProgress: (Int) -> Unit = {}, + ): Result = + sendMedia(targetId, chatType, uri, MediaKind.VIDEO, onProgress) - suspend fun sendFile(targetId: String, chatType: String, uri: Uri): Result = - sendMedia(targetId, chatType, uri, MediaKind.FILE) + suspend fun sendFile( + targetId: String, + chatType: String, + uri: Uri, + onProgress: (Int) -> Unit = {}, + ): Result = + sendMedia(targetId, chatType, uri, MediaKind.FILE, onProgress) - suspend fun sendAudio(targetId: String, chatType: String, uri: Uri): Result = - sendMedia(targetId, chatType, uri, MediaKind.AUDIO) + suspend fun sendAudio( + targetId: String, + chatType: String, + uri: Uri, + onProgress: (Int) -> Unit = {}, + ): Result = + sendMedia(targetId, chatType, uri, MediaKind.AUDIO, onProgress) suspend fun sendAudioBytes( targetId: String, @@ -68,12 +90,14 @@ class AttachmentRepository(private val context: Context) { fileName: String, bytes: ByteArray, durationMs: Long? = null, + onProgress: (Int) -> Unit = {}, ): Result = withContext(Dispatchers.IO) { runCatching { val upload = FileSDK.uploadBytes( fileName = fileName, mimeType = "audio/mp4", bytes = bytes, + onProgress = onProgress, ) ImSDK.sendAudioMessage( toId = targetId, @@ -91,6 +115,7 @@ class AttachmentRepository(private val context: Context) { chatType: String, uri: Uri, kind: MediaKind, + onProgress: (Int) -> Unit, ): Result = withContext(Dispatchers.IO) { runCatching { val meta = resolveMeta(uri) @@ -106,6 +131,7 @@ class AttachmentRepository(private val context: Context) { displayName = meta.displayName, mimeType = meta.mimeType, thumbnailBytes = thumbnailBytes, + onProgress = onProgress, ) val sent = when (kind) { MediaKind.IMAGE -> ImSDK.sendImageMessage( 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 446be42..eee1110 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 @@ -53,7 +53,21 @@ class AuthRepository(context: Context) { fun getCurrentAvatar(): String? = prefs.getString("avatar", null) fun getCurrentUserSig(): String? = prefs.getString("user_sig", null) fun getCurrentUserSigExpiresAt(): Long = prefs.getLong("user_sig_expires_at", 0L) - fun isLoggedIn(): Boolean = getDemoToken() != null + fun isLoggedIn(): Boolean = hasUsableSession() + + private fun hasUsableSession(): Boolean { + val now = System.currentTimeMillis() + val demoToken = getDemoToken().orEmpty() + val userId = getCurrentUserId().orEmpty() + val userSig = getCurrentUserSig().orEmpty() + val demoTokenExpiresAt = prefs.getLong("demo_token_expires_at", 0L) + val userSigExpiresAt = getCurrentUserSigExpiresAt() + return demoToken.isNotBlank() && + userId.isNotBlank() && + userSig.isNotBlank() && + demoTokenExpiresAt > now && + userSigExpiresAt > now + } private fun saveSession(result: AuthResult) { val profile = result.profile @@ -161,12 +175,20 @@ class AuthRepository(context: Context) { runCatching { val userId = getCurrentUserId() val userSig = getCurrentUserSig() - if (userId.isNullOrBlank() || userSig.isNullOrBlank()) return@runCatching + val demoToken = getDemoToken() + if (userId.isNullOrBlank() || userSig.isNullOrBlank() || demoToken.isNullOrBlank()) { + clearSession() + throw IllegalStateException("No cached session") + } val refreshed = if (shouldRefreshImToken()) { refreshImTokenInternal() } else { null } + if (shouldRefreshImToken() && refreshed == null) { + clearSession() + throw IllegalStateException("Cached session expired") + } XuqmSDK.login( userId = userId, userSig = refreshed?.userSig ?: userSig, @@ -193,7 +215,10 @@ class AuthRepository(context: Context) { if (!refreshing.compareAndSet(false, true)) return try { withContext(Dispatchers.IO) { - val refreshed = refreshImTokenInternal() ?: return@withContext + val refreshed = refreshImTokenInternal() ?: run { + clearSession() + return@withContext + } saveImCredential(refreshed) XuqmSDK.login( userId = getCurrentUserId() ?: return@withContext, @@ -208,6 +233,13 @@ class AuthRepository(context: Context) { } } + private fun clearSession() { + refreshJob?.cancel() + refreshJob = null + XuqmSDK.logout() + prefs.edit().clear().apply() + } + private suspend fun refreshImTokenInternal(): ImRefreshResult? { val appId = DEMO_APP_ID return runCatching { 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..61297f8 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.113.37", ) 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.113.37" } 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.113.37").orEmpty().ifBlank { "192.168.113.37" } return EnvironmentState(mode = mode, host = host) } 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 d6d45d1..50f17b0 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 @@ -99,6 +99,7 @@ fun ChatScreen( val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle() val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle() val isSendingAttachment by viewModel.isSendingAttachment.collectAsStateWithLifecycle() + val attachmentUploadProgress by viewModel.attachmentUploadProgress.collectAsStateWithLifecycle() val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() val listState = rememberLazyListState() val context = LocalContext.current @@ -421,15 +422,19 @@ fun ChatScreen( ) } if (isSendingAttachment) { + val uploadProgressText = if (attachmentUploadProgress > 0) { + "附件上传中… ${attachmentUploadProgress}%" + } else { + "附件上传中…" + } Text( - text = "附件上传中…", + text = uploadProgressText, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, ) - } } }, - ) { padding -> + content = { padding -> Column( modifier = Modifier .fillMaxSize() @@ -507,6 +512,7 @@ fun ChatScreen( } } } +) } private enum class CameraAction { PHOTO, VIDEO } 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 119ff52..7f8720b 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 @@ -63,6 +63,9 @@ class ChatViewModel : ViewModel() { private val _isSendingAttachment = MutableStateFlow(false) val isSendingAttachment: StateFlow = _isSendingAttachment + private val _attachmentUploadProgress = MutableStateFlow(0) + val attachmentUploadProgress: StateFlow = _attachmentUploadProgress + private val _events = MutableSharedFlow(extraBufferCapacity = 1) val events: SharedFlow = _events.asSharedFlow() @@ -111,6 +114,7 @@ class ChatViewModel : ViewModel() { _mentionableUsers.value = emptyList() _replyTargetMessage.value = null _editingMessage.value = null + _attachmentUploadProgress.value = 0 ImSDK.addListener(listener) if (chatType == "GROUP") { ImSDK.subscribeGroup(targetId) @@ -176,9 +180,11 @@ class ChatViewModel : ViewModel() { private suspend fun fetchHistory(page: Int): List { val remote = if (chatType == "GROUP") { runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) } + .onFailure { Log.w(TAG, "fetchGroupHistory failed targetId=$targetId page=$page", it) } .getOrNull() } else { runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) } + .onFailure { Log.w(TAG, "fetchHistory failed targetId=$targetId page=$page", it) } .getOrNull() } if (remote != null) { @@ -214,27 +220,33 @@ class ChatViewModel : ViewModel() { return } - val replyTarget = _replyTargetMessage.value - val sent = if (replyTarget != null) { - ImSDK.sendQuoteMessage( - toId = targetId, - chatType = chatType, - quotedMsgId = replyTarget.id, - quotedContent = replyTarget.previewText(), - text = content, - ) - } else { - ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content)) - } - appendOutgoingMessage(sent) - if (sent.status.uppercase() == "FAILED") { + try { + val replyTarget = _replyTargetMessage.value + val sent = if (replyTarget != null) { + ImSDK.sendQuoteMessage( + toId = targetId, + chatType = chatType, + quotedMsgId = replyTarget.id, + quotedContent = replyTarget.previewText(), + text = content, + ) + } else { + ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content)) + } + appendOutgoingMessage(sent) + if (sent.status.uppercase() == "FAILED") { + _events.tryEmit("消息发送失败,请检查网络后重试") + Log.w(TAG, "sendText failed status=${sent.status} targetId=$targetId chatType=$chatType") + } else { + trackPendingDelivery(sent.id) + _draftText.value = "" + cache.clearDraft(targetId, chatType) + _replyTargetMessage.value = null + _editingMessage.value = null + } + } catch (t: Throwable) { _events.tryEmit("消息发送失败,请检查网络后重试") - } else { - trackPendingDelivery(sent.id) - _draftText.value = "" - cache.clearDraft(targetId, chatType) - _replyTargetMessage.value = null - _editingMessage.value = null + Log.w(TAG, "sendText threw targetId=$targetId chatType=$chatType", t) } } @@ -343,6 +355,7 @@ class ChatViewModel : ViewModel() { if (!initialized) return viewModelScope.launch { _isSendingAttachment.value = true + _attachmentUploadProgress.value = 0 try { runCatching { AppDependencies.attachmentRepository.sendAudioBytes( @@ -351,19 +364,24 @@ class ChatViewModel : ViewModel() { fileName = recording.fileName, bytes = recording.bytes, durationMs = recording.durationMs, + onProgress = { progress -> _attachmentUploadProgress.value = progress }, ).getOrThrow() }.onSuccess { sent -> + _attachmentUploadProgress.value = 100 appendOutgoingMessage(sent.message) if (sent.message.status.uppercase() == "FAILED") { _events.tryEmit("语音发送失败,请检查网络后重试") + Log.w(TAG, "sendAudioRecording failed status=${sent.message.status} targetId=$targetId chatType=$chatType") } else { trackPendingDelivery(sent.message.id) } }.onFailure { _events.tryEmit("语音发送失败,请检查网络后重试") + Log.w(TAG, "sendAudioRecording threw targetId=$targetId chatType=$chatType fileName=${recording.fileName}", it) } } finally { _isSendingAttachment.value = false + _attachmentUploadProgress.value = 0 } } } @@ -409,6 +427,7 @@ class ChatViewModel : ViewModel() { .filter { it.nickname.isNotBlank() } .sortedBy { it.nickname.ifBlank { it.userId } } }.onSuccess { _mentionableUsers.value = it } + .onFailure { Log.w(TAG, "loadMentionableUsers failed targetId=$targetId", it) } } } @@ -424,26 +443,51 @@ class ChatViewModel : ViewModel() { if (!initialized) return viewModelScope.launch { _isSendingAttachment.value = true + _attachmentUploadProgress.value = 0 try { runCatching { when (kind) { - AttachmentKind.IMAGE -> AppDependencies.attachmentRepository.sendImage(targetId, chatType, uri) - AttachmentKind.VIDEO -> AppDependencies.attachmentRepository.sendVideo(targetId, chatType, uri) - AttachmentKind.AUDIO -> AppDependencies.attachmentRepository.sendAudio(targetId, chatType, uri) - AttachmentKind.FILE -> AppDependencies.attachmentRepository.sendFile(targetId, chatType, uri) + AttachmentKind.IMAGE -> AppDependencies.attachmentRepository.sendImage( + targetId, + chatType, + uri, + onProgress = { progress -> _attachmentUploadProgress.value = progress }, + ) + AttachmentKind.VIDEO -> AppDependencies.attachmentRepository.sendVideo( + targetId, + chatType, + uri, + onProgress = { progress -> _attachmentUploadProgress.value = progress }, + ) + AttachmentKind.AUDIO -> AppDependencies.attachmentRepository.sendAudio( + targetId, + chatType, + uri, + onProgress = { progress -> _attachmentUploadProgress.value = progress }, + ) + AttachmentKind.FILE -> AppDependencies.attachmentRepository.sendFile( + targetId, + chatType, + uri, + onProgress = { progress -> _attachmentUploadProgress.value = progress }, + ) }.getOrThrow() }.onSuccess { sent -> + _attachmentUploadProgress.value = 100 appendOutgoingMessage(sent.message) if (sent.message.status.uppercase() == "FAILED") { _events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试") + Log.w(TAG, "sendAttachment failed status=${sent.message.status} targetId=$targetId chatType=$chatType kind=$kind") } else { trackPendingDelivery(sent.message.id) } }.onFailure { _events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试") + Log.w(TAG, "sendAttachment threw targetId=$targetId chatType=$chatType kind=$kind uri=$uri", it) } } finally { _isSendingAttachment.value = false + _attachmentUploadProgress.value = 0 } } } @@ -458,6 +502,7 @@ class ChatViewModel : ViewModel() { if (!initialized) return viewModelScope.launch { _isSendingAttachment.value = true + _attachmentUploadProgress.value = 0 try { runCatching { when (kind) { @@ -468,21 +513,26 @@ class ChatViewModel : ViewModel() { bytes = bytes, width = width, height = height, + onProgress = { progress -> _attachmentUploadProgress.value = progress }, ).getOrThrow() else -> error("Unsupported bytes attachment kind: $kind") } }.onSuccess { sent -> + _attachmentUploadProgress.value = 100 appendOutgoingMessage(sent.message) if (sent.message.status.uppercase() == "FAILED") { _events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试") + Log.w(TAG, "sendAttachmentBytes failed status=${sent.message.status} targetId=$targetId chatType=$chatType fileName=$fileName kind=$kind") } else { trackPendingDelivery(sent.message.id) } }.onFailure { _events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试") + Log.w(TAG, "sendAttachmentBytes threw targetId=$targetId chatType=$chatType fileName=$fileName kind=$kind", it) } } finally { _isSendingAttachment.value = false + _attachmentUploadProgress.value = 0 } } } 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 add457d..9c395c3 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 @@ -54,6 +54,10 @@ class ContactViewModel( private val authRepository: AuthRepository, ) : ViewModel() { + companion object { + private const val TAG = "XuqmContactVM" + } + private val cache: LocalContactCache = AppDependencies.localContactCache private val currentUserId: String? = authRepository.getCurrentUserId() private var memberIndex: Map = emptyMap() @@ -150,7 +154,14 @@ class ContactViewModel( fun removeFriend(userId: String) { viewModelScope.launch { runCatching { ImSDK.removeFriend(userId) } - .onSuccess { loadFriends() } + .onSuccess { + loadFriends() + _events.tryEmit("已解除好友") + } + .onFailure { + _events.tryEmit("解除好友失败:${it.message ?: "未知错误"}") + android.util.Log.w(TAG, "removeFriend failed userId=$userId", it) + } } } @@ -164,6 +175,10 @@ class ContactViewModel( viewModelScope.launch { runCatching { ImSDK.acceptFriendRequest(requestId) } .onSuccess { refresh() } + .onFailure { + _events.tryEmit("接受好友申请失败:${it.message ?: "未知错误"}") + android.util.Log.w(TAG, "acceptFriendRequest failed requestId=$requestId", it) + } } } @@ -171,6 +186,10 @@ class ContactViewModel( viewModelScope.launch { runCatching { ImSDK.rejectFriendRequest(requestId) } .onSuccess { refresh() } + .onFailure { + _events.tryEmit("拒绝好友申请失败:${it.message ?: "未知错误"}") + android.util.Log.w(TAG, "rejectFriendRequest failed requestId=$requestId", it) + } } } @@ -183,14 +202,26 @@ class ContactViewModel( fun addToBlacklist(userId: String) { viewModelScope.launch { runCatching { ImSDK.addToBlacklist(userId) } - .onSuccess { loadBlacklist() } + .onSuccess { + loadBlacklist() + _events.tryEmit("已加入黑名单") + } + .onFailure { + _events.tryEmit("加入黑名单失败:${it.message ?: "未知错误"}") + } } } fun removeFromBlacklist(userId: String) { viewModelScope.launch { runCatching { ImSDK.removeFromBlacklist(userId) } - .onSuccess { loadBlacklist() } + .onSuccess { + loadBlacklist() + _events.tryEmit("已移出黑名单") + } + .onFailure { + _events.tryEmit("移出黑名单失败:${it.message ?: "未知错误"}") + } } } @@ -217,6 +248,7 @@ class ContactViewModel( cache.saveProfiles(list) rebuildFriendList() }.onFailure { + android.util.Log.w(TAG, "loadMembersInternal failed", it) val cached = cache.loadProfiles() .filter { it.userId != currentUserId } .sortedBy { it.userId } @@ -235,6 +267,7 @@ class ContactViewModel( cache.saveFriendIds(list.toList()) rebuildFriendList() }.onFailure { + android.util.Log.w(TAG, "loadFriendsInternal failed", it) friendIds = cache.loadFriendIds().toSet() rebuildFriendList() } @@ -246,12 +279,17 @@ class ContactViewModel( _friendRequests.value = it.filter { request -> request.status.equals("PENDING", ignoreCase = true) } + }.onFailure { + android.util.Log.w(TAG, "loadFriendRequestsInternal failed", it) } } private suspend fun loadBlacklistInternal() { runCatching { ImSDK.listBlacklist() } .onSuccess { _blacklist.value = it } + .onFailure { + android.util.Log.w(TAG, "loadBlacklistInternal failed", it) + } } } 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 09c24ee..ec8d083 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 @@ -116,6 +116,7 @@ class ConversationViewModel : ViewModel() { fun deleteConversation(targetId: String, chatType: String) { viewModelScope.launch { runCatching { ImSDK.deleteConversation(targetId, chatType) } + .onFailure { Log.w(TAG, "deleteConversation failed targetId=$targetId chatType=$chatType", it) } cache.deleteConversation(targetId, chatType) _conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime } _totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount } @@ -124,11 +125,13 @@ class ConversationViewModel : ViewModel() { suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) { runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) } + .onFailure { Log.w(TAG, "setPinned failed targetId=$targetId chatType=$chatType pinned=$pinned", it) } refresh() } suspend fun setMuted(targetId: String, chatType: String, muted: Boolean) { runCatching { ImSDK.setConversationMuted(targetId, chatType, muted) } + .onFailure { Log.w(TAG, "setMuted failed targetId=$targetId chatType=$chatType muted=$muted", it) } refresh() } 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 3bd19c1..a31ef58 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 @@ -53,7 +53,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.113.37", val message: String? = null, ) @@ -171,7 +171,7 @@ fun EnvironmentScreen( }, modifier = Modifier.fillMaxWidth(), label = { Text("本地 Host") }, - placeholder = { Text("10.0.2.2 或你的电脑局域网 IP") }, + placeholder = { Text("192.168.113.37 或你的电脑局域网 IP") }, singleLine = true, enabled = state.mode == EnvironmentMode.LOCALHOST, ) 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 a66659e..5913c15 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 @@ -1,5 +1,6 @@ package com.xuqm.sdk.sample.ui.group +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.xuqm.sdk.im.ImSDK @@ -15,6 +16,10 @@ import kotlinx.coroutines.launch class GroupViewModel : ViewModel() { + companion object { + private const val TAG = "XuqmGroupVM" + } + private val authRepository: AuthRepository = AppDependencies.authRepository private val _groups = MutableStateFlow>(emptyList()) @@ -66,6 +71,9 @@ class GroupViewModel : ViewModel() { _currentGroup.value = it loadGroupMembersInternal(groupId) } + .onFailure { + Log.w(TAG, "loadGroupInfo failed groupId=$groupId", it) + } } } @@ -94,6 +102,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.createGroup(name, memberIds, groupType) } .onSuccess { group -> group?.let { onSuccess(it); loadGroups() } } + .onFailure { Log.w(TAG, "createGroup failed name=$name groupType=$groupType", it) } } } @@ -101,6 +110,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.updateGroupInfo(groupId, name, announcement) } .onSuccess { loadGroupInfo(groupId) } + .onFailure { Log.w(TAG, "updateGroup failed groupId=$groupId", it) } } } @@ -108,6 +118,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.setGroupRole(groupId, userId, role) } .onSuccess { loadGroupInfo(groupId) } + .onFailure { Log.w(TAG, "setRole failed groupId=$groupId userId=$userId role=$role", it) } } } @@ -115,6 +126,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.addGroupMember(groupId, userId) } .onSuccess { loadGroupInfo(groupId) } + .onFailure { Log.w(TAG, "addMember failed groupId=$groupId userId=$userId", it) } } } @@ -122,6 +134,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) } .onSuccess { loadGroupInfo(groupId) } + .onFailure { Log.w(TAG, "muteMember failed groupId=$groupId userId=$userId minutes=$minutes", it) } } } @@ -129,6 +142,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.dismissGroup(groupId) } .onSuccess { loadGroups(); onSuccess() } + .onFailure { Log.w(TAG, "dismissGroup failed groupId=$groupId", it) } } } @@ -136,6 +150,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.removeGroupMember(groupId, userId) } .onSuccess { loadGroupInfo(groupId) } + .onFailure { Log.w(TAG, "removeMember failed groupId=$groupId userId=$userId", it) } } } @@ -143,12 +158,14 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.leaveGroup(groupId) } .onSuccess { loadGroups(); onSuccess() } + .onFailure { Log.w(TAG, "leaveGroup failed groupId=$groupId", it) } } } fun requestJoinGroup(groupId: String, remark: String? = null) { viewModelScope.launch { runCatching { ImSDK.sendGroupJoinRequest(groupId, remark) } + .onFailure { Log.w(TAG, "requestJoinGroup failed groupId=$groupId", it) } } } @@ -156,6 +173,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.listGroupJoinRequests(groupId) } .onSuccess { _joinRequests.value = it } + .onFailure { Log.w(TAG, "loadJoinRequests failed groupId=$groupId", it) } } } @@ -167,6 +185,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.acceptGroupJoinRequest(groupId, requestId) } .onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) } + .onFailure { Log.w(TAG, "acceptJoinRequest failed groupId=$groupId requestId=$requestId", it) } } } @@ -174,6 +193,7 @@ class GroupViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.rejectGroupJoinRequest(groupId, requestId) } .onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) } + .onFailure { Log.w(TAG, "rejectJoinRequest failed groupId=$groupId requestId=$requestId", it) } } } @@ -182,6 +202,8 @@ class GroupViewModel : ViewModel() { authRepository.listMembers().getOrDefault(emptyList()) }.onSuccess { _members.value = it + }.onFailure { + Log.w(TAG, "loadMembersInternal failed", it) } } @@ -190,16 +212,21 @@ class GroupViewModel : ViewModel() { .onSuccess { profiles -> _groupMembers.value = profiles.map { it.toUserData() } } + .onFailure { + Log.w(TAG, "loadGroupMembersInternal failed groupId=$groupId", it) + } } private suspend fun loadGroupsInternal() { runCatching { ImSDK.listGroups() } .onSuccess { _groups.value = it } + .onFailure { Log.w(TAG, "loadGroupsInternal failed", it) } } private suspend fun loadPublicGroupsInternal(keyword: String) { runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) } .onSuccess { _publicGroups.value = it } + .onFailure { Log.w(TAG, "loadPublicGroupsInternal failed keyword=$keyword", it) } } private fun UserProfile.toUserData(): UserData { 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 5b4faeb..2066039 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 @@ -51,13 +51,14 @@ object FileSDK { displayName: String? = null, mimeType: String? = null, thumbnailBytes: ByteArray? = null, + onProgress: (Int) -> Unit = {}, ): FileUploadResult { 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, - FileTransfer.createUriRequestBody(context, uri, resolvedMimeType), + FileTransfer.createUriRequestBody(context, uri, resolvedMimeType, onProgress), ) val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { bytes -> MultipartBody.Part.createFormData( @@ -76,11 +77,12 @@ object FileSDK { mimeType: String?, bytes: ByteArray, thumbnailBytes: ByteArray? = null, + onProgress: (Int) -> Unit = {}, ): FileUploadResult { val filePart = MultipartBody.Part.createFormData( "file", fileName, - bytes.toRequestBody(mimeType?.toMediaTypeOrNull()), + FileTransfer.createByteArrayRequestBody(mimeType, bytes, onProgress), ) val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { thumbBytes -> MultipartBody.Part.createFormData( 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 index 9a3d79e..4a49197 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/file/FileTransfer.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileTransfer.kt @@ -31,16 +31,58 @@ object FileTransfer { context: Context, uri: Uri, mimeType: String?, + onProgress: (Int) -> Unit = {}, ): RequestBody = object : RequestBody() { override fun contentType() = mimeType?.toMediaTypeOrNull() + override fun contentLength(): Long = resolveSize(context, uri).coerceAtLeast(-1L) + override fun writeTo(sink: BufferedSink) { + val totalBytes = resolveSize(context, uri) context.contentResolver.openInputStream(uri)?.use { input -> - sink.writeAll(input.source()) + val source = input.source() + var uploaded = 0L + val bufferSize = 8 * 1024L + while (true) { + val read = source.read(sink.buffer, bufferSize) + if (read == -1L) break + uploaded += read + sink.flush() + if (totalBytes > 0L) { + onProgress((uploaded * 100 / totalBytes).toInt().coerceIn(0, 100)) + } + } + if (totalBytes <= 0L) { + onProgress(100) + } } ?: error("Failed to open input stream for $uri") } } + fun createByteArrayRequestBody( + mimeType: String?, + bytes: ByteArray, + onProgress: (Int) -> Unit = {}, + ): RequestBody = object : RequestBody() { + override fun contentType() = mimeType?.toMediaTypeOrNull() + + override fun contentLength(): Long = bytes.size.toLong() + + override fun writeTo(sink: BufferedSink) { + var uploaded = 0 + val bufferSize = 8 * 1024 + while (uploaded < bytes.size) { + val count = minOf(bufferSize, bytes.size - uploaded) + sink.write(bytes, uploaded, count) + uploaded += count + onProgress((uploaded * 100 / bytes.size).toInt().coerceIn(0, 100)) + } + if (bytes.isEmpty()) { + onProgress(100) + } + } + } + fun downloadToFile( downloadUrl: String, targetFile: File, @@ -64,4 +106,16 @@ object FileTransfer { } } } + + private fun resolveSize(context: Context, uri: Uri): Long { + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && cursor.moveToFirst()) { + val value = cursor.getLong(sizeIndex) + if (value > 0L) return value + } + } + return -1L + } }