Вторая часть по кэшу

This commit is contained in:
Халимов Рустам
2026-05-08 22:48:56 +03:00
parent d4c41b333a
commit a0b50b57b0
21 changed files with 237 additions and 377 deletions
+1 -1
View File
@@ -135,7 +135,7 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:$work_version")
// Paging 3
val paging_version = "3.2.1"
val paging_version = "3.3.0"
implementation("androidx.paging:paging-runtime-ktx:$paging_version")
implementation("androidx.paging:paging-compose:$paging_version")
@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface ChatDao {
@Query("SELECT * FROM chats ORDER BY updatedAtMillis DESC")
@Query("SELECT * FROM chats ORDER BY lastMessageTimestamp DESC")
fun getAllChats(): Flow<List<ChatEntity>>
@Query("SELECT * FROM chats WHERE remoteId = :remoteId LIMIT 1")
@@ -29,7 +29,7 @@ interface ChatDao {
suspend fun updateChat(chat: ChatEntity)
@Query("UPDATE chats SET lastMessageText = :lastMessageText, lastMessageTimestamp = :timestamp WHERE remoteId = :chatId")
suspend fun updateLastMessage(chatId: String, lastMessageText: String?, timestamp: String?)
suspend fun updateLastMessage(chatId: String, lastMessageText: String?, timestamp: Long?)
@Query("UPDATE chats SET unreadCount = :count WHERE remoteId = :chatId")
suspend fun updateUnreadCount(chatId: String, count: Int)
@@ -14,6 +14,19 @@ interface MessageDao {
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
fun getMessagesByChatId(chatId: String): Flow<List<MessageEntity>>
/**
* Пагинированная загрузка сообщений для Paging 3
* sequenceId увеличивается от старых к новым, поэтому ORDER BY DESC для отображения newest внизу
*/
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId DESC LIMIT :limit OFFSET :offset")
suspend fun getMessagesPaged(chatId: String, offset: Int, limit: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId DESC LIMIT 1")
suspend fun getLastMessage(chatId: String): MessageEntity?
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC LIMIT 1")
suspend fun getFirstMessage(chatId: String): MessageEntity?
@Query("SELECT * FROM messages WHERE chatId = :chatId AND sequenceId > :afterSequenceId ORDER BY sequenceId ASC LIMIT :limit")
suspend fun getMessagesAfter(chatId: String, afterSequenceId: Long, limit: Int): List<MessageEntity>
@@ -31,7 +31,7 @@ data class ChatEntity(
val lastMessageText: String? = null,
val lastMessageTimestamp: String? = null,
val lastMessageTimestamp: Long? = null,
val updatedAtMillis: Long = System.currentTimeMillis()
)
@@ -7,6 +7,7 @@ import chats.domain.model.Chat
* Преобразует Domain Chat в Entity
*/
fun Chat.toEntity(): ChatEntity {
val timestamp = this.lastMessage?.createdAt?.let { parseTimestamp(it) }
return ChatEntity(
localId = this.id.takeIf { it.isNotBlank() } ?: java.util.UUID.randomUUID().toString(),
remoteId = this.id,
@@ -15,7 +16,7 @@ fun Chat.toEntity(): ChatEntity {
avatar = this.avatar,
unreadCount = this.unreadCount,
lastMessageText = this.lastMessage?.content,
lastMessageTimestamp = this.lastMessage?.createdAt
lastMessageTimestamp = timestamp
)
}
@@ -32,3 +33,14 @@ fun ChatEntity.toDomain(): Chat {
lastMessage = null // lastMessage загружается отдельно
)
}
/**
* Парсит ISO-8601 timestamp в Long (millis)
*/
private fun parseTimestamp(isoTimestamp: String): Long? {
return try {
java.time.ZonedDateTime.parse(isoTimestamp).toInstant().toEpochMilli()
} catch (e: Exception) {
null
}
}
@@ -0,0 +1,43 @@
package chats.data.local.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import chats.data.local.dao.MessageDao
import chats.data.local.database.MessageEntity
/**
* PagingSource для загрузки сообщений из Room Database
*/
class MessagePagingSource(
private val chatId: String,
private val messageDao: MessageDao
) : PagingSource<Int, MessageEntity>() {
companion object {
private const val PAGE_SIZE = 30
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> {
return try {
val position = params.key ?: 0 // Начинаем с 0
val messages = messageDao.getMessagesPaged(
chatId = chatId,
offset = position,
limit = PAGE_SIZE
)
LoadResult.Page(
data = messages,
prevKey = if (position > 0) position - PAGE_SIZE else null,
nextKey = if (messages.isEmpty()) null else position + PAGE_SIZE
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, MessageEntity>): Int? {
return state.anchorPosition
}
}
@@ -0,0 +1,101 @@
package chats.data.local.paging
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.local.database.AppDatabase
import chats.data.local.database.MessageEntity
import chats.data.local.mappers.toEntity
import chats.data.repository.toDomain as dtoToDomain
import chats.data.remote.api.ChatApi
import chats.domain.model.MessageStatus
import core.security.TokenManager
/**
* RemoteMediator для синхронизации сообщений с сервером.
* Работает с Room через withTransaction для атомарности.
*/
@OptIn(ExperimentalPagingApi::class)
class MessageRemoteMediator(
private val chatId: String,
private val messageDao: MessageDao,
private val chatDao: ChatDao,
private val chatApi: ChatApi,
private val tokenManager: TokenManager,
private val appDatabase: AppDatabase
) : RemoteMediator<Int, MessageEntity>() {
companion object {
private const val PAGE_SIZE = 30
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, MessageEntity>
): RemoteMediator.MediatorResult {
return try {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = "https://your-server.com"
val cursor = when (loadType) {
LoadType.REFRESH -> {
// При refresh загружаем новые сообщения после последнего локального
messageDao.getLastMessage(chatId)?.serverId
}
LoadType.PREPEND -> {
// Загружаем более старые сообщения перед первым локальным
messageDao.getFirstMessage(chatId)?.serverId
}
LoadType.APPEND -> {
// Загружаем новые сообщения после последнего локального
messageDao.getLastMessage(chatId)?.serverId
}
}
val remoteMessages = chatApi.getMessages(chatId, cursor = cursor, limit = PAGE_SIZE)
val endOfPaginationReached = remoteMessages.size < PAGE_SIZE
appDatabase.withTransaction {
if (loadType == LoadType.REFRESH) {
// При полном обновлении можно очистить старые SENT/DELIVERED/READ,
// но оставить локальные PENDING/FAILED
// messageDao.deleteMessagesByChatId(chatId) // Опционально
}
val entities = remoteMessages.map { dto ->
val existing = messageDao.getMessageByServerId(dto.id)
if (existing != null) {
// Обновляем существующее, сохраняя localId
existing.copy(
content = dto.content,
reactionsJson = com.google.gson.Gson().toJson(
dto.reactions?.associate { it.emoji to it.count } ?: emptyMap<String, Int>()
),
status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED,
updatedAtMillis = System.currentTimeMillis()
)
} else {
// Создаём новое
dto.dtoToDomain(currentUserId, baseUrl).toEntity(MessageStatus.SENT).copy(
localId = java.util.UUID.randomUUID().toString(),
serverId = dto.id,
idempotencyKey = dto.id
)
}
}
messageDao.insertMessages(entities)
}
RemoteMediator.MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (e: Exception) {
RemoteMediator.MediatorResult.Error(e)
}
}
override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH
}
@@ -1,6 +1,11 @@
package chats.data.repository
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
@@ -10,6 +15,8 @@ import chats.data.local.database.MessageEntity
import chats.data.local.mappers.createPendingMessageEntity
import chats.data.local.mappers.toDomain
import chats.data.local.mappers.toEntity
import chats.data.local.paging.MessagePagingSource
import chats.data.local.paging.MessageRemoteMediator
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.data.remote.dto.MessageDto
@@ -39,6 +46,7 @@ class ChatRepositoryImpl @Inject constructor(
private val messageDao: MessageDao,
private val chatDao: ChatDao,
private val workManager: WorkManager,
private val appDatabase: chats.data.local.database.AppDatabase,
private val hubClient: chats.data.remote.signalr.ChatHubClient
) : ChatRepository {
@@ -101,6 +109,34 @@ class ChatRepositoryImpl @Inject constructor(
}
}
/**
* Получает сообщения с помощью Paging 3 и RemoteMediator
*/
@OptIn(ExperimentalPagingApi::class)
override fun getMessagesPagingSource(chatId: String): Flow<PagingData<Message>> {
return Pager(
config = PagingConfig(
pageSize = 30,
prefetchDistance = 10,
enablePlaceholders = false,
initialLoadSize = 60
),
pagingSourceFactory = {
MessagePagingSource(chatId, messageDao)
},
remoteMediator = MessageRemoteMediator(
chatId = chatId,
messageDao = messageDao,
chatDao = chatDao,
chatApi = api,
tokenManager = tokenManager,
appDatabase = appDatabase
)
).flow.map { pagingData ->
pagingData.map { entity -> entity.toDomain() }
}
}
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = tokenManager.getUserId() ?: ""
@@ -26,7 +26,8 @@ class ChatSyncWorker @AssistedInject constructor(
@Assisted params: WorkerParameters,
private val chatDao: ChatDao,
private val messageDao: MessageDao,
private val chatApi: chats.data.remote.api.ChatApi
private val chatApi: chats.data.remote.api.ChatApi,
private val tokenManager: TokenManager
) : CoroutineWorker(context, params) {
companion object {
@@ -106,6 +107,11 @@ class ChatSyncWorker @AssistedInject constructor(
remoteChats.forEach { dto ->
val existingChat = chatDao.getChatByRemoteId(dto.id)
val lastMessage = dto.messages.firstOrNull()
val timestamp = lastMessage?.createdAt?.let {
try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() }
catch (e: Exception) { null }
}
val chatEntity = chats.data.local.database.ChatEntity(
localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(),
remoteId = dto.id,
@@ -113,8 +119,8 @@ class ChatSyncWorker @AssistedInject constructor(
name = dto.name ?: "",
avatar = dto.avatar,
unreadCount = dto.unreadCount,
lastMessageText = dto.messages.firstOrNull()?.content,
lastMessageTimestamp = dto.messages.firstOrNull()?.createdAt
lastMessageText = lastMessage?.content,
lastMessageTimestamp = timestamp
)
localChats.add(chatEntity)
}
@@ -1,8 +1,8 @@
package chats.domain.repository
import androidx.paging.PagingData
import chats.domain.model.Chat
import chats.domain.model.Message
import kotlinx.coroutines.flow.Flow
interface ChatRepository {
@@ -13,6 +13,7 @@ interface ChatRepository {
// Сообщения - Single Source of Truth через Flow
fun getMessagesFlow(chatId: String): Flow<List<Message>>
fun getMessagesPagingSource(chatId: String): Flow<PagingData<Message>>
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
// Отправка сообщений с поддержкой офлайн
@@ -594,35 +594,12 @@ class ChatDetailViewModel @Inject constructor(
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
val tempId = "temp_voice_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = null,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.AUDIO,
media = emptyList(),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
// Добавляем временное сообщение в стейт для индикации отправки
_state.update { it.copy(messages = listOf(tempMessage) + it.messages) }
viewModelScope.launch {
try {
android.util.Log.d("ChatDetailVM", "Starting voice upload...")
// 1. Upload the audio file
val url = repository.uploadMedia(file)
android.util.Log.d("ChatDetailVM", "Voice upload successful, url: $url")
// 2. Send the message with the attachment
val attachment = chats.data.remote.api.AttachmentRequest(
type = "voice",
url = url,
@@ -631,34 +608,18 @@ class ChatDetailViewModel @Inject constructor(
)
android.util.Log.d("ChatDetailVM", "Sending message with voice attachment...")
val sentMessage = repository.sendMessage(
repository.sendMessage(
chatId = chatId,
content = null,
type = "media",
attachments = listOf(attachment),
replyToId = replyToId
)
android.util.Log.d("ChatDetailVM", "Voice message sent successfully: ${sentMessage.id}")
// Заменяем временное сообщение на настоящее
_state.update { currentState ->
val filtered = currentState.messages.filter { it.id != tempId }
// Избегаем дубликатов, если SignalR уже добавил сообщение
if (filtered.any { it.id == sentMessage.id }) {
currentState.copy(messages = filtered)
} else {
currentState.copy(messages = listOf(sentMessage) + filtered)
}
}
// Сообщение автоматически появится в UI через Flow из Room
android.util.Log.d("ChatDetailVM", "Voice message queued successfully")
} catch (e: Exception) {
android.util.Log.e("ChatDetailVM", "Error sending voice message", e)
// Удаляем временное сообщение и показываем ошибку
_state.update { currentState ->
currentState.copy(
messages = currentState.messages.filter { it.id != tempId },
error = "Ошибка отправки голосового: ${e.localizedMessage}"
)
}
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
}
}
}
@@ -776,28 +737,6 @@ class ChatDetailViewModel @Inject constructor(
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
val tempId = "temp_gif_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = url,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering
media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
val attachment = chats.data.remote.api.AttachmentRequest(
@@ -806,10 +745,14 @@ class ChatDetailViewModel @Inject constructor(
fileName = "gif.gif",
fileSize = 0
)
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment), replyToId)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
repository.sendMessage(
chatId = chatId,
content = null,
type = "image",
attachments = listOf(attachment),
replyToId = replyToId
)
// Сообщение автоматически появится в UI через Flow из Room
// Add to recent
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
@@ -828,7 +771,6 @@ class ChatDetailViewModel @Inject constructor(
_state.update { it.copy(recentGifs = newList) }
}
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage) }
}
}