From 3974c061b8336097d0c5f4b39a2b3d5d76634ee3 Mon Sep 17 00:00:00 2001 From: alimu Date: Sun, 8 Mar 2026 19:47:28 +0400 Subject: [PATCH] feat(android): persist chat history in local Room database --- android-client/app/build.gradle.kts | 4 ++ .../client/data/local/ChatDatabase.kt | 34 +++++++++++++ .../data/local/ChatHistoryRepository.kt | 50 +++++++++++++++++++ .../client/data/local/ChatMessageDao.kt | 31 ++++++++++++ .../client/data/local/ChatMessageEntity.kt | 15 ++++++ .../onlinemsg/client/ui/ChatSessionManager.kt | 23 ++++++++- android-client/build.gradle.kts | 1 + 7 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts index 7581e5a..d40f9af 100644 --- a/android-client/app/build.gradle.kts +++ b/android-client/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.devtools.ksp") } android { @@ -71,6 +72,9 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.6.1") implementation("com.squareup.okhttp3:okhttp:4.12.0") debugImplementation("androidx.compose.ui:ui-tooling") diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt new file mode 100644 index 0000000..25ea563 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt @@ -0,0 +1,34 @@ +package com.onlinemsg.client.data.local + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database( + entities = [ChatMessageEntity::class], + version = 1, + exportSchema = false +) +abstract class ChatDatabase : RoomDatabase() { + abstract fun chatMessageDao(): ChatMessageDao + + companion object { + private const val DB_NAME = "onlinemsg_chat.db" + + @Volatile + private var instance: ChatDatabase? = null + + fun getInstance(context: Context): ChatDatabase { + return instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + ChatDatabase::class.java, + DB_NAME + ).build().also { db -> + instance = db + } + } + } + } +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt new file mode 100644 index 0000000..e0f0a61 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt @@ -0,0 +1,50 @@ +package com.onlinemsg.client.data.local + +import com.onlinemsg.client.ui.MessageChannel +import com.onlinemsg.client.ui.MessageRole +import com.onlinemsg.client.ui.UiMessage + +class ChatHistoryRepository(private val messageDao: ChatMessageDao) { + suspend fun loadMessages(limit: Int): List { + return messageDao.listAll() + .asSequence() + .mapNotNull { entity -> entity.toUiMessageOrNull() } + .toList() + .takeLast(limit) + } + + suspend fun appendMessage(message: UiMessage, limit: Int) { + messageDao.upsert(message.toEntity()) + messageDao.trimToLatest(limit) + } + + suspend fun clearAll() { + messageDao.clearAll() + } +} + +private fun UiMessage.toEntity(): ChatMessageEntity { + return ChatMessageEntity( + id = id, + role = role.name, + sender = sender, + subtitle = subtitle, + content = content, + channel = channel.name, + timestampMillis = timestampMillis + ) +} + +private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? { + val parsedRole = runCatching { MessageRole.valueOf(role) }.getOrNull() ?: return null + val parsedChannel = runCatching { MessageChannel.valueOf(channel) }.getOrNull() ?: return null + return UiMessage( + id = id, + role = parsedRole, + sender = sender, + subtitle = subtitle, + content = content, + channel = parsedChannel, + timestampMillis = timestampMillis + ) +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt new file mode 100644 index 0000000..1085e09 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageDao.kt @@ -0,0 +1,31 @@ +package com.onlinemsg.client.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface ChatMessageDao { + @Query("SELECT * FROM chat_messages ORDER BY timestampMillis ASC") + suspend fun listAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(message: ChatMessageEntity) + + @Query( + """ + DELETE FROM chat_messages + WHERE id NOT IN ( + SELECT id + FROM chat_messages + ORDER BY timestampMillis DESC + LIMIT :limit + ) + """ + ) + suspend fun trimToLatest(limit: Int) + + @Query("DELETE FROM chat_messages") + suspend fun clearAll() +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt new file mode 100644 index 0000000..e0e6dde --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt @@ -0,0 +1,15 @@ +package com.onlinemsg.client.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "chat_messages") +data class ChatMessageEntity( + @PrimaryKey val id: String, + val role: String, + val sender: String, + val subtitle: String, + val content: String, + val channel: String, + val timestampMillis: Long +) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt index b9a1f25..a9750ee 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt @@ -15,6 +15,8 @@ import androidx.core.content.ContextCompat import com.onlinemsg.client.MainActivity import com.onlinemsg.client.R import com.onlinemsg.client.data.crypto.RsaCryptoManager +import com.onlinemsg.client.data.local.ChatDatabase +import com.onlinemsg.client.data.local.ChatHistoryRepository import com.onlinemsg.client.data.network.OnlineMsgSocketClient import com.onlinemsg.client.data.preferences.ServerUrlFormatter import com.onlinemsg.client.data.preferences.UserPreferencesRepository @@ -60,6 +62,7 @@ object ChatSessionManager { private lateinit var app: Application private lateinit var preferencesRepository: UserPreferencesRepository private lateinit var cryptoManager: RsaCryptoManager + private lateinit var historyRepository: ChatHistoryRepository private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val socketClient = OnlineMsgSocketClient() private var initialized = false @@ -171,10 +174,14 @@ object ChatSessionManager { app = application preferencesRepository = UserPreferencesRepository(application, json) cryptoManager = RsaCryptoManager(application) + historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao()) ensureMessageNotificationChannel() scope.launch { val pref = preferencesRepository.preferencesFlow.first() + val historyMessages = withContext(Dispatchers.IO) { + historyRepository.loadMessages(MAX_MESSAGES) + } keepAliveRequested = pref.shouldAutoReconnect _uiState.update { current -> current.copy( @@ -185,7 +192,8 @@ object ChatSessionManager { showSystemMessages = pref.showSystemMessages, themeId = pref.themeId, useDynamicColor = pref.useDynamicColor, - language = pref.language + language = pref.language, + messages = historyMessages ) } // 如果上次会话启用了自动重连,则自动恢复连接 @@ -294,6 +302,11 @@ object ChatSessionManager { fun clearMessages() { cancelSystemMessageExpiryJobs() _uiState.update { it.copy(messages = emptyList()) } + scope.launch(Dispatchers.IO) { + runCatching { + historyRepository.clearAll() + } + } } /** @@ -894,6 +907,12 @@ object ChatSessionManager { } current.copy(messages = next) } + if (message.role == MessageRole.SYSTEM) return + scope.launch(Dispatchers.IO) { + runCatching { + historyRepository.appendMessage(message, MAX_MESSAGES) + } + } } /** @@ -1155,4 +1174,4 @@ object ChatSessionManager { private const val MAX_RECONNECT_DELAY_SECONDS = 30 private const val SYSTEM_MESSAGE_TTL_MS = 1_000L private const val MESSAGE_CHANNEL_ID = "onlinemsg_messages" -} \ No newline at end of file +} diff --git a/android-client/build.gradle.kts b/android-client/build.gradle.kts index dedf0a4..8c29eb8 100644 --- a/android-client/build.gradle.kts +++ b/android-client/build.gradle.kts @@ -2,4 +2,5 @@ plugins { id("com.android.application") version "8.5.2" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false + id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false }