android: add offline-first chat history reading and cache fallback
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -338,3 +338,8 @@
|
||||
- Added shared API error mapper (`ApiErrorMapper`) with mode-aware mapping (`DEFAULT`, `LOGIN`).
|
||||
- Switched auth/chat/message/media repositories to a single `Throwable -> AppError` mapping source.
|
||||
- Kept login-specific invalid-credentials mapping while standardizing unauthorized/server/network handling for other API calls.
|
||||
|
||||
### Step 57 - Offline-first message history reading
|
||||
- Added paged local history reading path by introducing configurable message observe limit (`observeMessages(chatId, limit)`).
|
||||
- Updated chat screen loading strategy to expand local Room-backed history first when loading older messages.
|
||||
- Added network-failure fallback in message sync/load-more: if network is unavailable but local cache exists, chat remains readable without blocking error.
|
||||
|
||||
@@ -27,6 +27,9 @@ interface MessageDao {
|
||||
limit: Int = 50,
|
||||
): Flow<List<MessageLocalModel>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE chat_id = :chatId")
|
||||
suspend fun countMessages(chatId: Long): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
|
||||
@@ -18,6 +18,7 @@ import ru.daemonlord.messenger.data.message.mapper.toDomain
|
||||
import ru.daemonlord.messenger.data.message.mapper.toEntity
|
||||
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||
@@ -61,8 +62,8 @@ class NetworkMessageRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun observeMessages(chatId: Long): Flow<List<MessageItem>> {
|
||||
return messageDao.observeRecentMessages(chatId = chatId).map { entities ->
|
||||
override fun observeMessages(chatId: Long, limit: Int): Flow<List<MessageItem>> {
|
||||
return messageDao.observeRecentMessages(chatId = chatId, limit = limit).map { entities ->
|
||||
entities.map { it.toDomain(currentUserId = currentUserId) }
|
||||
}
|
||||
}
|
||||
@@ -80,7 +81,13 @@ class NetworkMessageRepository @Inject constructor(
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
val mapped = error.toAppError()
|
||||
val hasCached = messageDao.countMessages(chatId = chatId) > 0
|
||||
if (mapped is AppError.Network && hasCached) {
|
||||
AppResult.Success(Unit)
|
||||
} else {
|
||||
AppResult.Error(mapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +109,17 @@ class NetworkMessageRepository @Inject constructor(
|
||||
}
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
val mapped = error.toAppError()
|
||||
val hasOlderLocal = messageDao.getMessagesPage(
|
||||
chatId = chatId,
|
||||
beforeMessageId = beforeMessageId,
|
||||
limit = 1,
|
||||
).isNotEmpty()
|
||||
if (mapped is AppError.Network && hasOlderLocal) {
|
||||
AppResult.Success(Unit)
|
||||
} else {
|
||||
AppResult.Error(mapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageReaction
|
||||
|
||||
interface MessageRepository {
|
||||
fun observeMessages(chatId: Long): Flow<List<MessageItem>>
|
||||
fun observeMessages(chatId: Long, limit: Int = 50): Flow<List<MessageItem>>
|
||||
suspend fun syncRecentMessages(chatId: Long): AppResult<Unit>
|
||||
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
|
||||
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>
|
||||
|
||||
@@ -8,7 +8,7 @@ import javax.inject.Inject
|
||||
class ObserveMessagesUseCase @Inject constructor(
|
||||
private val messageRepository: MessageRepository,
|
||||
) {
|
||||
operator fun invoke(chatId: Long): Flow<List<MessageItem>> {
|
||||
return messageRepository.observeMessages(chatId = chatId)
|
||||
operator fun invoke(chatId: Long, limit: Int = 50): Flow<List<MessageItem>> {
|
||||
return messageRepository.observeMessages(chatId = chatId, limit = limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||
@@ -36,6 +38,7 @@ import java.time.temporal.ChronoUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val observeMessagesUseCase: ObserveMessagesUseCase,
|
||||
@@ -60,6 +63,7 @@ class ChatViewModel @Inject constructor(
|
||||
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
|
||||
private val _uiState = MutableStateFlow(MessageUiState(chatId = chatId))
|
||||
val uiState: StateFlow<MessageUiState> = _uiState.asStateFlow()
|
||||
private val visibleMessagesLimit = MutableStateFlow(MESSAGES_PAGE_SIZE)
|
||||
private var lastDeliveredMessageId: Long? = null
|
||||
private var lastReadMessageId: Long? = null
|
||||
|
||||
@@ -415,6 +419,7 @@ class ChatViewModel @Inject constructor(
|
||||
fun loadMore() {
|
||||
val oldest = uiState.value.messages.firstOrNull() ?: return
|
||||
viewModelScope.launch {
|
||||
visibleMessagesLimit.value += MESSAGES_PAGE_SIZE
|
||||
_uiState.update { it.copy(isLoadingMore = true) }
|
||||
when (val result = loadMoreMessagesUseCase(chatId, beforeMessageId = oldest.id)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(isLoadingMore = false) }
|
||||
@@ -430,7 +435,9 @@ class ChatViewModel @Inject constructor(
|
||||
|
||||
private fun observeMessages() {
|
||||
viewModelScope.launch {
|
||||
observeMessagesUseCase(chatId).collectLatest { messages ->
|
||||
visibleMessagesLimit
|
||||
.flatMapLatest { limit -> observeMessagesUseCase(chatId = chatId, limit = limit) }
|
||||
.collectLatest { messages ->
|
||||
_uiState.update {
|
||||
val pinnedId = it.pinnedMessageId
|
||||
it.copy(
|
||||
@@ -596,4 +603,8 @@ class ChatViewModel @Inject constructor(
|
||||
handleRealtimeEventsUseCase.stop()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MESSAGES_PAGE_SIZE = 50
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
- [x] Room для чатов/сообщений/пользователей
|
||||
- [x] DataStore для настроек
|
||||
- [ ] Кэш медиа (Coil/Exo cache)
|
||||
- [ ] Offline-first чтение истории
|
||||
- [x] Offline-first чтение истории
|
||||
- [ ] Очередь отложенных действий (send/edit/delete)
|
||||
- [x] Конфликт-резолв и reconcile после reconnect
|
||||
|
||||
|
||||
Reference in New Issue
Block a user