diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 23c69dc..7b33be1 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -30,3 +30,10 @@ - Added Room entities: `chats`, `users_short` with sort-friendly indices. - Added `ChatDao` with `observeChats()`, `upsertChats()`, and transactional `clearAndReplaceChats()`. - Added `MessengerDatabase` and Hilt database wiring (`DatabaseModule`). + +### Step 6 - Chat API and repository sync +- Added chat REST API client for `/api/v1/chats` and `/api/v1/chats/{chat_id}`. +- Added chat DTOs and remote/local mappers (`ChatReadDto -> ChatEntity/UserShortEntity -> ChatItem`). +- Implemented `NetworkChatRepository` with cache-first flow strategy (Room first, then server sync). +- Added chat domain contracts/use-cases (`ChatRepository`, observe/refresh use-cases). +- Wired chat API/repository via Hilt modules. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt new file mode 100644 index 0000000..0abe398 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt @@ -0,0 +1,18 @@ +package ru.daemonlord.messenger.data.chat.api + +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query +import ru.daemonlord.messenger.data.chat.dto.ChatReadDto + +interface ChatApiService { + @GET("/api/v1/chats") + suspend fun getChats( + @Query("archived") archived: Boolean = false, + ): List + + @GET("/api/v1/chats/{chat_id}") + suspend fun getChatById( + @Path("chat_id") chatId: Long, + ): ChatReadDto +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt new file mode 100644 index 0000000..9e0e02b --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt @@ -0,0 +1,45 @@ +package ru.daemonlord.messenger.data.chat.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ChatReadDto( + val id: Long, + @SerialName("public_id") + val publicId: String, + val type: String, + val title: String? = null, + @SerialName("display_title") + val displayTitle: String, + val handle: String? = null, + @SerialName("avatar_url") + val avatarUrl: String? = null, + val archived: Boolean = false, + val pinned: Boolean = false, + val muted: Boolean = false, + @SerialName("unread_count") + val unreadCount: Int = 0, + @SerialName("unread_mentions_count") + val unreadMentionsCount: Int = 0, + @SerialName("counterpart_user_id") + val counterpartUserId: Long? = null, + @SerialName("counterpart_name") + val counterpartName: String? = null, + @SerialName("counterpart_username") + val counterpartUsername: String? = null, + @SerialName("counterpart_avatar_url") + val counterpartAvatarUrl: String? = null, + @SerialName("counterpart_is_online") + val counterpartIsOnline: Boolean? = null, + @SerialName("counterpart_last_seen_at") + val counterpartLastSeenAt: String? = null, + @SerialName("last_message_text") + val lastMessageText: String? = null, + @SerialName("last_message_type") + val lastMessageType: String? = null, + @SerialName("last_message_created_at") + val lastMessageCreatedAt: String? = null, + @SerialName("created_at") + val createdAt: String? = null, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt new file mode 100644 index 0000000..9319358 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt @@ -0,0 +1,43 @@ +package ru.daemonlord.messenger.data.chat.mapper + +import ru.daemonlord.messenger.data.chat.dto.ChatReadDto +import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity +import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity + +fun ChatReadDto.toChatEntity(): ChatEntity { + return ChatEntity( + id = id, + publicId = publicId, + type = type, + title = title, + displayTitle = displayTitle, + handle = handle, + avatarUrl = avatarUrl, + archived = archived, + pinned = pinned, + muted = muted, + unreadCount = unreadCount, + unreadMentionsCount = unreadMentionsCount, + counterpartUserId = counterpartUserId, + counterpartName = counterpartName, + counterpartUsername = counterpartUsername, + counterpartAvatarUrl = counterpartAvatarUrl, + counterpartIsOnline = counterpartIsOnline, + counterpartLastSeenAt = counterpartLastSeenAt, + lastMessageText = lastMessageText, + lastMessageType = lastMessageType, + lastMessageCreatedAt = lastMessageCreatedAt, + updatedSortAt = lastMessageCreatedAt ?: createdAt, + ) +} + +fun ChatReadDto.toUserShortEntityOrNull(): UserShortEntity? { + val userId = counterpartUserId ?: return null + val displayName = counterpartName ?: counterpartUsername ?: return null + return UserShortEntity( + id = userId, + displayName = displayName, + username = counterpartUsername, + avatarUrl = counterpartAvatarUrl, + ) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt new file mode 100644 index 0000000..f1f3aa0 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt @@ -0,0 +1,90 @@ +package ru.daemonlord.messenger.data.chat.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.channels.awaitClose +import retrofit2.HttpException +import ru.daemonlord.messenger.data.chat.api.ChatApiService +import ru.daemonlord.messenger.data.chat.local.dao.ChatDao +import ru.daemonlord.messenger.data.chat.mapper.toChatEntity +import ru.daemonlord.messenger.data.chat.mapper.toDomain +import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull +import ru.daemonlord.messenger.domain.chat.model.ChatItem +import ru.daemonlord.messenger.domain.chat.repository.ChatRepository +import ru.daemonlord.messenger.domain.common.AppError +import ru.daemonlord.messenger.domain.common.AppResult +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkChatRepository @Inject constructor( + private val chatApiService: ChatApiService, + private val chatDao: ChatDao, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ChatRepository { + + override fun observeChats(archived: Boolean): Flow> { + return channelFlow { + val dbCollection = launch { + chatDao.observeChats(archived = archived).collect { rows -> + send(rows.map { it.toDomain() }) + } + } + launch(ioDispatcher) { + refreshChats(archived = archived) + } + awaitClose { dbCollection.cancel() } + } + } + + override suspend fun refreshChats(archived: Boolean): AppResult = withContext(ioDispatcher) { + try { + val chats = chatApiService.getChats(archived = archived) + val chatEntities = chats.map { it.toChatEntity() } + val userEntities = chats.mapNotNull { it.toUserShortEntityOrNull() } + chatDao.clearAndReplaceChats( + archived = archived, + chats = chatEntities, + users = userEntities, + ) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun refreshChat(chatId: Long): AppResult = withContext(ioDispatcher) { + try { + val chat = chatApiService.getChatById(chatId = chatId) + chatDao.upsertUsers(chat.toUserShortEntityOrNull()?.let(::listOf).orEmpty()) + chatDao.upsertChats(listOf(chat.toChatEntity())) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun deleteChat(chatId: Long) { + withContext(ioDispatcher) { + chatDao.deleteChat(chatId = chatId) + } + } + + 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 bb23286..e036a2e 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 @@ -13,6 +13,7 @@ import ru.daemonlord.messenger.BuildConfig 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 com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit import javax.inject.Singleton @@ -108,4 +109,10 @@ object NetworkModule { fun provideAuthApiService(retrofit: Retrofit): AuthApiService { return retrofit.create(AuthApiService::class.java) } + + @Provides + @Singleton + fun provideChatApiService(retrofit: Retrofit): ChatApiService { + return retrofit.create(ChatApiService::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 ce33978..a27da73 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 @@ -5,7 +5,9 @@ import dagger.Module 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.domain.auth.repository.AuthRepository +import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import javax.inject.Singleton @Module @@ -17,4 +19,10 @@ abstract class RepositoryModule { abstract fun bindAuthRepository( repository: NetworkAuthRepository, ): AuthRepository + + @Binds + @Singleton + abstract fun bindChatRepository( + repository: NetworkChatRepository, + ): ChatRepository } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt new file mode 100644 index 0000000..fc8e85e --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt @@ -0,0 +1,12 @@ +package ru.daemonlord.messenger.domain.chat.repository + +import kotlinx.coroutines.flow.Flow +import ru.daemonlord.messenger.domain.chat.model.ChatItem +import ru.daemonlord.messenger.domain.common.AppResult + +interface ChatRepository { + fun observeChats(archived: Boolean): Flow> + suspend fun refreshChats(archived: Boolean): AppResult + suspend fun refreshChat(chatId: Long): AppResult + suspend fun deleteChat(chatId: Long) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/ObserveChatsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/ObserveChatsUseCase.kt new file mode 100644 index 0000000..0e61fee --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/ObserveChatsUseCase.kt @@ -0,0 +1,14 @@ +package ru.daemonlord.messenger.domain.chat.usecase + +import kotlinx.coroutines.flow.Flow +import ru.daemonlord.messenger.domain.chat.model.ChatItem +import ru.daemonlord.messenger.domain.chat.repository.ChatRepository +import javax.inject.Inject + +class ObserveChatsUseCase @Inject constructor( + private val chatRepository: ChatRepository, +) { + operator fun invoke(archived: Boolean): Flow> { + return chatRepository.observeChats(archived = archived) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/RefreshChatsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/RefreshChatsUseCase.kt new file mode 100644 index 0000000..536c115 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/RefreshChatsUseCase.kt @@ -0,0 +1,13 @@ +package ru.daemonlord.messenger.domain.chat.usecase + +import ru.daemonlord.messenger.domain.chat.repository.ChatRepository +import ru.daemonlord.messenger.domain.common.AppResult +import javax.inject.Inject + +class RefreshChatsUseCase @Inject constructor( + private val chatRepository: ChatRepository, +) { + suspend operator fun invoke(archived: Boolean): AppResult { + return chatRepository.refreshChats(archived = archived) + } +}