From 43b772a394a0436238660fbddcb2dbfdf75f6c0c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 15:32:05 +0300 Subject: [PATCH] android: add deferred offline queue for send edit delete actions --- android/CHANGELOG.md | 6 + .../data/chat/local/db/MessengerDatabase.kt | 6 +- .../data/message/local/dao/MessageDao.kt | 3 + .../local/dao/PendingMessageActionDao.kt | 36 ++++ .../entity/PendingMessageActionEntity.kt | 35 ++++ .../repository/NetworkMessageRepository.kt | 174 +++++++++++++++++- .../daemonlord/messenger/di/DatabaseModule.kt | 6 + .../NetworkMessageRepositoryTest.kt | 1 + docs/android-checklist.md | 2 +- 9 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/PendingMessageActionDao.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/PendingMessageActionEntity.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 1d2d81d..06e362e 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -347,3 +347,9 @@ ### Step 58 - Keep authenticated session when offline at app start - Updated auth restore flow in `AuthViewModel`: network errors during session restore no longer force logout when local tokens exist. - App now opens authenticated flow in offline mode instead of redirecting to login. + +### Step 59 - Deferred message action queue (send/edit/delete) +- Added Room-backed pending action queue (`pending_message_actions`) for message operations that fail due to network issues. +- Implemented enqueue + optimistic behavior for `sendText`, `editMessage`, and `deleteMessage` on network failures. +- Added automatic pending-action flush on chat sync/load-more and before new message operations. +- Kept non-network server failures as immediate errors (no queueing), while allowing offline continuation. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt index 640e10c..0a24f01 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt @@ -6,8 +6,10 @@ import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity import ru.daemonlord.messenger.data.message.local.dao.MessageDao +import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity import ru.daemonlord.messenger.data.message.local.entity.MessageEntity +import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity @Database( entities = [ @@ -15,11 +17,13 @@ import ru.daemonlord.messenger.data.message.local.entity.MessageEntity UserShortEntity::class, MessageEntity::class, MessageAttachmentEntity::class, + PendingMessageActionEntity::class, ], - version = 8, + version = 9, exportSchema = false, ) abstract class MessengerDatabase : RoomDatabase() { abstract fun chatDao(): ChatDao abstract fun messageDao(): MessageDao + abstract fun pendingMessageActionDao(): PendingMessageActionDao } 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 eeebfcc..9dc6c77 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 @@ -30,6 +30,9 @@ interface MessageDao { @Query("SELECT COUNT(*) FROM messages WHERE chat_id = :chatId") suspend fun countMessages(chatId: Long): Int + @Query("SELECT chat_id FROM messages WHERE id = :messageId LIMIT 1") + suspend fun getChatIdByMessageId(messageId: Long): Long? + @Query( """ SELECT * FROM messages diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/PendingMessageActionDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/PendingMessageActionDao.kt new file mode 100644 index 0000000..af2b320 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/dao/PendingMessageActionDao.kt @@ -0,0 +1,36 @@ +package ru.daemonlord.messenger.data.message.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity + +@Dao +interface PendingMessageActionDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun enqueue(action: PendingMessageActionEntity): Long + + @Query( + """ + SELECT * FROM pending_message_actions + ORDER BY created_at ASC, id ASC + LIMIT :limit + """ + ) + suspend fun listPending(limit: Int = 50): List + + @Query("DELETE FROM pending_message_actions WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query( + """ + UPDATE pending_message_actions + SET attempts = attempts + 1 + WHERE id = :id + """ + ) + suspend fun incrementAttempts(id: Long) +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/PendingMessageActionEntity.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/PendingMessageActionEntity.kt new file mode 100644 index 0000000..92643d1 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/PendingMessageActionEntity.kt @@ -0,0 +1,35 @@ +package ru.daemonlord.messenger.data.message.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "pending_message_actions", + indices = [Index(value = ["created_at"]), Index(value = ["chat_id"])], +) +data class PendingMessageActionEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Long = 0L, + @ColumnInfo(name = "type") + val type: String, + @ColumnInfo(name = "chat_id") + val chatId: Long, + @ColumnInfo(name = "message_id") + val messageId: Long?, + @ColumnInfo(name = "local_message_id") + val localMessageId: Long?, + @ColumnInfo(name = "text") + val text: String?, + @ColumnInfo(name = "reply_to_message_id") + val replyToMessageId: Long?, + @ColumnInfo(name = "for_all") + val forAll: Boolean?, + @ColumnInfo(name = "attempts") + val attempts: Int, + @ColumnInfo(name = "created_at") + val createdAt: String, +) + 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 9a5e89d..a5d58a4 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 @@ -13,7 +13,9 @@ import ru.daemonlord.messenger.data.message.api.MessageApiService import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.message.local.dao.MessageDao +import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao import ru.daemonlord.messenger.data.message.local.entity.MessageEntity +import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity import ru.daemonlord.messenger.data.message.mapper.toDomain import ru.daemonlord.messenger.data.message.mapper.toEntity import ru.daemonlord.messenger.data.media.api.MediaApiService @@ -43,6 +45,7 @@ import kotlinx.serialization.json.jsonPrimitive class NetworkMessageRepository @Inject constructor( private val messageApiService: MessageApiService, private val messageDao: MessageDao, + private val pendingMessageActionDao: PendingMessageActionDao, private val chatDao: ChatDao, private val mediaRepository: MediaRepository, private val mediaApiService: MediaApiService, @@ -69,6 +72,7 @@ class NetworkMessageRepository @Inject constructor( } override suspend fun syncRecentMessages(chatId: Long): AppResult = withContext(ioDispatcher) { + flushPendingActions(chatId = chatId) try { val remoteMessages = messageApiService.getMessages(chatId = chatId) val messageEntities = remoteMessages.map { it.toEntity() } @@ -92,6 +96,7 @@ class NetworkMessageRepository @Inject constructor( } override suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult = withContext(ioDispatcher) { + flushPendingActions(chatId = chatId) try { val page = messageApiService.getMessages( chatId = chatId, @@ -128,6 +133,7 @@ class NetworkMessageRepository @Inject constructor( text: String, replyToMessageId: Long?, ): AppResult = withContext(ioDispatcher) { + flushPendingActions(chatId = chatId) val tempId = -System.currentTimeMillis() val tempMessage = MessageEntity( id = tempId, @@ -177,12 +183,39 @@ class NetworkMessageRepository @Inject constructor( ) AppResult.Success(Unit) } catch (error: Throwable) { - messageDao.deleteMessage(tempId) - AppResult.Error(error.toAppError()) + val mapped = error.toAppError() + if (mapped is AppError.Network) { + pendingMessageActionDao.enqueue( + PendingMessageActionEntity( + type = PendingActionType.SEND_TEXT.name, + chatId = chatId, + messageId = null, + localMessageId = tempId, + text = text, + replyToMessageId = replyToMessageId, + forAll = null, + attempts = 0, + createdAt = java.time.Instant.now().toString(), + ) + ) + AppResult.Success(Unit) + } else { + messageDao.deleteMessage(tempId) + AppResult.Error(mapped) + } } } override suspend fun editMessage(messageId: Long, newText: String): AppResult = withContext(ioDispatcher) { + val chatId = messageDao.getChatIdByMessageId(messageId) ?: 0L + if (chatId > 0L) { + flushPendingActions(chatId = chatId) + } + messageDao.updateMessageText( + messageId = messageId, + text = newText, + updatedAt = java.time.Instant.now().toString(), + ) try { val updated = messageApiService.editMessage( messageId = messageId, @@ -191,11 +224,33 @@ class NetworkMessageRepository @Inject constructor( messageDao.upsertMessages(listOf(updated.toEntity())) AppResult.Success(Unit) } catch (error: Throwable) { - AppResult.Error(error.toAppError()) + val mapped = error.toAppError() + if (mapped is AppError.Network) { + pendingMessageActionDao.enqueue( + PendingMessageActionEntity( + type = PendingActionType.EDIT.name, + chatId = chatId, + messageId = messageId, + localMessageId = null, + text = newText, + replyToMessageId = null, + forAll = null, + attempts = 0, + createdAt = java.time.Instant.now().toString(), + ) + ) + AppResult.Success(Unit) + } else { + AppResult.Error(mapped) + } } } override suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult = withContext(ioDispatcher) { + val chatId = messageDao.getChatIdByMessageId(messageId) ?: 0L + if (chatId > 0L) { + flushPendingActions(chatId = chatId) + } try { messageApiService.deleteMessage( messageId = messageId, @@ -204,7 +259,26 @@ class NetworkMessageRepository @Inject constructor( messageDao.deleteMessage(messageId) AppResult.Success(Unit) } catch (error: Throwable) { - AppResult.Error(error.toAppError()) + val mapped = error.toAppError() + if (mapped is AppError.Network) { + messageDao.deleteMessage(messageId) + pendingMessageActionDao.enqueue( + PendingMessageActionEntity( + type = PendingActionType.DELETE.name, + chatId = chatId, + messageId = messageId, + localMessageId = null, + text = null, + replyToMessageId = null, + forAll = forAll, + attempts = 0, + createdAt = java.time.Instant.now().toString(), + ) + ) + AppResult.Success(Unit) + } else { + AppResult.Error(mapped) + } } } @@ -397,6 +471,98 @@ class NetworkMessageRepository @Inject constructor( } } + private suspend fun flushPendingActions(chatId: Long) { + val pending = pendingMessageActionDao.listPending(limit = 100) + for (action in pending) { + if (action.chatId != chatId && action.chatId != 0L) continue + when (val result = performPendingAction(action)) { + is AppResult.Success -> pendingMessageActionDao.deleteById(action.id) + is AppResult.Error -> { + pendingMessageActionDao.incrementAttempts(action.id) + if (result.reason is AppError.Network) { + return + } else { + pendingMessageActionDao.deleteById(action.id) + } + } + } + } + } + + private suspend fun performPendingAction(action: PendingMessageActionEntity): AppResult { + val type = runCatching { PendingActionType.valueOf(action.type) }.getOrNull() + ?: return AppResult.Success(Unit) + return when (type) { + PendingActionType.SEND_TEXT -> { + val text = action.text ?: return AppResult.Success(Unit) + val chatId = action.chatId + runCatching { + messageApiService.sendMessage( + request = MessageCreateRequestDto( + chatId = chatId, + type = "text", + text = text, + clientMessageId = UUID.randomUUID().toString(), + replyToMessageId = action.replyToMessageId, + ) + ) + }.fold( + onSuccess = { sent -> + action.localMessageId?.let { messageDao.deleteMessage(it) } + messageDao.upsertMessages(listOf(sent.toEntity())) + chatDao.updateLastMessage( + chatId = chatId, + lastMessageText = sent.text, + lastMessageType = sent.type, + lastMessageCreatedAt = sent.createdAt, + updatedSortAt = sent.createdAt, + ) + AppResult.Success(Unit) + }, + onFailure = { error -> AppResult.Error(error.toAppError()) } + ) + } + + PendingActionType.EDIT -> { + val messageId = action.messageId ?: return AppResult.Success(Unit) + val text = action.text ?: return AppResult.Success(Unit) + runCatching { + messageApiService.editMessage( + messageId = messageId, + request = MessageUpdateRequestDto(text = text), + ) + }.fold( + onSuccess = { updated -> + messageDao.upsertMessages(listOf(updated.toEntity())) + AppResult.Success(Unit) + }, + onFailure = { error -> AppResult.Error(error.toAppError()) } + ) + } + + PendingActionType.DELETE -> { + val messageId = action.messageId ?: return AppResult.Success(Unit) + runCatching { + messageApiService.deleteMessage( + messageId = messageId, + forAll = action.forAll == true, + ) + }.fold( + onSuccess = { + AppResult.Success(Unit) + }, + onFailure = { error -> AppResult.Error(error.toAppError()) } + ) + } + } + } + + private enum class PendingActionType { + SEND_TEXT, + EDIT, + DELETE, + } + private fun String.extractUserIdFromJwt(): Long? { val payloadPart = split('.').getOrNull(1) ?: return null val normalized = payloadPart diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/DatabaseModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/DatabaseModule.kt index 622a4eb..dbda703 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/DatabaseModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/DatabaseModule.kt @@ -10,6 +10,7 @@ import dagger.hilt.components.SingletonComponent import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase import ru.daemonlord.messenger.data.message.local.dao.MessageDao +import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao import javax.inject.Singleton @Module @@ -36,4 +37,9 @@ object DatabaseModule { @Provides @Singleton fun provideMessageDao(database: MessengerDatabase): MessageDao = database.messageDao() + + @Provides + @Singleton + fun providePendingMessageActionDao(database: MessengerDatabase): PendingMessageActionDao = + database.pendingMessageActionDao() } diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt index 4c46101..a17ca97 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt @@ -107,6 +107,7 @@ class NetworkMessageRepositoryTest { return NetworkMessageRepository( messageApiService = fakeApi, messageDao = db.messageDao(), + pendingMessageActionDao = db.pendingMessageActionDao(), chatDao = db.chatDao(), mediaRepository = FakeMediaRepository(), mediaApiService = FakeMediaApiService(), diff --git a/docs/android-checklist.md b/docs/android-checklist.md index 611699f..121de81 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -22,7 +22,7 @@ - [x] DataStore для настроек - [ ] Кэш медиа (Coil/Exo cache) - [x] Offline-first чтение истории -- [ ] Очередь отложенных действий (send/edit/delete) +- [x] Очередь отложенных действий (send/edit/delete) - [x] Конфликт-резолв и reconcile после reconnect ## 4. Авторизация и аккаунт