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("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
} }
android { android {
@ -71,6 +72,9 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("androidx.datastore:datastore-preferences:1.1.1") 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") implementation("com.squareup.okhttp3:okhttp:4.12.0")
debugImplementation("androidx.compose.ui:ui-tooling") 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.MainActivity
import com.onlinemsg.client.R import com.onlinemsg.client.R
import com.onlinemsg.client.data.crypto.RsaCryptoManager 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.network.OnlineMsgSocketClient
import com.onlinemsg.client.data.preferences.ServerUrlFormatter import com.onlinemsg.client.data.preferences.ServerUrlFormatter
import com.onlinemsg.client.data.preferences.UserPreferencesRepository import com.onlinemsg.client.data.preferences.UserPreferencesRepository
@ -60,6 +62,7 @@ object ChatSessionManager {
private lateinit var app: Application private lateinit var app: Application
private lateinit var preferencesRepository: UserPreferencesRepository private lateinit var preferencesRepository: UserPreferencesRepository
private lateinit var cryptoManager: RsaCryptoManager private lateinit var cryptoManager: RsaCryptoManager
private lateinit var historyRepository: ChatHistoryRepository
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val socketClient = OnlineMsgSocketClient() private val socketClient = OnlineMsgSocketClient()
private var initialized = false private var initialized = false
@ -171,10 +174,14 @@ object ChatSessionManager {
app = application app = application
preferencesRepository = UserPreferencesRepository(application, json) preferencesRepository = UserPreferencesRepository(application, json)
cryptoManager = RsaCryptoManager(application) cryptoManager = RsaCryptoManager(application)
historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao())
ensureMessageNotificationChannel() ensureMessageNotificationChannel()
scope.launch { scope.launch {
val pref = preferencesRepository.preferencesFlow.first() val pref = preferencesRepository.preferencesFlow.first()
val historyMessages = withContext(Dispatchers.IO) {
historyRepository.loadMessages(MAX_MESSAGES)
}
keepAliveRequested = pref.shouldAutoReconnect keepAliveRequested = pref.shouldAutoReconnect
_uiState.update { current -> _uiState.update { current ->
current.copy( current.copy(
@ -185,7 +192,8 @@ object ChatSessionManager {
showSystemMessages = pref.showSystemMessages, showSystemMessages = pref.showSystemMessages,
themeId = pref.themeId, themeId = pref.themeId,
useDynamicColor = pref.useDynamicColor, useDynamicColor = pref.useDynamicColor,
language = pref.language language = pref.language,
messages = historyMessages
) )
} }
// 如果上次会话启用了自动重连,则自动恢复连接 // 如果上次会话启用了自动重连,则自动恢复连接
@ -294,6 +302,11 @@ object ChatSessionManager {
fun clearMessages() { fun clearMessages() {
cancelSystemMessageExpiryJobs() cancelSystemMessageExpiryJobs()
_uiState.update { it.copy(messages = emptyList()) } _uiState.update { it.copy(messages = emptyList()) }
scope.launch(Dispatchers.IO) {
runCatching {
historyRepository.clearAll()
}
}
} }
/** /**
@ -894,6 +907,12 @@ object ChatSessionManager {
} }
current.copy(messages = next) current.copy(messages = next)
} }
if (message.role == MessageRole.SYSTEM) return
scope.launch(Dispatchers.IO) {
runCatching {
historyRepository.appendMessage(message, MAX_MESSAGES)
}
}
} }
/** /**

@ -2,4 +2,5 @@ plugins {
id("com.android.application") version "8.5.2" apply false 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.android") version "1.9.24" apply false
id("org.jetbrains.kotlin.plugin.serialization") 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