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`).
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user