android: add room schema and chat list domain models

This commit is contained in:
Codex
2026-03-08 22:26:08 +03:00
parent 390dcb8b2d
commit f838fe1d5d
10 changed files with 356 additions and 0 deletions

View File

@@ -24,3 +24,9 @@
### Step 4 - Unit tests
- Added `DataStoreTokenRepositoryTest` for token save/read and clear behavior.
- Added `NetworkAuthRepositoryTest` for login success path and 401 -> `InvalidCredentials` error mapping.
### Step 5 - Chat Room models and persistence core
- Added domain chat model (`ChatItem`) for chat list rendering concerns.
- 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`).

View File

@@ -80,6 +80,9 @@ dependencies {
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
implementation("com.google.dagger:hilt-android:2.52")
kapt("com.google.dagger:hilt-compiler:2.52")

View File

@@ -0,0 +1,110 @@
package ru.daemonlord.messenger.data.chat.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
@Dao
interface ChatDao {
@Query(
"""
SELECT
c.id,
c.public_id,
c.type,
c.title,
COALESCE(c.display_title, u.display_name) AS display_title,
c.handle,
COALESCE(c.avatar_url, u.avatar_url) AS avatar_url,
c.archived,
c.pinned,
c.muted,
c.unread_count,
c.unread_mentions_count,
COALESCE(c.counterpart_name, u.display_name) AS counterpart_name,
COALESCE(c.counterpart_username, u.username) AS counterpart_username,
COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url,
c.counterpart_is_online,
c.counterpart_last_seen_at,
c.last_message_text,
c.last_message_type,
c.last_message_created_at,
c.updated_sort_at
FROM chats c
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
WHERE c.archived = :archived
ORDER BY c.pinned DESC, c.updated_sort_at DESC, c.id DESC
"""
)
fun observeChats(archived: Boolean): Flow<List<ChatListLocalModel>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChats(chats: List<ChatEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertUsers(users: List<UserShortEntity>)
@Query("DELETE FROM chats WHERE archived = :archived")
suspend fun deleteChatsByArchived(archived: Boolean)
@Query("DELETE FROM chats WHERE id = :chatId")
suspend fun deleteChat(chatId: Long)
@Query(
"""
UPDATE chats
SET counterpart_is_online = :isOnline,
counterpart_last_seen_at = :lastSeenAt
WHERE id = :chatId
"""
)
suspend fun updatePresence(chatId: Long, isOnline: Boolean, lastSeenAt: String?)
@Query(
"""
UPDATE chats
SET last_message_text = :lastMessageText,
last_message_type = :lastMessageType,
last_message_created_at = :lastMessageCreatedAt,
updated_sort_at = :updatedSortAt
WHERE id = :chatId
"""
)
suspend fun updateLastMessage(
chatId: Long,
lastMessageText: String?,
lastMessageType: String?,
lastMessageCreatedAt: String?,
updatedSortAt: String?,
)
@Query(
"""
UPDATE chats
SET unread_count = CASE
WHEN :incrementBy > 0 THEN unread_count + :incrementBy
ELSE unread_count
END
WHERE id = :chatId
"""
)
suspend fun incrementUnread(chatId: Long, incrementBy: Int = 1)
@Transaction
suspend fun clearAndReplaceChats(
archived: Boolean,
chats: List<ChatEntity>,
users: List<UserShortEntity>,
) {
upsertUsers(users)
deleteChatsByArchived(archived = archived)
upsertChats(chats)
}
}

View File

@@ -0,0 +1,19 @@
package ru.daemonlord.messenger.data.chat.local.db
import androidx.room.Database
import androidx.room.RoomDatabase
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
@Database(
entities = [
ChatEntity::class,
UserShortEntity::class,
],
version = 1,
exportSchema = false,
)
abstract class MessengerDatabase : RoomDatabase() {
abstract fun chatDao(): ChatDao
}

View File

@@ -0,0 +1,61 @@
package ru.daemonlord.messenger.data.chat.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "chats",
indices = [
Index(value = ["archived", "pinned", "updated_sort_at"]),
Index(value = ["archived", "last_message_created_at"]),
],
)
data class ChatEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "public_id")
val publicId: String,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "title")
val title: String?,
@ColumnInfo(name = "display_title")
val displayTitle: String,
@ColumnInfo(name = "handle")
val handle: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
@ColumnInfo(name = "archived")
val archived: Boolean,
@ColumnInfo(name = "pinned")
val pinned: Boolean,
@ColumnInfo(name = "muted")
val muted: Boolean,
@ColumnInfo(name = "unread_count")
val unreadCount: Int,
@ColumnInfo(name = "unread_mentions_count")
val unreadMentionsCount: Int,
@ColumnInfo(name = "counterpart_user_id")
val counterpartUserId: Long?,
@ColumnInfo(name = "counterpart_name")
val counterpartName: String?,
@ColumnInfo(name = "counterpart_username")
val counterpartUsername: String?,
@ColumnInfo(name = "counterpart_avatar_url")
val counterpartAvatarUrl: String?,
@ColumnInfo(name = "counterpart_is_online")
val counterpartIsOnline: Boolean?,
@ColumnInfo(name = "counterpart_last_seen_at")
val counterpartLastSeenAt: String?,
@ColumnInfo(name = "last_message_text")
val lastMessageText: String?,
@ColumnInfo(name = "last_message_type")
val lastMessageType: String?,
@ColumnInfo(name = "last_message_created_at")
val lastMessageCreatedAt: String?,
@ColumnInfo(name = "updated_sort_at")
val updatedSortAt: String?,
)

View File

@@ -0,0 +1,20 @@
package ru.daemonlord.messenger.data.chat.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "users_short",
)
data class UserShortEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "display_name")
val displayName: String,
@ColumnInfo(name = "username")
val username: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
)

View File

@@ -0,0 +1,48 @@
package ru.daemonlord.messenger.data.chat.local.model
import androidx.room.ColumnInfo
data class ChatListLocalModel(
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "public_id")
val publicId: String,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "title")
val title: String?,
@ColumnInfo(name = "display_title")
val displayTitle: String,
@ColumnInfo(name = "handle")
val handle: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
@ColumnInfo(name = "archived")
val archived: Boolean,
@ColumnInfo(name = "pinned")
val pinned: Boolean,
@ColumnInfo(name = "muted")
val muted: Boolean,
@ColumnInfo(name = "unread_count")
val unreadCount: Int,
@ColumnInfo(name = "unread_mentions_count")
val unreadMentionsCount: Int,
@ColumnInfo(name = "counterpart_name")
val counterpartName: String?,
@ColumnInfo(name = "counterpart_username")
val counterpartUsername: String?,
@ColumnInfo(name = "counterpart_avatar_url")
val counterpartAvatarUrl: String?,
@ColumnInfo(name = "counterpart_is_online")
val counterpartIsOnline: Boolean?,
@ColumnInfo(name = "counterpart_last_seen_at")
val counterpartLastSeenAt: String?,
@ColumnInfo(name = "last_message_text")
val lastMessageText: String?,
@ColumnInfo(name = "last_message_type")
val lastMessageType: String?,
@ColumnInfo(name = "last_message_created_at")
val lastMessageCreatedAt: String?,
@ColumnInfo(name = "updated_sort_at")
val updatedSortAt: String?,
)

View File

@@ -0,0 +1,30 @@
package ru.daemonlord.messenger.data.chat.mapper
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
import ru.daemonlord.messenger.domain.chat.model.ChatItem
fun ChatListLocalModel.toDomain(): ChatItem {
return ChatItem(
id = id,
publicId = publicId,
type = type,
title = title,
displayTitle = displayTitle,
handle = handle,
avatarUrl = avatarUrl,
archived = archived,
pinned = pinned,
muted = muted,
unreadCount = unreadCount,
unreadMentionsCount = unreadMentionsCount,
counterpartUsername = counterpartUsername,
counterpartName = counterpartName,
counterpartAvatarUrl = counterpartAvatarUrl,
counterpartIsOnline = counterpartIsOnline,
counterpartLastSeenAt = counterpartLastSeenAt,
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
updatedSortAt = updatedSortAt,
)
}

View File

@@ -0,0 +1,34 @@
package ru.daemonlord.messenger.di
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context,
): MessengerDatabase {
return Room.databaseBuilder(
context,
MessengerDatabase::class.java,
"messenger.db",
).fallbackToDestructiveMigration()
.build()
}
@Provides
@Singleton
fun provideChatDao(database: MessengerDatabase): ChatDao = database.chatDao()
}

View File

@@ -0,0 +1,25 @@
package ru.daemonlord.messenger.domain.chat.model
data class ChatItem(
val id: Long,
val publicId: String,
val type: String,
val title: String?,
val displayTitle: String,
val handle: String?,
val avatarUrl: String?,
val archived: Boolean,
val pinned: Boolean,
val muted: Boolean,
val unreadCount: Int,
val unreadMentionsCount: Int,
val counterpartUsername: String?,
val counterpartName: String?,
val counterpartAvatarUrl: String?,
val counterpartIsOnline: Boolean?,
val counterpartLastSeenAt: String?,
val lastMessageText: String?,
val lastMessageType: String?,
val lastMessageCreatedAt: String?,
val updatedSortAt: String?,
)