Вторая часть по кэшу
This commit is contained in:
@@ -135,7 +135,7 @@ dependencies {
|
||||
implementation("androidx.work:work-runtime-ktx:$work_version")
|
||||
|
||||
// Paging 3
|
||||
val paging_version = "3.2.1"
|
||||
val paging_version = "3.3.0"
|
||||
implementation("androidx.paging:paging-runtime-ktx:$paging_version")
|
||||
implementation("androidx.paging:paging-compose:$paging_version")
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
@Dao
|
||||
interface ChatDao {
|
||||
|
||||
@Query("SELECT * FROM chats ORDER BY updatedAtMillis DESC")
|
||||
@Query("SELECT * FROM chats ORDER BY lastMessageTimestamp DESC")
|
||||
fun getAllChats(): Flow<List<ChatEntity>>
|
||||
|
||||
@Query("SELECT * FROM chats WHERE remoteId = :remoteId LIMIT 1")
|
||||
@@ -29,7 +29,7 @@ interface ChatDao {
|
||||
suspend fun updateChat(chat: ChatEntity)
|
||||
|
||||
@Query("UPDATE chats SET lastMessageText = :lastMessageText, lastMessageTimestamp = :timestamp WHERE remoteId = :chatId")
|
||||
suspend fun updateLastMessage(chatId: String, lastMessageText: String?, timestamp: String?)
|
||||
suspend fun updateLastMessage(chatId: String, lastMessageText: String?, timestamp: Long?)
|
||||
|
||||
@Query("UPDATE chats SET unreadCount = :count WHERE remoteId = :chatId")
|
||||
suspend fun updateUnreadCount(chatId: String, count: Int)
|
||||
|
||||
@@ -14,6 +14,19 @@ interface MessageDao {
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
|
||||
fun getMessagesByChatId(chatId: String): Flow<List<MessageEntity>>
|
||||
|
||||
/**
|
||||
* Пагинированная загрузка сообщений для Paging 3
|
||||
* sequenceId увеличивается от старых к новым, поэтому ORDER BY DESC для отображения newest внизу
|
||||
*/
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId DESC LIMIT :limit OFFSET :offset")
|
||||
suspend fun getMessagesPaged(chatId: String, offset: Int, limit: Int): List<MessageEntity>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId DESC LIMIT 1")
|
||||
suspend fun getLastMessage(chatId: String): MessageEntity?
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC LIMIT 1")
|
||||
suspend fun getFirstMessage(chatId: String): MessageEntity?
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId AND sequenceId > :afterSequenceId ORDER BY sequenceId ASC LIMIT :limit")
|
||||
suspend fun getMessagesAfter(chatId: String, afterSequenceId: Long, limit: Int): List<MessageEntity>
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ data class ChatEntity(
|
||||
|
||||
val lastMessageText: String? = null,
|
||||
|
||||
val lastMessageTimestamp: String? = null,
|
||||
val lastMessageTimestamp: Long? = null,
|
||||
|
||||
val updatedAtMillis: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import chats.domain.model.Chat
|
||||
* Преобразует Domain Chat в Entity
|
||||
*/
|
||||
fun Chat.toEntity(): ChatEntity {
|
||||
val timestamp = this.lastMessage?.createdAt?.let { parseTimestamp(it) }
|
||||
return ChatEntity(
|
||||
localId = this.id.takeIf { it.isNotBlank() } ?: java.util.UUID.randomUUID().toString(),
|
||||
remoteId = this.id,
|
||||
@@ -15,7 +16,7 @@ fun Chat.toEntity(): ChatEntity {
|
||||
avatar = this.avatar,
|
||||
unreadCount = this.unreadCount,
|
||||
lastMessageText = this.lastMessage?.content,
|
||||
lastMessageTimestamp = this.lastMessage?.createdAt
|
||||
lastMessageTimestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,3 +33,14 @@ fun ChatEntity.toDomain(): Chat {
|
||||
lastMessage = null // lastMessage загружается отдельно
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит ISO-8601 timestamp в Long (millis)
|
||||
*/
|
||||
private fun parseTimestamp(isoTimestamp: String): Long? {
|
||||
return try {
|
||||
java.time.ZonedDateTime.parse(isoTimestamp).toInstant().toEpochMilli()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package chats.data.local.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import chats.data.local.dao.MessageDao
|
||||
import chats.data.local.database.MessageEntity
|
||||
|
||||
/**
|
||||
* PagingSource для загрузки сообщений из Room Database
|
||||
*/
|
||||
class MessagePagingSource(
|
||||
private val chatId: String,
|
||||
private val messageDao: MessageDao
|
||||
) : PagingSource<Int, MessageEntity>() {
|
||||
|
||||
companion object {
|
||||
private const val PAGE_SIZE = 30
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> {
|
||||
return try {
|
||||
val position = params.key ?: 0 // Начинаем с 0
|
||||
|
||||
val messages = messageDao.getMessagesPaged(
|
||||
chatId = chatId,
|
||||
offset = position,
|
||||
limit = PAGE_SIZE
|
||||
)
|
||||
|
||||
LoadResult.Page(
|
||||
data = messages,
|
||||
prevKey = if (position > 0) position - PAGE_SIZE else null,
|
||||
nextKey = if (messages.isEmpty()) null else position + PAGE_SIZE
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, MessageEntity>): Int? {
|
||||
return state.anchorPosition
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package chats.data.local.paging
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import androidx.room.withTransaction
|
||||
import chats.data.local.dao.ChatDao
|
||||
import chats.data.local.dao.MessageDao
|
||||
import chats.data.local.database.AppDatabase
|
||||
import chats.data.local.database.MessageEntity
|
||||
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.security.TokenManager
|
||||
|
||||
/**
|
||||
* RemoteMediator для синхронизации сообщений с сервером.
|
||||
* Работает с Room через withTransaction для атомарности.
|
||||
*/
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class MessageRemoteMediator(
|
||||
private val chatId: String,
|
||||
private val messageDao: MessageDao,
|
||||
private val chatDao: ChatDao,
|
||||
private val chatApi: ChatApi,
|
||||
private val tokenManager: TokenManager,
|
||||
private val appDatabase: AppDatabase
|
||||
) : RemoteMediator<Int, MessageEntity>() {
|
||||
|
||||
companion object {
|
||||
private const val PAGE_SIZE = 30
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, MessageEntity>
|
||||
): RemoteMediator.MediatorResult {
|
||||
return try {
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val baseUrl = "https://your-server.com"
|
||||
|
||||
val cursor = when (loadType) {
|
||||
LoadType.REFRESH -> {
|
||||
// При refresh загружаем новые сообщения после последнего локального
|
||||
messageDao.getLastMessage(chatId)?.serverId
|
||||
}
|
||||
LoadType.PREPEND -> {
|
||||
// Загружаем более старые сообщения перед первым локальным
|
||||
messageDao.getFirstMessage(chatId)?.serverId
|
||||
}
|
||||
LoadType.APPEND -> {
|
||||
// Загружаем новые сообщения после последнего локального
|
||||
messageDao.getLastMessage(chatId)?.serverId
|
||||
}
|
||||
}
|
||||
|
||||
val remoteMessages = chatApi.getMessages(chatId, cursor = cursor, limit = PAGE_SIZE)
|
||||
val endOfPaginationReached = remoteMessages.size < PAGE_SIZE
|
||||
|
||||
appDatabase.withTransaction {
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
// При полном обновлении можно очистить старые SENT/DELIVERED/READ,
|
||||
// но оставить локальные PENDING/FAILED
|
||||
// messageDao.deleteMessagesByChatId(chatId) // Опционально
|
||||
}
|
||||
|
||||
val entities = remoteMessages.map { dto ->
|
||||
val existing = messageDao.getMessageByServerId(dto.id)
|
||||
if (existing != null) {
|
||||
// Обновляем существующее, сохраняя localId
|
||||
existing.copy(
|
||||
content = dto.content,
|
||||
reactionsJson = com.google.gson.Gson().toJson(
|
||||
dto.reactions?.associate { it.emoji to it.count } ?: emptyMap<String, Int>()
|
||||
),
|
||||
status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED,
|
||||
updatedAtMillis = System.currentTimeMillis()
|
||||
)
|
||||
} else {
|
||||
// Создаём новое
|
||||
dto.dtoToDomain(currentUserId, baseUrl).toEntity(MessageStatus.SENT).copy(
|
||||
localId = java.util.UUID.randomUUID().toString(),
|
||||
serverId = dto.id,
|
||||
idempotencyKey = dto.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
messageDao.insertMessages(entities)
|
||||
}
|
||||
|
||||
RemoteMediator.MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
|
||||
} catch (e: Exception) {
|
||||
RemoteMediator.MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package chats.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.map
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.WorkManager
|
||||
@@ -10,6 +15,8 @@ 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.local.paging.MessagePagingSource
|
||||
import chats.data.local.paging.MessageRemoteMediator
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.api.SendMessageRequest
|
||||
import chats.data.remote.dto.MessageDto
|
||||
@@ -39,6 +46,7 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
private val messageDao: MessageDao,
|
||||
private val chatDao: ChatDao,
|
||||
private val workManager: WorkManager,
|
||||
private val appDatabase: chats.data.local.database.AppDatabase,
|
||||
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
||||
) : ChatRepository {
|
||||
|
||||
@@ -101,6 +109,34 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает сообщения с помощью Paging 3 и RemoteMediator
|
||||
*/
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
override fun getMessagesPagingSource(chatId: String): Flow<PagingData<Message>> {
|
||||
return Pager(
|
||||
config = PagingConfig(
|
||||
pageSize = 30,
|
||||
prefetchDistance = 10,
|
||||
enablePlaceholders = false,
|
||||
initialLoadSize = 60
|
||||
),
|
||||
pagingSourceFactory = {
|
||||
MessagePagingSource(chatId, messageDao)
|
||||
},
|
||||
remoteMediator = MessageRemoteMediator(
|
||||
chatId = chatId,
|
||||
messageDao = messageDao,
|
||||
chatDao = chatDao,
|
||||
chatApi = api,
|
||||
tokenManager = tokenManager,
|
||||
appDatabase = appDatabase
|
||||
)
|
||||
).flow.map { pagingData ->
|
||||
pagingData.map { entity -> entity.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() ?: ""
|
||||
|
||||
@@ -26,7 +26,8 @@ class ChatSyncWorker @AssistedInject constructor(
|
||||
@Assisted params: WorkerParameters,
|
||||
private val chatDao: ChatDao,
|
||||
private val messageDao: MessageDao,
|
||||
private val chatApi: chats.data.remote.api.ChatApi
|
||||
private val chatApi: chats.data.remote.api.ChatApi,
|
||||
private val tokenManager: TokenManager
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
@@ -106,6 +107,11 @@ class ChatSyncWorker @AssistedInject constructor(
|
||||
|
||||
remoteChats.forEach { dto ->
|
||||
val existingChat = 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 chatEntity = chats.data.local.database.ChatEntity(
|
||||
localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(),
|
||||
remoteId = dto.id,
|
||||
@@ -113,8 +119,8 @@ class ChatSyncWorker @AssistedInject constructor(
|
||||
name = dto.name ?: "",
|
||||
avatar = dto.avatar,
|
||||
unreadCount = dto.unreadCount,
|
||||
lastMessageText = dto.messages.firstOrNull()?.content,
|
||||
lastMessageTimestamp = dto.messages.firstOrNull()?.createdAt
|
||||
lastMessageText = lastMessage?.content,
|
||||
lastMessageTimestamp = timestamp
|
||||
)
|
||||
localChats.add(chatEntity)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package chats.domain.repository
|
||||
|
||||
import androidx.paging.PagingData
|
||||
import chats.domain.model.Chat
|
||||
import chats.domain.model.Message
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ChatRepository {
|
||||
@@ -13,6 +13,7 @@ interface ChatRepository {
|
||||
|
||||
// Сообщения - Single Source of Truth через Flow
|
||||
fun getMessagesFlow(chatId: String): Flow<List<Message>>
|
||||
fun getMessagesPagingSource(chatId: String): Flow<PagingData<Message>>
|
||||
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
|
||||
|
||||
// Отправка сообщений с поддержкой офлайн
|
||||
|
||||
@@ -594,35 +594,12 @@ class ChatDetailViewModel @Inject constructor(
|
||||
val replyToId = _state.value.replyingMessage?.id
|
||||
_state.update { it.copy(replyingMessage = null) }
|
||||
|
||||
val tempId = "temp_voice_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
|
||||
val tempMessage = Message(
|
||||
id = tempId,
|
||||
chatId = chatId,
|
||||
senderId = userId,
|
||||
content = null,
|
||||
createdAt = java.util.Date().toString(),
|
||||
mediaType = chats.domain.model.MediaType.AUDIO,
|
||||
media = emptyList(),
|
||||
senderName = "Вы",
|
||||
senderAvatar = null,
|
||||
reactions = emptyMap(),
|
||||
isRead = false,
|
||||
sequenceId = 0
|
||||
)
|
||||
|
||||
// Добавляем временное сообщение в стейт для индикации отправки
|
||||
_state.update { it.copy(messages = listOf(tempMessage) + it.messages) }
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
android.util.Log.d("ChatDetailVM", "Starting voice upload...")
|
||||
// 1. Upload the audio file
|
||||
val url = repository.uploadMedia(file)
|
||||
android.util.Log.d("ChatDetailVM", "Voice upload successful, url: $url")
|
||||
|
||||
// 2. Send the message with the attachment
|
||||
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||
type = "voice",
|
||||
url = url,
|
||||
@@ -631,34 +608,18 @@ class ChatDetailViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
android.util.Log.d("ChatDetailVM", "Sending message with voice attachment...")
|
||||
val sentMessage = repository.sendMessage(
|
||||
repository.sendMessage(
|
||||
chatId = chatId,
|
||||
content = null,
|
||||
type = "media",
|
||||
attachments = listOf(attachment),
|
||||
replyToId = replyToId
|
||||
)
|
||||
android.util.Log.d("ChatDetailVM", "Voice message sent successfully: ${sentMessage.id}")
|
||||
|
||||
// Заменяем временное сообщение на настоящее
|
||||
_state.update { currentState ->
|
||||
val filtered = currentState.messages.filter { it.id != tempId }
|
||||
// Избегаем дубликатов, если SignalR уже добавил сообщение
|
||||
if (filtered.any { it.id == sentMessage.id }) {
|
||||
currentState.copy(messages = filtered)
|
||||
} else {
|
||||
currentState.copy(messages = listOf(sentMessage) + filtered)
|
||||
}
|
||||
}
|
||||
// Сообщение автоматически появится в UI через Flow из Room
|
||||
android.util.Log.d("ChatDetailVM", "Voice message queued successfully")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatDetailVM", "Error sending voice message", e)
|
||||
// Удаляем временное сообщение и показываем ошибку
|
||||
_state.update { currentState ->
|
||||
currentState.copy(
|
||||
messages = currentState.messages.filter { it.id != tempId },
|
||||
error = "Ошибка отправки голосового: ${e.localizedMessage}"
|
||||
)
|
||||
}
|
||||
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -776,28 +737,6 @@ class ChatDetailViewModel @Inject constructor(
|
||||
val replyToId = _state.value.replyingMessage?.id
|
||||
_state.update { it.copy(replyingMessage = null) }
|
||||
|
||||
val tempId = "temp_gif_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
|
||||
val tempMessage = Message(
|
||||
id = tempId,
|
||||
chatId = chatId,
|
||||
senderId = userId,
|
||||
content = url,
|
||||
createdAt = java.util.Date().toString(),
|
||||
mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering
|
||||
media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")),
|
||||
senderName = "Вы",
|
||||
senderAvatar = null,
|
||||
reactions = emptyMap(),
|
||||
isRead = false,
|
||||
sequenceId = 0
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
repository.saveMessage(tempMessage)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||
@@ -806,10 +745,14 @@ class ChatDetailViewModel @Inject constructor(
|
||||
fileName = "gif.gif",
|
||||
fileSize = 0
|
||||
)
|
||||
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment), replyToId)
|
||||
|
||||
repository.deleteLocalMessage(tempId)
|
||||
repository.saveMessage(sentMessage)
|
||||
repository.sendMessage(
|
||||
chatId = chatId,
|
||||
content = null,
|
||||
type = "image",
|
||||
attachments = listOf(attachment),
|
||||
replyToId = replyToId
|
||||
)
|
||||
// Сообщение автоматически появится в UI через Flow из Room
|
||||
|
||||
// Add to recent
|
||||
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
|
||||
@@ -828,7 +771,6 @@ class ChatDetailViewModel @Inject constructor(
|
||||
_state.update { it.copy(recentGifs = newList) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
repository.deleteLocalMessage(tempId)
|
||||
_state.update { it.copy(error = e.localizedMessage) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user