android: add chats api and cache-first repository sync
This commit is contained in:
@@ -30,3 +30,10 @@
|
|||||||
- Added Room entities: `chats`, `users_short` with sort-friendly indices.
|
- Added Room entities: `chats`, `users_short` with sort-friendly indices.
|
||||||
- Added `ChatDao` with `observeChats()`, `upsertChats()`, and transactional `clearAndReplaceChats()`.
|
- Added `ChatDao` with `observeChats()`, `upsertChats()`, and transactional `clearAndReplaceChats()`.
|
||||||
- Added `MessengerDatabase` and Hilt database wiring (`DatabaseModule`).
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import ru.daemonlord.messenger.BuildConfig
|
|||||||
import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
|
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 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
|
||||||
@@ -108,4 +109,10 @@ object NetworkModule {
|
|||||||
fun provideAuthApiService(retrofit: Retrofit): AuthApiService {
|
fun provideAuthApiService(retrofit: Retrofit): AuthApiService {
|
||||||
return retrofit.create(AuthApiService::class.java)
|
return retrofit.create(AuthApiService::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideChatApiService(retrofit: Retrofit): ChatApiService {
|
||||||
|
return retrofit.create(ChatApiService::class.java)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import dagger.Module
|
|||||||
import dagger.hilt.InstallIn
|
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.domain.auth.repository.AuthRepository
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -17,4 +19,10 @@ abstract class RepositoryModule {
|
|||||||
abstract fun bindAuthRepository(
|
abstract fun bindAuthRepository(
|
||||||
repository: NetworkAuthRepository,
|
repository: NetworkAuthRepository,
|
||||||
): AuthRepository
|
): AuthRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindChatRepository(
|
||||||
|
repository: NetworkChatRepository,
|
||||||
|
): ChatRepository
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user