Заготовка

This commit is contained in:
Халимов Рустам
2026-05-07 23:27:37 +03:00
parent 2d2e4b685e
commit 9daa786cfb
33 changed files with 1662 additions and 178 deletions

View File

@@ -1,144 +1,299 @@
package chats.data.repository
import android.util.Log
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
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.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.workers.ChatSyncWorker
import chats.data.workers.SendMessageWorker
import chats.domain.model.Chat
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.model.MessageStatus
import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import chats.data.remote.signalr.ReadMessagesRequest
import core.security.TokenManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import javax.inject.Inject
import kotlinx.coroutines.flow.map
/**
* Реализация ChatRepository с поддержкой офлайн-режима.
* Использует паттерн Single Source of Truth: UI всегда берет данные из Room.
*/
class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig,
private val messageDao: MessageDao,
private val chatDao: ChatDao,
private val workManager: WorkManager,
private val hubClient: chats.data.remote.signalr.ChatHubClient
) : ChatRepository {
private val gson = com.google.gson.Gson()
// ==================== Чаты ====================
override fun getChatsFlow(): Flow<List<Chat>> {
// Single Source of Truth - данные из Room
return chatDao.getAllChats().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
return try {
// Пробуем получить с сервера
val remoteChats = api.getChats()
val domainChats = remoteChats.map { it.toDomain(currentUserId, baseUrl) }
// Сохраняем в локальную БД
val entities = domainChats.map { it.toEntity() }
chatDao.insertChats(entities)
domainChats
} catch (e: Exception) {
Log.w("ChatRepo", "Failed to fetch chats from server, returning local", e)
// При ошибке возвращаем локальные данные
emptyList()
}
}
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
// Кэш отключён - всегда возвращаем пустой поток
return kotlinx.coroutines.flow.flowOf(emptyList())
override suspend fun syncChats() {
try {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val remoteChats = api.getChats()
val entities = remoteChats.map { dto ->
val existing = chatDao.getChatByRemoteId(dto.id)
dto.toDomain(currentUserId, baseUrl).toEntity().copy(
localId = existing?.localId ?: java.util.UUID.randomUUID().toString()
)
}
chatDao.insertChats(entities)
} catch (e: Exception) {
Log.e("ChatRepo", "Sync chats failed", e)
}
}
// ==================== Сообщения ====================
override fun getMessagesFlow(chatId: String): Flow<List<Message>> {
// Single Source of Truth - всегда из Room
return messageDao.getMessagesByChatId(chatId).map { entities ->
entities.map { it.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() ?: ""
return try {
android.util.Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
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}")
Log.d("ChatRepo", "Received ${messages.size} messages")
// Сохраняем в локальную БД
saveMessagesToLocal(messages, chatId, currentUserId)
}
// Мапим в доменные модели. По умолчанию считаем прочитанными,
// так как unreadCount нам тут не критичен для истории.
messages.map { msg ->
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
}
} catch (e: Exception) {
android.util.Log.e("ChatRepo", "Fetch messages failed", e)
Log.e("ChatRepo", "Fetch messages failed, returning local", e)
emptyList()
}
}
override suspend fun sendMessage(
chatId: String,
content: String?,
chatId: String,
content: String?,
type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?,
replyToId: String?,
forwardedFromId: String?
): Message {
val request = SendMessageRequest(
content = content,
type = type,
attachments = attachments,
replyToId = replyToId,
forwardedFromId = forwardedFromId
)
android.util.Log.d("ChatRepoImpl", "sendMessage request: $request")
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val userId = tokenManager.getUserId() ?: ""
return try {
val messageId = api.sendMessage(chatId, request)
android.util.Log.d("ChatRepoImpl", "sendMessage response: $messageId")
// Поскольку сервер вернул только ID, создаем заглушку Message.
// Настоящее сообщение придет через SignalR.
Message(
id = messageId,
chatId = chatId,
senderId = userId,
senderName = "", // Будет обновлено через SignalR
content = content,
sequenceId = 0,
createdAt = java.time.ZonedDateTime.now().toString(),
media = attachments?.map {
chats.domain.model.Media(
id = java.util.UUID.randomUUID().toString(),
type = it.type,
url = it.url,
filename = it.fileName,
size = it.fileSize
)
} ?: emptyList(),
mediaType = when(type) {
"image" -> chats.domain.model.MediaType.IMAGE
"video" -> chats.domain.model.MediaType.VIDEO
"audio", "voice" -> chats.domain.model.MediaType.AUDIO
else -> chats.domain.model.MediaType.TEXT
}
val userName = tokenManager.getUsername() ?: userId
// 1. Создаем локальное сообщение со статусом PENDING
val mediaType = when (type) {
"image" -> "IMAGE"
"video" -> "VIDEO"
"audio", "voice" -> "AUDIO"
else -> "TEXT"
}
val mediaJson = attachments?.map {
chats.domain.model.Media(
id = java.util.UUID.randomUUID().toString(),
type = it.type,
url = it.url,
filename = it.fileName,
size = it.fileSize
)
}?.let { gson.toJson(it) } ?: "[]"
val pendingEntity = createPendingMessageEntity(
chatId = chatId,
senderId = userId,
senderName = userName,
content = content,
mediaType = mediaType,
mediaJson = mediaJson,
replyToId = replyToId
)
// 2. Сохраняем в Room
messageDao.insertMessage(pendingEntity)
// 3. Ставим задачу в WorkManager для отправки
val workRequest = SendMessageWorker.createWorkRequest(pendingEntity.localId)
workManager.enqueueUniqueWork(
"send_${pendingEntity.localId}",
ExistingWorkPolicy.REPLACE,
workRequest
)
Log.d("ChatRepo", "Queued message for sending: ${pendingEntity.localId}")
// 4. Возвращаем доменную модель для немедленного отображения в UI
return pendingEntity.toDomain()
}
override suspend fun retryFailedMessage(localId: String) {
val message = messageDao.getMessageByLocalId(localId)
?: return
if (message.status != MessageStatus.FAILED) return
// Сбрасываем статус и ставим в очередь
messageDao.updateMessageStatus(
localId = localId,
status = MessageStatus.PENDING,
updatedAtMillis = System.currentTimeMillis()
)
val workRequest = SendMessageWorker.createWorkRequest(localId)
workManager.enqueueUniqueWork(
"send_$localId",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
override suspend fun deleteLocalMessage(messageId: String) {
messageDao.deleteMessageByLocalId(messageId)
}
override suspend fun saveMessage(message: Message) {
// Сохраняем входящее сообщение из SignalR
val entity = message.toEntity(MessageStatus.DELIVERED)
messageDao.insertMessage(entity)
}
// ==================== Синхронизация ====================
override suspend fun syncMessagesForChat(chatId: String) {
val workRequest = ChatSyncWorker.createOneTimeWorkRequest(chatId)
workManager.enqueue(workRequest)
}
override suspend fun schedulePeriodicSync() {
val workRequest = ChatSyncWorker.createPeriodicWorkRequest()
workManager.enqueueUniquePeriodicWork(
"periodic_chat_sync",
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
}
// ==================== Вспомогательные методы ====================
private suspend fun saveMessagesToLocal(messages: List<chats.data.remote.dto.MessageDto>, chatId: String, currentUserId: String) {
messages.forEach { dto ->
val existing = messageDao.getMessageByServerId(dto.id)
if (existing == null) {
val entity = MessageEntity(
localId = java.util.UUID.randomUUID().toString(),
serverId = dto.id,
idempotencyKey = dto.id,
chatId = chatId,
senderId = dto.senderId ?: dto.sender?.id ?: currentUserId,
senderName = dto.sender?.displayName ?: dto.sender?.username ?: "",
senderAvatar = dto.sender?.avatarUrl,
content = dto.content,
sequenceId = dto.sequenceId?.toLong() ?: 0L,
createdAt = dto.createdAt ?: java.time.ZonedDateTime.now().toString(),
mediaType = dto.type ?: "TEXT",
mediaJson = gson.toJson(dto.media.map {
chats.domain.model.Media(
id = it.id,
type = it.type,
url = it.url,
filename = it.filename,
size = it.size,
duration = it.duration
)
}),
reactionsJson = gson.toJson(
dto.reactions?.associate { it.emoji to it.count } ?: emptyMap<String, Int>()
),
status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED,
createdAtMillis = System.currentTimeMillis(),
updatedAtMillis = System.currentTimeMillis()
)
messageDao.insertMessage(entity)
}
}
}
// ==================== Остальные методы ====================
override suspend fun addReaction(messageId: String, emoji: String) {
try {
api.addReaction(messageId, emoji)
} catch (e: Exception) {
android.util.Log.e("ChatRepoImpl", "sendMessage error", e)
Log.e("ChatRepo", "Add reaction failed", e)
throw e
}
}
override suspend fun addReaction(messageId: String, emoji: String) {
api.addReaction(messageId, emoji)
}
override suspend fun sendTypingStatus(chatId: String) {
api.sendTypingStatus(chatId)
try {
api.sendTypingStatus(chatId)
} catch (e: Exception) {
// Игнорируем ошибки typing status
}
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
try {
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId")
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("ChatRepo", "Error marking messages as read", e)
}
}
override suspend fun saveMessage(message: Message) {
// Кэш отключён
}
override suspend fun deleteLocalMessage(messageId: String) {
// Локальное удаление не поддерживается без кэша
}
override suspend fun uploadMedia(file: java.io.File): String {
val mimeType = when (file.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
@@ -180,10 +335,10 @@ class ChatRepositoryImpl @Inject constructor(
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val returnedId = api.editMessage(messageId, request)
return Message(
id = returnedId,
chatId = "",
chatId = "",
senderId = currentUserId,
senderName = "",
content = content,