Кэш чатов

This commit is contained in:
Халимов Рустам
2026-04-20 00:25:40 +03:00
parent 945134f029
commit 7c66e1c0c0
35 changed files with 2131 additions and 92 deletions
@@ -1,113 +1,211 @@
package chats.data.repository
import android.content.Context
import android.util.Log
import androidx.paging.*
import chats.data.paging.MessageRemoteMediator
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import chats.data.remote.dto.MediaItemDto
import chats.data.remote.dto.ReactionDto
import chats.data.signalr.MessageSignalRHandler
import chats.data.sync.MessageSyncWorker
import chats.domain.model.Chat
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository
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 chats.data.remote.signalr.ReadMessagesRequest
import chats.data.remote.signalr.ChatHubClient
import core.security.TokenManager
import com.google.gson.Gson
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.flow.map
import javax.inject.Singleton
/**
* Основная реализация репозитория чатов
*
* Архитектура Offline-first:
* 1. Все данные читаются из локальной базы Room
* 2. При изменении данных - сначала запись в БД, потом синхронизация с сервером
* 3. SignalR обновления сразу записываются в БД
* 4. WorkManager обрабатывает фоновую синхронизацию
*
* Conflict Resolution:
* - Серверные данные имеют приоритет над локальными
* - Исключение: сообщения в процессе отправки (SYNCING) или редактирования
*/
@OptIn(ExperimentalPagingApi::class)
@Singleton
class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig,
private val messageDao: core.database.data.MessageDao,
private val hubClient: chats.data.remote.signalr.ChatHubClient
private val dao: MessageDao,
private val database: ChatDatabase,
private val hubClient: ChatHubClient,
private val signalRHandler: MessageSignalRHandler,
private val context: Context
) : ChatRepository {
private val gson = com.google.gson.Gson()
private val gson = Gson()
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
init {
signalRHandler.startListening()
Log.d(TAG, "ChatRepositoryImpl initialized")
}
override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
}
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
override fun getMessagesPaging(chatId: String): Flow<PagingData<Message>> {
val pagingConfig = PagingConfig(
pageSize = 30,
prefetchDistance = 10,
initialLoadSize = 50,
enablePlaceholders = false
)
return Pager(
config = pagingConfig,
pagingSourceFactory = { dao.getMessagesPagingSource(chatId) },
remoteMediator = MessageRemoteMediator(
chatId = chatId,
api = api,
database = database,
dao = dao,
serverConfig = serverConfig,
tokenManager = tokenManager
)
).flow.map { pagingData ->
pagingData.map { entity -> entity.toDomain(baseUrl, gson) }
}
}
override fun getMessagesFlow(chatId: String): Flow<List<Message>> {
return dao.getMessages(chatId).map { entities ->
entities.map { it.toDomain(baseUrl, gson) }
}
}
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
override suspend fun getMessages(
chatId: String, cursor: String?, pivot: Long?, limit: Int?
): List<Message> {
val currentUserId = tokenManager.getUserId() ?: ""
return try {
android.util.Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
Log.d(TAG, "Fetching messages from API: chatId=$chatId")
val messages = api.getMessages(chatId, cursor = cursor, limit = limit)
if (messages.isNotEmpty()) {
android.util.Log.d("ChatRepo", "Received ${messages.size} messages. TopSeq: ${messages.first().sequenceId}, BottomSeq: ${messages.last().sequenceId}")
val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) }
dao.upsertMessages(entities)
Log.d(TAG, "Cached ${entities.size} messages")
}
// Мапим в доменные модели. По умолчанию считаем прочитанными,
// так как unreadCount нам тут не критичен для истории.
messages.map { msg ->
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
}
messages.map { msg -> msg.toDomain(currentUserId, baseUrl).copy(isRead = true) }
} catch (e: Exception) {
android.util.Log.e("ChatRepo", "Fetch messages failed", e)
Log.e(TAG, "Fetch messages failed", e)
emptyList()
}
}
override suspend fun sendMessage(
chatId: String,
content: String?,
type: String,
chatId: String, content: String?, type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?,
replyToId: String?,
forwardedFromId: String?
replyToId: String?, forwardedFromId: String?
): Message {
val request = SendMessageRequest(
content = content,
type = type,
attachments = attachments,
replyToId = replyToId,
forwardedFromId = forwardedFromId
)
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val userId = tokenManager.getUserId() ?: ""
return api.sendMessage(chatId, request).toDomain(userId, baseUrl)
val currentTime = System.currentTimeMillis()
val localId = "local_${currentTime}_${chatId}"
val localMessage = MessageEntity(
id = localId, chatId = chatId, senderId = userId,
senderName = "Вы", senderAvatar = null, content = content,
sequenceId = 0,
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
mediaType = type.uppercase(),
mediaJson = "[]",
reactionsJson = "{}",
isRead = true, replyToId = replyToId,
syncStatus = SyncStatus.SYNCING,
isDeletedLocally = false, isEditedLocally = false,
editedContent = null, lastUpdated = currentTime
)
dao.insertMessage(localMessage)
Log.d(TAG, "Saved local message: $localId")
MessageSyncWorker.scheduleSync(context)
// Создаём доменную модель вручную для локального сообщения
return Message(
id = localId,
chatId = chatId,
senderId = userId,
senderName = "Вы",
senderAvatar = null,
content = content,
sequenceId = 0,
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
media = emptyList(),
mediaType = when (type) {
"image" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio" -> MediaType.AUDIO
"gif" -> MediaType.GIF
else -> MediaType.TEXT
},
reactions = emptyMap(),
isRead = true,
isPinned = false,
isForwarded = false,
forwardedFromName = null,
replyTo = null
)
}
override suspend fun addReaction(messageId: String, emoji: String) {
api.addReaction(messageId, emoji)
hubClient.addReaction(messageId, "", emoji)
}
override suspend fun sendTypingStatus(chatId: String) {
api.sendTypingStatus(chatId)
hubClient.sendTypingIndicator(chatId)
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
try {
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId. Caller stack: ${android.util.Log.getStackTraceString(Throwable())}")
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
} catch (e: Exception) {
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
Log.e(TAG, "Error marking messages as read", e)
}
// Обновляем локальную БД
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
dao.markMessagesAsRead(chatId, lastReadSequenceId)
}
override suspend fun saveMessage(message: Message) {
android.util.Log.d("ChatRepo", "DB cache disabled, skipping save: ${message.id}")
dao.insertMessage(message.toEntity(gson))
}
override suspend fun deleteLocalMessage(messageId: String) {
messageDao.deleteMessage(messageId)
dao.markAsDeletedLocally(messageId)
MessageSyncWorker.scheduleSync(context)
}
override suspend fun editLocalMessage(messageId: String, newContent: String) {
dao.markAsEditedLocally(messageId, newContent)
MessageSyncWorker.scheduleSync(context)
}
override suspend fun uploadMedia(file: java.io.File): String {
@@ -124,34 +222,36 @@ class ChatRepositoryImpl @Inject constructor(
return api.uploadFile(body).url
}
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.getTrendingGifs(page).data.data
}
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> =
api.getTrendingGifs(page).data.data
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.searchGifs(query, page).data.data
}
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> =
api.searchGifs(query, page).data.data
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
return api.getGifCategories().data.categories
}
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> =
api.getGifCategories().data.categories
override suspend fun createPersonalChat(userId: String): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
}
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
api.deleteMessage(messageId, forEveryone)
messageDao.deleteMessage(messageId)
dao.deleteMessage(messageId)
}
override suspend fun editMessage(messageId: String, content: String): Message {
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
val response = api.editMessage(messageId, request)
dao.insertMessage(response.toEntity(baseUrl, currentUserId, gson))
return response.toDomain(currentUserId, baseUrl)
}
companion object {
private const val TAG = "ChatRepositoryImpl"
}
}