android: add chats api and cache-first repository sync

This commit is contained in:
Codex
2026-03-08 22:27:50 +03:00
parent f838fe1d5d
commit d006998867
10 changed files with 257 additions and 0 deletions

View File

@@ -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.

View File

@@ -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<ChatReadDto>
@GET("/api/v1/chats/{chat_id}")
suspend fun getChatById(
@Path("chat_id") chatId: Long,
): ChatReadDto
}

View File

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

View File

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

View File

@@ -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<List<ChatItem>> {
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<Unit> = 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<Unit> = 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)
}
}
}

View File

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

View File

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

View File

@@ -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<List<ChatItem>>
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
suspend fun refreshChat(chatId: Long): AppResult<Unit>
suspend fun deleteChat(chatId: Long)
}

View File

@@ -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<List<ChatItem>> {
return chatRepository.observeChats(archived = archived)
}
}

View File

@@ -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<Unit> {
return chatRepository.refreshChats(archived = archived)
}
}