Кэш чатов
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# Архитектура Offline-first для мессенджера Knot
|
||||
|
||||
## Обзор
|
||||
|
||||
Система кэширования истории чатов реализует паттерн **Offline-first** с использованием:
|
||||
- **Room** - локальная база данных
|
||||
- **Paging 3** - пагинация с RemoteMediator
|
||||
- **WorkManager** - фоновая синхронизация
|
||||
- **SignalR** - real-time обновления
|
||||
|
||||
## Компоненты
|
||||
|
||||
### 1. Data Layer
|
||||
|
||||
#### MessageEntity
|
||||
```kotlin
|
||||
@Entity(tableName = "messages")
|
||||
data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val chatId: String,
|
||||
val senderId: String,
|
||||
val content: String?,
|
||||
val sequenceId: Int,
|
||||
val createdAt: String,
|
||||
|
||||
// Поля синхронизации
|
||||
val syncStatus: SyncStatus, // SYNCED, SYNCING, FAILED
|
||||
val isDeletedLocally: Boolean, // Помечено на удаление
|
||||
val isEditedLocally: Boolean, // Помечено на редактирование
|
||||
val editedContent: String?, // Новое содержимое
|
||||
val lastUpdated: Long // Время последнего изменения
|
||||
)
|
||||
```
|
||||
|
||||
#### MessageDao
|
||||
Основные методы:
|
||||
- `getMessagesPagingSource()` - PagingSource для Paging 3
|
||||
- `upsertMessage()` - Вставка/обновление с разрешением конфликтов
|
||||
- `markAsDeletedLocally()` - Пометка на удаление
|
||||
- `markAsEditedLocally()` - Пометка на редактирование
|
||||
- `getPendingSyncMessages()` - Получение сообщений для синхронизации
|
||||
|
||||
### 2. Pagination (Paging 3)
|
||||
|
||||
#### MessageRemoteMediator
|
||||
Управляет загрузкой данных:
|
||||
- **REFRESH** - первая загрузка последних сообщений
|
||||
- **APPEND** - загрузка более старых сообщений (прокрутка вниз)
|
||||
- **PREPEND** - загрузка более новых сообщений (прокрутка вверх)
|
||||
|
||||
Логика:
|
||||
1. Проверяет наличие данных в Room
|
||||
2. При необходимости загружает из API
|
||||
3. Сохраняет в Room
|
||||
4. Paging читает из локальной базы
|
||||
|
||||
### 3. Background Sync (WorkManager)
|
||||
|
||||
#### MessageSyncWorker
|
||||
Обрабатывает отложенную синхронизацию:
|
||||
- Отправка новых сообщений (SYNCING)
|
||||
- Обновление отредактированных (isEditedLocally = true)
|
||||
- Удаление помеченных (isDeletedLocally = true)
|
||||
- Повтор при ошибках (FAILED)
|
||||
|
||||
Политика повторных попыток:
|
||||
- Экспоненциальная задержка
|
||||
- Максимум 3 попытки
|
||||
- Требуется подключение к сети
|
||||
|
||||
### 4. Real-time Updates (SignalR)
|
||||
|
||||
#### MessageSignalRHandler
|
||||
Обрабатывает события:
|
||||
- `new_message` - новое сообщение
|
||||
- `message_edited` - редактирование
|
||||
- `message_deleted` - удаление
|
||||
- `messages_read` - прочтение
|
||||
- `reaction_added/removed` - реакции
|
||||
|
||||
Все изменения сразу записываются в Room → UI обновляется через Flow
|
||||
|
||||
### 5. Repository
|
||||
|
||||
#### ChatRepositoryImpl
|
||||
Единая точка входа для ViewModel:
|
||||
- `getMessagesPaging()` - Paging 3 поток
|
||||
- `getMessagesFlow()` - простой Flow списка
|
||||
- `sendMessage()` - отправка с локальным сохранением
|
||||
- `deleteLocalMessage()` - локальное удаление
|
||||
- `editLocalMessage()` - локальное редактирование
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
Приоритет данных:
|
||||
1. **Сообщения в процессе отправки (SYNCING)** - локальные данные имеют приоритет
|
||||
2. **Сообщения в процессе редактирования** - локальные данные имеют приоритет
|
||||
3. **Все остальные случаи** - серверные данные имеют приоритет
|
||||
|
||||
## Схема работы
|
||||
|
||||
### Отправка сообщения
|
||||
```
|
||||
User → sendMessage() → Сохранение в Room (SYNCING) → UI показывает сообщение
|
||||
→ WorkManager планирует синхронизацию
|
||||
→ Отправка на сервер
|
||||
→ Обновление статуса (SYNCED)
|
||||
```
|
||||
|
||||
### Получение сообщений
|
||||
```
|
||||
UI ← getMessagesPaging() ← Room ← RemoteMediator ← API
|
||||
↑
|
||||
└─── SignalR обновления
|
||||
```
|
||||
|
||||
### Удаление сообщения
|
||||
```
|
||||
User → deleteLocalMessage() → Пометка (isDeletedLocally = true)
|
||||
→ WorkManager удаляет на сервере
|
||||
→ Удаление из Room
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
### Paging 3 в ViewModel
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val repository: ChatRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val messages: Flow<PagingData<Message>> =
|
||||
repository.getMessagesPaging(chatId)
|
||||
.cachedIn(viewModelScope)
|
||||
}
|
||||
```
|
||||
|
||||
### Офлайн отправка
|
||||
```kotlin
|
||||
// Сообщение сразу появится в UI
|
||||
val message = repository.sendMessage(
|
||||
chatId = chatId,
|
||||
content = "Hello"
|
||||
)
|
||||
|
||||
// Синхронизация произойдёт в фоне
|
||||
```
|
||||
|
||||
## Миграции
|
||||
|
||||
При обновлении схемы БД используется миграция `MIGRATION_1_2`:
|
||||
- Добавляет поля синхронизации
|
||||
- Сохраняет существующие данные
|
||||
- Устанавливает значения по умолчанию
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Юнит-тесты
|
||||
- MessageDao тесты
|
||||
- MessageRemoteMediator тесты
|
||||
- ChatRepositoryImpl тесты
|
||||
|
||||
### Интеграционные тесты
|
||||
- Синхронизация с сервером
|
||||
- Обработка конфликтов
|
||||
- WorkManager сценарии
|
||||
@@ -0,0 +1,178 @@
|
||||
# Сводка реализации системы кэширования
|
||||
|
||||
## 📁 Созданные файлы
|
||||
|
||||
### Data Layer
|
||||
1. **core/database/data/ChatDatabase.kt** (обновлён)
|
||||
- Добавлены поля синхронизации в MessageEntity
|
||||
- Расширен MessageDao методами для Paging и офлайн-операций
|
||||
- Добавлен SyncStatusConverter для Room
|
||||
|
||||
2. **core/database/data/Migrations.kt** (новый)
|
||||
- Миграция MIGRATION_1_2 для обновления схемы БД
|
||||
|
||||
3. **core/di/DatabaseModule.kt** (обновлён)
|
||||
- Добавлена миграция
|
||||
- Изменено имя БД на константу
|
||||
|
||||
4. **core/di/WorkManagerModule.kt** (новый)
|
||||
- DI модуль для WorkManager
|
||||
|
||||
### Paging 3
|
||||
5. **chats/data/paging/MessageRemoteMediator.kt** (новый)
|
||||
- RemoteMediator для загрузки данных из сети
|
||||
- Управление пагинацией (REFRESH, APPEND, PREPEND)
|
||||
- Сохранение в Room
|
||||
|
||||
6. **chats/data/paging/MessagePagingSource.kt** (новый)
|
||||
- PagingSource для чтения из Room
|
||||
|
||||
### Sync (WorkManager)
|
||||
7. **chats/data/sync/MessageSyncWorker.kt** (новый)
|
||||
- Worker для фоновой синхронизации
|
||||
- Обработка отправки, редактирования, удаления
|
||||
- Политика повторных попыток (exponential backoff)
|
||||
|
||||
### SignalR Integration
|
||||
8. **chats/data/signalr/MessageSignalRHandler.kt** (новый)
|
||||
- Обработчик SignalR событий
|
||||
- Обновление локального кэша в реальном времени
|
||||
- Разрешение конфликтов
|
||||
|
||||
### Repository
|
||||
9. **chats/domain/repository/ChatRepository.kt** (обновлён)
|
||||
- Добавлен метод getMessagesPaging()
|
||||
- Добавлены editLocalMessage()
|
||||
|
||||
10. **chats/data/repository/ChatRepositoryImpl.kt** (обновлён)
|
||||
- Полная реализация Offline-first
|
||||
- Интеграция Paging 3, SignalR, WorkManager
|
||||
- Conflict Resolution логика
|
||||
|
||||
### DI
|
||||
11. **chats/di/ChatModule.kt** (обновлён)
|
||||
- Регистрация MessageSignalRHandler
|
||||
- Обновлён ChatRepositoryImpl с новыми зависимостями
|
||||
|
||||
### Domain Models
|
||||
12. **chats/domain/model/ChatModels.kt** (обновлён)
|
||||
- Добавлен ChatMember
|
||||
- Добавлено поле members в Chat
|
||||
|
||||
### Application
|
||||
13. **app/src/main/kotlin/com/knot/messenger/MainApplication.kt** (обновлён)
|
||||
- Реализация Configuration.Provider для WorkManager
|
||||
- Интеграция Hilt WorkerFactory
|
||||
|
||||
### Документация
|
||||
14. **chats/ARCHITECTURE.md** (новый)
|
||||
- Описание архитектуры
|
||||
- Схема работы компонентов
|
||||
|
||||
15. **chats/USAGE_EXAMPLES.md** (новый)
|
||||
- Примеры использования
|
||||
- Best practices
|
||||
|
||||
## 🔧 Изменения в зависимостях (app/build.gradle.kts)
|
||||
|
||||
Добавлено:
|
||||
```kotlin
|
||||
// Paging 3
|
||||
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||
implementation("androidx.paging:paging-compose:3.2.1")
|
||||
|
||||
// WorkManager + Hilt
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
implementation("androidx.hilt:hilt-work:1.1.0")
|
||||
kapt("androidx.hilt:hilt-compiler:1.1.0")
|
||||
```
|
||||
|
||||
## 🏗️ Архитектурные решения
|
||||
|
||||
### 1. Offline-first подход
|
||||
- Все данные читаются из локальной Room базы
|
||||
- Сетевые запросы только для синхронизации
|
||||
- UI всегда работает с локальными данными
|
||||
|
||||
### 2. Paging 3 с RemoteMediator
|
||||
- Единый источник истины - Room
|
||||
- RemoteMediator управляет загрузкой из сети
|
||||
- Автоматическая инвалидация при изменениях
|
||||
|
||||
### 3. Conflict Resolution
|
||||
- **SYNCING/EDITING**: локальные данные имеют приоритет
|
||||
- **SYNCED**: серверные данные имеют приоритет
|
||||
- SignalR события применяются аккуратно
|
||||
|
||||
### 4. Background Sync
|
||||
- WorkManager для надёжной доставки
|
||||
- Exponential backoff при ошибках
|
||||
- Требуется NetzwerkType.CONNECTED
|
||||
|
||||
### 5. Real-time Updates
|
||||
- SignalR события → Room → Flow → UI
|
||||
- Автоматическое обновление UI
|
||||
- Минимальная задержка
|
||||
|
||||
## 📊 Схема потока данных
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ SignalR │────▶│ SignalR │────▶│ Room │
|
||||
│ (Server) │ │ Handler │ │ (SQLite) │
|
||||
└─────────────┘ └──────────────┘ └──────┬──────┘
|
||||
│
|
||||
┌─────────────┐ ┌──────────────┐ ┌──────▼──────┐
|
||||
│ API │◀───▶│ Remote │◀───▶│ Paging │
|
||||
│ (Retrofit) │ │ Mediator │ │ Source │
|
||||
└─────────────┘ └──────────────┘ └──────┬──────┘
|
||||
│
|
||||
┌─────────────┐ ┌──────────────┐ ┌──────▼──────┐
|
||||
│ WorkManager│◀───▶│ Repository │◀───▶│ UI │
|
||||
│ (Sync) │ │ │ │ (Flow) │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
## ✅ Checklist реализации
|
||||
|
||||
- [x] MessageEntity с полями синхронизации
|
||||
- [x] MessageDao с PagingSource методами
|
||||
- [x] MessageRemoteMediator для Paging 3
|
||||
- [x] MessageSyncWorker для WorkManager
|
||||
- [x] MessageSignalRHandler для real-time
|
||||
- [x] ChatRepositoryImpl с полной логикой
|
||||
- [x] DI модули обновлены
|
||||
- [x] Миграция БД
|
||||
- [x] Hilt Worker интеграция
|
||||
- [x] Документация
|
||||
|
||||
## 🚀 Следующие шаги
|
||||
|
||||
1. **Тестирование**
|
||||
- Юнит-тесты для MessageDao
|
||||
- Интеграционные тесты для Repository
|
||||
- UI тесты с Paging 3
|
||||
|
||||
2. **Мониторинг**
|
||||
- Логирование синхронизации
|
||||
- Метрики ошибок
|
||||
- Analytics офлайн-режима
|
||||
|
||||
3. **Оптимизация**
|
||||
- Индексы в БД для производительности
|
||||
- Кэширование изображений
|
||||
- Оптимизация запросов
|
||||
|
||||
4. **Улучшения**
|
||||
- Поиск по сообщениям
|
||||
- Избранные сообщения
|
||||
- Архивация чатов
|
||||
|
||||
## 🔍 Ключевые особенности
|
||||
|
||||
1. **Мгновенный UI** - сообщения появляются сразу
|
||||
2. **Надёжная синхронизация** - WorkManager гарантирует доставку
|
||||
3. **Real-time** - SignalR для мгновенных обновлений
|
||||
4. **Офлайн-работа** - полное функционирование без сети
|
||||
5. **Разрешение конфликтов** - умная логика приоритетов
|
||||
6. **Пагинация** - эффективная работа с большими чатами
|
||||
@@ -0,0 +1,296 @@
|
||||
# Руководство по интеграции
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Добавление зависимостей
|
||||
|
||||
В `app/build.gradle.kts` уже добавлены:
|
||||
```kotlin
|
||||
// Paging 3
|
||||
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||
implementation("androidx.paging:paging-compose:3.2.1")
|
||||
|
||||
// WorkManager + Hilt
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
implementation("androidx.hilt:hilt-work:1.1.0")
|
||||
kapt("androidx.hilt:hilt-compiler:1.1.0")
|
||||
```
|
||||
|
||||
### 2. Обновление Application класса
|
||||
|
||||
`MainApplication.kt` уже обновлён:
|
||||
```kotlin
|
||||
@HiltAndroidApp
|
||||
class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
|
||||
@Inject lateinit var workerFactory: WorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.setMinimumLoggingLevel(android.util.Log.INFO)
|
||||
.build()
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Миграция базы данных
|
||||
|
||||
База данных автоматически обновится при первом запуске благодаря `MIGRATION_1_2`.
|
||||
|
||||
## Использование в ViewModel
|
||||
|
||||
### Вариант 1: Paging 3 (рекомендуется для больших чатов)
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||
|
||||
val messages: Flow<PagingData<Message>> = repository
|
||||
.getMessagesPaging(chatId)
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
fun sendMessage(content: String) {
|
||||
viewModelScope.launch {
|
||||
repository.sendMessage(chatId, content, "text")
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessage(messageId: String) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteLocalMessage(messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Вариант 2: Простой Flow (для небольших чатов)
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||
|
||||
val messages: Flow<List<Message>> = repository
|
||||
.getMessagesFlow(chatId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
}
|
||||
```
|
||||
|
||||
## Использование в UI (Compose)
|
||||
|
||||
### С Paging 3
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
|
||||
val messages by viewModel.messages.collectAsLazyPagingItems()
|
||||
|
||||
LazyColumn(
|
||||
reverseLayout = true, // Сообщения снизу вверх
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(
|
||||
count = messages.itemCount,
|
||||
key = messages.key
|
||||
) { index ->
|
||||
messages[index]?.let { message ->
|
||||
MessageItem(message = message)
|
||||
}
|
||||
}
|
||||
|
||||
// Индикатор загрузки
|
||||
when {
|
||||
messages.loadState.refresh is LoadState.Loading -> {
|
||||
item { LoadingIndicator() }
|
||||
}
|
||||
messages.loadState.append is LoadState.Loading -> {
|
||||
item { LoadingIndicator() }
|
||||
}
|
||||
}
|
||||
|
||||
// Ошибки
|
||||
messages.loadState.append.let { loadState ->
|
||||
if (loadState is LoadState.Error) {
|
||||
item {
|
||||
Text("Ошибка: ${loadState.error.message}")
|
||||
Button(onClick = { messages.retry() }) {
|
||||
Text("Повторить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### С простым Flow
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
|
||||
LazyColumn(reverseLayout = true) {
|
||||
items(messages) { message ->
|
||||
MessageItem(message = message)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Отправка сообщения
|
||||
|
||||
```kotlin
|
||||
// Мгновенное отображение в UI
|
||||
viewModel.sendMessage("Привет!")
|
||||
|
||||
// Сообщение сохраняется локально и появляется в UI сразу
|
||||
// WorkManager отправит его на сервер в фоне
|
||||
```
|
||||
|
||||
## Удаление сообщения
|
||||
|
||||
```kotlin
|
||||
// Мягкое удаление (через WorkManager)
|
||||
viewModel.deleteMessage(messageId)
|
||||
|
||||
// Или немедленное удаление
|
||||
viewModelScope.launch {
|
||||
repository.deleteMessage(messageId, forEveryone = false)
|
||||
}
|
||||
```
|
||||
|
||||
## Редактирование сообщения
|
||||
|
||||
```kotlin
|
||||
viewModelScope.launch {
|
||||
repository.editLocalMessage(messageId, "Новый текст")
|
||||
}
|
||||
```
|
||||
|
||||
## Мониторинг синхронизации
|
||||
|
||||
```kotlin
|
||||
// В ViewModel
|
||||
val syncStatus: Flow<List<MessageEntity>> = messageDao
|
||||
.getPendingSyncMessagesFlow()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
|
||||
|
||||
// В UI
|
||||
val pendingMessages by syncStatus.collectAsState()
|
||||
if (pendingMessages.isNotEmpty()) {
|
||||
Text("${pendingMessages.size} сообщений ожидают отправки")
|
||||
}
|
||||
```
|
||||
|
||||
## Обработка офлайн-режима
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MessageItem(message: Message) {
|
||||
val isPending = message.id.startsWith("local_")
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = message.content ?: "",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Индикатор отправки
|
||||
if (isPending) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
// Статус прочтения
|
||||
Icon(
|
||||
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Проверка сборки
|
||||
|
||||
```bash
|
||||
cd client-mobile
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
## Возможные проблемы и решения
|
||||
|
||||
### 1. Ошибка: "WorkerFactory not found"
|
||||
**Решение:** Убедитесь, что `MainApplication` реализует `Configuration.Provider`
|
||||
|
||||
### 2. Ошибка: "Table messages has no column named syncStatus"
|
||||
**Решение:** Проверьте, что миграция `MIGRATION_1_2` добавлена в `DatabaseModule`
|
||||
|
||||
### 3. Paging не загружает данные
|
||||
**Решение:** Проверьте логи `MessageRemoteMediator` - возможны проблемы с API
|
||||
|
||||
### 4. Сообщения не синхронизируются
|
||||
**Решение:** Проверьте WorkManager логи и наличие сетевого подключения
|
||||
|
||||
### 5. SignalR не подключается
|
||||
**Решение:** Проверьте `ChatHubClient.connect()` - должен вызываться после авторизации
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Юнит-тесты
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `message saved locally should have SYNCING status`() = runTest {
|
||||
val message = MessageEntity(
|
||||
id = "test",
|
||||
chatId = "chat1",
|
||||
// ...
|
||||
syncStatus = SyncStatus.SYNCING
|
||||
)
|
||||
dao.insertMessage(message)
|
||||
|
||||
val saved = dao.getMessageById("test")
|
||||
assertEquals(SyncStatus.SYNCING, saved?.syncStatus)
|
||||
}
|
||||
```
|
||||
|
||||
### Интеграционные тесты
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `sending message should save locally and sync to server`() = runTest {
|
||||
// Arrange
|
||||
val repository = ChatRepositoryImpl(...)
|
||||
|
||||
// Act
|
||||
val message = repository.sendMessage("chat1", "Hello", "text")
|
||||
|
||||
// Assert
|
||||
assertTrue(message.id.startsWith("local_"))
|
||||
|
||||
// Wait for sync
|
||||
delay(5000)
|
||||
|
||||
val synced = dao.getMessageById(message.id)
|
||||
assertEquals(SyncStatus.SYNCED, synced?.syncStatus)
|
||||
}
|
||||
```
|
||||
|
||||
## Дополнительные ресурсы
|
||||
|
||||
- [Paging 3 Documentation](https://developer.android.com/topic/libraries/architecture/paging/v3-overview)
|
||||
- [WorkManager Documentation](https://developer.android.com/topic/libraries/architecture/workmanager)
|
||||
- [Room Documentation](https://developer.android.com/training/data-storage/room)
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) - детальное описание архитектуры
|
||||
- [USAGE_EXAMPLES.md](USAGE_EXAMPLES.md) - больше примеров использования
|
||||
@@ -0,0 +1,262 @@
|
||||
# Примеры использования системы кэширования
|
||||
|
||||
## 1. Paging 3 в ViewModel
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||
|
||||
// Paging 3 поток для UI
|
||||
val messages: Flow<PagingData<Message>> = repository
|
||||
.getMessagesPaging(chatId)
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
// Простой Flow для небольших чатов
|
||||
val messagesList: Flow<List<Message>> = repository
|
||||
.getMessagesFlow(chatId)
|
||||
}
|
||||
```
|
||||
|
||||
## 2. UI с Paging 3 (Jetpack Compose)
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
|
||||
val messages by viewModel.messages.collectAsLazyPagingItems()
|
||||
|
||||
LazyColumn {
|
||||
items(
|
||||
count = messages.itemCount,
|
||||
key = messages.key
|
||||
) { index ->
|
||||
val message = messages[index]
|
||||
message?.let {
|
||||
MessageItem(message = it)
|
||||
}
|
||||
}
|
||||
|
||||
// Индикаторы загрузки
|
||||
when {
|
||||
messages.loadState.refresh is LoadState.Loading -> {
|
||||
item { LoadingIndicator() }
|
||||
}
|
||||
messages.loadState.append is LoadState.Loading -> {
|
||||
item { LoadingIndicator() }
|
||||
}
|
||||
messages.loadState.prepend is LoadState.Loading -> {
|
||||
item { LoadingIndicator() }
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка ошибок
|
||||
messages.loadState.append.let { loadState ->
|
||||
if (loadState is LoadState.Error) {
|
||||
item {
|
||||
ErrorView(
|
||||
message = loadState.error.message,
|
||||
onRetry = { messages.retry() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Отправка сообщения (Offline-first)
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val repository: ChatRepository
|
||||
) : ViewModel() {
|
||||
|
||||
fun sendMessage(chatId: String, content: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Сообщение сразу сохраняется локально и появляется в UI
|
||||
val message = repository.sendMessage(
|
||||
chatId = chatId,
|
||||
content = content,
|
||||
type = "text"
|
||||
)
|
||||
|
||||
// UI обновляется мгновенно через Flow
|
||||
Log.d("ChatViewModel", "Message saved locally: ${message.id}")
|
||||
|
||||
// Синхронизация с сервером произойдёт в фоне
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Failed to send message", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Удаление сообщения
|
||||
|
||||
```kotlin
|
||||
fun deleteMessage(messageId: String) {
|
||||
viewModelScope.launch {
|
||||
// Локальное удаление (сообщение скрывается из UI)
|
||||
repository.deleteLocalMessage(messageId)
|
||||
|
||||
// WorkManager удалит сообщение на сервере в фоне
|
||||
// При получении подтверждения - сообщение удаляется из БД
|
||||
}
|
||||
}
|
||||
|
||||
// Или немедленное удаление (если онлайн)
|
||||
fun deleteMessageImmediately(messageId: String, forEveryone: Boolean) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteMessage(messageId, forEveryone)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Редактирование сообщения
|
||||
|
||||
```kotlin
|
||||
fun editMessage(messageId: String, newContent: String) {
|
||||
viewModelScope.launch {
|
||||
// Локальное редактирование
|
||||
repository.editLocalMessage(messageId, newContent)
|
||||
|
||||
// UI обновляется мгновенно
|
||||
// WorkManager отправит изменения на сервер в фоне
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Отслеживание статуса синхронизации
|
||||
|
||||
```kotlin
|
||||
// Наблюдение за сообщениями, ожидающими синхронизации
|
||||
fun observePendingMessages() {
|
||||
viewModelScope.launch {
|
||||
messageDao.getPendingSyncMessagesFlow().collect { messages ->
|
||||
if (messages.isNotEmpty()) {
|
||||
Log.d("Sync", "${messages.size} messages pending sync")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка статуса конкретного сообщения
|
||||
fun isMessageSynced(messageId: String): Boolean {
|
||||
return runBlocking {
|
||||
val message = messageDao.getMessageById(messageId)
|
||||
message?.syncStatus == SyncStatus.SYNCED
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Обработка ошибок синхронизации
|
||||
|
||||
```kotlin
|
||||
fun retryFailedMessages() {
|
||||
viewModelScope.launch {
|
||||
val failedMessages = messageDao.getFailedSyncMessages()
|
||||
|
||||
failedMessages.forEach { message ->
|
||||
when {
|
||||
message.isDeletedLocally -> {
|
||||
// Повторить удаление
|
||||
MessageSyncWorker.scheduleSync(context)
|
||||
}
|
||||
message.isEditedLocally -> {
|
||||
// Повторить редактирование
|
||||
MessageSyncWorker.scheduleSync(context)
|
||||
}
|
||||
else -> {
|
||||
// Повторить отправку
|
||||
MessageSyncWorker.scheduleSync(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Прочтение сообщений
|
||||
|
||||
```kotlin
|
||||
fun markAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||
viewModelScope.launch {
|
||||
// Отправляем статус прочтения через SignalR
|
||||
repository.markMessagesAsRead(chatId, lastMessageId, lastReadSequenceId)
|
||||
|
||||
// Локальная база обновляется автоматически
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Real-time обновления
|
||||
|
||||
SignalR события обрабатываются автоматически:
|
||||
- Новые сообщения появляются в UI мгновенно
|
||||
- Редактирования/удаления синхронизируются
|
||||
- Статусы прочтения обновляются
|
||||
- Реакции отображаются в реальном времени
|
||||
|
||||
```kotlin
|
||||
// Обработчик SignalR уже интегрирован в ChatRepositoryImpl
|
||||
// Дополнительные действия можно добавить в MessageSignalRHandler
|
||||
```
|
||||
|
||||
## 10. Кэширование в ViewModel
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||
|
||||
// Кэшируем PagingData в scope ViewModel
|
||||
val messages: Flow<PagingData<Message>> = repository
|
||||
.getMessagesPaging(chatId)
|
||||
.cachedIn(viewModelScope) // Важно для сохранения состояния
|
||||
|
||||
// При повороте экрана пагинация сохраняется
|
||||
}
|
||||
```
|
||||
|
||||
## Рекомендации
|
||||
|
||||
### 1. Выбор между Paging 3 и Flow
|
||||
- **Paging 3** - для больших чатов (>100 сообщений)
|
||||
- **Flow<List>** - для небольших чатов или когда нужна вся история сразу
|
||||
|
||||
### 2. Обработка офлайн-режима
|
||||
```kotlin
|
||||
// UI должен показывать статус сообщения
|
||||
@Composable
|
||||
fun MessageItem(message: Message) {
|
||||
val isPending = message.id.startsWith("local_")
|
||||
|
||||
Row {
|
||||
Text(text = message.content)
|
||||
if (isPending) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Конфликты данных
|
||||
- Локальные изменения имеют приоритет во время отправки
|
||||
- Серверные данные перезаписывают локальные после SYNCED
|
||||
- SignalR события всегда применяются к актуальным данным
|
||||
|
||||
### 4. Производительность
|
||||
- Используйте `cachedIn(viewModelScope)` для PagingData
|
||||
- Избегайте частых вызовов `getMessages()` из сети
|
||||
- Позволяйте WorkManager управлять синхронизацией
|
||||
@@ -0,0 +1,80 @@
|
||||
package chats.data.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import core.database.data.MessageDao
|
||||
import core.database.data.MessageEntity
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* PagingSource для загрузки сообщений из локальной базы Room
|
||||
* Данные сортируются по sequenceId DESC (новые сообщения первыми)
|
||||
*/
|
||||
class MessagePagingSource(
|
||||
private val dao: MessageDao,
|
||||
private val chatId: String
|
||||
) : PagingSource<Int, MessageEntity>() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessagePagingSource"
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, MessageEntity>): Int? {
|
||||
Log.d(TAG, "getRefreshKey() called")
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
val anchorPage = state.closestPageToPosition(anchorPosition)
|
||||
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> {
|
||||
Log.d(TAG, ">>> load() called with: position=${params.key}, loadSize=${params.loadSize}")
|
||||
return try {
|
||||
// При DESC порядке:
|
||||
// - key = sequenceId последнего сообщения в текущей странице
|
||||
// - APPEND = загружаем сообщения С МЕНЬШИМ sequenceId (старые)
|
||||
// - PREPEND = загружаем сообщения С БОЛЬШИМ sequenceId (новые)
|
||||
|
||||
val position = params.key // sequenceId граничного сообщения
|
||||
val loadSize = params.loadSize
|
||||
|
||||
Log.d(TAG, "Loading: chatId=$chatId, position=$position, loadSize=$loadSize")
|
||||
|
||||
val messages = if (position == null) {
|
||||
// Первая загрузка - получаем самые последние сообщения (включая maxSeq)
|
||||
val maxSeq = dao.getMaxSequenceId(chatId)
|
||||
Log.d(TAG, "First load: maxSeq=$maxSeq")
|
||||
if (maxSeq == null) {
|
||||
Log.d(TAG, "No messages in database")
|
||||
emptyList()
|
||||
} else {
|
||||
// Используем <= чтобы включить самое последнее сообщение
|
||||
dao.getMessagesUpToAndIncluding(chatId, maxSeq, loadSize)
|
||||
}
|
||||
} else {
|
||||
// Загружаем сообщения с sequenceId < position (старые)
|
||||
Log.d(TAG, "Append: loading messages before sequenceId=$position")
|
||||
dao.getMessagesBefore(chatId, position, loadSize)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Loaded ${messages.size} messages, first=${messages.firstOrNull()?.sequenceId}, last=${messages.lastOrNull()?.sequenceId}")
|
||||
|
||||
// Для DESC порядка:
|
||||
// - prevKey = максимальный sequenceId в странице (для загрузки новых)
|
||||
// - nextKey = минимальный sequenceId в странице (для загрузки старых)
|
||||
val prevKey = messages.firstOrNull()?.sequenceId?.plus(1)
|
||||
val nextKey = messages.lastOrNull()?.sequenceId?.minus(1)
|
||||
|
||||
Log.d(TAG, "prevKey=$prevKey, nextKey=$nextKey")
|
||||
|
||||
LoadResult.Page(
|
||||
data = messages,
|
||||
prevKey = prevKey,
|
||||
nextKey = nextKey
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading messages", e)
|
||||
LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package chats.data.paging
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.*
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import chats.domain.model.MediaType
|
||||
import com.google.gson.Gson
|
||||
import core.database.data.ChatDatabase
|
||||
import core.database.data.MessageDao
|
||||
import core.database.data.MessageEntity
|
||||
import core.database.data.SyncStatus
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
|
||||
/**
|
||||
* RemoteMediator для Paging 3
|
||||
* Управляет загрузкой сообщений из сети и кэшированием в Room
|
||||
*
|
||||
* Логика работы:
|
||||
* 1. При первой загрузке (REFRESH) - загружаем последние сообщения
|
||||
* 2. При прокрутке вниз (APPEND) - загружаем более старые сообщения
|
||||
* 3. При прокрутке вверх (PREPEND) - загружаем более новые сообщения
|
||||
* 4. Данные сохраняются в Room, Paging читает из базы
|
||||
*/
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class MessageRemoteMediator(
|
||||
private val chatId: String,
|
||||
private val api: ChatApi,
|
||||
private val database: ChatDatabase,
|
||||
private val dao: core.database.data.MessageDao,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val tokenManager: TokenManager
|
||||
) : RemoteMediator<Int, MessageEntity>() {
|
||||
|
||||
private val gson = Gson()
|
||||
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
|
||||
/**
|
||||
* Состояние пагинации
|
||||
*/
|
||||
data class MessageRemoteMediatorState(
|
||||
val lastSequenceId: Int?,
|
||||
val firstSequenceId: Int?
|
||||
)
|
||||
|
||||
override suspend fun initialize(): InitializeAction {
|
||||
// Всегда запускаем refresh для проверки кэша
|
||||
return InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, MessageEntity>
|
||||
): MediatorResult {
|
||||
Log.d(TAG, ">>> load() loadType=$loadType")
|
||||
return try {
|
||||
// Проверяем наличие данных в локальной БД
|
||||
val localMinSeq = dao.getMinSequenceId(chatId)
|
||||
val localMaxSeq = dao.getMaxSequenceId(chatId)
|
||||
Log.d(TAG, "Local DB: minSeq=$localMinSeq, maxSeq=$localMaxSeq")
|
||||
|
||||
when (loadType) {
|
||||
LoadType.REFRESH -> {
|
||||
// Если есть локальные данные - не делаем API запрос
|
||||
if (localMaxSeq != null) {
|
||||
Log.d(TAG, "REFRESH: Using cached data (maxSeq=$localMaxSeq)")
|
||||
return MediatorResult.Success(endOfPaginationReached = false)
|
||||
}
|
||||
// Нет данных - загружаем последние сообщения
|
||||
Log.d(TAG, "REFRESH: No cached data, fetching from API")
|
||||
}
|
||||
LoadType.APPEND -> {
|
||||
Log.d(TAG, "APPEND: Loading older messages")
|
||||
}
|
||||
LoadType.PREPEND -> {
|
||||
Log.d(TAG, "PREPEND: Loading newer messages")
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем pivot для API запроса
|
||||
val pivotValue: Long? = when (loadType) {
|
||||
LoadType.REFRESH -> null // Последние сообщения
|
||||
LoadType.APPEND -> {
|
||||
// Старые сообщения - берём минимальный sequenceId из текущей страницы
|
||||
(state.pages.lastOrNull()?.data?.lastOrNull()?.sequenceId
|
||||
?: localMinSeq)?.toLong()?.minus(1)
|
||||
}
|
||||
LoadType.PREPEND -> {
|
||||
// Новые сообщения - берём максимальный sequenceId
|
||||
(state.pages.firstOrNull()?.data?.firstOrNull()?.sequenceId
|
||||
?: localMaxSeq)?.toLong()?.plus(1)
|
||||
}
|
||||
}
|
||||
|
||||
val limit = when (loadType) {
|
||||
LoadType.REFRESH -> state.config.initialLoadSize
|
||||
else -> state.config.pageSize
|
||||
}
|
||||
|
||||
Log.d(TAG, "API call: chatId=$chatId, pivot=$pivotValue, limit=$limit")
|
||||
|
||||
// Выполняем запрос к API
|
||||
val messages = try {
|
||||
api.getMessages(
|
||||
chatId = chatId,
|
||||
cursor = null,
|
||||
pivot = pivotValue,
|
||||
limit = limit
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "API call failed - offline?", e)
|
||||
// При ошибке сети и наличии локальных данных - успех
|
||||
if ((localMinSeq != null || localMaxSeq != null)) {
|
||||
Log.d(TAG, "Offline mode: returning success with cached data")
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received ${messages.size} messages from API")
|
||||
|
||||
if (messages.isEmpty()) {
|
||||
Log.d(TAG, "End of pagination - no more messages")
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
|
||||
// Сохраняем в базу
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val entities = messages.map { dto ->
|
||||
dto.toEntity(currentUserId, baseUrl, gson)
|
||||
}
|
||||
Log.d(TAG, "Saving ${entities.size} messages to DB, first seq=${entities.firstOrNull()?.sequenceId}, last seq=${entities.lastOrNull()?.sequenceId}")
|
||||
dao.upsertMessages(entities)
|
||||
|
||||
// Проверяем что данные сохранились
|
||||
val savedCount = dao.getMessagesCount(chatId)
|
||||
Log.d(TAG, "Total messages in DB after save: $savedCount")
|
||||
|
||||
val endOfPaginationReached = messages.size < limit
|
||||
Log.d(TAG, "endOfPaginationReached=$endOfPaginationReached")
|
||||
MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading messages", e)
|
||||
MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessageRemoteMediator"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function для преобразования DTO в Entity
|
||||
*/
|
||||
private fun MessageDto.toEntity(
|
||||
currentUserId: String,
|
||||
baseUrl: String,
|
||||
gson: Gson
|
||||
): MessageEntity {
|
||||
// Определяем тип медиа
|
||||
val mediaType = when {
|
||||
media.isEmpty() -> MediaType.TEXT.name
|
||||
media.any { it.type.startsWith("image") || it.url.endsWith(".gif") } -> MediaType.GIF.name
|
||||
media.any { it.type.startsWith("image") } -> MediaType.IMAGE.name
|
||||
media.any { it.type.startsWith("video") } -> MediaType.VIDEO.name
|
||||
media.any { it.type.startsWith("audio") } -> MediaType.AUDIO.name
|
||||
else -> MediaType.FILE.name
|
||||
}
|
||||
|
||||
// Сериализуем медиа в JSON
|
||||
val mediaJson = gson.toJson(media)
|
||||
|
||||
// Сериализуем реакции в JSON
|
||||
val reactionsMap = reactions?.associate { it.emoji to it.count } ?: emptyMap()
|
||||
val reactionsJson = gson.toJson(reactionsMap)
|
||||
|
||||
// Определяем, прочитано ли сообщение текущим пользователем
|
||||
val isRead = senderId == currentUserId
|
||||
|
||||
return MessageEntity(
|
||||
id = id,
|
||||
chatId = chatId ?: "",
|
||||
senderId = senderId ?: "",
|
||||
senderName = sender?.displayName ?: sender?.username ?: "Unknown",
|
||||
senderAvatar = sender?.avatarUrl,
|
||||
content = content,
|
||||
sequenceId = sequenceId ?: 0,
|
||||
createdAt = createdAt ?: "",
|
||||
mediaType = mediaType,
|
||||
mediaJson = mediaJson,
|
||||
reactionsJson = reactionsJson,
|
||||
isRead = isRead,
|
||||
replyToId = replyTo?.id,
|
||||
syncStatus = SyncStatus.SYNCED,
|
||||
isDeletedLocally = false,
|
||||
isEditedLocally = false,
|
||||
editedContent = null,
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
@@ -1,113 +1,211 @@
|
||||
package chats.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.paging.*
|
||||
import chats.data.paging.MessageRemoteMediator
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.api.SendMessageRequest
|
||||
import chats.data.remote.dto.ChatDto
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import chats.data.remote.dto.MediaItemDto
|
||||
import chats.data.remote.dto.ReactionDto
|
||||
import chats.data.signalr.MessageSignalRHandler
|
||||
import chats.data.sync.MessageSyncWorker
|
||||
import chats.domain.model.Chat
|
||||
import chats.domain.model.Message
|
||||
import chats.domain.model.MediaType
|
||||
import chats.domain.repository.ChatRepository
|
||||
import core.database.data.ChatDatabase
|
||||
import core.database.data.MessageDao
|
||||
import core.database.data.MessageEntity
|
||||
import core.database.data.SyncStatus
|
||||
import core.network.ServerConfig
|
||||
import chats.data.remote.signalr.ReadMessagesRequest
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import core.security.TokenManager
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Основная реализация репозитория чатов
|
||||
*
|
||||
* Архитектура Offline-first:
|
||||
* 1. Все данные читаются из локальной базы Room
|
||||
* 2. При изменении данных - сначала запись в БД, потом синхронизация с сервером
|
||||
* 3. SignalR обновления сразу записываются в БД
|
||||
* 4. WorkManager обрабатывает фоновую синхронизацию
|
||||
*
|
||||
* Conflict Resolution:
|
||||
* - Серверные данные имеют приоритет над локальными
|
||||
* - Исключение: сообщения в процессе отправки (SYNCING) или редактирования
|
||||
*/
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
@Singleton
|
||||
class ChatRepositoryImpl @Inject constructor(
|
||||
private val api: ChatApi,
|
||||
private val tokenManager: TokenManager,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val messageDao: core.database.data.MessageDao,
|
||||
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
||||
private val dao: MessageDao,
|
||||
private val database: ChatDatabase,
|
||||
private val hubClient: ChatHubClient,
|
||||
private val signalRHandler: MessageSignalRHandler,
|
||||
private val context: Context
|
||||
) : ChatRepository {
|
||||
private val gson = com.google.gson.Gson()
|
||||
|
||||
private val gson = Gson()
|
||||
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
|
||||
init {
|
||||
signalRHandler.startListening()
|
||||
Log.d(TAG, "ChatRepositoryImpl initialized")
|
||||
}
|
||||
|
||||
override suspend fun getChats(): List<Chat> {
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
|
||||
}
|
||||
|
||||
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
|
||||
override fun getMessagesPaging(chatId: String): Flow<PagingData<Message>> {
|
||||
val pagingConfig = PagingConfig(
|
||||
pageSize = 30,
|
||||
prefetchDistance = 10,
|
||||
initialLoadSize = 50,
|
||||
enablePlaceholders = false
|
||||
)
|
||||
|
||||
return Pager(
|
||||
config = pagingConfig,
|
||||
pagingSourceFactory = { dao.getMessagesPagingSource(chatId) },
|
||||
remoteMediator = MessageRemoteMediator(
|
||||
chatId = chatId,
|
||||
api = api,
|
||||
database = database,
|
||||
dao = dao,
|
||||
serverConfig = serverConfig,
|
||||
tokenManager = tokenManager
|
||||
)
|
||||
).flow.map { pagingData ->
|
||||
pagingData.map { entity -> entity.toDomain(baseUrl, gson) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMessagesFlow(chatId: String): Flow<List<Message>> {
|
||||
return dao.getMessages(chatId).map { entities ->
|
||||
entities.map { it.toDomain(baseUrl, gson) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
override suspend fun getMessages(
|
||||
chatId: String, cursor: String?, pivot: Long?, limit: Int?
|
||||
): List<Message> {
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
return try {
|
||||
android.util.Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
|
||||
Log.d(TAG, "Fetching messages from API: chatId=$chatId")
|
||||
val messages = api.getMessages(chatId, cursor = cursor, limit = limit)
|
||||
|
||||
if (messages.isNotEmpty()) {
|
||||
android.util.Log.d("ChatRepo", "Received ${messages.size} messages. TopSeq: ${messages.first().sequenceId}, BottomSeq: ${messages.last().sequenceId}")
|
||||
val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) }
|
||||
dao.upsertMessages(entities)
|
||||
Log.d(TAG, "Cached ${entities.size} messages")
|
||||
}
|
||||
|
||||
// Мапим в доменные модели. По умолчанию считаем прочитанными,
|
||||
// так как unreadCount нам тут не критичен для истории.
|
||||
messages.map { msg ->
|
||||
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
|
||||
}
|
||||
messages.map { msg -> msg.toDomain(currentUserId, baseUrl).copy(isRead = true) }
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatRepo", "Fetch messages failed", e)
|
||||
Log.e(TAG, "Fetch messages failed", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(
|
||||
chatId: String,
|
||||
content: String?,
|
||||
type: String,
|
||||
chatId: String, content: String?, type: String,
|
||||
attachments: List<chats.data.remote.api.AttachmentRequest>?,
|
||||
replyToId: String?,
|
||||
forwardedFromId: String?
|
||||
replyToId: String?, forwardedFromId: String?
|
||||
): Message {
|
||||
val request = SendMessageRequest(
|
||||
content = content,
|
||||
type = type,
|
||||
attachments = attachments,
|
||||
replyToId = replyToId,
|
||||
forwardedFromId = forwardedFromId
|
||||
)
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
val userId = tokenManager.getUserId() ?: ""
|
||||
return api.sendMessage(chatId, request).toDomain(userId, baseUrl)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
val localId = "local_${currentTime}_${chatId}"
|
||||
val localMessage = MessageEntity(
|
||||
id = localId, chatId = chatId, senderId = userId,
|
||||
senderName = "Вы", senderAvatar = null, content = content,
|
||||
sequenceId = 0,
|
||||
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
|
||||
mediaType = type.uppercase(),
|
||||
mediaJson = "[]",
|
||||
reactionsJson = "{}",
|
||||
isRead = true, replyToId = replyToId,
|
||||
syncStatus = SyncStatus.SYNCING,
|
||||
isDeletedLocally = false, isEditedLocally = false,
|
||||
editedContent = null, lastUpdated = currentTime
|
||||
)
|
||||
|
||||
dao.insertMessage(localMessage)
|
||||
Log.d(TAG, "Saved local message: $localId")
|
||||
MessageSyncWorker.scheduleSync(context)
|
||||
|
||||
// Создаём доменную модель вручную для локального сообщения
|
||||
return Message(
|
||||
id = localId,
|
||||
chatId = chatId,
|
||||
senderId = userId,
|
||||
senderName = "Вы",
|
||||
senderAvatar = null,
|
||||
content = content,
|
||||
sequenceId = 0,
|
||||
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
|
||||
media = emptyList(),
|
||||
mediaType = when (type) {
|
||||
"image" -> MediaType.IMAGE
|
||||
"video" -> MediaType.VIDEO
|
||||
"audio" -> MediaType.AUDIO
|
||||
"gif" -> MediaType.GIF
|
||||
else -> MediaType.TEXT
|
||||
},
|
||||
reactions = emptyMap(),
|
||||
isRead = true,
|
||||
isPinned = false,
|
||||
isForwarded = false,
|
||||
forwardedFromName = null,
|
||||
replyTo = null
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun addReaction(messageId: String, emoji: String) {
|
||||
api.addReaction(messageId, emoji)
|
||||
hubClient.addReaction(messageId, "", emoji)
|
||||
}
|
||||
|
||||
override suspend fun sendTypingStatus(chatId: String) {
|
||||
api.sendTypingStatus(chatId)
|
||||
hubClient.sendTypingIndicator(chatId)
|
||||
}
|
||||
|
||||
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||
try {
|
||||
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId. Caller stack: ${android.util.Log.getStackTraceString(Throwable())}")
|
||||
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
||||
hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
|
||||
Log.e(TAG, "Error marking messages as read", e)
|
||||
}
|
||||
// Обновляем локальную БД
|
||||
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
|
||||
dao.markMessagesAsRead(chatId, lastReadSequenceId)
|
||||
}
|
||||
|
||||
override suspend fun saveMessage(message: Message) {
|
||||
android.util.Log.d("ChatRepo", "DB cache disabled, skipping save: ${message.id}")
|
||||
dao.insertMessage(message.toEntity(gson))
|
||||
}
|
||||
|
||||
override suspend fun deleteLocalMessage(messageId: String) {
|
||||
messageDao.deleteMessage(messageId)
|
||||
dao.markAsDeletedLocally(messageId)
|
||||
MessageSyncWorker.scheduleSync(context)
|
||||
}
|
||||
|
||||
override suspend fun editLocalMessage(messageId: String, newContent: String) {
|
||||
dao.markAsEditedLocally(messageId, newContent)
|
||||
MessageSyncWorker.scheduleSync(context)
|
||||
}
|
||||
|
||||
override suspend fun uploadMedia(file: java.io.File): String {
|
||||
@@ -124,34 +222,36 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
return api.uploadFile(body).url
|
||||
}
|
||||
|
||||
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> {
|
||||
return api.getTrendingGifs(page).data.data
|
||||
}
|
||||
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> =
|
||||
api.getTrendingGifs(page).data.data
|
||||
|
||||
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
|
||||
return api.searchGifs(query, page).data.data
|
||||
}
|
||||
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> =
|
||||
api.searchGifs(query, page).data.data
|
||||
|
||||
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
|
||||
return api.getGifCategories().data.categories
|
||||
}
|
||||
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> =
|
||||
api.getGifCategories().data.categories
|
||||
|
||||
override suspend fun createPersonalChat(userId: String): Chat {
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
|
||||
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
|
||||
}
|
||||
|
||||
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
|
||||
api.deleteMessage(messageId, forEveryone)
|
||||
messageDao.deleteMessage(messageId)
|
||||
dao.deleteMessage(messageId)
|
||||
}
|
||||
|
||||
override suspend fun editMessage(messageId: String, content: String): Message {
|
||||
val request = SendMessageRequest(content = content)
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
|
||||
val response = api.editMessage(messageId, request)
|
||||
dao.insertMessage(response.toEntity(baseUrl, currentUserId, gson))
|
||||
return response.toDomain(currentUserId, baseUrl)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChatRepositoryImpl"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package chats.data.signalr
|
||||
|
||||
import android.util.Log
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.remote.signalr.MessagesReadEvent
|
||||
import chats.data.remote.signalr.ReactionEvent
|
||||
import chats.data.repository.toEntity
|
||||
import com.google.gson.Gson
|
||||
import core.database.data.MessageDao
|
||||
import core.database.data.MessageEntity
|
||||
import core.database.data.SyncStatus
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Обработчик SignalR событий для обновления локального кэша
|
||||
*
|
||||
* Обрабатывает события:
|
||||
* - new_message: новое сообщение в чате
|
||||
* - message_edited: сообщение отредактировано
|
||||
* - message_deleted: сообщение удалено
|
||||
* - messages_read: сообщения прочитаны
|
||||
* - reaction_added/removed: реакция добавлена/удалена
|
||||
*
|
||||
* Все изменения сразу записываются в Room, UI обновляется через Flow
|
||||
*/
|
||||
@Singleton
|
||||
class MessageSignalRHandler @Inject constructor(
|
||||
private val hubClient: ChatHubClient,
|
||||
private val dao: MessageDao,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val tokenManager: TokenManager
|
||||
) {
|
||||
private val gson = Gson()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
|
||||
private var isListening = false
|
||||
|
||||
/**
|
||||
* Начинает прослушивание SignalR событий
|
||||
* Вызывается один раз при инициализации приложения
|
||||
*/
|
||||
fun startListening() {
|
||||
if (isListening) {
|
||||
Log.w(TAG, "Already listening to SignalR events")
|
||||
return
|
||||
}
|
||||
|
||||
isListening = true
|
||||
Log.d(TAG, "Started listening to SignalR events")
|
||||
|
||||
scope.launch {
|
||||
hubClient.events.collectLatest { event ->
|
||||
handleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Останавливает прослушивание событий
|
||||
*/
|
||||
fun stopListening() {
|
||||
isListening = false
|
||||
scope.cancel()
|
||||
Log.d(TAG, "Stopped listening to SignalR events")
|
||||
}
|
||||
|
||||
private suspend fun handleEvent(event: ChatEvent) {
|
||||
try {
|
||||
when (event) {
|
||||
is ChatEvent.NewMessage -> handleNewMessage(event.message)
|
||||
is ChatEvent.MessageEdited -> handleMessageEdited(event.messageId, event.chatId, event.content)
|
||||
is ChatEvent.MessageDeleted -> handleMessageDeleted(event.messageId, event.chatId)
|
||||
is ChatEvent.MessagesRead -> handleMessagesRead(event.chatId, event.lastReadSequenceId)
|
||||
is ChatEvent.ReactionUpdated -> handleReactionUpdated(
|
||||
event.messageId,
|
||||
event.emoji,
|
||||
event.isRemoved
|
||||
)
|
||||
else -> Log.d(TAG, "Unhandled event: ${event::class.simpleName}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling SignalR event: ${event::class.simpleName}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleNewMessage(dto: MessageDto) {
|
||||
Log.d(TAG, "New message received: ${dto.id} in chat ${dto.chatId}")
|
||||
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val entity = dto.toEntity(baseUrl, currentUserId, gson).copy(
|
||||
syncStatus = SyncStatus.SYNCED
|
||||
)
|
||||
|
||||
val existing = dao.getMessageById(dto.id)
|
||||
if (existing != null && existing.syncStatus == SyncStatus.SYNCING) {
|
||||
val syncedEntity = entity.copy(
|
||||
syncStatus = SyncStatus.SYNCED,
|
||||
isDeletedLocally = false,
|
||||
isEditedLocally = false,
|
||||
editedContent = null
|
||||
)
|
||||
dao.insertMessage(syncedEntity)
|
||||
Log.d(TAG, "Merged local message with server response: ${dto.id}")
|
||||
} else {
|
||||
dao.insertMessage(entity)
|
||||
Log.d(TAG, "Saved new message: ${dto.id}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleMessageEdited(messageId: String, chatId: String, content: String) {
|
||||
Log.d(TAG, "Message edited: $messageId")
|
||||
|
||||
val existing = dao.getMessageById(messageId)
|
||||
if (existing != null) {
|
||||
if (existing.isEditedLocally) {
|
||||
Log.d(TAG, "Message is being edited locally, skipping server update: $messageId")
|
||||
return
|
||||
}
|
||||
|
||||
val updated = existing.copy(
|
||||
content = content,
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
)
|
||||
dao.insertMessage(updated)
|
||||
Log.d(TAG, "Updated edited message: $messageId")
|
||||
} else {
|
||||
Log.w(TAG, "Edited message not found in cache: $messageId")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleMessageDeleted(messageId: String, chatId: String) {
|
||||
Log.d(TAG, "Message deleted: $messageId")
|
||||
|
||||
val existing = dao.getMessageById(messageId)
|
||||
if (existing?.isDeletedLocally == true) {
|
||||
dao.deleteMessage(messageId)
|
||||
Log.d(TAG, "Completed local deletion: $messageId")
|
||||
} else {
|
||||
dao.deleteMessage(messageId)
|
||||
Log.d(TAG, "Removed deleted message from cache: $messageId")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleMessagesRead(chatId: String, lastReadSequenceId: Int?) {
|
||||
if (lastReadSequenceId == null) {
|
||||
Log.w(TAG, "Messages read event with null sequenceId")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Messages read in chat $chatId up to sequenceId $lastReadSequenceId")
|
||||
dao.markMessagesAsRead(chatId, lastReadSequenceId)
|
||||
}
|
||||
|
||||
private suspend fun handleReactionUpdated(messageId: String, emoji: String, isRemoved: Boolean) {
|
||||
Log.d(TAG, "Reaction ${if (isRemoved) "removed" else "added"}: $emoji on message $messageId")
|
||||
|
||||
val existing = dao.getMessageById(messageId)
|
||||
if (existing != null) {
|
||||
val reactions = gson.fromJson<Map<String, Int>>(existing.reactionsJson, Map::class.java) ?: emptyMap()
|
||||
val updatedReactions = reactions.toMutableMap()
|
||||
|
||||
if (isRemoved) {
|
||||
val currentCount = updatedReactions[emoji] ?: 0
|
||||
if (currentCount > 1) {
|
||||
updatedReactions[emoji] = currentCount - 1
|
||||
} else {
|
||||
updatedReactions.remove(emoji)
|
||||
}
|
||||
} else {
|
||||
updatedReactions[emoji] = (updatedReactions[emoji] ?: 0) + 1
|
||||
}
|
||||
|
||||
val updated = existing.copy(
|
||||
reactionsJson = gson.toJson(updatedReactions),
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
)
|
||||
dao.insertMessage(updated)
|
||||
Log.d(TAG, "Updated reactions for message: $messageId")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessageSignalRHandler"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package chats.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.*
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.api.SendMessageRequest
|
||||
import core.database.data.MessageDao
|
||||
import core.database.data.MessageEntity
|
||||
import core.database.data.SyncStatus
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import com.google.gson.Gson
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* WorkManager Worker для фоновой синхронизации сообщений
|
||||
*
|
||||
* Обрабатывает:
|
||||
* 1. Отправку новых сообщений (SYNCING)
|
||||
* 2. Обновление отредактированных сообщений (isEditedLocally = true)
|
||||
* 3. Удаление сообщений (isDeletedLocally = true)
|
||||
* 4. Повторную отправку при ошибках (FAILED)
|
||||
*
|
||||
* Политика повторных попыток:
|
||||
* - Экспоненциальная задержка (backoff)
|
||||
* - Максимум 3 попытки
|
||||
* - Таймаут 10 минут на выполнение
|
||||
*/
|
||||
@HiltWorker
|
||||
class MessageSyncWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val dao: MessageDao,
|
||||
private val api: ChatApi,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val tokenManager: TokenManager
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "message_sync_worker"
|
||||
private const val TAG = "MessageSyncWorker"
|
||||
|
||||
/**
|
||||
* Планирует синхронизацию
|
||||
* Вызывается при изменении сообщений в базе
|
||||
*/
|
||||
fun scheduleSync(context: Context) {
|
||||
Log.d(TAG, "Scheduling sync")
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(false)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<MessageSyncWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.addTag(WORK_NAME)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_NAME,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
|
||||
Log.d(TAG, "Sync scheduled")
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменяет запланированную синхронизацию
|
||||
*/
|
||||
fun cancelSync(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
Log.d(TAG, "Sync cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
Log.d(TAG, "Starting message sync. Attempt: ${runAttemptCount + 1}")
|
||||
|
||||
if (runAttemptCount >= 3) {
|
||||
Log.e(TAG, "Max retry attempts reached")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
return try {
|
||||
val pendingMessages = dao.getPendingSyncMessages()
|
||||
Log.d(TAG, "Found ${pendingMessages.size} messages to sync")
|
||||
|
||||
if (pendingMessages.isEmpty()) {
|
||||
Log.d(TAG, "No pending messages. Sync complete.")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
var successCount = 0
|
||||
var failureCount = 0
|
||||
|
||||
for (message in pendingMessages) {
|
||||
try {
|
||||
when {
|
||||
message.isDeletedLocally -> {
|
||||
handleDeleteMessage(message)
|
||||
successCount++
|
||||
}
|
||||
message.isEditedLocally -> {
|
||||
handleEditMessage(message)
|
||||
successCount++
|
||||
}
|
||||
message.syncStatus == SyncStatus.SYNCING -> {
|
||||
handleSendMessage(message)
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to sync message ${message.id}", e)
|
||||
dao.markAsSyncFailed(message.id)
|
||||
failureCount++
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sync completed. Success: $successCount, Failed: $failureCount")
|
||||
|
||||
if (failureCount > 0 && successCount == 0) {
|
||||
Result.retry()
|
||||
} else {
|
||||
Result.success()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Sync failed with exception", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleSendMessage(message: MessageEntity) {
|
||||
Log.d(TAG, "Sending message: ${message.id}")
|
||||
|
||||
val attachments = parseAttachments(message.mediaJson)
|
||||
val request = SendMessageRequest(
|
||||
content = message.content,
|
||||
type = message.mediaType.lowercase(),
|
||||
attachments = attachments,
|
||||
replyToId = message.replyToId
|
||||
)
|
||||
|
||||
val response = api.sendMessage(message.chatId, request)
|
||||
|
||||
val syncedMessage = message.copy(
|
||||
id = response.id,
|
||||
sequenceId = response.sequenceId ?: message.sequenceId,
|
||||
createdAt = response.createdAt ?: message.createdAt,
|
||||
syncStatus = SyncStatus.SYNCED,
|
||||
isDeletedLocally = false,
|
||||
isEditedLocally = false,
|
||||
editedContent = null,
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
dao.insertMessage(syncedMessage)
|
||||
Log.d(TAG, "Message sent successfully: ${response.id}")
|
||||
}
|
||||
|
||||
private suspend fun handleEditMessage(message: MessageEntity) {
|
||||
Log.d(TAG, "Editing message: ${message.id}")
|
||||
|
||||
val newContent = message.editedContent ?: message.content
|
||||
val request = SendMessageRequest(content = newContent)
|
||||
|
||||
val response = api.editMessage(message.id, request)
|
||||
|
||||
val syncedMessage = message.copy(
|
||||
content = response.content,
|
||||
syncStatus = SyncStatus.SYNCED,
|
||||
isEditedLocally = false,
|
||||
editedContent = null,
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
dao.insertMessage(syncedMessage)
|
||||
Log.d(TAG, "Message edited successfully: ${message.id}")
|
||||
}
|
||||
|
||||
private suspend fun handleDeleteMessage(message: MessageEntity) {
|
||||
Log.d(TAG, "Deleting message: ${message.id}")
|
||||
|
||||
val response = api.deleteMessage(message.id, forEveryone = false)
|
||||
|
||||
if (response.isSuccessful || response.code() == 404) {
|
||||
dao.deleteMessage(message.id)
|
||||
Log.d(TAG, "Message deleted successfully: ${message.id}")
|
||||
} else {
|
||||
throw Exception("Delete failed with code: ${response.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAttachments(mediaJson: String): List<chats.data.remote.api.AttachmentRequest>? {
|
||||
return try {
|
||||
val mediaList = gson.fromJson(mediaJson, Array::class.java)
|
||||
?.map { elem ->
|
||||
val map = elem as Map<*, *>
|
||||
chats.data.remote.api.AttachmentRequest(
|
||||
type = map["type"] as? String ?: "file",
|
||||
url = map["url"] as? String ?: "",
|
||||
fileName = map["filename"] as? String ?: "file",
|
||||
fileSize = (map["size"] as? Number)?.toLong() ?: 0L
|
||||
)
|
||||
}
|
||||
mediaList?.takeIf { it.isNotEmpty() }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse attachments", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
package chats.di
|
||||
|
||||
import android.content.Context
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.repository.ChatRepositoryImpl
|
||||
import chats.data.signalr.MessageSignalRHandler
|
||||
import chats.domain.repository.ChatRepository
|
||||
import core.database.data.ChatDatabase
|
||||
import core.database.data.MessageDao
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Singleton
|
||||
@@ -26,14 +30,28 @@ object ChatModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatRepository(
|
||||
api: ChatApi,
|
||||
tokenManager: TokenManager,
|
||||
fun provideMessageSignalRHandler(
|
||||
hubClient: ChatHubClient,
|
||||
dao: MessageDao,
|
||||
serverConfig: ServerConfig,
|
||||
messageDao: MessageDao,
|
||||
hubClient: chats.data.remote.signalr.ChatHubClient
|
||||
tokenManager: TokenManager
|
||||
): MessageSignalRHandler {
|
||||
return MessageSignalRHandler(hubClient, dao, serverConfig, tokenManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatRepository(
|
||||
api: ChatApi,
|
||||
tokenManager: TokenManager,
|
||||
serverConfig: ServerConfig,
|
||||
dao: MessageDao,
|
||||
database: ChatDatabase,
|
||||
hubClient: ChatHubClient,
|
||||
signalRHandler: MessageSignalRHandler,
|
||||
@ApplicationContext context: Context
|
||||
): ChatRepository {
|
||||
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient)
|
||||
return ChatRepositoryImpl(api, tokenManager, serverConfig, dao, database, hubClient, signalRHandler, context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -6,5 +6,13 @@ data class Chat(
|
||||
val name: String,
|
||||
val avatar: String?,
|
||||
val unreadCount: Int,
|
||||
val lastMessage: Message?
|
||||
val lastMessage: Message? = null,
|
||||
val members: List<ChatMember> = emptyList()
|
||||
)
|
||||
|
||||
data class ChatMember(
|
||||
val userId: String,
|
||||
val username: String,
|
||||
val displayName: String? = null,
|
||||
val avatarUrl: String? = null
|
||||
)
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package chats.domain.repository
|
||||
|
||||
import androidx.paging.PagingData
|
||||
import chats.domain.model.Chat
|
||||
import chats.domain.model.Message
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ChatRepository {
|
||||
suspend fun getChats(): List<Chat>
|
||||
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
|
||||
|
||||
// Flow для UI (простой список)
|
||||
fun getMessagesFlow(chatId: String): Flow<List<Message>>
|
||||
|
||||
// Paging 3 для пагинированного списка
|
||||
fun getMessagesPaging(chatId: String): Flow<PagingData<Message>>
|
||||
|
||||
// Загрузка из сети (для начальной синхронизации)
|
||||
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
|
||||
|
||||
suspend fun sendMessage(
|
||||
chatId: String,
|
||||
content: String?,
|
||||
@@ -15,16 +25,23 @@ interface ChatRepository {
|
||||
replyToId: String? = null,
|
||||
forwardedFromId: String? = null
|
||||
): Message
|
||||
|
||||
suspend fun addReaction(messageId: String, emoji: String)
|
||||
suspend fun sendTypingStatus(chatId: String)
|
||||
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
|
||||
|
||||
// Локальные операции с офлайн-поддержкой
|
||||
suspend fun saveMessage(message: Message)
|
||||
suspend fun deleteLocalMessage(messageId: String)
|
||||
suspend fun editLocalMessage(messageId: String, newContent: String)
|
||||
|
||||
suspend fun uploadMedia(file: java.io.File): String
|
||||
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
|
||||
suspend fun createPersonalChat(userId: String): Chat
|
||||
|
||||
// Серверные операции
|
||||
suspend fun deleteMessage(messageId: String, forEveryone: Boolean)
|
||||
suspend fun editMessage(messageId: String, content: String): Message
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ import java.io.File
|
||||
import javax.inject.Inject
|
||||
import core.utils.copyUriToFile
|
||||
import core.utils.ImageUtils
|
||||
|
||||
import chats.data.remote.api.KlipyGifDto
|
||||
|
||||
private const val TAG = "ChatDetailViewModel"
|
||||
|
||||
data class ChatDetailState(
|
||||
val messages: List<Message> = emptyList(),
|
||||
val chatName: String? = null,
|
||||
@@ -134,8 +135,26 @@ class ChatDetailViewModel @Inject constructor(
|
||||
|
||||
fun refreshMessages(chatId: String) {
|
||||
viewModelScope.launch {
|
||||
val messages = repository.getMessages(chatId)
|
||||
updateMessages(messages)
|
||||
try {
|
||||
// Сначала пытаемся загрузить из сети
|
||||
val messages = repository.getMessages(chatId)
|
||||
if (messages.isNotEmpty()) {
|
||||
updateMessages(messages)
|
||||
return@launch
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.d(TAG, "Network load failed, trying cache")
|
||||
}
|
||||
|
||||
// Если сеть не доступна или пуста - загружаем из Room
|
||||
try {
|
||||
val cachedMessages = repository.getMessagesFlow(chatId).first()
|
||||
android.util.Log.d(TAG, "Loaded ${cachedMessages.size} messages from cache")
|
||||
updateMessages(cachedMessages)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Cache load failed", e)
|
||||
_state.update { it.copy(isLoading = false, messages = emptyList()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +186,29 @@ class ChatDetailViewModel @Inject constructor(
|
||||
|
||||
private fun loadChatInfo(chatId: String) {
|
||||
viewModelScope.launch {
|
||||
// Пробуем загрузить из API
|
||||
try {
|
||||
val chats = repository.getChats()
|
||||
val chat = chats.find { it.id == chatId }
|
||||
chat?.let { c ->
|
||||
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
|
||||
android.util.Log.d(TAG, "Loaded chat info from API: ${c.name}")
|
||||
return@launch
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore info load error
|
||||
android.util.Log.d(TAG, "API load failed, will use messages for title")
|
||||
}
|
||||
|
||||
// Если API недоступно - берём имя из сообщений (для личных чатов)
|
||||
try {
|
||||
val cachedMessages = repository.getMessagesFlow(chatId).first()
|
||||
val otherUserMessage = cachedMessages.firstOrNull { it.senderId != getCurrentUserId() }
|
||||
otherUserMessage?.let { msg ->
|
||||
_state.update { it.copy(chatName = msg.senderName, chatAvatar = msg.senderAvatar) }
|
||||
android.util.Log.d(TAG, "Loaded chat title from messages: ${msg.senderName}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to load chat title from messages", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,7 +225,7 @@ class ChatDetailViewModel @Inject constructor(
|
||||
s.copy(messages = updatedMessages)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For handling reaction removed event
|
||||
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
|
||||
_state.update { s ->
|
||||
@@ -377,7 +411,7 @@ class ChatDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun onForward(message: Message) {
|
||||
_state.update { it.copy(forwardingMessages = listOf(message)) }
|
||||
loadChatsForForwarding()
|
||||
|
||||
@@ -37,7 +37,7 @@ fun HorizontalDividerComponent(
|
||||
fun ChatListScreen(
|
||||
viewModel: ChatListViewModel,
|
||||
storyViewModel: StoryViewModel,
|
||||
onChatClick: (String) -> Unit,
|
||||
onChatClick: (String, String) -> Unit,
|
||||
onStoryClick: (Int) -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
@@ -112,7 +112,7 @@ fun ChatListScreen(
|
||||
}
|
||||
} else {
|
||||
items(state.chats) { chat ->
|
||||
ChatItem(chat = chat, onClick = onChatClick)
|
||||
ChatItem(chat = chat, onClick = { onChatClick(chat.id, chat.name) })
|
||||
HorizontalDividerComponent(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
thickness = 0.5.dp,
|
||||
|
||||
Reference in New Issue
Block a user