From 7c3c8d11bc43caf4caee7be5890c0e9886aea2c0 Mon Sep 17 00:00:00 2001 From: sevenhill Date: Sat, 4 Jul 2026 14:17:28 +0300 Subject: [PATCH] Recover stale Android chat ids --- android/app/build.gradle.kts | 4 +- .../java/xyz/kusoft/qmax/ui/QMaxViewModel.kt | 94 ++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5131c97..eeaaa01 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "xyz.kusoft.qmax" minSdk = 26 targetSdk = 36 - versionCode = 47 - versionName = "0.1.46" + versionCode = 48 + versionName = "0.1.47" buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index ef6665d..c2c3eaa 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -20,6 +20,7 @@ import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto import xyz.kusoft.qmax.core.model.MessageDeletedDto import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.QMaxSession +import xyz.kusoft.qmax.core.network.QMaxHttpException import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient import java.io.File @@ -378,8 +379,15 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { if (cached.isNotEmpty() && state.value.selectedChat?.id == chat.id && state.value.messages.isEmpty()) { state.value = state.value.copy(messages = cached) } - val fresh = withTimeout(MessageSyncTimeoutMs) { - repository.messages(session, chat.id) + val fresh = try { + withTimeout(MessageSyncTimeoutMs) { + repository.messages(session, chat.id) + } + } catch (error: QMaxHttpException) { + if (error.code == 404 && recoverMissingChat(session, chat)) { + return@launchLoading + } + throw error } if (state.value.selectedChat?.id == chat.id) { val selected = state.value.selectedChat?.copy(unreadCount = 0) @@ -393,6 +401,61 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { } } + private suspend fun recoverMissingChat(session: QMaxSession, staleChat: ChatDto): Boolean { + val freshChats = runCatching { + withTimeout(ChatListTimeoutMs) { + repository.chats(session) + } + }.getOrElse { + return false + } + + val orderedFresh = normalizeChats(freshChats) + val replacement = findReplacementChat(staleChat, orderedFresh) + if (replacement == null) { + state.value = state.value.copy( + selectedChat = null, + chats = orderedFresh, + messages = emptyList(), + error = null + ) + repository.cacheChats(session, orderedFresh) + return true + } + + val openedChat = replacement.copy(unreadCount = 0) + rememberChatRead(openedChat) + val chats = normalizeChats( + orderedFresh.map { chat -> + if (chat.id == openedChat.id) chat.copy(unreadCount = 0) else chat + }, + selectedChatId = openedChat.id + ) + state.value = state.value.copy( + selectedChat = openedChat, + chats = chats, + messages = emptyList(), + chatPresenceText = null, + error = null + ) + repository.cacheChats(session, chats) + syncChatRead(openedChat.id) + realtime.joinChat(openedChat.id) + + val freshMessages = withTimeout(MessageSyncTimeoutMs) { + repository.messages(session, openedChat.id) + } + if (state.value.selectedChat?.id == openedChat.id) { + state.value = state.value.copy( + selectedChat = state.value.selectedChat?.copy(unreadCount = 0), + chats = normalizeChats(state.value.chats, selectedChatId = openedChat.id), + messages = freshMessages + ) + persistCurrentChats() + } + return true + } + fun sendMessage() = sendComposer(emptyList()) fun sendComposer(attachmentUris: List, onSuccess: () -> Unit = {}) = launchLoading(false) { @@ -731,6 +794,33 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { } } + private fun findReplacementChat(staleChat: ChatDto, freshChats: List): ChatDto? { + staleChat.externalId?.takeIf { it.isNotBlank() }?.let { externalId -> + freshChats.firstOrNull { it.externalId == externalId }?.let { return it } + } + + val titleMatches = freshChats.filter { it.title.equals(staleChat.title, ignoreCase = true) } + if (titleMatches.size == 1) { + return titleMatches.first() + } + + val staleAvatar = staleChat.avatarUrl.orEmpty() + if (staleAvatar.isNotBlank()) { + titleMatches.firstOrNull { it.avatarUrl.orEmpty() == staleAvatar }?.let { return it } + val staleFingerprint = avatarFingerprint(staleAvatar) + titleMatches.firstOrNull { avatarFingerprint(it.avatarUrl.orEmpty()) == staleFingerprint }?.let { return it } + } + + return null + } + + private fun avatarFingerprint(value: String): String { + if (value.isBlank()) return "" + return runCatching { + android.net.Uri.parse(value).getQueryParameter("r") + }.getOrNull()?.takeIf { it.isNotBlank() } ?: value + } + private suspend fun refreshPresence(chatId: String) { val session = state.value.session ?: return val presence = runCatching {