From f838fe1d5d06a3c3d917232e18b46a453f84cbed Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 8 Mar 2026 22:26:08 +0300 Subject: [PATCH] android: add room schema and chat list domain models --- android/CHANGELOG.md | 6 + android/app/build.gradle.kts | 3 + .../messenger/data/chat/local/dao/ChatDao.kt | 110 ++++++++++++++++++ .../data/chat/local/db/MessengerDatabase.kt | 19 +++ .../data/chat/local/entity/ChatEntity.kt | 61 ++++++++++ .../data/chat/local/entity/UserShortEntity.kt | 20 ++++ .../chat/local/model/ChatListLocalModel.kt | 48 ++++++++ .../data/chat/mapper/ChatLocalMapper.kt | 30 +++++ .../daemonlord/messenger/di/DatabaseModule.kt | 34 ++++++ .../messenger/domain/chat/model/ChatItem.kt | 25 ++++ 10 files changed, 356 insertions(+) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/UserShortEntity.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/di/DatabaseModule.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 1c1f2e1..23c69dc 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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`). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6a9bcfd..ebe86db 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt new file mode 100644 index 0000000..e876832 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertChats(chats: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertUsers(users: List) + + @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, + users: List, + ) { + upsertUsers(users) + deleteChatsByArchived(archived = archived) + upsertChats(chats) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt new file mode 100644 index 0000000..c9866b6 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt @@ -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 +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt new file mode 100644 index 0000000..d197f78 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt @@ -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?, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/UserShortEntity.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/UserShortEntity.kt new file mode 100644 index 0000000..307dc78 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/UserShortEntity.kt @@ -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?, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt new file mode 100644 index 0000000..5aa420a --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt @@ -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?, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt new file mode 100644 index 0000000..5368ad6 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt @@ -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, + ) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/DatabaseModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/DatabaseModule.kt new file mode 100644 index 0000000..36b8a40 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/DatabaseModule.kt @@ -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() +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt new file mode 100644 index 0000000..4b82bc2 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt @@ -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?, +)