diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index a6579f3..d8d892a 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/MessageDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/MessageDao.kt index e2d17f3..eeebfcc 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/MessageDao.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/MessageDao.kt @@ -27,6 +27,9 @@ interface MessageDao { limit: Int = 50, ): Flow> + @Query("SELECT COUNT(*) FROM messages WHERE chat_id = :chatId") + suspend fun countMessages(chatId: Long): Int + @Query( """ SELECT * FROM messages diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index 8b04a24..9a5e89d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -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> { - return messageDao.observeRecentMessages(chatId = chatId).map { entities -> + override fun observeMessages(chatId: Long, limit: Int): Flow> { + 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) + } } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt index 3cfa764..df836bd 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt @@ -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> + fun observeMessages(chatId: Long, limit: Int = 50): Flow> suspend fun syncRecentMessages(chatId: Long): AppResult suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ObserveMessagesUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ObserveMessagesUseCase.kt index c2aad89..e5327e6 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ObserveMessagesUseCase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ObserveMessagesUseCase.kt @@ -8,7 +8,7 @@ import javax.inject.Inject class ObserveMessagesUseCase @Inject constructor( private val messageRepository: MessageRepository, ) { - operator fun invoke(chatId: Long): Flow> { - return messageRepository.observeMessages(chatId = chatId) + operator fun invoke(chatId: Long, limit: Int = 50): Flow> { + return messageRepository.observeMessages(chatId = chatId, limit = limit) } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 050b5c1..827a9f3 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -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 = _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 + } } diff --git a/docs/android-checklist.md b/docs/android-checklist.md index d069cc4..611699f 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -21,7 +21,7 @@ - [x] Room для чатов/сообщений/пользователей - [x] DataStore для настроек - [ ] Кэш медиа (Coil/Exo cache) -- [ ] Offline-first чтение истории +- [x] Offline-first чтение истории - [ ] Очередь отложенных действий (send/edit/delete) - [x] Конфликт-резолв и reconcile после reconnect