From 5a0add4d5c1054feb41d75e59627d9faa294583f Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 8 Mar 2026 23:06:30 +0300 Subject: [PATCH] android: add message api contracts and repository usecases --- android/CHANGELOG.md | 7 + .../data/message/api/MessageApiService.kt | 38 ++++ .../messenger/data/message/dto/MessageDtos.kt | 40 +++++ .../data/message/local/dao/MessageDao.kt | 19 ++ .../data/message/mapper/MessageMappers.kt | 38 ++++ .../repository/NetworkMessageRepository.kt | 163 ++++++++++++++++++ .../daemonlord/messenger/di/NetworkModule.kt | 7 + .../messenger/di/RepositoryModule.kt | 8 + .../message/repository/MessageRepository.kt | 14 ++ .../message/usecase/DeleteMessageUseCase.kt | 13 ++ .../message/usecase/EditMessageUseCase.kt | 13 ++ .../usecase/LoadMoreMessagesUseCase.kt | 13 ++ .../message/usecase/ObserveMessagesUseCase.kt | 14 ++ .../message/usecase/SendTextMessageUseCase.kt | 17 ++ .../usecase/SyncRecentMessagesUseCase.kt | 13 ++ 15 files changed, 417 insertions(+) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/DeleteMessageUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/EditMessageUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/LoadMoreMessagesUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ObserveMessagesUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendTextMessageUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SyncRecentMessagesUseCase.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 0ddd576..25ec182 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -71,3 +71,10 @@ - Added `MessageDao` with observe/pagination/upsert/delete APIs. - Updated `MessengerDatabase` schema to include message tables and DAO. - Added Hilt DI provider for `MessageDao`. + +### Step 12 - Sprint A / 2) Message API + repository +- Added message REST API client for history/send/edit/delete endpoints. +- Added message DTOs and mappers (`MessageReadDto -> MessageEntity -> MessageItem`). +- Added `MessageRepository` contracts/use-cases for observe/sync/pagination/send/edit/delete. +- Implemented `NetworkMessageRepository` with cache-first observation and optimistic text send. +- Wired message API and repository into Hilt modules. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt new file mode 100644 index 0000000..95ff57d --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt @@ -0,0 +1,38 @@ +package ru.daemonlord.messenger.data.message.api + +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query +import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageReadDto +import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto + +interface MessageApiService { + @GET("/api/v1/messages/{chat_id}") + suspend fun getMessages( + @Path("chat_id") chatId: Long, + @Query("limit") limit: Int = 50, + @Query("before_id") beforeId: Long? = null, + ): List + + @POST("/api/v1/messages") + suspend fun sendMessage( + @Body request: MessageCreateRequestDto, + ): MessageReadDto + + @PUT("/api/v1/messages/{message_id}") + suspend fun editMessage( + @Path("message_id") messageId: Long, + @Body request: MessageUpdateRequestDto, + ): MessageReadDto + + @DELETE("/api/v1/messages/{message_id}") + suspend fun deleteMessage( + @Path("message_id") messageId: Long, + @Query("for_all") forAll: Boolean = false, + ) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt new file mode 100644 index 0000000..be19189 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt @@ -0,0 +1,40 @@ +package ru.daemonlord.messenger.data.message.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MessageReadDto( + val id: Long, + @SerialName("chat_id") + val chatId: Long, + @SerialName("sender_id") + val senderId: Long, + @SerialName("reply_to_message_id") + val replyToMessageId: Long? = null, + val type: String, + val text: String? = null, + @SerialName("delivery_status") + val deliveryStatus: String? = null, + @SerialName("created_at") + val createdAt: String, + @SerialName("updated_at") + val updatedAt: String? = null, +) + +@Serializable +data class MessageCreateRequestDto( + @SerialName("chat_id") + val chatId: Long, + val type: String, + val text: String, + @SerialName("client_message_id") + val clientMessageId: String, + @SerialName("reply_to_message_id") + val replyToMessageId: Long? = null, +) + +@Serializable +data class MessageUpdateRequestDto( + val text: String, +) 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 963398a..92280f9 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 @@ -52,6 +52,25 @@ interface MessageDao { @Query("DELETE FROM messages WHERE id = :messageId") suspend fun deleteMessage(messageId: Long) + @Query( + """ + UPDATE messages + SET text = :text, + updated_at = :updatedAt + WHERE id = :messageId + """ + ) + suspend fun updateMessageText(messageId: Long, text: String?, updatedAt: String?) + + @Query( + """ + UPDATE messages + SET status = :status + WHERE id = :messageId + """ + ) + suspend fun updateMessageStatus(messageId: Long, status: String?) + @Transaction suspend fun clearAndReplaceMessages( chatId: Long, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt new file mode 100644 index 0000000..1437c58 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt @@ -0,0 +1,38 @@ +package ru.daemonlord.messenger.data.message.mapper + +import ru.daemonlord.messenger.data.message.dto.MessageReadDto +import ru.daemonlord.messenger.data.message.local.entity.MessageEntity +import ru.daemonlord.messenger.domain.message.model.MessageItem + +fun MessageReadDto.toEntity(): MessageEntity { + return MessageEntity( + id = id, + chatId = chatId, + senderId = senderId, + senderDisplayName = null, + senderUsername = null, + senderAvatarUrl = null, + replyToMessageId = replyToMessageId, + type = type, + text = text, + status = deliveryStatus, + createdAt = createdAt, + updatedAt = updatedAt, + ) +} + +fun MessageEntity.toDomain(currentUserId: Long?): MessageItem { + return MessageItem( + id = id, + chatId = chatId, + senderId = senderId, + senderDisplayName = senderDisplayName, + type = type, + text = text, + createdAt = createdAt, + updatedAt = updatedAt, + isOutgoing = currentUserId != null && currentUserId == senderId, + status = status, + replyToMessageId = replyToMessageId, + ) +} 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 new file mode 100644 index 0000000..d9edf03 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -0,0 +1,163 @@ +package ru.daemonlord.messenger.data.message.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import ru.daemonlord.messenger.data.chat.local.dao.ChatDao +import ru.daemonlord.messenger.data.message.api.MessageApiService +import ru.daemonlord.messenger.data.message.local.dao.MessageDao +import ru.daemonlord.messenger.data.message.local.entity.MessageEntity +import ru.daemonlord.messenger.data.message.mapper.toDomain +import ru.daemonlord.messenger.data.message.mapper.toEntity +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.message.model.MessageItem +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto +import java.io.IOException +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +@Singleton +class NetworkMessageRepository @Inject constructor( + private val messageApiService: MessageApiService, + private val messageDao: MessageDao, + private val chatDao: ChatDao, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : MessageRepository { + + private val currentUserId: Long? = null + + override fun observeMessages(chatId: Long): Flow> { + return messageDao.observeRecentMessages(chatId = chatId).map { entities -> + entities.map { it.toDomain(currentUserId = currentUserId) } + } + } + + override suspend fun syncRecentMessages(chatId: Long): AppResult = withContext(ioDispatcher) { + try { + val remoteMessages = messageApiService.getMessages(chatId = chatId) + val messageEntities = remoteMessages.map { it.toEntity() } + messageDao.clearAndReplaceMessages( + chatId = chatId, + messages = messageEntities, + attachments = emptyList(), + ) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult = withContext(ioDispatcher) { + try { + val page = messageApiService.getMessages( + chatId = chatId, + beforeId = beforeMessageId, + ) + if (page.isNotEmpty()) { + messageDao.upsertMessages(page.map { it.toEntity() }) + } + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun sendTextMessage( + chatId: Long, + text: String, + replyToMessageId: Long?, + ): AppResult = withContext(ioDispatcher) { + val tempId = -System.currentTimeMillis() + val tempMessage = MessageEntity( + id = tempId, + chatId = chatId, + senderId = currentUserId ?: 0L, + senderDisplayName = null, + senderUsername = null, + senderAvatarUrl = null, + replyToMessageId = replyToMessageId, + type = "text", + text = text, + status = "pending", + createdAt = java.time.Instant.now().toString(), + updatedAt = null, + ) + messageDao.upsertMessages(listOf(tempMessage)) + chatDao.updateLastMessage( + chatId = chatId, + lastMessageText = text, + lastMessageType = "text", + lastMessageCreatedAt = tempMessage.createdAt, + updatedSortAt = tempMessage.createdAt, + ) + try { + val sent = messageApiService.sendMessage( + request = MessageCreateRequestDto( + chatId = chatId, + type = "text", + text = text, + clientMessageId = UUID.randomUUID().toString(), + replyToMessageId = replyToMessageId, + ) + ) + messageDao.deleteMessage(tempId) + messageDao.upsertMessages(listOf(sent.toEntity())) + chatDao.updateLastMessage( + chatId = chatId, + lastMessageText = sent.text, + lastMessageType = sent.type, + lastMessageCreatedAt = sent.createdAt, + updatedSortAt = sent.createdAt, + ) + AppResult.Success(Unit) + } catch (error: Throwable) { + messageDao.deleteMessage(tempId) + AppResult.Error(error.toAppError()) + } + } + + override suspend fun editMessage(messageId: Long, newText: String): AppResult = withContext(ioDispatcher) { + try { + val updated = messageApiService.editMessage( + messageId = messageId, + request = MessageUpdateRequestDto(text = newText), + ) + messageDao.upsertMessages(listOf(updated.toEntity())) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult = withContext(ioDispatcher) { + try { + messageApiService.deleteMessage( + messageId = messageId, + forAll = forAll, + ) + messageDao.deleteMessage(messageId) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + private fun Throwable.toAppError(): AppError { + return when (this) { + is IOException -> AppError.Network + is HttpException -> if (code() == 401 || code() == 403) { + AppError.Unauthorized + } else { + AppError.Server(message = message()) + } + else -> AppError.Unknown(cause = this) + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt index c788f74..bd09b88 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt @@ -16,6 +16,7 @@ import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.chat.api.ChatApiService +import ru.daemonlord.messenger.data.message.api.MessageApiService import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit import javax.inject.Singleton @@ -123,4 +124,10 @@ object NetworkModule { fun provideChatApiService(retrofit: Retrofit): ChatApiService { return retrofit.create(ChatApiService::class.java) } + + @Provides + @Singleton + fun provideMessageApiService(retrofit: Retrofit): MessageApiService { + return retrofit.create(MessageApiService::class.java) + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt index a27da73..56a3961 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt @@ -6,8 +6,10 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository +import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository import ru.daemonlord.messenger.domain.auth.repository.AuthRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository +import ru.daemonlord.messenger.domain.message.repository.MessageRepository import javax.inject.Singleton @Module @@ -25,4 +27,10 @@ abstract class RepositoryModule { abstract fun bindChatRepository( repository: NetworkChatRepository, ): ChatRepository + + @Binds + @Singleton + abstract fun bindMessageRepository( + repository: NetworkMessageRepository, + ): MessageRepository } 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 new file mode 100644 index 0000000..9a1f304 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt @@ -0,0 +1,14 @@ +package ru.daemonlord.messenger.domain.message.repository + +import kotlinx.coroutines.flow.Flow +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.model.MessageItem + +interface MessageRepository { + fun observeMessages(chatId: Long): 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 + suspend fun editMessage(messageId: Long, newText: String): AppResult + suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/DeleteMessageUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/DeleteMessageUseCase.kt new file mode 100644 index 0000000..b7eb8c1 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/DeleteMessageUseCase.kt @@ -0,0 +1,13 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class DeleteMessageUseCase @Inject constructor( + private val messageRepository: MessageRepository, +) { + suspend operator fun invoke(messageId: Long, forAll: Boolean): AppResult { + return messageRepository.deleteMessage(messageId = messageId, forAll = forAll) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/EditMessageUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/EditMessageUseCase.kt new file mode 100644 index 0000000..23830fd --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/EditMessageUseCase.kt @@ -0,0 +1,13 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class EditMessageUseCase @Inject constructor( + private val messageRepository: MessageRepository, +) { + suspend operator fun invoke(messageId: Long, newText: String): AppResult { + return messageRepository.editMessage(messageId = messageId, newText = newText) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/LoadMoreMessagesUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/LoadMoreMessagesUseCase.kt new file mode 100644 index 0000000..51eb74d --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/LoadMoreMessagesUseCase.kt @@ -0,0 +1,13 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class LoadMoreMessagesUseCase @Inject constructor( + private val messageRepository: MessageRepository, +) { + suspend operator fun invoke(chatId: Long, beforeMessageId: Long): AppResult { + return messageRepository.loadMoreMessages(chatId = chatId, beforeMessageId = beforeMessageId) + } +} 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 new file mode 100644 index 0000000..c2aad89 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ObserveMessagesUseCase.kt @@ -0,0 +1,14 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import kotlinx.coroutines.flow.Flow +import ru.daemonlord.messenger.domain.message.model.MessageItem +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class ObserveMessagesUseCase @Inject constructor( + private val messageRepository: MessageRepository, +) { + operator fun invoke(chatId: Long): Flow> { + return messageRepository.observeMessages(chatId = chatId) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendTextMessageUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendTextMessageUseCase.kt new file mode 100644 index 0000000..1386392 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendTextMessageUseCase.kt @@ -0,0 +1,17 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class SendTextMessageUseCase @Inject constructor( + private val messageRepository: MessageRepository, +) { + suspend operator fun invoke(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult { + return messageRepository.sendTextMessage( + chatId = chatId, + text = text, + replyToMessageId = replyToMessageId, + ) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SyncRecentMessagesUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SyncRecentMessagesUseCase.kt new file mode 100644 index 0000000..904a193 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SyncRecentMessagesUseCase.kt @@ -0,0 +1,13 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class SyncRecentMessagesUseCase @Inject constructor( + private val messageRepository: MessageRepository, +) { + suspend operator fun invoke(chatId: Long): AppResult { + return messageRepository.syncRecentMessages(chatId = chatId) + } +}