Кэш чатов
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package chats.data.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import core.database.data.MessageDao
|
||||
import core.database.data.MessageEntity
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* PagingSource для загрузки сообщений из локальной базы Room
|
||||
* Данные сортируются по sequenceId DESC (новые сообщения первыми)
|
||||
*/
|
||||
class MessagePagingSource(
|
||||
private val dao: MessageDao,
|
||||
private val chatId: String
|
||||
) : PagingSource<Int, MessageEntity>() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessagePagingSource"
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, MessageEntity>): Int? {
|
||||
Log.d(TAG, "getRefreshKey() called")
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
val anchorPage = state.closestPageToPosition(anchorPosition)
|
||||
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> {
|
||||
Log.d(TAG, ">>> load() called with: position=${params.key}, loadSize=${params.loadSize}")
|
||||
return try {
|
||||
// При DESC порядке:
|
||||
// - key = sequenceId последнего сообщения в текущей странице
|
||||
// - APPEND = загружаем сообщения С МЕНЬШИМ sequenceId (старые)
|
||||
// - PREPEND = загружаем сообщения С БОЛЬШИМ sequenceId (новые)
|
||||
|
||||
val position = params.key // sequenceId граничного сообщения
|
||||
val loadSize = params.loadSize
|
||||
|
||||
Log.d(TAG, "Loading: chatId=$chatId, position=$position, loadSize=$loadSize")
|
||||
|
||||
val messages = if (position == null) {
|
||||
// Первая загрузка - получаем самые последние сообщения (включая maxSeq)
|
||||
val maxSeq = dao.getMaxSequenceId(chatId)
|
||||
Log.d(TAG, "First load: maxSeq=$maxSeq")
|
||||
if (maxSeq == null) {
|
||||
Log.d(TAG, "No messages in database")
|
||||
emptyList()
|
||||
} else {
|
||||
// Используем <= чтобы включить самое последнее сообщение
|
||||
dao.getMessagesUpToAndIncluding(chatId, maxSeq, loadSize)
|
||||
}
|
||||
} else {
|
||||
// Загружаем сообщения с sequenceId < position (старые)
|
||||
Log.d(TAG, "Append: loading messages before sequenceId=$position")
|
||||
dao.getMessagesBefore(chatId, position, loadSize)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Loaded ${messages.size} messages, first=${messages.firstOrNull()?.sequenceId}, last=${messages.lastOrNull()?.sequenceId}")
|
||||
|
||||
// Для DESC порядка:
|
||||
// - prevKey = максимальный sequenceId в странице (для загрузки новых)
|
||||
// - nextKey = минимальный sequenceId в странице (для загрузки старых)
|
||||
val prevKey = messages.firstOrNull()?.sequenceId?.plus(1)
|
||||
val nextKey = messages.lastOrNull()?.sequenceId?.minus(1)
|
||||
|
||||
Log.d(TAG, "prevKey=$prevKey, nextKey=$nextKey")
|
||||
|
||||
LoadResult.Page(
|
||||
data = messages,
|
||||
prevKey = prevKey,
|
||||
nextKey = nextKey
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading messages", e)
|
||||
LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package chats.data.paging
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.*
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import chats.domain.model.MediaType
|
||||
import com.google.gson.Gson
|
||||
import core.database.data.ChatDatabase
|
||||
import core.database.data.MessageDao
|
||||
import core.database.data.MessageEntity
|
||||
import core.database.data.SyncStatus
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
|
||||
/**
|
||||
* RemoteMediator для Paging 3
|
||||
* Управляет загрузкой сообщений из сети и кэшированием в Room
|
||||
*
|
||||
* Логика работы:
|
||||
* 1. При первой загрузке (REFRESH) - загружаем последние сообщения
|
||||
* 2. При прокрутке вниз (APPEND) - загружаем более старые сообщения
|
||||
* 3. При прокрутке вверх (PREPEND) - загружаем более новые сообщения
|
||||
* 4. Данные сохраняются в Room, Paging читает из базы
|
||||
*/
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class MessageRemoteMediator(
|
||||
private val chatId: String,
|
||||
private val api: ChatApi,
|
||||
private val database: ChatDatabase,
|
||||
private val dao: core.database.data.MessageDao,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val tokenManager: TokenManager
|
||||
) : RemoteMediator<Int, MessageEntity>() {
|
||||
|
||||
private val gson = Gson()
|
||||
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
|
||||
/**
|
||||
* Состояние пагинации
|
||||
*/
|
||||
data class MessageRemoteMediatorState(
|
||||
val lastSequenceId: Int?,
|
||||
val firstSequenceId: Int?
|
||||
)
|
||||
|
||||
override suspend fun initialize(): InitializeAction {
|
||||
// Всегда запускаем refresh для проверки кэша
|
||||
return InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, MessageEntity>
|
||||
): MediatorResult {
|
||||
Log.d(TAG, ">>> load() loadType=$loadType")
|
||||
return try {
|
||||
// Проверяем наличие данных в локальной БД
|
||||
val localMinSeq = dao.getMinSequenceId(chatId)
|
||||
val localMaxSeq = dao.getMaxSequenceId(chatId)
|
||||
Log.d(TAG, "Local DB: minSeq=$localMinSeq, maxSeq=$localMaxSeq")
|
||||
|
||||
when (loadType) {
|
||||
LoadType.REFRESH -> {
|
||||
// Если есть локальные данные - не делаем API запрос
|
||||
if (localMaxSeq != null) {
|
||||
Log.d(TAG, "REFRESH: Using cached data (maxSeq=$localMaxSeq)")
|
||||
return MediatorResult.Success(endOfPaginationReached = false)
|
||||
}
|
||||
// Нет данных - загружаем последние сообщения
|
||||
Log.d(TAG, "REFRESH: No cached data, fetching from API")
|
||||
}
|
||||
LoadType.APPEND -> {
|
||||
Log.d(TAG, "APPEND: Loading older messages")
|
||||
}
|
||||
LoadType.PREPEND -> {
|
||||
Log.d(TAG, "PREPEND: Loading newer messages")
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем pivot для API запроса
|
||||
val pivotValue: Long? = when (loadType) {
|
||||
LoadType.REFRESH -> null // Последние сообщения
|
||||
LoadType.APPEND -> {
|
||||
// Старые сообщения - берём минимальный sequenceId из текущей страницы
|
||||
(state.pages.lastOrNull()?.data?.lastOrNull()?.sequenceId
|
||||
?: localMinSeq)?.toLong()?.minus(1)
|
||||
}
|
||||
LoadType.PREPEND -> {
|
||||
// Новые сообщения - берём максимальный sequenceId
|
||||
(state.pages.firstOrNull()?.data?.firstOrNull()?.sequenceId
|
||||
?: localMaxSeq)?.toLong()?.plus(1)
|
||||
}
|
||||
}
|
||||
|
||||
val limit = when (loadType) {
|
||||
LoadType.REFRESH -> state.config.initialLoadSize
|
||||
else -> state.config.pageSize
|
||||
}
|
||||
|
||||
Log.d(TAG, "API call: chatId=$chatId, pivot=$pivotValue, limit=$limit")
|
||||
|
||||
// Выполняем запрос к API
|
||||
val messages = try {
|
||||
api.getMessages(
|
||||
chatId = chatId,
|
||||
cursor = null,
|
||||
pivot = pivotValue,
|
||||
limit = limit
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "API call failed - offline?", e)
|
||||
// При ошибке сети и наличии локальных данных - успех
|
||||
if ((localMinSeq != null || localMaxSeq != null)) {
|
||||
Log.d(TAG, "Offline mode: returning success with cached data")
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received ${messages.size} messages from API")
|
||||
|
||||
if (messages.isEmpty()) {
|
||||
Log.d(TAG, "End of pagination - no more messages")
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
|
||||
// Сохраняем в базу
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val entities = messages.map { dto ->
|
||||
dto.toEntity(currentUserId, baseUrl, gson)
|
||||
}
|
||||
Log.d(TAG, "Saving ${entities.size} messages to DB, first seq=${entities.firstOrNull()?.sequenceId}, last seq=${entities.lastOrNull()?.sequenceId}")
|
||||
dao.upsertMessages(entities)
|
||||
|
||||
// Проверяем что данные сохранились
|
||||
val savedCount = dao.getMessagesCount(chatId)
|
||||
Log.d(TAG, "Total messages in DB after save: $savedCount")
|
||||
|
||||
val endOfPaginationReached = messages.size < limit
|
||||
Log.d(TAG, "endOfPaginationReached=$endOfPaginationReached")
|
||||
MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading messages", e)
|
||||
MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessageRemoteMediator"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function для преобразования DTO в Entity
|
||||
*/
|
||||
private fun MessageDto.toEntity(
|
||||
currentUserId: String,
|
||||
baseUrl: String,
|
||||
gson: Gson
|
||||
): MessageEntity {
|
||||
// Определяем тип медиа
|
||||
val mediaType = when {
|
||||
media.isEmpty() -> MediaType.TEXT.name
|
||||
media.any { it.type.startsWith("image") || it.url.endsWith(".gif") } -> MediaType.GIF.name
|
||||
media.any { it.type.startsWith("image") } -> MediaType.IMAGE.name
|
||||
media.any { it.type.startsWith("video") } -> MediaType.VIDEO.name
|
||||
media.any { it.type.startsWith("audio") } -> MediaType.AUDIO.name
|
||||
else -> MediaType.FILE.name
|
||||
}
|
||||
|
||||
// Сериализуем медиа в JSON
|
||||
val mediaJson = gson.toJson(media)
|
||||
|
||||
// Сериализуем реакции в JSON
|
||||
val reactionsMap = reactions?.associate { it.emoji to it.count } ?: emptyMap()
|
||||
val reactionsJson = gson.toJson(reactionsMap)
|
||||
|
||||
// Определяем, прочитано ли сообщение текущим пользователем
|
||||
val isRead = senderId == currentUserId
|
||||
|
||||
return MessageEntity(
|
||||
id = id,
|
||||
chatId = chatId ?: "",
|
||||
senderId = senderId ?: "",
|
||||
senderName = sender?.displayName ?: sender?.username ?: "Unknown",
|
||||
senderAvatar = sender?.avatarUrl,
|
||||
content = content,
|
||||
sequenceId = sequenceId ?: 0,
|
||||
createdAt = createdAt ?: "",
|
||||
mediaType = mediaType,
|
||||
mediaJson = mediaJson,
|
||||
reactionsJson = reactionsJson,
|
||||
isRead = isRead,
|
||||
replyToId = replyTo?.id,
|
||||
syncStatus = SyncStatus.SYNCED,
|
||||
isDeletedLocally = false,
|
||||
isEditedLocally = false,
|
||||
editedContent = null,
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user