android: add offline-first chat history reading and cache fallback
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-09 15:26:11 +03:00
parent 16c21d1bb7
commit 89755394f7
7 changed files with 45 additions and 9 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -21,7 +21,7 @@
- [x] Room для чатов/сообщений/пользователей
- [x] DataStore для настроек
- [ ] Кэш медиа (Coil/Exo cache)
- [ ] Offline-first чтение истории
- [x] Offline-first чтение истории
- [ ] Очередь отложенных действий (send/edit/delete)
- [x] Конфликт-резолв и reconcile после reconnect