Files
forkmessager/client-mobile/core/database/data/ChatDatabase.kt
Халимов Рустам 7c66e1c0c0 Кэш чатов
2026-04-20 00:25:40 +03:00

210 lines
8.9 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package core.database.data
import androidx.paging.PagingSource
import androidx.room.*
import kotlinx.coroutines.flow.Flow
/**
* Статус синхронизации сообщения с сервером
*/
enum class SyncStatus {
SYNCED, // Сообщение успешно синхронизировано
SYNCING, // Сообщение отправляется на сервер
FAILED // Ошибка синхронизации
}
/**
* Entity для хранения сообщений в локальной базе данных
* Поддерживает офлайн-работу и фоновую синхронизацию
*/
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val id: String,
val chatId: String,
val senderId: String,
val senderName: String,
val senderAvatar: String?,
val content: String?,
val sequenceId: Int,
val createdAt: String,
val mediaType: String,
val mediaJson: String,
val reactionsJson: String,
val isRead: Boolean,
val replyToId: String? = null,
// Поля для офлайн-синхронизации
@ColumnInfo(defaultValue = "SYNCED")
val syncStatus: SyncStatus = SyncStatus.SYNCED,
@ColumnInfo(defaultValue = "0")
val isDeletedLocally: Boolean = false,
@ColumnInfo(defaultValue = "0")
val isEditedLocally: Boolean = false,
val editedContent: String? = null,
@ColumnInfo(defaultValue = "0")
val lastUpdated: Long = 0
)
@Dao
interface MessageDao {
// ==================== Flow для UI ====================
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 ORDER BY sequenceId ASC")
fun getMessages(chatId: String): Flow<List<MessageEntity>>
// ==================== Paging 3 ====================
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 ORDER BY sequenceId DESC")
fun getMessagesPagingSource(chatId: String): PagingSource<Int, MessageEntity>
// Загружаем сообщения С МЕНЬШИМ sequenceId (старые), порядок DESC для PagingSource
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 AND sequenceId < :sequenceId ORDER BY sequenceId DESC LIMIT :limit")
suspend fun getMessagesBefore(chatId: String, sequenceId: Int, limit: Int): List<MessageEntity>
// Загружаем сообщения до и ВКЛЮЧАЯ sequenceId, порядок DESC
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 AND sequenceId <= :sequenceId ORDER BY sequenceId DESC LIMIT :limit")
suspend fun getMessagesUpToAndIncluding(chatId: String, sequenceId: Int, limit: Int): List<MessageEntity>
// Загружаем сообщения С БОЛЬШИМ sequenceId (новые), порядок ASC
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 AND sequenceId > :sequenceId ORDER BY sequenceId ASC LIMIT :limit")
suspend fun getMessagesAfter(chatId: String, sequenceId: Int, limit: Int): List<MessageEntity>
@Query("SELECT MAX(sequenceId) FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0")
suspend fun getMaxSequenceId(chatId: String): Int?
@Query("SELECT MIN(sequenceId) FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0")
suspend fun getMinSequenceId(chatId: String): Int?
// ==================== Основные операции ====================
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List<MessageEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage(message: MessageEntity)
/**
* Upsert - вставляет или обновляет сообщение
* Приоритет: серверные данные > локальные (кроме сообщений в процессе отправки)
*/
@Transaction
suspend fun upsertMessage(message: MessageEntity) {
val existing = getMessageById(message.id)
if (existing != null) {
// Сохраняем локальные изменения если сообщение в процессе отправки
val syncedMessage = when {
existing.syncStatus == SyncStatus.SYNCING || existing.isEditedLocally -> {
message.copy(
syncStatus = existing.syncStatus,
isEditedLocally = existing.isEditedLocally,
editedContent = existing.editedContent,
lastUpdated = existing.lastUpdated
)
}
else -> message
}
insertMessage(syncedMessage)
} else {
insertMessage(message)
}
}
@Transaction
suspend fun upsertMessages(messages: List<MessageEntity>) {
messages.forEach { upsertMessage(it) }
}
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
suspend fun getMessageById(id: String): MessageEntity?
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
fun getMessageByIdFlow(id: String): Flow<MessageEntity?>
@Query("DELETE FROM messages WHERE chatId = :chatId")
suspend fun clearChat(chatId: String)
@Query("DELETE FROM messages WHERE id = :messageId")
suspend fun deleteMessage(messageId: String)
@Query("UPDATE messages SET isRead = 1 WHERE chatId = :chatId AND sequenceId <= :lastReadSequenceId AND isRead = 0")
suspend fun markMessagesAsRead(chatId: String, lastReadSequenceId: Int)
// ==================== Офлайн операции ====================
/**
* Помечает сообщение как удалённое локально
* Фактическое удаление произойдёт после синхронизации с сервером
*/
@Query("UPDATE messages SET isDeletedLocally = 1, syncStatus = 'SYNCING', lastUpdated = :timestamp WHERE id = :messageId")
suspend fun markAsDeletedLocally(messageId: String, timestamp: Long = System.currentTimeMillis())
/**
* Помечает сообщение как отредактированное локально
*/
@Query("UPDATE messages SET isEditedLocally = 1, editedContent = :newContent, syncStatus = 'SYNCING', lastUpdated = :timestamp WHERE id = :messageId")
suspend fun markAsEditedLocally(messageId: String, newContent: String, timestamp: Long = System.currentTimeMillis())
/**
* Сбрасывает локальные изменения после успешной синхронизации
*/
@Query("UPDATE messages SET syncStatus = 'SYNCED', isDeletedLocally = 0, isEditedLocally = 0, editedContent = NULL WHERE id = :messageId")
suspend fun markAsSynced(messageId: String)
/**
* Устанавливает статус FAILED для сообщений с ошибкой синхронизации
*/
@Query("UPDATE messages SET syncStatus = 'FAILED' WHERE id = :messageId")
suspend fun markAsSyncFailed(messageId: String)
/**
* Получает все сообщения, требующие синхронизации
*/
@Query("SELECT * FROM messages WHERE syncStatus = 'SYNCING' OR isDeletedLocally = 1 ORDER BY lastUpdated ASC")
suspend fun getPendingSyncMessages(): List<MessageEntity>
@Query("SELECT * FROM messages WHERE syncStatus = 'SYNCING' OR isDeletedLocally = 1 ORDER BY lastUpdated ASC")
fun getPendingSyncMessagesFlow(): Flow<List<MessageEntity>>
/**
* Получает сообщения с_failed статусом для повторной отправки
*/
@Query("SELECT * FROM messages WHERE syncStatus = 'FAILED' ORDER BY lastUpdated ASC")
suspend fun getFailedSyncMessages(): List<MessageEntity>
// ==================== Утилиты ====================
@Query("SELECT COUNT(*) FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0")
suspend fun getMessagesCount(chatId: String): Int
@Query("SELECT COUNT(*) FROM messages WHERE chatId = :chatId AND isRead = 0 AND isDeletedLocally = 0")
suspend fun getUnreadCount(chatId: String): Int
@Query("SELECT EXISTS(SELECT 1 FROM messages WHERE id = :id)")
suspend fun exists(id: String): Boolean
}
@Database(entities = [MessageEntity::class], version = 2)
@TypeConverters(SyncStatusConverter::class)
abstract class ChatDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
companion object {
const val DATABASE_NAME = "knot_chat_database"
}
}
/**
* Конвертер для Enum SyncStatus
*/
class SyncStatusConverter {
@TypeConverter
fun fromSyncStatus(status: SyncStatus): String = status.name
@TypeConverter
fun toSyncStatus(value: String): SyncStatus = SyncStatus.valueOf(value)
}