feat(android): persist chat history in local Room database

pull/14/head
alimu 2 weeks ago
parent 8658f21e2b
commit 3974c061b8

@ -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")

@ -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
}
}
}
}
}

@ -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<UiMessage> {
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
)
}

@ -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<ChatMessageEntity>
@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()
}

@ -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
)

@ -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"
}
}

@ -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
}

Loading…
Cancel
Save