Офлайн режим, начало

This commit is contained in:
Халимов Рустам
2026-04-20 01:00:35 +03:00
parent 7c66e1c0c0
commit 38e7184a0d
18 changed files with 214 additions and 35 deletions
Binary file not shown.
Binary file not shown.
@@ -60,4 +60,5 @@
<string name="reply_gif">GIF</string> <string name="reply_gif">GIF</string>
<string name="reply_prefix">Reply to </string> <string name="reply_prefix">Reply to </string>
<string name="reply_self">yourself</string> <string name="reply_self">yourself</string>
<string name="no_messages_yet">No messages yet</string>
</resources> </resources>
@@ -82,4 +82,5 @@
<string name="reply_gif">GIF</string> <string name="reply_gif">GIF</string>
<string name="reply_prefix">Ответ </string> <string name="reply_prefix">Ответ </string>
<string name="reply_self">самому себе</string> <string name="reply_self">самому себе</string>
<string name="no_messages_yet">Сообщений пока нет</string>
</resources> </resources>
@@ -15,6 +15,7 @@ import chats.domain.model.Message
import chats.domain.model.MediaType import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import core.database.data.ChatDatabase import core.database.data.ChatDatabase
import core.database.data.ChatDao
import core.database.data.MessageDao import core.database.data.MessageDao
import core.database.data.MessageEntity import core.database.data.MessageEntity
import core.database.data.SyncStatus import core.database.data.SyncStatus
@@ -51,7 +52,8 @@ class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi, private val api: ChatApi,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val serverConfig: ServerConfig, private val serverConfig: ServerConfig,
private val dao: MessageDao, private val messageDao: MessageDao,
private val chatDao: ChatDao,
private val database: ChatDatabase, private val database: ChatDatabase,
private val hubClient: ChatHubClient, private val hubClient: ChatHubClient,
private val signalRHandler: MessageSignalRHandler, private val signalRHandler: MessageSignalRHandler,
@@ -68,7 +70,43 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun getChats(): List<Chat> { override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: "" val currentUserId = tokenManager.getUserId() ?: ""
return api.getChats().map { it.toDomain(currentUserId, baseUrl) } return try {
// Пробуем загрузить из сети
val chats = api.getChats().map { it.toDomain(currentUserId, baseUrl) }
// Кэшируем в Room
val entities = chats.map { it.toEntity() }
chatDao.insertChats(entities)
// Кэшируем последние сообщения
chats.forEach { chat ->
chat.lastMessage?.let { msg ->
messageDao.upsertMessage(msg.toEntity(gson))
}
}
android.util.Log.d(TAG, "Cached ${entities.size} chats with messages")
chats
} catch (e: Exception) {
android.util.Log.d(TAG, "Network load failed, using cache")
// При ошибке - возвращаем из кэша с загрузкой последних сообщений
chatDao.getAllChats().map { entity ->
val lastMessage = entity.lastMessageId?.let { messageId ->
messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
}
entity.toDomain(currentUserId, baseUrl, lastMessage)
}
}
}
override fun getChatsFlow(): Flow<List<Chat>> {
val currentUserId = tokenManager.getUserId() ?: ""
return chatDao.getAllChatsFlow().map { entities ->
entities.map { entity ->
// Загружаем последнее сообщение из базы для каждого чата
val lastMessage = entity.lastMessageId?.let { messageId ->
messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
}
entity.toDomain(currentUserId, baseUrl, lastMessage)
}
}
} }
override fun getMessagesPaging(chatId: String): Flow<PagingData<Message>> { override fun getMessagesPaging(chatId: String): Flow<PagingData<Message>> {
@@ -78,15 +116,15 @@ class ChatRepositoryImpl @Inject constructor(
initialLoadSize = 50, initialLoadSize = 50,
enablePlaceholders = false enablePlaceholders = false
) )
return Pager( return Pager(
config = pagingConfig, config = pagingConfig,
pagingSourceFactory = { dao.getMessagesPagingSource(chatId) }, pagingSourceFactory = { messageDao.getMessagesPagingSource(chatId) },
remoteMediator = MessageRemoteMediator( remoteMediator = MessageRemoteMediator(
chatId = chatId, chatId = chatId,
api = api, api = api,
database = database, database = database,
dao = dao, dao = messageDao,
serverConfig = serverConfig, serverConfig = serverConfig,
tokenManager = tokenManager tokenManager = tokenManager
) )
@@ -96,7 +134,7 @@ class ChatRepositoryImpl @Inject constructor(
} }
override fun getMessagesFlow(chatId: String): Flow<List<Message>> { override fun getMessagesFlow(chatId: String): Flow<List<Message>> {
return dao.getMessages(chatId).map { entities -> return messageDao.getMessages(chatId).map { entities ->
entities.map { it.toDomain(baseUrl, gson) } entities.map { it.toDomain(baseUrl, gson) }
} }
} }
@@ -111,7 +149,7 @@ class ChatRepositoryImpl @Inject constructor(
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) } val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) }
dao.upsertMessages(entities) messageDao.upsertMessages(entities)
Log.d(TAG, "Cached ${entities.size} messages") Log.d(TAG, "Cached ${entities.size} messages")
} }
@@ -145,7 +183,7 @@ class ChatRepositoryImpl @Inject constructor(
editedContent = null, lastUpdated = currentTime editedContent = null, lastUpdated = currentTime
) )
dao.insertMessage(localMessage) messageDao.insertMessage(localMessage)
Log.d(TAG, "Saved local message: $localId") Log.d(TAG, "Saved local message: $localId")
MessageSyncWorker.scheduleSync(context) MessageSyncWorker.scheduleSync(context)
@@ -191,20 +229,20 @@ class ChatRepositoryImpl @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error marking messages as read", e) Log.e(TAG, "Error marking messages as read", e)
} }
dao.markMessagesAsRead(chatId, lastReadSequenceId) messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
} }
override suspend fun saveMessage(message: Message) { override suspend fun saveMessage(message: Message) {
dao.insertMessage(message.toEntity(gson)) messageDao.insertMessage(message.toEntity(gson))
} }
override suspend fun deleteLocalMessage(messageId: String) { override suspend fun deleteLocalMessage(messageId: String) {
dao.markAsDeletedLocally(messageId) messageDao.markAsDeletedLocally(messageId)
MessageSyncWorker.scheduleSync(context) MessageSyncWorker.scheduleSync(context)
} }
override suspend fun editLocalMessage(messageId: String, newContent: String) { override suspend fun editLocalMessage(messageId: String, newContent: String) {
dao.markAsEditedLocally(messageId, newContent) messageDao.markAsEditedLocally(messageId, newContent)
MessageSyncWorker.scheduleSync(context) MessageSyncWorker.scheduleSync(context)
} }
@@ -239,14 +277,14 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) { override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
api.deleteMessage(messageId, forEveryone) api.deleteMessage(messageId, forEveryone)
dao.deleteMessage(messageId) messageDao.deleteMessage(messageId)
} }
override suspend fun editMessage(messageId: String, content: String): Message { override suspend fun editMessage(messageId: String, content: String): Message {
val request = SendMessageRequest(content = content) val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: "" val currentUserId = tokenManager.getUserId() ?: ""
val response = api.editMessage(messageId, request) val response = api.editMessage(messageId, request)
dao.insertMessage(response.toEntity(baseUrl, currentUserId, gson)) messageDao.insertMessage(response.toEntity(baseUrl, currentUserId, gson))
return response.toDomain(currentUserId, baseUrl) return response.toDomain(currentUserId, baseUrl)
} }
+41 -1
View File
@@ -2,6 +2,9 @@ package chats.data.repository
import chats.data.remote.dto.* import chats.data.remote.dto.*
import chats.domain.model.* import chats.domain.model.*
import core.database.data.ChatEntity
import core.database.data.MessageEntity
import core.database.data.SyncStatus
// Mappers // Mappers
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat { fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
@@ -23,6 +26,38 @@ fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
) )
} }
fun Chat.toEntity(): ChatEntity {
return ChatEntity(
id = id,
name = name,
avatar = avatar,
type = type,
lastMessageId = lastMessage?.id,
lastMessageText = lastMessage?.content,
lastMessageAt = lastMessage?.createdAt?.let {
try { java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).parse(it)?.time ?: 0L }
catch (e: Exception) { 0L }
} ?: 0L,
unreadCount = unreadCount,
isPinned = false
)
}
fun ChatEntity.toDomain(
currentUserId: String,
baseUrl: String,
lastMessage: Message? = null
): Chat {
return Chat(
id = id,
type = type,
name = name,
avatar = avatar,
unreadCount = unreadCount,
lastMessage = lastMessage
)
}
fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity { fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
return core.database.data.MessageEntity( return core.database.data.MessageEntity(
id = id, id = id,
@@ -37,7 +72,12 @@ fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEnti
mediaJson = gson.toJson(media), mediaJson = gson.toJson(media),
reactionsJson = gson.toJson(reactions), reactionsJson = gson.toJson(reactions),
isRead = isRead, isRead = isRead,
replyToId = replyTo?.id replyToId = replyTo?.id,
syncStatus = SyncStatus.SYNCED,
isDeletedLocally = false,
isEditedLocally = false,
editedContent = null,
lastUpdated = System.currentTimeMillis()
) )
} }
+10 -2
View File
@@ -7,6 +7,7 @@ import chats.data.repository.ChatRepositoryImpl
import chats.data.signalr.MessageSignalRHandler import chats.data.signalr.MessageSignalRHandler
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import core.database.data.ChatDatabase import core.database.data.ChatDatabase
import core.database.data.ChatDao
import core.database.data.MessageDao import core.database.data.MessageDao
import core.network.ServerConfig import core.network.ServerConfig
import core.security.TokenManager import core.security.TokenManager
@@ -39,19 +40,26 @@ object ChatModule {
return MessageSignalRHandler(hubClient, dao, serverConfig, tokenManager) return MessageSignalRHandler(hubClient, dao, serverConfig, tokenManager)
} }
@Provides
@Singleton
fun provideChatDao(database: ChatDatabase): ChatDao {
return database.chatDao()
}
@Provides @Provides
@Singleton @Singleton
fun provideChatRepository( fun provideChatRepository(
api: ChatApi, api: ChatApi,
tokenManager: TokenManager, tokenManager: TokenManager,
serverConfig: ServerConfig, serverConfig: ServerConfig,
dao: MessageDao, messageDao: MessageDao,
chatDao: ChatDao,
database: ChatDatabase, database: ChatDatabase,
hubClient: ChatHubClient, hubClient: ChatHubClient,
signalRHandler: MessageSignalRHandler, signalRHandler: MessageSignalRHandler,
@ApplicationContext context: Context @ApplicationContext context: Context
): ChatRepository { ): ChatRepository {
return ChatRepositoryImpl(api, tokenManager, serverConfig, dao, database, hubClient, signalRHandler, context) return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, chatDao, database, hubClient, signalRHandler, context)
} }
@Provides @Provides
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow
interface ChatRepository { interface ChatRepository {
suspend fun getChats(): List<Chat> suspend fun getChats(): List<Chat>
fun getChatsFlow(): Flow<List<Chat>>
// Flow для UI (простой список) // Flow для UI (простой список)
fun getMessagesFlow(chatId: String): Flow<List<Message>> fun getMessagesFlow(chatId: String): Flow<List<Message>>
@@ -14,6 +14,8 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import chats.data.repository.toDomain import chats.data.repository.toDomain
private const val TAG = "ChatListViewModel"
data class ChatListState( data class ChatListState(
val chats: List<Chat> = emptyList(), val chats: List<Chat> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -77,11 +79,26 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true) } _state.update { it.copy(isLoading = true) }
try { try {
val chats = repository.getChats() // Пробуем загрузить из сети (это закэширует в Room)
_state.update { it.copy(chats = sortChats(chats), isLoading = false) } repository.getChats()
} catch (e: Exception) { } catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.message) } android.util.Log.d(TAG, "Initial load failed, will use cache")
} }
// Подписываемся на Flow из Room (всегда работает, даже оффлайн)
repository.getChatsFlow()
.catch { e ->
android.util.Log.e(TAG, "Flow error", e)
emit(emptyList())
}
.collect { chats ->
_state.update {
it.copy(
chats = sortChats(chats),
isLoading = false
)
}
}
} }
} }
@@ -4,24 +4,21 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.Text import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chats.domain.model.Chat
import androidx.compose.foundation.shape.RoundedCornerShape
import core.presentation.components.AppAvatar
import chats.domain.model.MediaType
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import chats.domain.model.Chat
import chats.domain.model.MediaType
import core.presentation.components.AppAvatar
import ru.knot.messager.R
@Composable @Composable
fun ChatItem( fun ChatItem(
@@ -74,14 +71,15 @@ fun ChatItem(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
val context = LocalContext.current
val previewText = remember(chat.lastMessage) { val previewText = remember(chat.lastMessage) {
val msg = chat.lastMessage val msg = chat.lastMessage
if (msg == null) return@remember "No messages yet" if (msg == null) return@remember context.getString(R.string.no_messages_yet)
if (!msg.content.isNullOrBlank()) { if (!msg.content.isNullOrBlank()) {
msg.content msg.content
} else if (msg.mediaType == MediaType.AUDIO) { } else if (msg.mediaType == MediaType.AUDIO) {
"Голосовое сообщение" context.getString(R.string.voice_message)
} else if (msg.media.isNotEmpty()) { } else if (msg.media.isNotEmpty()) {
when (msg.mediaType) { when (msg.mediaType) {
MediaType.IMAGE -> "Фото" MediaType.IMAGE -> "Фото"
@@ -90,7 +88,7 @@ fun ChatItem(
else -> "Медиа" else -> "Медиа"
} }
} else { } else {
"Сообщение" context.getString(R.string.message)
} }
} }
Text( Text(
@@ -13,6 +13,23 @@ enum class SyncStatus {
FAILED // Ошибка синхронизации FAILED // Ошибка синхронизации
} }
/**
* Entity для хранения чатов в локальной базе данных
*/
@Entity(tableName = "chats")
data class ChatEntity(
@PrimaryKey val id: String,
val name: String,
val avatar: String?,
val type: String = "personal", // personal, group, saved
val lastMessageId: String? = null,
val lastMessageText: String? = null,
val lastMessageAt: Long = 0,
val unreadCount: Int = 0,
val isPinned: Boolean = false,
val lastUpdated: Long = System.currentTimeMillis()
)
/** /**
* Entity для хранения сообщений в локальной базе данных * Entity для хранения сообщений в локальной базе данных
* Поддерживает офлайн-работу и фоновую синхронизацию * Поддерживает офлайн-работу и фоновую синхронизацию
@@ -187,10 +204,44 @@ interface MessageDao {
suspend fun exists(id: String): Boolean suspend fun exists(id: String): Boolean
} }
@Database(entities = [MessageEntity::class], version = 2) @Dao
interface ChatDao {
@Query("SELECT * FROM chats ORDER BY isPinned DESC, lastMessageAt DESC")
fun getAllChatsFlow(): Flow<List<ChatEntity>>
@Query("SELECT * FROM chats ORDER BY isPinned DESC, lastMessageAt DESC")
suspend fun getAllChats(): List<ChatEntity>
@Query("SELECT * FROM chats WHERE id = :chatId LIMIT 1")
suspend fun getChatById(chatId: String): ChatEntity?
@Query("SELECT * FROM chats WHERE id = :chatId LIMIT 1")
fun getChatByIdFlow(chatId: String): Flow<ChatEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertChat(chat: ChatEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertChats(chats: List<ChatEntity>)
@Query("DELETE FROM chats WHERE id = :chatId")
suspend fun deleteChat(chatId: String)
@Query("DELETE FROM chats")
suspend fun clearAll()
@Query("UPDATE chats SET unreadCount = :count WHERE id = :chatId")
suspend fun updateUnreadCount(chatId: String, count: Int)
@Query("UPDATE chats SET lastMessageId = :lastMessageId, lastMessageText = :lastMessageText, lastMessageAt = :lastMessageAt WHERE id = :chatId")
suspend fun updateLastMessage(chatId: String, lastMessageId: String?, lastMessageText: String?, lastMessageAt: Long)
}
@Database(entities = [MessageEntity::class, ChatEntity::class], version = 3)
@TypeConverters(SyncStatusConverter::class) @TypeConverters(SyncStatusConverter::class)
abstract class ChatDatabase : RoomDatabase() { abstract class ChatDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao abstract fun messageDao(): MessageDao
abstract fun chatDao(): ChatDao
companion object { companion object {
const val DATABASE_NAME = "knot_chat_database" const val DATABASE_NAME = "knot_chat_database"
@@ -37,3 +37,26 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
""".trimIndent()) """.trimIndent())
} }
} }
/**
* Миграция с версии 2 на версию 3
* Добавляет таблицу chats для кэширования списка чатов
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("""
CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
avatar TEXT,
type TEXT NOT NULL DEFAULT 'personal',
lastMessageId TEXT,
lastMessageText TEXT,
lastMessageAt INTEGER NOT NULL DEFAULT 0,
unreadCount INTEGER NOT NULL DEFAULT 0,
isPinned INTEGER NOT NULL DEFAULT 0,
lastUpdated INTEGER NOT NULL DEFAULT 0
)
""".trimIndent())
}
}
+2 -1
View File
@@ -5,6 +5,7 @@ import androidx.room.Room
import core.database.data.ChatDatabase import core.database.data.ChatDatabase
import core.database.data.MessageDao import core.database.data.MessageDao
import core.database.data.MIGRATION_1_2 import core.database.data.MIGRATION_1_2
import core.database.data.MIGRATION_2_3
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -24,7 +25,7 @@ object DatabaseModule {
ChatDatabase::class.java, ChatDatabase::class.java,
ChatDatabase.DATABASE_NAME ChatDatabase.DATABASE_NAME
) )
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build() .build()
} }