diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt new file mode 100644 index 0000000..dd2c6d9 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt @@ -0,0 +1,160 @@ +package com.xuqm.sdk.sample.data.local + +import android.content.Context +import com.xuqm.sdk.im.model.ConversationData +import com.xuqm.sdk.im.model.ImMessage +import org.json.JSONArray +import org.json.JSONObject + +class LocalImCache(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun loadConversations(): List = readConversationList(KEY_CONVERSATIONS) + + fun saveConversations(conversations: List) { + prefs.edit().putString(KEY_CONVERSATIONS, serializeConversationList(conversations)).apply() + } + + fun loadHistory(targetId: String, chatType: String): List = + readMessageList(historyKey(targetId, chatType)) + + fun saveHistory(targetId: String, chatType: String, messages: List) { + prefs.edit().putString(historyKey(targetId, chatType), serializeMessageList(messages)).apply() + } + + fun getHistoryPage(targetId: String, chatType: String, page: Int, size: Int): List { + val history = loadHistory(targetId, chatType).sortedByDescending { it.createdAt } + val fromIndex = page * size + if (fromIndex >= history.size) return emptyList() + return history.drop(fromIndex).take(size) + } + + fun searchHistory( + targetId: String, + chatType: String, + keyword: String, + ): List { + val normalized = keyword.trim() + if (normalized.isBlank()) return emptyList() + return loadHistory(targetId, chatType) + .filter { it.matches(normalized) } + .sortedByDescending { it.createdAt } + } + + fun mergeHistory(targetId: String, chatType: String, messages: List) { + val merged = (loadHistory(targetId, chatType) + messages) + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + saveHistory(targetId, chatType, merged) + } + + private fun readConversationList(key: String): List { + val raw = prefs.getString(key, null) ?: return emptyList() + return runCatching { + val array = JSONArray(raw) + buildList { + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + add( + ConversationData( + targetId = obj.optString("targetId"), + chatType = obj.optString("chatType"), + lastMsgContent = obj.optString("lastMsgContent").takeIf { it.isNotBlank() }, + lastMsgType = obj.optString("lastMsgType").takeIf { it.isNotBlank() }, + lastMsgTime = obj.optLong("lastMsgTime"), + unreadCount = obj.optInt("unreadCount"), + isMuted = obj.optBoolean("isMuted"), + isPinned = obj.optBoolean("isPinned"), + ) + ) + } + } + }.getOrDefault(emptyList()) + } + + private fun readMessageList(key: String): List { + val raw = prefs.getString(key, null) ?: return emptyList() + return runCatching { + val array = JSONArray(raw) + buildList { + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + add( + ImMessage( + id = obj.optString("id"), + appId = obj.optString("appId"), + fromId = obj.optString("fromId"), + toId = obj.optString("toId"), + chatType = obj.optString("chatType"), + msgType = obj.optString("msgType"), + content = obj.optString("content"), + status = obj.optString("status"), + mentionedUserIds = obj.optString("mentionedUserIds").takeIf { it.isNotBlank() }, + createdAt = obj.optLong("createdAt"), + ) + ) + } + } + }.getOrDefault(emptyList()) + } + + private fun serializeConversationList(conversations: List): String { + val array = JSONArray() + conversations.forEach { conversation -> + array.put( + JSONObject().apply { + put("targetId", conversation.targetId) + put("chatType", conversation.chatType) + put("lastMsgContent", conversation.lastMsgContent ?: "") + put("lastMsgType", conversation.lastMsgType ?: "") + put("lastMsgTime", conversation.lastMsgTime) + put("unreadCount", conversation.unreadCount) + put("isMuted", conversation.isMuted) + put("isPinned", conversation.isPinned) + } + ) + } + return array.toString() + } + + private fun serializeMessageList(messages: List): String { + val array = JSONArray() + messages.forEach { message -> + array.put( + JSONObject().apply { + put("id", message.id) + put("appId", message.appId) + put("fromId", message.fromId) + put("toId", message.toId) + put("chatType", message.chatType) + put("msgType", message.msgType) + put("content", message.content) + put("status", message.status) + put("mentionedUserIds", message.mentionedUserIds ?: "") + put("createdAt", message.createdAt) + } + ) + } + return array.toString() + } + + private fun ImMessage.matches(keyword: String): Boolean { + val plainText = when (msgType.uppercase()) { + "TEXT" -> runCatching { JSONObject(content).optString("text") }.getOrDefault(content) + "NOTIFY" -> runCatching { JSONObject(content).optString("content") }.getOrDefault(content) + else -> content + } + return plainText.contains(keyword, ignoreCase = true) || + fromId.contains(keyword, ignoreCase = true) || + toId.contains(keyword, ignoreCase = true) || + msgType.contains(keyword, ignoreCase = true) + } + + private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId" + + private companion object { + const val PREFS_NAME = "xuqm_demo_im_cache" + const val KEY_CONVERSATIONS = "conversations" + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt index baaf6a0..aa1076e 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt @@ -2,7 +2,9 @@ package com.xuqm.sdk.sample.di import android.content.Context import com.xuqm.sdk.sample.data.repo.AuthRepository +import com.xuqm.sdk.sample.data.local.LocalContactCache import com.xuqm.sdk.sample.data.repo.EnvironmentRepository +import com.xuqm.sdk.sample.data.local.LocalImCache object AppDependencies { @@ -10,10 +12,16 @@ object AppDependencies { private set lateinit var environmentRepository: EnvironmentRepository private set + lateinit var localImCache: LocalImCache + private set + lateinit var localContactCache: LocalContactCache + private set fun init(context: Context) { environmentRepository = EnvironmentRepository(context) environmentRepository.current() + localImCache = LocalImCache(context) + localContactCache = LocalContactCache(context) authRepository = AuthRepository(context) } } 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 4f288b0..8d63363 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 @@ -2,7 +2,6 @@ package com.xuqm.sdk.sample.ui.chat import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -18,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -41,10 +41,11 @@ import androidx.compose.ui.draw.clip 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.im.model.ImMessage import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner +import com.xuqm.sdk.ui.InitialAvatar +import com.xuqm.sdk.ui.SearchBarField import kotlinx.coroutines.flow.distinctUntilChanged @OptIn(ExperimentalMaterial3Api::class) @@ -58,18 +59,24 @@ fun ChatScreen( viewModel: ChatViewModel = viewModel(), ) { val messages by viewModel.messages.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val searchResults by viewModel.searchResults.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("") } + var showSearchBar by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.init(targetId, chatType) } LaunchedEffect(scrollSignal) { - if (messages.isNotEmpty()) listState.animateScrollToItem(0) + if (messages.isNotEmpty() && searchQuery.isBlank()) { + listState.animateScrollToItem(0) + } } - LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size) { + LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size, searchQuery) { + if (searchQuery.isNotBlank()) return@LaunchedEffect snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 } .distinctUntilChanged() .collect { lastVisibleIndex -> @@ -94,6 +101,16 @@ fun ChatScreen( } }, actions = { + IconButton( + onClick = { + showSearchBar = !showSearchBar + if (!showSearchBar) { + viewModel.clearSearch() + } + }, + ) { + Icon(Icons.Default.Search, contentDescription = "搜索") + } if (chatType == "GROUP" && onGroupSettings != null) { IconButton(onClick = onGroupSettings) { Icon(Icons.Default.Settings, contentDescription = null) @@ -129,29 +146,71 @@ fun ChatScreen( } }, ) { padding -> - LazyColumn( - state = listState, - reverseLayout = true, + Column( modifier = Modifier .fillMaxSize() .padding(padding), ) { - items(messages, key = { it.id }) { msg -> - MessageBubble( - message = msg, - isOwn = msg.fromId == viewModel.currentUserId, + if (showSearchBar || searchQuery.isNotBlank()) { + SearchBarField( + value = searchQuery, + onValueChange = viewModel::searchCachedMessages, + placeholder = "搜索当前会话本地消息", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), ) } - if (isLoadingMore) { - item(key = "loading-more") { - Text( - "加载历史中...", - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, - ) + + if (searchQuery.isNotBlank()) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + items(searchResults, key = { it.id }) { msg -> + MessageBubble( + message = msg, + isOwn = msg.fromId == viewModel.currentUserId, + ) + } + if (searchResults.isEmpty()) { + item { + Text( + "没有找到匹配的本地消息", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + } else { + LazyColumn( + state = listState, + reverseLayout = true, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + items(messages, key = { it.id }) { msg -> + MessageBubble( + message = msg, + 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 6a44e43..0bf1b1c 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 @@ -5,12 +5,14 @@ import androidx.lifecycle.viewModelScope import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.listener.ImEventListener import com.xuqm.sdk.im.model.ImMessage +import com.xuqm.sdk.sample.di.AppDependencies import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class ChatViewModel : ViewModel() { + private val cache = AppDependencies.localImCache private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages @@ -23,6 +25,12 @@ class ChatViewModel : ViewModel() { private val _scrollToBottomSignal = MutableStateFlow(0L) val scrollToBottomSignal: StateFlow = _scrollToBottomSignal + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery + + private val _searchResults = MutableStateFlow>(emptyList()) + val searchResults: StateFlow> = _searchResults + val currentUserId: String get() = ImSDK.currentUserId private lateinit var targetId: String @@ -34,6 +42,10 @@ class ChatViewModel : ViewModel() { override fun onMessage(message: ImMessage) { if (isRelevant(message)) { prependMessage(message) + cache.mergeHistory(targetId, chatType, _messages.value) + if (_searchQuery.value.isNotBlank()) { + _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) + } requestScrollToBottom() } } @@ -41,6 +53,10 @@ class ChatViewModel : ViewModel() { override fun onGroupMessage(message: ImMessage) { if (isRelevant(message)) { prependMessage(message) + cache.mergeHistory(targetId, chatType, _messages.value) + if (_searchQuery.value.isNotBlank()) { + _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) + } requestScrollToBottom() } } @@ -52,9 +68,11 @@ class ChatViewModel : ViewModel() { this.chatType = chatType nextHistoryPage = 0 initialized = true - _messages.value = emptyList() + _messages.value = cache.getHistoryPage(targetId, chatType, 0, HISTORY_PAGE_SIZE) _hasMoreHistory.value = true _isLoadingMore.value = false + _searchQuery.value = "" + _searchResults.value = emptyList() ImSDK.addListener(listener) loadInitialHistory() viewModelScope.launch { @@ -86,18 +104,31 @@ class ChatViewModel : ViewModel() { nextHistoryPage += 1 } _hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE + if (history.isNotEmpty()) { + cache.saveHistory(targetId, chatType, mergeHistory(_messages.value)) + } _isLoadingMore.value = false } } private suspend fun fetchHistory(page: Int): List { - return if (chatType == "GROUP") { + val remote = if (chatType == "GROUP") { runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) } - .getOrDefault(emptyList()) + .getOrNull() } else { runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) } - .getOrDefault(emptyList()) + .getOrNull() } + if (remote != null) { + val normalized = remote.sortedByDescending { it.createdAt } + if (page == 0) { + cache.saveHistory(targetId, chatType, normalized) + } else { + cache.mergeHistory(targetId, chatType, normalized) + } + return normalized + } + return cache.getHistoryPage(targetId, chatType, page, HISTORY_PAGE_SIZE) } fun sendText(content: String) { @@ -105,11 +136,28 @@ class ChatViewModel : ViewModel() { ImSDK.sendMessage(targetId, chatType, "TEXT", content) } + fun searchCachedMessages(query: String) { + _searchQuery.value = query + _searchResults.value = if (query.isBlank()) { + emptyList() + } else { + cache.searchHistory(targetId, chatType, query) + } + } + + fun clearSearch() { + _searchQuery.value = "" + _searchResults.value = emptyList() + } + private fun prependMessage(message: ImMessage) { if (_messages.value.any { it.id == message.id }) return _messages.value = listOf(message) + _messages.value } + private fun mergeHistory(messages: List): List = + messages.distinctBy { it.id }.sortedByDescending { it.createdAt } + private fun requestScrollToBottom() { _scrollToBottomSignal.value = _scrollToBottomSignal.value + 1 } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt index 51c47f7..6a576be 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.ui.InitialAvatar +import com.xuqm.sdk.ui.SearchBarField import com.xuqm.sdk.utils.TimeFormatters import kotlinx.coroutines.launch @@ -45,20 +46,52 @@ fun ConversationScreen( ) { val conversations by viewModel.conversations.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + var query by remember { mutableStateOf("") } + val filtered = remember(conversations, query) { + conversations.filter { conversation -> + if (query.isBlank()) return@filter true + val keyword = query.trim() + conversation.targetId.contains(keyword, ignoreCase = true) || + (conversation.lastMsgContent ?: "").contains(keyword, ignoreCase = true) || + conversation.chatType.contains(keyword, ignoreCase = true) + } + } - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(conversations, key = { "${it.chatType}_${it.targetId}" }) { conv -> - ConversationItem( - conversation = conv, - onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) }, - onPinToggle = { - scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) } - }, - onMuteToggle = { - scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) } - }, + Column(modifier = Modifier.fillMaxSize()) { + SearchBarField( + value = query, + onValueChange = { query = it }, + placeholder = "搜索会话 / 最近消息", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + + if (filtered.isEmpty()) { + Text( + "没有匹配的会话", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, ) - HorizontalDivider(modifier = Modifier.padding(start = 72.dp)) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + items(filtered, key = { "${it.chatType}_${it.targetId}" }) { conv -> + ConversationItem( + conversation = conv, + onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) }, + onPinToggle = { + scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) } + }, + onMuteToggle = { + scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) } + }, + ) + HorizontalDivider(modifier = Modifier.padding(start = 72.dp)) + } + } } } } 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 2070b9a..1cc0707 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 @@ -6,12 +6,14 @@ import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.listener.ImEventListener import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.im.model.ImMessage +import com.xuqm.sdk.sample.di.AppDependencies import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class ConversationViewModel : ViewModel() { + private val cache = AppDependencies.localImCache private val _conversations = MutableStateFlow>(emptyList()) val conversations: StateFlow> = _conversations @@ -22,6 +24,7 @@ class ConversationViewModel : ViewModel() { init { ImSDK.addListener(listener) + _conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime } refresh() } @@ -29,7 +32,13 @@ class ConversationViewModel : ViewModel() { viewModelScope.launch { runCatching { ImSDK.listConversations() } .onSuccess { list -> - _conversations.value = list.sortedByDescending { it.lastMsgTime } + val sorted = list.sortedByDescending { it.lastMsgTime } + cache.saveConversations(sorted) + _conversations.value = sorted + } + .onFailure { + val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime } + if (cached.isNotEmpty()) _conversations.value = cached } } }