android: add message api contracts and repository usecases
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-08 23:06:30 +03:00
parent 5ad89fc05b
commit 5a0add4d5c
15 changed files with 417 additions and 0 deletions

View File

@@ -71,3 +71,10 @@
- Added `MessageDao` with observe/pagination/upsert/delete APIs. - Added `MessageDao` with observe/pagination/upsert/delete APIs.
- Updated `MessengerDatabase` schema to include message tables and DAO. - Updated `MessengerDatabase` schema to include message tables and DAO.
- Added Hilt DI provider for `MessageDao`. - 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.

View File

@@ -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<MessageReadDto>
@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,
)
}

View File

@@ -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,
)

View File

@@ -52,6 +52,25 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE id = :messageId") @Query("DELETE FROM messages WHERE id = :messageId")
suspend fun deleteMessage(messageId: Long) 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 @Transaction
suspend fun clearAndReplaceMessages( suspend fun clearAndReplaceMessages(
chatId: Long, chatId: Long,

View File

@@ -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,
)
}

View File

@@ -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<List<MessageItem>> {
return messageDao.observeRecentMessages(chatId = chatId).map { entities ->
entities.map { it.toDomain(currentUserId = currentUserId) }
}
}
override suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> = 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<Unit> = 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<Unit> = 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<Unit> = 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<Unit> = 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)
}
}
}

View File

@@ -16,6 +16,7 @@ import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.chat.api.ChatApiService 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 com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
@@ -123,4 +124,10 @@ object NetworkModule {
fun provideChatApiService(retrofit: Retrofit): ChatApiService { fun provideChatApiService(retrofit: Retrofit): ChatApiService {
return retrofit.create(ChatApiService::class.java) return retrofit.create(ChatApiService::class.java)
} }
@Provides
@Singleton
fun provideMessageApiService(retrofit: Retrofit): MessageApiService {
return retrofit.create(MessageApiService::class.java)
}
} }

View File

@@ -6,8 +6,10 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository 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.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -25,4 +27,10 @@ abstract class RepositoryModule {
abstract fun bindChatRepository( abstract fun bindChatRepository(
repository: NetworkChatRepository, repository: NetworkChatRepository,
): ChatRepository ): ChatRepository
@Binds
@Singleton
abstract fun bindMessageRepository(
repository: NetworkMessageRepository,
): MessageRepository
} }

View File

@@ -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<List<MessageItem>>
suspend fun syncRecentMessages(chatId: 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 editMessage(messageId: Long, newText: String): AppResult<Unit>
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
}

View File

@@ -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<Unit> {
return messageRepository.deleteMessage(messageId = messageId, forAll = forAll)
}
}

View File

@@ -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<Unit> {
return messageRepository.editMessage(messageId = messageId, newText = newText)
}
}

View File

@@ -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<Unit> {
return messageRepository.loadMoreMessages(chatId = chatId, beforeMessageId = beforeMessageId)
}
}

View File

@@ -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<List<MessageItem>> {
return messageRepository.observeMessages(chatId = chatId)
}
}

View File

@@ -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<Unit> {
return messageRepository.sendTextMessage(
chatId = chatId,
text = text,
replyToMessageId = replyToMessageId,
)
}
}

View File

@@ -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<Unit> {
return messageRepository.syncRecentMessages(chatId = chatId)
}
}