feat(chat): 实现聊天界面功能并完善文档说明
- 新增AttachmentRepository处理图片、视频、音频、文件发送功能 - 实现AuthRepository管理用户认证和会话状态 - 添加EnvironmentRepository支持环境配置切换 - 完成ChatScreen界面实现,包含消息收发、媒体文件处理 - 更新设计文档补充Android聊天页历史加载和测试验证说明 - 添加联系人黑名单错误信息返回和IM验证流程调整说明
这个提交包含在:
父节点
a948abd845
当前提交
026f8e874c
@ -80,14 +80,14 @@ XuqmSDK.login(
|
|||||||
默认使用外网域名。若要本地联调,可在 `Application.onCreate()` 里切换:
|
默认使用外网域名。若要本地联调,可在 `Application.onCreate()` 里切换:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
XuqmSDK.useLocalServiceEndpoints("10.0.2.2")
|
XuqmSDK.useLocalServiceEndpoints("192.168.113.37")
|
||||||
// 物理设备可改成你的电脑局域网 IP
|
// 物理设备可改成你的电脑局域网 IP
|
||||||
```
|
```
|
||||||
|
|
||||||
如果是 sample-app,也可以直接调用:
|
如果是 sample-app,也可以直接调用:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
SampleEnvironmentConfig.useLocalhost("10.0.2.2")
|
SampleEnvironmentConfig.useLocalhost("192.168.113.37")
|
||||||
```
|
```
|
||||||
|
|
||||||
切换后,HTTP API 会立即走新端点;如果 IM 已登录,SDK 会自动重新连接。
|
切换后,HTTP API 会立即走新端点;如果 IM 已登录,SDK 会自动重新连接。
|
||||||
|
|||||||
61
docs/DEBUG_PROGRESS.md
普通文件
61
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 侧的联调回归。
|
||||||
@ -4,15 +4,10 @@ import android.app.Application
|
|||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
import com.xuqm.sdk.core.LogLevel
|
import com.xuqm.sdk.core.LogLevel
|
||||||
import com.xuqm.sdk.sample.di.AppDependencies
|
import com.xuqm.sdk.sample.di.AppDependencies
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class XuqmSampleApp : Application() {
|
class XuqmSampleApp : Application() {
|
||||||
|
|
||||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
AppDependencies.init(this)
|
AppDependencies.init(this)
|
||||||
@ -21,7 +16,7 @@ class XuqmSampleApp : Application() {
|
|||||||
appKey = "ak_demo_chat",
|
appKey = "ak_demo_chat",
|
||||||
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
|
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
|
||||||
)
|
)
|
||||||
appScope.launch {
|
runBlocking {
|
||||||
AppDependencies.authRepository.restoreSdkSession()
|
AppDependencies.authRepository.restoreSdkSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,8 +24,13 @@ data class PreparedAttachment(
|
|||||||
|
|
||||||
class AttachmentRepository(private val context: Context) {
|
class AttachmentRepository(private val context: Context) {
|
||||||
|
|
||||||
suspend fun sendImage(targetId: String, chatType: String, uri: Uri): Result<PreparedAttachment> =
|
suspend fun sendImage(
|
||||||
sendMedia(targetId, chatType, uri, MediaKind.IMAGE)
|
targetId: String,
|
||||||
|
chatType: String,
|
||||||
|
uri: Uri,
|
||||||
|
onProgress: (Int) -> Unit = {},
|
||||||
|
): Result<PreparedAttachment> =
|
||||||
|
sendMedia(targetId, chatType, uri, MediaKind.IMAGE, onProgress)
|
||||||
|
|
||||||
suspend fun sendImageBytes(
|
suspend fun sendImageBytes(
|
||||||
targetId: String,
|
targetId: String,
|
||||||
@ -34,12 +39,14 @@ class AttachmentRepository(private val context: Context) {
|
|||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
width: Int? = null,
|
width: Int? = null,
|
||||||
height: Int? = null,
|
height: Int? = null,
|
||||||
|
onProgress: (Int) -> Unit = {},
|
||||||
): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
|
): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val upload = FileSDK.uploadBytes(
|
val upload = FileSDK.uploadBytes(
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
mimeType = "image/jpeg",
|
mimeType = "image/jpeg",
|
||||||
bytes = bytes,
|
bytes = bytes,
|
||||||
|
onProgress = onProgress,
|
||||||
)
|
)
|
||||||
ImSDK.sendImageMessage(
|
ImSDK.sendImageMessage(
|
||||||
toId = targetId,
|
toId = targetId,
|
||||||
@ -53,14 +60,29 @@ class AttachmentRepository(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendVideo(targetId: String, chatType: String, uri: Uri): Result<PreparedAttachment> =
|
suspend fun sendVideo(
|
||||||
sendMedia(targetId, chatType, uri, MediaKind.VIDEO)
|
targetId: String,
|
||||||
|
chatType: String,
|
||||||
|
uri: Uri,
|
||||||
|
onProgress: (Int) -> Unit = {},
|
||||||
|
): Result<PreparedAttachment> =
|
||||||
|
sendMedia(targetId, chatType, uri, MediaKind.VIDEO, onProgress)
|
||||||
|
|
||||||
suspend fun sendFile(targetId: String, chatType: String, uri: Uri): Result<PreparedAttachment> =
|
suspend fun sendFile(
|
||||||
sendMedia(targetId, chatType, uri, MediaKind.FILE)
|
targetId: String,
|
||||||
|
chatType: String,
|
||||||
|
uri: Uri,
|
||||||
|
onProgress: (Int) -> Unit = {},
|
||||||
|
): Result<PreparedAttachment> =
|
||||||
|
sendMedia(targetId, chatType, uri, MediaKind.FILE, onProgress)
|
||||||
|
|
||||||
suspend fun sendAudio(targetId: String, chatType: String, uri: Uri): Result<PreparedAttachment> =
|
suspend fun sendAudio(
|
||||||
sendMedia(targetId, chatType, uri, MediaKind.AUDIO)
|
targetId: String,
|
||||||
|
chatType: String,
|
||||||
|
uri: Uri,
|
||||||
|
onProgress: (Int) -> Unit = {},
|
||||||
|
): Result<PreparedAttachment> =
|
||||||
|
sendMedia(targetId, chatType, uri, MediaKind.AUDIO, onProgress)
|
||||||
|
|
||||||
suspend fun sendAudioBytes(
|
suspend fun sendAudioBytes(
|
||||||
targetId: String,
|
targetId: String,
|
||||||
@ -68,12 +90,14 @@ class AttachmentRepository(private val context: Context) {
|
|||||||
fileName: String,
|
fileName: String,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
durationMs: Long? = null,
|
durationMs: Long? = null,
|
||||||
|
onProgress: (Int) -> Unit = {},
|
||||||
): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
|
): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val upload = FileSDK.uploadBytes(
|
val upload = FileSDK.uploadBytes(
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
mimeType = "audio/mp4",
|
mimeType = "audio/mp4",
|
||||||
bytes = bytes,
|
bytes = bytes,
|
||||||
|
onProgress = onProgress,
|
||||||
)
|
)
|
||||||
ImSDK.sendAudioMessage(
|
ImSDK.sendAudioMessage(
|
||||||
toId = targetId,
|
toId = targetId,
|
||||||
@ -91,6 +115,7 @@ class AttachmentRepository(private val context: Context) {
|
|||||||
chatType: String,
|
chatType: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
kind: MediaKind,
|
kind: MediaKind,
|
||||||
|
onProgress: (Int) -> Unit,
|
||||||
): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
|
): Result<PreparedAttachment> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val meta = resolveMeta(uri)
|
val meta = resolveMeta(uri)
|
||||||
@ -106,6 +131,7 @@ class AttachmentRepository(private val context: Context) {
|
|||||||
displayName = meta.displayName,
|
displayName = meta.displayName,
|
||||||
mimeType = meta.mimeType,
|
mimeType = meta.mimeType,
|
||||||
thumbnailBytes = thumbnailBytes,
|
thumbnailBytes = thumbnailBytes,
|
||||||
|
onProgress = onProgress,
|
||||||
)
|
)
|
||||||
val sent = when (kind) {
|
val sent = when (kind) {
|
||||||
MediaKind.IMAGE -> ImSDK.sendImageMessage(
|
MediaKind.IMAGE -> ImSDK.sendImageMessage(
|
||||||
|
|||||||
@ -53,7 +53,21 @@ class AuthRepository(context: Context) {
|
|||||||
fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
|
fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
|
||||||
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
|
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
|
||||||
fun getCurrentUserSigExpiresAt(): Long = prefs.getLong("user_sig_expires_at", 0L)
|
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) {
|
private fun saveSession(result: AuthResult) {
|
||||||
val profile = result.profile
|
val profile = result.profile
|
||||||
@ -161,12 +175,20 @@ class AuthRepository(context: Context) {
|
|||||||
runCatching {
|
runCatching {
|
||||||
val userId = getCurrentUserId()
|
val userId = getCurrentUserId()
|
||||||
val userSig = getCurrentUserSig()
|
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()) {
|
val refreshed = if (shouldRefreshImToken()) {
|
||||||
refreshImTokenInternal()
|
refreshImTokenInternal()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
if (shouldRefreshImToken() && refreshed == null) {
|
||||||
|
clearSession()
|
||||||
|
throw IllegalStateException("Cached session expired")
|
||||||
|
}
|
||||||
XuqmSDK.login(
|
XuqmSDK.login(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
userSig = refreshed?.userSig ?: userSig,
|
userSig = refreshed?.userSig ?: userSig,
|
||||||
@ -193,7 +215,10 @@ class AuthRepository(context: Context) {
|
|||||||
if (!refreshing.compareAndSet(false, true)) return
|
if (!refreshing.compareAndSet(false, true)) return
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val refreshed = refreshImTokenInternal() ?: return@withContext
|
val refreshed = refreshImTokenInternal() ?: run {
|
||||||
|
clearSession()
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
saveImCredential(refreshed)
|
saveImCredential(refreshed)
|
||||||
XuqmSDK.login(
|
XuqmSDK.login(
|
||||||
userId = getCurrentUserId() ?: return@withContext,
|
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? {
|
private suspend fun refreshImTokenInternal(): ImRefreshResult? {
|
||||||
val appId = DEMO_APP_ID
|
val appId = DEMO_APP_ID
|
||||||
return runCatching {
|
return runCatching {
|
||||||
|
|||||||
@ -10,7 +10,7 @@ enum class EnvironmentMode {
|
|||||||
|
|
||||||
data class EnvironmentState(
|
data class EnvironmentState(
|
||||||
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
|
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
|
||||||
val host: String = "10.0.2.2",
|
val host: String = "192.168.113.37",
|
||||||
)
|
)
|
||||||
|
|
||||||
class EnvironmentRepository(context: Context) {
|
class EnvironmentRepository(context: Context) {
|
||||||
@ -25,7 +25,7 @@ class EnvironmentRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setLocalhost(host: String) {
|
fun setLocalhost(host: String) {
|
||||||
val normalizedHost = host.trim().ifBlank { "10.0.2.2" }
|
val normalizedHost = host.trim().ifBlank { "192.168.113.37" }
|
||||||
save(EnvironmentState(mode = EnvironmentMode.LOCALHOST, host = normalizedHost))
|
save(EnvironmentState(mode = EnvironmentMode.LOCALHOST, host = normalizedHost))
|
||||||
SampleEnvironmentConfig.useLocalhost(normalizedHost)
|
SampleEnvironmentConfig.useLocalhost(normalizedHost)
|
||||||
}
|
}
|
||||||
@ -34,7 +34,7 @@ class EnvironmentRepository(context: Context) {
|
|||||||
val mode = runCatching {
|
val mode = runCatching {
|
||||||
EnvironmentMode.valueOf(prefs.getString(KEY_MODE, EnvironmentMode.EXTERNAL.name)!!)
|
EnvironmentMode.valueOf(prefs.getString(KEY_MODE, EnvironmentMode.EXTERNAL.name)!!)
|
||||||
}.getOrDefault(EnvironmentMode.EXTERNAL)
|
}.getOrDefault(EnvironmentMode.EXTERNAL)
|
||||||
val host = prefs.getString(KEY_HOST, "10.0.2.2").orEmpty().ifBlank { "10.0.2.2" }
|
val host = prefs.getString(KEY_HOST, "192.168.113.37").orEmpty().ifBlank { "192.168.113.37" }
|
||||||
return EnvironmentState(mode = mode, host = host)
|
return EnvironmentState(mode = mode, host = host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -99,6 +99,7 @@ fun ChatScreen(
|
|||||||
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
||||||
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
||||||
val isSendingAttachment by viewModel.isSendingAttachment.collectAsStateWithLifecycle()
|
val isSendingAttachment by viewModel.isSendingAttachment.collectAsStateWithLifecycle()
|
||||||
|
val attachmentUploadProgress by viewModel.attachmentUploadProgress.collectAsStateWithLifecycle()
|
||||||
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -421,15 +422,19 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isSendingAttachment) {
|
if (isSendingAttachment) {
|
||||||
|
val uploadProgressText = if (attachmentUploadProgress > 0) {
|
||||||
|
"附件上传中… ${attachmentUploadProgress}%"
|
||||||
|
} else {
|
||||||
|
"附件上传中…"
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "附件上传中…",
|
text = uploadProgressText,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
color = MaterialTheme.colorScheme.outline,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { padding ->
|
content = { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -507,6 +512,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class CameraAction { PHOTO, VIDEO }
|
private enum class CameraAction { PHOTO, VIDEO }
|
||||||
|
|||||||
@ -63,6 +63,9 @@ class ChatViewModel : ViewModel() {
|
|||||||
private val _isSendingAttachment = MutableStateFlow(false)
|
private val _isSendingAttachment = MutableStateFlow(false)
|
||||||
val isSendingAttachment: StateFlow<Boolean> = _isSendingAttachment
|
val isSendingAttachment: StateFlow<Boolean> = _isSendingAttachment
|
||||||
|
|
||||||
|
private val _attachmentUploadProgress = MutableStateFlow(0)
|
||||||
|
val attachmentUploadProgress: StateFlow<Int> = _attachmentUploadProgress
|
||||||
|
|
||||||
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
val events: SharedFlow<String> = _events.asSharedFlow()
|
val events: SharedFlow<String> = _events.asSharedFlow()
|
||||||
|
|
||||||
@ -111,6 +114,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
_mentionableUsers.value = emptyList()
|
_mentionableUsers.value = emptyList()
|
||||||
_replyTargetMessage.value = null
|
_replyTargetMessage.value = null
|
||||||
_editingMessage.value = null
|
_editingMessage.value = null
|
||||||
|
_attachmentUploadProgress.value = 0
|
||||||
ImSDK.addListener(listener)
|
ImSDK.addListener(listener)
|
||||||
if (chatType == "GROUP") {
|
if (chatType == "GROUP") {
|
||||||
ImSDK.subscribeGroup(targetId)
|
ImSDK.subscribeGroup(targetId)
|
||||||
@ -176,9 +180,11 @@ class ChatViewModel : ViewModel() {
|
|||||||
private suspend fun fetchHistory(page: Int): List<ImMessage> {
|
private suspend fun fetchHistory(page: Int): List<ImMessage> {
|
||||||
val remote = if (chatType == "GROUP") {
|
val remote = if (chatType == "GROUP") {
|
||||||
runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
||||||
|
.onFailure { Log.w(TAG, "fetchGroupHistory failed targetId=$targetId page=$page", it) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
} else {
|
} else {
|
||||||
runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
||||||
|
.onFailure { Log.w(TAG, "fetchHistory failed targetId=$targetId page=$page", it) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
if (remote != null) {
|
if (remote != null) {
|
||||||
@ -214,27 +220,33 @@ class ChatViewModel : ViewModel() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val replyTarget = _replyTargetMessage.value
|
try {
|
||||||
val sent = if (replyTarget != null) {
|
val replyTarget = _replyTargetMessage.value
|
||||||
ImSDK.sendQuoteMessage(
|
val sent = if (replyTarget != null) {
|
||||||
toId = targetId,
|
ImSDK.sendQuoteMessage(
|
||||||
chatType = chatType,
|
toId = targetId,
|
||||||
quotedMsgId = replyTarget.id,
|
chatType = chatType,
|
||||||
quotedContent = replyTarget.previewText(),
|
quotedMsgId = replyTarget.id,
|
||||||
text = content,
|
quotedContent = replyTarget.previewText(),
|
||||||
)
|
text = content,
|
||||||
} else {
|
)
|
||||||
ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content))
|
} else {
|
||||||
}
|
ImSDK.sendTextMessage(targetId, chatType, content, extractMentionedUserIds(content))
|
||||||
appendOutgoingMessage(sent)
|
}
|
||||||
if (sent.status.uppercase() == "FAILED") {
|
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("消息发送失败,请检查网络后重试")
|
_events.tryEmit("消息发送失败,请检查网络后重试")
|
||||||
} else {
|
Log.w(TAG, "sendText threw targetId=$targetId chatType=$chatType", t)
|
||||||
trackPendingDelivery(sent.id)
|
|
||||||
_draftText.value = ""
|
|
||||||
cache.clearDraft(targetId, chatType)
|
|
||||||
_replyTargetMessage.value = null
|
|
||||||
_editingMessage.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -343,6 +355,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
if (!initialized) return
|
if (!initialized) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isSendingAttachment.value = true
|
_isSendingAttachment.value = true
|
||||||
|
_attachmentUploadProgress.value = 0
|
||||||
try {
|
try {
|
||||||
runCatching {
|
runCatching {
|
||||||
AppDependencies.attachmentRepository.sendAudioBytes(
|
AppDependencies.attachmentRepository.sendAudioBytes(
|
||||||
@ -351,19 +364,24 @@ class ChatViewModel : ViewModel() {
|
|||||||
fileName = recording.fileName,
|
fileName = recording.fileName,
|
||||||
bytes = recording.bytes,
|
bytes = recording.bytes,
|
||||||
durationMs = recording.durationMs,
|
durationMs = recording.durationMs,
|
||||||
|
onProgress = { progress -> _attachmentUploadProgress.value = progress },
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
}.onSuccess { sent ->
|
}.onSuccess { sent ->
|
||||||
|
_attachmentUploadProgress.value = 100
|
||||||
appendOutgoingMessage(sent.message)
|
appendOutgoingMessage(sent.message)
|
||||||
if (sent.message.status.uppercase() == "FAILED") {
|
if (sent.message.status.uppercase() == "FAILED") {
|
||||||
_events.tryEmit("语音发送失败,请检查网络后重试")
|
_events.tryEmit("语音发送失败,请检查网络后重试")
|
||||||
|
Log.w(TAG, "sendAudioRecording failed status=${sent.message.status} targetId=$targetId chatType=$chatType")
|
||||||
} else {
|
} else {
|
||||||
trackPendingDelivery(sent.message.id)
|
trackPendingDelivery(sent.message.id)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
_events.tryEmit("语音发送失败,请检查网络后重试")
|
_events.tryEmit("语音发送失败,请检查网络后重试")
|
||||||
|
Log.w(TAG, "sendAudioRecording threw targetId=$targetId chatType=$chatType fileName=${recording.fileName}", it)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isSendingAttachment.value = false
|
_isSendingAttachment.value = false
|
||||||
|
_attachmentUploadProgress.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -409,6 +427,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
.filter { it.nickname.isNotBlank() }
|
.filter { it.nickname.isNotBlank() }
|
||||||
.sortedBy { it.nickname.ifBlank { it.userId } }
|
.sortedBy { it.nickname.ifBlank { it.userId } }
|
||||||
}.onSuccess { _mentionableUsers.value = it }
|
}.onSuccess { _mentionableUsers.value = it }
|
||||||
|
.onFailure { Log.w(TAG, "loadMentionableUsers failed targetId=$targetId", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -424,26 +443,51 @@ class ChatViewModel : ViewModel() {
|
|||||||
if (!initialized) return
|
if (!initialized) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isSendingAttachment.value = true
|
_isSendingAttachment.value = true
|
||||||
|
_attachmentUploadProgress.value = 0
|
||||||
try {
|
try {
|
||||||
runCatching {
|
runCatching {
|
||||||
when (kind) {
|
when (kind) {
|
||||||
AttachmentKind.IMAGE -> AppDependencies.attachmentRepository.sendImage(targetId, chatType, uri)
|
AttachmentKind.IMAGE -> AppDependencies.attachmentRepository.sendImage(
|
||||||
AttachmentKind.VIDEO -> AppDependencies.attachmentRepository.sendVideo(targetId, chatType, uri)
|
targetId,
|
||||||
AttachmentKind.AUDIO -> AppDependencies.attachmentRepository.sendAudio(targetId, chatType, uri)
|
chatType,
|
||||||
AttachmentKind.FILE -> AppDependencies.attachmentRepository.sendFile(targetId, chatType, uri)
|
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()
|
}.getOrThrow()
|
||||||
}.onSuccess { sent ->
|
}.onSuccess { sent ->
|
||||||
|
_attachmentUploadProgress.value = 100
|
||||||
appendOutgoingMessage(sent.message)
|
appendOutgoingMessage(sent.message)
|
||||||
if (sent.message.status.uppercase() == "FAILED") {
|
if (sent.message.status.uppercase() == "FAILED") {
|
||||||
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
|
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
|
||||||
|
Log.w(TAG, "sendAttachment failed status=${sent.message.status} targetId=$targetId chatType=$chatType kind=$kind")
|
||||||
} else {
|
} else {
|
||||||
trackPendingDelivery(sent.message.id)
|
trackPendingDelivery(sent.message.id)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
|
_events.tryEmit("${kind.previewLabel()}发送失败,请检查网络后重试")
|
||||||
|
Log.w(TAG, "sendAttachment threw targetId=$targetId chatType=$chatType kind=$kind uri=$uri", it)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isSendingAttachment.value = false
|
_isSendingAttachment.value = false
|
||||||
|
_attachmentUploadProgress.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -458,6 +502,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
if (!initialized) return
|
if (!initialized) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isSendingAttachment.value = true
|
_isSendingAttachment.value = true
|
||||||
|
_attachmentUploadProgress.value = 0
|
||||||
try {
|
try {
|
||||||
runCatching {
|
runCatching {
|
||||||
when (kind) {
|
when (kind) {
|
||||||
@ -468,21 +513,26 @@ class ChatViewModel : ViewModel() {
|
|||||||
bytes = bytes,
|
bytes = bytes,
|
||||||
width = width,
|
width = width,
|
||||||
height = height,
|
height = height,
|
||||||
|
onProgress = { progress -> _attachmentUploadProgress.value = progress },
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
else -> error("Unsupported bytes attachment kind: $kind")
|
else -> error("Unsupported bytes attachment kind: $kind")
|
||||||
}
|
}
|
||||||
}.onSuccess { sent ->
|
}.onSuccess { sent ->
|
||||||
|
_attachmentUploadProgress.value = 100
|
||||||
appendOutgoingMessage(sent.message)
|
appendOutgoingMessage(sent.message)
|
||||||
if (sent.message.status.uppercase() == "FAILED") {
|
if (sent.message.status.uppercase() == "FAILED") {
|
||||||
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
|
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
|
||||||
|
Log.w(TAG, "sendAttachmentBytes failed status=${sent.message.status} targetId=$targetId chatType=$chatType fileName=$fileName kind=$kind")
|
||||||
} else {
|
} else {
|
||||||
trackPendingDelivery(sent.message.id)
|
trackPendingDelivery(sent.message.id)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
|
_events.tryEmit("${AttachmentKind.IMAGE.previewLabel()}发送失败,请检查网络后重试")
|
||||||
|
Log.w(TAG, "sendAttachmentBytes threw targetId=$targetId chatType=$chatType fileName=$fileName kind=$kind", it)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isSendingAttachment.value = false
|
_isSendingAttachment.value = false
|
||||||
|
_attachmentUploadProgress.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,10 @@ class ContactViewModel(
|
|||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "XuqmContactVM"
|
||||||
|
}
|
||||||
|
|
||||||
private val cache: LocalContactCache = AppDependencies.localContactCache
|
private val cache: LocalContactCache = AppDependencies.localContactCache
|
||||||
private val currentUserId: String? = authRepository.getCurrentUserId()
|
private val currentUserId: String? = authRepository.getCurrentUserId()
|
||||||
private var memberIndex: Map<String, UserData> = emptyMap()
|
private var memberIndex: Map<String, UserData> = emptyMap()
|
||||||
@ -150,7 +154,14 @@ class ContactViewModel(
|
|||||||
fun removeFriend(userId: String) {
|
fun removeFriend(userId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.removeFriend(userId) }
|
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 {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.acceptFriendRequest(requestId) }
|
runCatching { ImSDK.acceptFriendRequest(requestId) }
|
||||||
.onSuccess { refresh() }
|
.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 {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.rejectFriendRequest(requestId) }
|
runCatching { ImSDK.rejectFriendRequest(requestId) }
|
||||||
.onSuccess { refresh() }
|
.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) {
|
fun addToBlacklist(userId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.addToBlacklist(userId) }
|
runCatching { ImSDK.addToBlacklist(userId) }
|
||||||
.onSuccess { loadBlacklist() }
|
.onSuccess {
|
||||||
|
loadBlacklist()
|
||||||
|
_events.tryEmit("已加入黑名单")
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_events.tryEmit("加入黑名单失败:${it.message ?: "未知错误"}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeFromBlacklist(userId: String) {
|
fun removeFromBlacklist(userId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.removeFromBlacklist(userId) }
|
runCatching { ImSDK.removeFromBlacklist(userId) }
|
||||||
.onSuccess { loadBlacklist() }
|
.onSuccess {
|
||||||
|
loadBlacklist()
|
||||||
|
_events.tryEmit("已移出黑名单")
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_events.tryEmit("移出黑名单失败:${it.message ?: "未知错误"}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,6 +248,7 @@ class ContactViewModel(
|
|||||||
cache.saveProfiles(list)
|
cache.saveProfiles(list)
|
||||||
rebuildFriendList()
|
rebuildFriendList()
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
|
android.util.Log.w(TAG, "loadMembersInternal failed", it)
|
||||||
val cached = cache.loadProfiles()
|
val cached = cache.loadProfiles()
|
||||||
.filter { it.userId != currentUserId }
|
.filter { it.userId != currentUserId }
|
||||||
.sortedBy { it.userId }
|
.sortedBy { it.userId }
|
||||||
@ -235,6 +267,7 @@ class ContactViewModel(
|
|||||||
cache.saveFriendIds(list.toList())
|
cache.saveFriendIds(list.toList())
|
||||||
rebuildFriendList()
|
rebuildFriendList()
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
|
android.util.Log.w(TAG, "loadFriendsInternal failed", it)
|
||||||
friendIds = cache.loadFriendIds().toSet()
|
friendIds = cache.loadFriendIds().toSet()
|
||||||
rebuildFriendList()
|
rebuildFriendList()
|
||||||
}
|
}
|
||||||
@ -246,12 +279,17 @@ class ContactViewModel(
|
|||||||
_friendRequests.value = it.filter { request ->
|
_friendRequests.value = it.filter { request ->
|
||||||
request.status.equals("PENDING", ignoreCase = true)
|
request.status.equals("PENDING", ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
}.onFailure {
|
||||||
|
android.util.Log.w(TAG, "loadFriendRequestsInternal failed", it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadBlacklistInternal() {
|
private suspend fun loadBlacklistInternal() {
|
||||||
runCatching { ImSDK.listBlacklist() }
|
runCatching { ImSDK.listBlacklist() }
|
||||||
.onSuccess { _blacklist.value = it }
|
.onSuccess { _blacklist.value = it }
|
||||||
|
.onFailure {
|
||||||
|
android.util.Log.w(TAG, "loadBlacklistInternal failed", it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -116,6 +116,7 @@ class ConversationViewModel : ViewModel() {
|
|||||||
fun deleteConversation(targetId: String, chatType: String) {
|
fun deleteConversation(targetId: String, chatType: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.deleteConversation(targetId, chatType) }
|
runCatching { ImSDK.deleteConversation(targetId, chatType) }
|
||||||
|
.onFailure { Log.w(TAG, "deleteConversation failed targetId=$targetId chatType=$chatType", it) }
|
||||||
cache.deleteConversation(targetId, chatType)
|
cache.deleteConversation(targetId, chatType)
|
||||||
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
||||||
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
|
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
|
||||||
@ -124,11 +125,13 @@ class ConversationViewModel : ViewModel() {
|
|||||||
|
|
||||||
suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) {
|
suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) {
|
||||||
runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) }
|
runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) }
|
||||||
|
.onFailure { Log.w(TAG, "setPinned failed targetId=$targetId chatType=$chatType pinned=$pinned", it) }
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setMuted(targetId: String, chatType: String, muted: Boolean) {
|
suspend fun setMuted(targetId: String, chatType: String, muted: Boolean) {
|
||||||
runCatching { ImSDK.setConversationMuted(targetId, chatType, muted) }
|
runCatching { ImSDK.setConversationMuted(targetId, chatType, muted) }
|
||||||
|
.onFailure { Log.w(TAG, "setMuted failed targetId=$targetId chatType=$chatType muted=$muted", it) }
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
|
|
||||||
data class EnvironmentUiState(
|
data class EnvironmentUiState(
|
||||||
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
|
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
|
||||||
val host: String = "10.0.2.2",
|
val host: String = "192.168.113.37",
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -171,7 +171,7 @@ fun EnvironmentScreen(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("本地 Host") },
|
label = { Text("本地 Host") },
|
||||||
placeholder = { Text("10.0.2.2 或你的电脑局域网 IP") },
|
placeholder = { Text("192.168.113.37 或你的电脑局域网 IP") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = state.mode == EnvironmentMode.LOCALHOST,
|
enabled = state.mode == EnvironmentMode.LOCALHOST,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.xuqm.sdk.sample.ui.group
|
package com.xuqm.sdk.sample.ui.group
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
@ -15,6 +16,10 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
class GroupViewModel : ViewModel() {
|
class GroupViewModel : ViewModel() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "XuqmGroupVM"
|
||||||
|
}
|
||||||
|
|
||||||
private val authRepository: AuthRepository = AppDependencies.authRepository
|
private val authRepository: AuthRepository = AppDependencies.authRepository
|
||||||
|
|
||||||
private val _groups = MutableStateFlow<List<ImGroup>>(emptyList())
|
private val _groups = MutableStateFlow<List<ImGroup>>(emptyList())
|
||||||
@ -66,6 +71,9 @@ class GroupViewModel : ViewModel() {
|
|||||||
_currentGroup.value = it
|
_currentGroup.value = it
|
||||||
loadGroupMembersInternal(groupId)
|
loadGroupMembersInternal(groupId)
|
||||||
}
|
}
|
||||||
|
.onFailure {
|
||||||
|
Log.w(TAG, "loadGroupInfo failed groupId=$groupId", it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,6 +102,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.createGroup(name, memberIds, groupType) }
|
runCatching { ImSDK.createGroup(name, memberIds, groupType) }
|
||||||
.onSuccess { group -> group?.let { onSuccess(it); loadGroups() } }
|
.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 {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.updateGroupInfo(groupId, name, announcement) }
|
runCatching { ImSDK.updateGroupInfo(groupId, name, announcement) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
|
.onFailure { Log.w(TAG, "updateGroup failed groupId=$groupId", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +118,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.setGroupRole(groupId, userId, role) }
|
runCatching { ImSDK.setGroupRole(groupId, userId, role) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.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 {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.addGroupMember(groupId, userId) }
|
runCatching { ImSDK.addGroupMember(groupId, userId) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
|
.onFailure { Log.w(TAG, "addMember failed groupId=$groupId userId=$userId", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,6 +134,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) }
|
runCatching { ImSDK.muteGroupMember(groupId, userId, minutes) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.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 {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.dismissGroup(groupId) }
|
runCatching { ImSDK.dismissGroup(groupId) }
|
||||||
.onSuccess { loadGroups(); onSuccess() }
|
.onSuccess { loadGroups(); onSuccess() }
|
||||||
|
.onFailure { Log.w(TAG, "dismissGroup failed groupId=$groupId", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,6 +150,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.removeGroupMember(groupId, userId) }
|
runCatching { ImSDK.removeGroupMember(groupId, userId) }
|
||||||
.onSuccess { loadGroupInfo(groupId) }
|
.onSuccess { loadGroupInfo(groupId) }
|
||||||
|
.onFailure { Log.w(TAG, "removeMember failed groupId=$groupId userId=$userId", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,12 +158,14 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.leaveGroup(groupId) }
|
runCatching { ImSDK.leaveGroup(groupId) }
|
||||||
.onSuccess { loadGroups(); onSuccess() }
|
.onSuccess { loadGroups(); onSuccess() }
|
||||||
|
.onFailure { Log.w(TAG, "leaveGroup failed groupId=$groupId", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestJoinGroup(groupId: String, remark: String? = null) {
|
fun requestJoinGroup(groupId: String, remark: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.sendGroupJoinRequest(groupId, remark) }
|
runCatching { ImSDK.sendGroupJoinRequest(groupId, remark) }
|
||||||
|
.onFailure { Log.w(TAG, "requestJoinGroup failed groupId=$groupId", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,6 +173,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.listGroupJoinRequests(groupId) }
|
runCatching { ImSDK.listGroupJoinRequests(groupId) }
|
||||||
.onSuccess { _joinRequests.value = it }
|
.onSuccess { _joinRequests.value = it }
|
||||||
|
.onFailure { Log.w(TAG, "loadJoinRequests failed groupId=$groupId", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,6 +185,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.acceptGroupJoinRequest(groupId, requestId) }
|
runCatching { ImSDK.acceptGroupJoinRequest(groupId, requestId) }
|
||||||
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
|
.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 {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.rejectGroupJoinRequest(groupId, requestId) }
|
runCatching { ImSDK.rejectGroupJoinRequest(groupId, requestId) }
|
||||||
.onSuccess { loadJoinRequests(groupId); loadGroupInfo(groupId) }
|
.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())
|
authRepository.listMembers().getOrDefault(emptyList())
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_members.value = it
|
_members.value = it
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "loadMembersInternal failed", it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,16 +212,21 @@ class GroupViewModel : ViewModel() {
|
|||||||
.onSuccess { profiles ->
|
.onSuccess { profiles ->
|
||||||
_groupMembers.value = profiles.map { it.toUserData() }
|
_groupMembers.value = profiles.map { it.toUserData() }
|
||||||
}
|
}
|
||||||
|
.onFailure {
|
||||||
|
Log.w(TAG, "loadGroupMembersInternal failed groupId=$groupId", it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadGroupsInternal() {
|
private suspend fun loadGroupsInternal() {
|
||||||
runCatching { ImSDK.listGroups() }
|
runCatching { ImSDK.listGroups() }
|
||||||
.onSuccess { _groups.value = it }
|
.onSuccess { _groups.value = it }
|
||||||
|
.onFailure { Log.w(TAG, "loadGroupsInternal failed", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadPublicGroupsInternal(keyword: String) {
|
private suspend fun loadPublicGroupsInternal(keyword: String) {
|
||||||
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
|
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
|
||||||
.onSuccess { _publicGroups.value = it }
|
.onSuccess { _publicGroups.value = it }
|
||||||
|
.onFailure { Log.w(TAG, "loadPublicGroupsInternal failed keyword=$keyword", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun UserProfile.toUserData(): UserData {
|
private fun UserProfile.toUserData(): UserData {
|
||||||
|
|||||||
@ -51,13 +51,14 @@ object FileSDK {
|
|||||||
displayName: String? = null,
|
displayName: String? = null,
|
||||||
mimeType: String? = null,
|
mimeType: String? = null,
|
||||||
thumbnailBytes: ByteArray? = null,
|
thumbnailBytes: ByteArray? = null,
|
||||||
|
onProgress: (Int) -> Unit = {},
|
||||||
): FileUploadResult {
|
): FileUploadResult {
|
||||||
val resolvedName = displayName?.takeIf { it.isNotBlank() } ?: FileTransfer.resolveDisplayName(context, uri)
|
val resolvedName = displayName?.takeIf { it.isNotBlank() } ?: FileTransfer.resolveDisplayName(context, uri)
|
||||||
val resolvedMimeType = mimeType?.takeIf { it.isNotBlank() } ?: context.contentResolver.getType(uri)
|
val resolvedMimeType = mimeType?.takeIf { it.isNotBlank() } ?: context.contentResolver.getType(uri)
|
||||||
val filePart = MultipartBody.Part.createFormData(
|
val filePart = MultipartBody.Part.createFormData(
|
||||||
"file",
|
"file",
|
||||||
resolvedName,
|
resolvedName,
|
||||||
FileTransfer.createUriRequestBody(context, uri, resolvedMimeType),
|
FileTransfer.createUriRequestBody(context, uri, resolvedMimeType, onProgress),
|
||||||
)
|
)
|
||||||
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { bytes ->
|
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { bytes ->
|
||||||
MultipartBody.Part.createFormData(
|
MultipartBody.Part.createFormData(
|
||||||
@ -76,11 +77,12 @@ object FileSDK {
|
|||||||
mimeType: String?,
|
mimeType: String?,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
thumbnailBytes: ByteArray? = null,
|
thumbnailBytes: ByteArray? = null,
|
||||||
|
onProgress: (Int) -> Unit = {},
|
||||||
): FileUploadResult {
|
): FileUploadResult {
|
||||||
val filePart = MultipartBody.Part.createFormData(
|
val filePart = MultipartBody.Part.createFormData(
|
||||||
"file",
|
"file",
|
||||||
fileName,
|
fileName,
|
||||||
bytes.toRequestBody(mimeType?.toMediaTypeOrNull()),
|
FileTransfer.createByteArrayRequestBody(mimeType, bytes, onProgress),
|
||||||
)
|
)
|
||||||
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { thumbBytes ->
|
val thumbnailPart = thumbnailBytes?.takeIf { it.isNotEmpty() }?.let { thumbBytes ->
|
||||||
MultipartBody.Part.createFormData(
|
MultipartBody.Part.createFormData(
|
||||||
|
|||||||
@ -31,16 +31,58 @@ object FileTransfer {
|
|||||||
context: Context,
|
context: Context,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String?,
|
mimeType: String?,
|
||||||
|
onProgress: (Int) -> Unit = {},
|
||||||
): RequestBody = object : RequestBody() {
|
): RequestBody = object : RequestBody() {
|
||||||
override fun contentType() = mimeType?.toMediaTypeOrNull()
|
override fun contentType() = mimeType?.toMediaTypeOrNull()
|
||||||
|
|
||||||
|
override fun contentLength(): Long = resolveSize(context, uri).coerceAtLeast(-1L)
|
||||||
|
|
||||||
override fun writeTo(sink: BufferedSink) {
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
val totalBytes = resolveSize(context, uri)
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
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")
|
} ?: 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(
|
fun downloadToFile(
|
||||||
downloadUrl: String,
|
downloadUrl: String,
|
||||||
targetFile: File,
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户