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`). - 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. - 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. - 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, limit: Int = 50,
): Flow<List<MessageLocalModel>> ): Flow<List<MessageLocalModel>>
@Query("SELECT COUNT(*) FROM messages WHERE chat_id = :chatId")
suspend fun countMessages(chatId: Long): Int
@Query( @Query(
""" """
SELECT * FROM messages 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.message.mapper.toEntity
import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.di.IoDispatcher 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.common.AppResult
import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.model.MessageItem
@@ -61,8 +62,8 @@ class NetworkMessageRepository @Inject constructor(
} }
} }
override fun observeMessages(chatId: Long): Flow<List<MessageItem>> { override fun observeMessages(chatId: Long, limit: Int): Flow<List<MessageItem>> {
return messageDao.observeRecentMessages(chatId = chatId).map { entities -> return messageDao.observeRecentMessages(chatId = chatId, limit = limit).map { entities ->
entities.map { it.toDomain(currentUserId = currentUserId) } entities.map { it.toDomain(currentUserId = currentUserId) }
} }
} }
@@ -80,7 +81,13 @@ class NetworkMessageRepository @Inject constructor(
) )
AppResult.Success(Unit) AppResult.Success(Unit)
} catch (error: Throwable) { } 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) AppResult.Success(Unit)
} catch (error: Throwable) { } 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 import ru.daemonlord.messenger.domain.message.model.MessageReaction
interface MessageRepository { 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 syncRecentMessages(chatId: Long): AppResult<Unit>
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit> suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): 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( class ObserveMessagesUseCase @Inject constructor(
private val messageRepository: MessageRepository, private val messageRepository: MessageRepository,
) { ) {
operator fun invoke(chatId: Long): Flow<List<MessageItem>> { operator fun invoke(chatId: Long, limit: Int = 50): Flow<List<MessageItem>> {
return messageRepository.observeMessages(chatId = chatId) return messageRepository.observeMessages(chatId = chatId, limit = limit)
} }
} }

View File

@@ -4,11 +4,13 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
@@ -36,6 +38,7 @@ import java.time.temporal.ChronoUnit
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class ChatViewModel @Inject constructor( class ChatViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val observeMessagesUseCase: ObserveMessagesUseCase, private val observeMessagesUseCase: ObserveMessagesUseCase,
@@ -60,6 +63,7 @@ class ChatViewModel @Inject constructor(
private val chatId: Long = checkNotNull(savedStateHandle["chatId"]) private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
private val _uiState = MutableStateFlow(MessageUiState(chatId = chatId)) private val _uiState = MutableStateFlow(MessageUiState(chatId = chatId))
val uiState: StateFlow<MessageUiState> = _uiState.asStateFlow() val uiState: StateFlow<MessageUiState> = _uiState.asStateFlow()
private val visibleMessagesLimit = MutableStateFlow(MESSAGES_PAGE_SIZE)
private var lastDeliveredMessageId: Long? = null private var lastDeliveredMessageId: Long? = null
private var lastReadMessageId: Long? = null private var lastReadMessageId: Long? = null
@@ -415,6 +419,7 @@ class ChatViewModel @Inject constructor(
fun loadMore() { fun loadMore() {
val oldest = uiState.value.messages.firstOrNull() ?: return val oldest = uiState.value.messages.firstOrNull() ?: return
viewModelScope.launch { viewModelScope.launch {
visibleMessagesLimit.value += MESSAGES_PAGE_SIZE
_uiState.update { it.copy(isLoadingMore = true) } _uiState.update { it.copy(isLoadingMore = true) }
when (val result = loadMoreMessagesUseCase(chatId, beforeMessageId = oldest.id)) { when (val result = loadMoreMessagesUseCase(chatId, beforeMessageId = oldest.id)) {
is AppResult.Success -> _uiState.update { it.copy(isLoadingMore = false) } is AppResult.Success -> _uiState.update { it.copy(isLoadingMore = false) }
@@ -430,7 +435,9 @@ class ChatViewModel @Inject constructor(
private fun observeMessages() { private fun observeMessages() {
viewModelScope.launch { viewModelScope.launch {
observeMessagesUseCase(chatId).collectLatest { messages -> visibleMessagesLimit
.flatMapLatest { limit -> observeMessagesUseCase(chatId = chatId, limit = limit) }
.collectLatest { messages ->
_uiState.update { _uiState.update {
val pinnedId = it.pinnedMessageId val pinnedId = it.pinnedMessageId
it.copy( it.copy(
@@ -596,4 +603,8 @@ class ChatViewModel @Inject constructor(
handleRealtimeEventsUseCase.stop() handleRealtimeEventsUseCase.stop()
super.onCleared() super.onCleared()
} }
private companion object {
const val MESSAGES_PAGE_SIZE = 50
}
} }

View File

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