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 b5868ef..5404dd6 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,9 +4,15 @@ 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 class XuqmSampleApp : Application() { + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override fun onCreate() { super.onCreate() AppDependencies.init(this) @@ -15,5 +21,8 @@ class XuqmSampleApp : Application() { appId = "ak_demo_chat", logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN, ) + appScope.launch { + AppDependencies.authRepository.restoreSdkSession() + } } } 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 12a2750..8260f66 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 @@ -38,6 +38,7 @@ class AuthRepository(context: Context) { fun getCurrentUserId(): String? = prefs.getString("user_id", null) fun getCurrentNickname(): String? = prefs.getString("nickname", null) fun getCurrentAvatar(): String? = prefs.getString("avatar", null) + fun getCurrentUserSig(): String? = prefs.getString("user_sig", null) fun isLoggedIn(): Boolean = getDemoToken() != null private fun saveSession(result: AuthResult) { @@ -113,6 +114,21 @@ class AuthRepository(context: Context) { runCatching { api.searchUsers(keyword = keyword).data ?: emptyList() } } + suspend fun restoreSdkSession(): Result = + withContext(Dispatchers.IO) { + runCatching { + val userId = getCurrentUserId() + val userSig = getCurrentUserSig() + if (userId.isNullOrBlank() || userSig.isNullOrBlank()) return@runCatching + XuqmSDK.login( + userId = userId, + userSig = userSig, + nickname = getCurrentNickname(), + avatar = getCurrentAvatar(), + ) + } + } + fun logout() { XuqmSDK.logout() prefs.edit().clear().apply() 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 aeb806d..4f288b0 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 @@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -41,7 +42,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.xuqm.sdk.im.model.ImMessage +import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.ui.InitialAvatar +import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner +import kotlinx.coroutines.flow.distinctUntilChanged @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -54,31 +58,51 @@ fun ChatScreen( viewModel: ChatViewModel = viewModel(), ) { val messages by viewModel.messages.collectAsStateWithLifecycle() + val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle() + val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle() + val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle() + val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() val listState = rememberLazyListState() var input by remember { mutableStateOf("") } LaunchedEffect(Unit) { viewModel.init(targetId, chatType) } - LaunchedEffect(messages.size) { + LaunchedEffect(scrollSignal) { if (messages.isNotEmpty()) listState.animateScrollToItem(0) } + LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 } + .distinctUntilChanged() + .collect { lastVisibleIndex -> + val shouldLoadMore = hasMoreHistory && + !isLoadingMore && + messages.isNotEmpty() && + lastVisibleIndex >= messages.lastIndex - 2 + if (shouldLoadMore) { + viewModel.loadMoreHistory() + } + } + } Scaffold( topBar = { - TopAppBar( - title = { Text(targetName) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) - } - }, - actions = { - if (chatType == "GROUP" && onGroupSettings != null) { - IconButton(onClick = onGroupSettings) { - Icon(Icons.Default.Settings, contentDescription = null) + Column { + TopAppBar( + title = { Text(targetName) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } - } - }, - ) + }, + actions = { + if (chatType == "GROUP" && onGroupSettings != null) { + IconButton(onClick = onGroupSettings) { + Icon(Icons.Default.Settings, contentDescription = null) + } + } + }, + ) + ConnectionStatusBanner(connectionState) + } }, bottomBar = { Row( @@ -118,6 +142,18 @@ fun ChatScreen( isOwn = msg.fromId == viewModel.currentUserId, ) } + if (isLoadingMore) { + item(key = "loading-more") { + Text( + "加载历史中...", + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } } } } 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 02b9e27..6a44e43 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 @@ -14,42 +14,89 @@ class ChatViewModel : ViewModel() { private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore: StateFlow = _isLoadingMore + + private val _hasMoreHistory = MutableStateFlow(true) + val hasMoreHistory: StateFlow = _hasMoreHistory + + private val _scrollToBottomSignal = MutableStateFlow(0L) + val scrollToBottomSignal: StateFlow = _scrollToBottomSignal + 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 listener = object : ImEventListener { override fun onMessage(message: ImMessage) { - if (message.fromId == targetId || message.toId == targetId) { - _messages.value = listOf(message) + _messages.value + if (isRelevant(message)) { + prependMessage(message) + requestScrollToBottom() } } + override fun onGroupMessage(message: ImMessage) { - if (message.toId == targetId) { - _messages.value = listOf(message) + _messages.value + if (isRelevant(message)) { + prependMessage(message) + requestScrollToBottom() } } } fun init(targetId: String, chatType: String) { + if (initialized && this.targetId == targetId && this.chatType == chatType) return this.targetId = targetId this.chatType = chatType + nextHistoryPage = 0 + initialized = true + _messages.value = emptyList() + _hasMoreHistory.value = true + _isLoadingMore.value = false ImSDK.addListener(listener) - loadHistory() + loadInitialHistory() viewModelScope.launch { runCatching { ImSDK.markRead(targetId, chatType) } } } - fun loadHistory() { + fun loadInitialHistory() { + if (!initialized) return + loadPage(replace = true) + } + + fun loadMoreHistory() { + if (!initialized || _isLoadingMore.value || !_hasMoreHistory.value) return + loadPage(replace = false) + } + + private fun loadPage(replace: Boolean) { viewModelScope.launch { - val history = if (chatType == "GROUP") { - runCatching { ImSDK.fetchGroupHistory(targetId) }.getOrDefault(emptyList()) - } else { - runCatching { ImSDK.fetchHistory(targetId) }.getOrDefault(emptyList()) + _isLoadingMore.value = true + val page = if (replace) 0 else nextHistoryPage + val history = fetchHistory(page) + if (replace) { + _messages.value = history + nextHistoryPage = 1 + requestScrollToBottom() + } else if (history.isNotEmpty()) { + _messages.value = (_messages.value + history).distinctBy { it.id } + nextHistoryPage += 1 } - _messages.value = history + _hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE + _isLoadingMore.value = false + } + } + + private suspend fun fetchHistory(page: Int): List { + return if (chatType == "GROUP") { + runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) } + .getOrDefault(emptyList()) + } else { + runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) } + .getOrDefault(emptyList()) } } @@ -58,7 +105,29 @@ class ChatViewModel : ViewModel() { ImSDK.sendMessage(targetId, chatType, "TEXT", content) } + private fun prependMessage(message: ImMessage) { + if (_messages.value.any { it.id == message.id }) return + _messages.value = listOf(message) + _messages.value + } + + private fun requestScrollToBottom() { + _scrollToBottomSignal.value = _scrollToBottomSignal.value + 1 + } + + private fun isRelevant(message: ImMessage): Boolean { + return if (chatType == "GROUP") { + message.chatType == "GROUP" && message.toId == targetId + } else { + message.chatType != "GROUP" && (message.fromId == targetId || message.toId == targetId) + } + } + override fun onCleared() { ImSDK.removeListener(listener) + initialized = false + } + + private companion object { + const val HISTORY_PAGE_SIZE = 20 } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/common/ConnectionStatusBanner.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/common/ConnectionStatusBanner.kt new file mode 100644 index 0000000..421204c --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/common/ConnectionStatusBanner.kt @@ -0,0 +1,47 @@ +package com.xuqm.sdk.sample.ui.common + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.xuqm.sdk.im.model.ImConnectionState + +@Composable +fun ConnectionStatusBanner(state: ImConnectionState) { + when (state) { + ImConnectionState.Connected -> Unit + ImConnectionState.Connecting -> StatusBanner( + text = "IM 连接中...", + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + is ImConnectionState.Disconnected -> StatusBanner( + text = "IM 已断开${state.reason?.let { ":$it" } ?: ""}", + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} + +@Composable +private fun StatusBanner( + text: String, + containerColor: androidx.compose.ui.graphics.Color, + contentColor: androidx.compose.ui.graphics.Color, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = containerColor, + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = contentColor, + style = MaterialTheme.typography.bodySmall, + ) + } +} 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 2dc099a..ff8c00d 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 @@ -41,6 +41,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.model.ImGroup +import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -153,19 +154,23 @@ fun GroupSettingsScreen( viewModel: GroupViewModel = viewModel(), ) { val group by viewModel.currentGroup.collectAsStateWithLifecycle() + val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) } Scaffold( topBar = { - TopAppBar( - title = { Text(group?.name ?: "群设置") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + androidx.compose.foundation.layout.Column { + TopAppBar( + title = { Text(group?.name ?: "群设置") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } } - }, - ) + ) + ConnectionStatusBanner(connectionState) + } }, ) { padding -> Column( diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt index 1cc6d8b..67d92bf 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt @@ -25,11 +25,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.sample.ui.contact.ContactScreen import com.xuqm.sdk.sample.ui.conversation.ConversationScreen import com.xuqm.sdk.sample.ui.group.GroupListScreen import com.xuqm.sdk.sample.ui.profile.ProfileScreen import com.xuqm.sdk.sample.ui.update.UpdateScreen +import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner private data class BottomTab(val label: String, val icon: ImageVector) @@ -50,17 +53,21 @@ fun MainScreen( onLogout: () -> Unit, ) { var selectedTab by remember { mutableIntStateOf(0) } + val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() Scaffold( topBar = { - TopAppBar( - title = { Text(tabs[selectedTab].label) }, - actions = { - IconButton(onClick = onOpenEnvironment) { - Icon(Icons.Default.Settings, contentDescription = "环境设置") + androidx.compose.foundation.layout.Column { + TopAppBar( + title = { Text(tabs[selectedTab].label) }, + actions = { + IconButton(onClick = onOpenEnvironment) { + Icon(Icons.Default.Settings, contentDescription = "环境设置") + } } - }, - ) + ) + ConnectionStatusBanner(connectionState) + } }, bottomBar = { NavigationBar { 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 e32d55f..e064dfa 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 @@ -10,17 +10,35 @@ import com.xuqm.sdk.im.api.SetMutedRequest import com.xuqm.sdk.im.api.SetPinnedRequest import com.xuqm.sdk.im.api.UpdateGroupRequest 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.ImGroup import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.network.ApiClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow object ImSDK { private var client: ImClient? = null private val api: ImApi get() = ApiClient.create(ImApi::class.java, ServiceEndpointRegistry.imApiBaseUrl) + private val connectionListener = object : ImEventListener { + override fun onConnected() { + _connectionState.value = ImConnectionState.Connected + } + + override fun onDisconnected(reason: String?) { + _connectionState.value = ImConnectionState.Disconnected(reason) + } + + override fun onError(error: String) { + _connectionState.value = ImConnectionState.Disconnected(error) + } + } + private val _connectionState = MutableStateFlow(ImConnectionState.Disconnected("未连接")) + val connectionState: StateFlow = _connectionState var currentUserId: String = "" private set @@ -131,7 +149,9 @@ object ImSDK { private fun connectWithToken(token: String) { XuqmSDK.tokenStore.saveToken(token) client?.disconnect() + _connectionState.value = ImConnectionState.Connecting client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId) + client?.addListener(connectionListener) client?.connect() } @@ -139,6 +159,7 @@ object ImSDK { client?.disconnect() client = null currentUserId = "" + _connectionState.value = ImConnectionState.Disconnected("已断开") if (clearTokenStore) { XuqmSDK.tokenStore.clear() } diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImConnectionState.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImConnectionState.kt new file mode 100644 index 0000000..4376d29 --- /dev/null +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImConnectionState.kt @@ -0,0 +1,7 @@ +package com.xuqm.sdk.im.model + +sealed interface ImConnectionState { + data object Connecting : ImConnectionState + data object Connected : ImConnectionState + data class Disconnected(val reason: String? = null) : ImConnectionState +}