Заготовка
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user