Правки

This commit is contained in:
Халимов Рустам
2026-05-13 13:48:34 +03:00
parent 00ed4b5959
commit df4feeeee5
12 changed files with 263 additions and 82 deletions

View File

@@ -17,7 +17,7 @@ import chats.data.local.dao.UserProfileDao
ChatEntity::class,
UserProfileEntity::class
],
version = 1,
version = 2,
exportSchema = false
)
@TypeConverters(MessageStatusConverter::class)

View File

@@ -33,5 +33,7 @@ data class ChatEntity(
val lastMessageTimestamp: Long? = null,
val lastMessageJson: String? = null,
val updatedAtMillis: Long = System.currentTimeMillis()
)

View File

@@ -2,6 +2,9 @@ package chats.data.local.mappers
import chats.data.local.database.ChatEntity
import chats.domain.model.Chat
import com.google.gson.Gson
private val gson = Gson()
/**
* Преобразует Domain Chat в Entity
@@ -16,7 +19,8 @@ fun Chat.toEntity(): ChatEntity {
avatar = this.avatar,
unreadCount = this.unreadCount,
lastMessageText = this.lastMessage?.content,
lastMessageTimestamp = timestamp
lastMessageTimestamp = timestamp,
lastMessageJson = this.lastMessage?.let { gson.toJson(it) }
)
}
@@ -24,13 +28,20 @@ fun Chat.toEntity(): ChatEntity {
* Преобразует Entity в Domain Chat
*/
fun ChatEntity.toDomain(): Chat {
val lastMessage = this.lastMessageJson?.let { json ->
try {
gson.fromJson(json, chats.domain.model.Message::class.java)
} catch (e: Exception) {
null
}
}
return Chat(
id = this.remoteId ?: this.localId,
type = this.type,
name = this.name,
avatar = this.avatar,
unreadCount = this.unreadCount,
lastMessage = null // lastMessage загружается отдельно
lastMessage = lastMessage
)
}

View File

@@ -13,6 +13,7 @@ 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.network.ServerConfig
import core.security.TokenManager
/**
@@ -26,7 +27,8 @@ class MessageRemoteMediator(
private val chatDao: ChatDao,
private val chatApi: ChatApi,
private val tokenManager: TokenManager,
private val appDatabase: AppDatabase
private val appDatabase: AppDatabase,
private val serverConfig: ServerConfig
) : RemoteMediator<Int, MessageEntity>() {
companion object {
@@ -39,7 +41,7 @@ class MessageRemoteMediator(
): RemoteMediator.MediatorResult {
return try {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = "https://your-server.com"
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val cursor = when (loadType) {
LoadType.REFRESH -> {

View File

@@ -59,6 +59,9 @@ interface ChatApi {
@POST("messages/{messageId}/reactions")
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
@POST("chats/favorites")
suspend fun getOrCreateFavorites(): ChatDto
@POST("chats/personal")
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto

View File

@@ -64,6 +64,7 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val gson = com.google.gson.Gson()
return try {
// Пробуем получить с сервера
@@ -71,7 +72,12 @@ class ChatRepositoryImpl @Inject constructor(
val domainChats = remoteChats.map { it.toDomain(currentUserId, baseUrl) }
// Сохраняем в локальную БД
val entities = domainChats.map { it.toEntity() }
val entities = domainChats.map { chat ->
val existing = chatDao.getChatByRemoteId(chat.id)
chat.toEntity().copy(
localId = existing?.localId ?: java.util.UUID.randomUUID().toString()
)
}
chatDao.insertChats(entities)
domainChats
@@ -87,11 +93,21 @@ class ChatRepositoryImpl @Inject constructor(
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val remoteChats = api.getChats()
val gson = com.google.gson.Gson()
val entities = remoteChats.map { dto ->
val existing = 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 lastMessageDomain = lastMessage?.toDomain(currentUserId, baseUrl)
dto.toDomain(currentUserId, baseUrl).toEntity().copy(
localId = existing?.localId ?: java.util.UUID.randomUUID().toString()
localId = existing?.localId ?: java.util.UUID.randomUUID().toString(),
lastMessageText = lastMessage?.content,
lastMessageTimestamp = timestamp,
lastMessageJson = lastMessageDomain?.let { gson.toJson(it) }
)
}
chatDao.insertChats(entities)
@@ -130,7 +146,8 @@ class ChatRepositoryImpl @Inject constructor(
chatDao = chatDao,
chatApi = api,
tokenManager = tokenManager,
appDatabase = appDatabase
appDatabase = appDatabase,
serverConfig = serverConfig
)
).flow.map { pagingData ->
pagingData.map { entity -> entity.toDomain() }
@@ -266,6 +283,7 @@ class ChatRepositoryImpl @Inject constructor(
// ==================== Вспомогательные методы ====================
private suspend fun saveMessagesToLocal(messages: List<chats.data.remote.dto.MessageDto>, chatId: String, currentUserId: String) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
messages.forEach { dto ->
val existing = messageDao.getMessageByServerId(dto.id)
if (existing == null) {
@@ -276,19 +294,19 @@ class ChatRepositoryImpl @Inject constructor(
chatId = chatId,
senderId = dto.senderId ?: dto.sender?.id ?: currentUserId,
senderName = dto.sender?.displayName ?: dto.sender?.username ?: "",
senderAvatar = dto.sender?.avatarUrl,
senderAvatar = dto.sender?.avatarUrl?.let { if (it.startsWith("http")) it else "$baseUrl$it" },
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 {
mediaJson = gson.toJson(dto.media.map { mediaItem ->
chats.domain.model.Media(
id = it.id,
type = it.type,
url = it.url,
filename = it.filename,
size = it.size,
duration = it.duration
id = mediaItem.id,
type = mediaItem.type,
url = (mediaItem.url as? String ?: "").let { url -> if (url.startsWith("http")) url else "$baseUrl$url" },
filename = mediaItem.filename,
size = mediaItem.size,
duration = mediaItem.duration
)
}),
reactionsJson = gson.toJson(
@@ -322,6 +340,10 @@ class ChatRepositoryImpl @Inject constructor(
}
}
override suspend fun resetUnreadCount(chatId: String) {
chatDao.updateUnreadCount(chatId, 0)
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
try {
hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
@@ -356,6 +378,15 @@ class ChatRepositoryImpl @Inject constructor(
return api.getGifCategories().data.categories
}
override suspend fun getOrCreateFavorites(): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val dto = api.getOrCreateFavorites()
val chat = dto.toDomain(currentUserId, baseUrl)
chatDao.insertChat(chat.toEntity())
return chat
}
override suspend fun createPersonalChat(userId: String): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")

View File

@@ -6,6 +6,8 @@ import androidx.hilt.work.HiltWorker
import androidx.work.*
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.remote.dto.*
import chats.data.repository.toDomain
import chats.domain.model.MessageStatus
import core.security.TokenManager
import dagger.assisted.Assisted
@@ -27,7 +29,8 @@ class ChatSyncWorker @AssistedInject constructor(
private val chatDao: ChatDao,
private val messageDao: MessageDao,
private val chatApi: chats.data.remote.api.ChatApi,
private val tokenManager: TokenManager
private val tokenManager: TokenManager,
private val serverConfig: core.network.ServerConfig
) : CoroutineWorker(context, params) {
companion object {
@@ -104,6 +107,9 @@ class ChatSyncWorker @AssistedInject constructor(
private suspend fun syncChats() {
val remoteChats = chatApi.getChats()
val localChats = mutableListOf<chats.data.local.database.ChatEntity>()
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val gson = com.google.gson.Gson()
remoteChats.forEach { dto ->
val existingChat = chatDao.getChatByRemoteId(dto.id)
@@ -112,6 +118,7 @@ class ChatSyncWorker @AssistedInject constructor(
try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() }
catch (e: Exception) { null }
}
val lastMessageDomain = lastMessage?.toDomain(currentUserId, baseUrl)
val chatEntity = chats.data.local.database.ChatEntity(
localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(),
remoteId = dto.id,
@@ -120,11 +127,40 @@ class ChatSyncWorker @AssistedInject constructor(
avatar = dto.avatar,
unreadCount = dto.unreadCount,
lastMessageText = lastMessage?.content,
lastMessageTimestamp = timestamp
lastMessageTimestamp = timestamp,
lastMessageJson = lastMessageDomain?.let { gson.toJson(it) }
)
localChats.add(chatEntity)
}
// Если сервер не вернул Избранное — создаём/получаем его отдельно
val hasFavorites = remoteChats.any { it.type == "favorites" }
if (!hasFavorites) {
try {
val favDto = chatApi.getOrCreateFavorites()
val existingChat = chatDao.getChatByRemoteId(favDto.id)
val favTimestamp = favDto.messages.firstOrNull()?.createdAt?.let {
try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() } catch (e: Exception) { null }
}
val favLastMessageDomain = favDto.messages.firstOrNull()?.toDomain(currentUserId, baseUrl)
val favEntity = chats.data.local.database.ChatEntity(
localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(),
remoteId = favDto.id,
type = favDto.type,
name = favDto.name ?: "Избранное",
avatar = favDto.avatar,
unreadCount = favDto.unreadCount,
lastMessageText = favDto.messages.firstOrNull()?.content,
lastMessageTimestamp = favTimestamp,
lastMessageJson = favLastMessageDomain?.let { gson.toJson(it) }
)
localChats.add(favEntity)
Log.d("ChatSyncWorker", "Created favorites chat: ${favDto.id}")
} catch (e: Exception) {
Log.e("ChatSyncWorker", "Failed to get or create favorites", e)
}
}
chatDao.insertChats(localChats)
Log.d("ChatSyncWorker", "Synced ${localChats.size} chats")
}
@@ -150,6 +186,7 @@ class ChatSyncWorker @AssistedInject constructor(
) {
val currentUserId = tokenManager.getUserId() ?: ""
val gson = com.google.gson.Gson()
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
messages.forEach { dto ->
val existing = messageDao.getMessageByServerId(dto.id)
@@ -161,19 +198,19 @@ class ChatSyncWorker @AssistedInject constructor(
chatId = chatId,
senderId = dto.senderId ?: dto.sender?.id ?: currentUserId,
senderName = dto.sender?.displayName ?: dto.sender?.username ?: "",
senderAvatar = dto.sender?.avatarUrl,
senderAvatar = dto.sender?.avatarUrl?.let { if (it.startsWith("http")) it else "$baseUrl$it" },
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 {
mediaJson = gson.toJson(dto.media.map { mediaItem ->
chats.domain.model.Media(
id = it.id,
type = it.type,
url = it.url,
filename = it.filename,
size = it.size,
duration = it.duration
id = mediaItem.id,
type = mediaItem.type,
url = (mediaItem.url as? String ?: "").let { url -> if (url.startsWith("http")) url else "$baseUrl$url" },
filename = mediaItem.filename,
size = mediaItem.size,
duration = mediaItem.duration
)
}),
reactionsJson = gson.toJson(

View File

@@ -6,6 +6,7 @@ import androidx.hilt.work.HiltWorker
import androidx.work.*
import chats.data.local.dao.MessageDao
import chats.data.local.database.MessageEntity
import chats.data.remote.api.AttachmentRequest
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.domain.model.MessageStatus
@@ -99,9 +100,28 @@ class SendMessageWorker @AssistedInject constructor(
)
// Выполняем запрос к API с Idempotency-Key
// Десериализуем media из JSON
val mediaList = try {
val gson = com.google.gson.Gson()
val type = object : com.google.gson.reflect.TypeToken<List<chats.domain.model.Media>>() {}.type
gson.fromJson<List<chats.domain.model.Media>>(message.mediaJson, type) ?: emptyList()
} catch (e: Exception) {
emptyList<chats.domain.model.Media>()
}
val attachments = mediaList.map {
AttachmentRequest(
type = it.type,
url = it.url,
fileName = it.filename ?: "",
fileSize = it.size ?: 0L
)
}.takeIf { it.isNotEmpty() }
val request = SendMessageRequest(
content = message.content,
type = message.mediaType.lowercase(),
attachments = attachments,
replyToId = message.replyToServerId
)