Кэш чатов
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -122,8 +122,18 @@ dependencies {
|
||||
val room_version = "2.6.1"
|
||||
implementation("androidx.room:room-runtime:$room_version")
|
||||
implementation("androidx.room:room-ktx:$room_version")
|
||||
implementation("androidx.room:room-paging:$room_version")
|
||||
kapt("androidx.room:room-compiler:$room_version")
|
||||
|
||||
// Paging 3
|
||||
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||
implementation("androidx.paging:paging-compose:3.2.1")
|
||||
|
||||
// WorkManager
|
||||
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")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
|
||||
@@ -16,3 +16,4 @@ class MainApplication : Application(), ImageLoaderFactory {
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
167
client-mobile/chats/ARCHITECTURE.md
Normal file
167
client-mobile/chats/ARCHITECTURE.md
Normal file
@@ -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 сценарии
|
||||
178
client-mobile/chats/IMPLEMENTATION_SUMMARY.md
Normal file
178
client-mobile/chats/IMPLEMENTATION_SUMMARY.md
Normal file
@@ -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. **Пагинация** - эффективная работа с большими чатами
|
||||
296
client-mobile/chats/INTEGRATION_GUIDE.md
Normal file
296
client-mobile/chats/INTEGRATION_GUIDE.md
Normal file
@@ -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) - больше примеров использования
|
||||
262
client-mobile/chats/USAGE_EXAMPLES.md
Normal file
262
client-mobile/chats/USAGE_EXAMPLES.md
Normal file
@@ -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 управлять синхронизацией
|
||||
80
client-mobile/chats/data/paging/MessagePagingSource.kt
Normal file
80
client-mobile/chats/data/paging/MessagePagingSource.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
203
client-mobile/chats/data/paging/MessageRemoteMediator.kt
Normal file
203
client-mobile/chats/data/paging/MessageRemoteMediator.kt
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
194
client-mobile/chats/data/signalr/MessageSignalRHandler.kt
Normal file
194
client-mobile/chats/data/signalr/MessageSignalRHandler.kt
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
225
client-mobile/chats/data/sync/MessageSyncWorker.kt
Normal file
225
client-mobile/chats/data/sync/MessageSyncWorker.kt
Normal file
@@ -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,
|
||||
|
||||
@@ -31,7 +31,7 @@ fun ContactListScreen(
|
||||
onContactClick: (String) -> Unit,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onAddContact: (String) -> Unit,
|
||||
onStartChat: (String) -> Unit,
|
||||
onStartChat: (String, String) -> Unit,
|
||||
onAcceptRequest: (String) -> Unit,
|
||||
onDeclineRequest: (String) -> Unit
|
||||
) {
|
||||
@@ -136,7 +136,7 @@ fun ContactListScreen(
|
||||
contact = contact,
|
||||
onClick = { onContactClick(contact.id) },
|
||||
onAddClick = { onAddContact(contact.id) },
|
||||
onChatClick = { onStartChat(contact.id) },
|
||||
onChatClick = { onStartChat(contact.id, contact.displayName ?: contact.effectiveUsername) },
|
||||
isSearchMode = searchQuery.isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,14 +106,14 @@ class ContactListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun startChat(userId: String) {
|
||||
fun startChat(userId: String, userName: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// В вебе мы ищем существующий чат или создаем новый.
|
||||
// В мобилке мы для начала можем просто вызвать createPersonalChat.
|
||||
// Бэкенд обычно возвращает существующий чат, если он уже есть.
|
||||
val chat = chatRepository.createPersonalChat(userId)
|
||||
navigationManager.navigateToChat(chat.id)
|
||||
navigationManager.navigateToChat(chat.id, userName)
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(error = e.message) }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
package core.database.data
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Статус синхронизации сообщения с сервером
|
||||
*/
|
||||
enum class SyncStatus {
|
||||
SYNCED, // Сообщение успешно синхронизировано
|
||||
SYNCING, // Сообщение отправляется на сервер
|
||||
FAILED // Ошибка синхронизации
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity для хранения сообщений в локальной базе данных
|
||||
* Поддерживает офлайн-работу и фоновую синхронизацию
|
||||
*/
|
||||
@Entity(tableName = "messages")
|
||||
data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
@@ -14,20 +28,102 @@ data class MessageEntity(
|
||||
val sequenceId: Int,
|
||||
val createdAt: String,
|
||||
val mediaType: String,
|
||||
val mediaJson: String, // Simplified for now
|
||||
val mediaJson: String,
|
||||
val reactionsJson: String,
|
||||
val isRead: Boolean,
|
||||
val replyToId: String? = null
|
||||
val replyToId: String? = null,
|
||||
|
||||
// Поля для офлайн-синхронизации
|
||||
@ColumnInfo(defaultValue = "SYNCED")
|
||||
val syncStatus: SyncStatus = SyncStatus.SYNCED,
|
||||
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val isDeletedLocally: Boolean = false,
|
||||
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val isEditedLocally: Boolean = false,
|
||||
|
||||
val editedContent: String? = null,
|
||||
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val lastUpdated: Long = 0
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
|
||||
// ==================== Flow для UI ====================
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 ORDER BY sequenceId ASC")
|
||||
fun getMessages(chatId: String): Flow<List<MessageEntity>>
|
||||
|
||||
// ==================== Paging 3 ====================
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 ORDER BY sequenceId DESC")
|
||||
fun getMessagesPagingSource(chatId: String): PagingSource<Int, MessageEntity>
|
||||
|
||||
// Загружаем сообщения С МЕНЬШИМ sequenceId (старые), порядок DESC для PagingSource
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 AND sequenceId < :sequenceId ORDER BY sequenceId DESC LIMIT :limit")
|
||||
suspend fun getMessagesBefore(chatId: String, sequenceId: Int, limit: Int): List<MessageEntity>
|
||||
|
||||
// Загружаем сообщения до и ВКЛЮЧАЯ sequenceId, порядок DESC
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 AND sequenceId <= :sequenceId ORDER BY sequenceId DESC LIMIT :limit")
|
||||
suspend fun getMessagesUpToAndIncluding(chatId: String, sequenceId: Int, limit: Int): List<MessageEntity>
|
||||
|
||||
// Загружаем сообщения С БОЛЬШИМ sequenceId (новые), порядок ASC
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 AND sequenceId > :sequenceId ORDER BY sequenceId ASC LIMIT :limit")
|
||||
suspend fun getMessagesAfter(chatId: String, sequenceId: Int, limit: Int): List<MessageEntity>
|
||||
|
||||
@Query("SELECT MAX(sequenceId) FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0")
|
||||
suspend fun getMaxSequenceId(chatId: String): Int?
|
||||
|
||||
@Query("SELECT MIN(sequenceId) FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0")
|
||||
suspend fun getMinSequenceId(chatId: String): Int?
|
||||
|
||||
// ==================== Основные операции ====================
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessage(message: MessageEntity)
|
||||
|
||||
/**
|
||||
* Upsert - вставляет или обновляет сообщение
|
||||
* Приоритет: серверные данные > локальные (кроме сообщений в процессе отправки)
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun upsertMessage(message: MessageEntity) {
|
||||
val existing = getMessageById(message.id)
|
||||
if (existing != null) {
|
||||
// Сохраняем локальные изменения если сообщение в процессе отправки
|
||||
val syncedMessage = when {
|
||||
existing.syncStatus == SyncStatus.SYNCING || existing.isEditedLocally -> {
|
||||
message.copy(
|
||||
syncStatus = existing.syncStatus,
|
||||
isEditedLocally = existing.isEditedLocally,
|
||||
editedContent = existing.editedContent,
|
||||
lastUpdated = existing.lastUpdated
|
||||
)
|
||||
}
|
||||
else -> message
|
||||
}
|
||||
insertMessage(syncedMessage)
|
||||
} else {
|
||||
insertMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
suspend fun upsertMessages(messages: List<MessageEntity>) {
|
||||
messages.forEach { upsertMessage(it) }
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
|
||||
suspend fun getMessageById(id: String): MessageEntity?
|
||||
|
||||
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
|
||||
fun getMessageByIdFlow(id: String): Flow<MessageEntity?>
|
||||
|
||||
@Query("DELETE FROM messages WHERE chatId = :chatId")
|
||||
suspend fun clearChat(chatId: String)
|
||||
|
||||
@@ -36,9 +132,78 @@ interface MessageDao {
|
||||
|
||||
@Query("UPDATE messages SET isRead = 1 WHERE chatId = :chatId AND sequenceId <= :lastReadSequenceId AND isRead = 0")
|
||||
suspend fun markMessagesAsRead(chatId: String, lastReadSequenceId: Int)
|
||||
|
||||
// ==================== Офлайн операции ====================
|
||||
|
||||
/**
|
||||
* Помечает сообщение как удалённое локально
|
||||
* Фактическое удаление произойдёт после синхронизации с сервером
|
||||
*/
|
||||
@Query("UPDATE messages SET isDeletedLocally = 1, syncStatus = 'SYNCING', lastUpdated = :timestamp WHERE id = :messageId")
|
||||
suspend fun markAsDeletedLocally(messageId: String, timestamp: Long = System.currentTimeMillis())
|
||||
|
||||
/**
|
||||
* Помечает сообщение как отредактированное локально
|
||||
*/
|
||||
@Query("UPDATE messages SET isEditedLocally = 1, editedContent = :newContent, syncStatus = 'SYNCING', lastUpdated = :timestamp WHERE id = :messageId")
|
||||
suspend fun markAsEditedLocally(messageId: String, newContent: String, timestamp: Long = System.currentTimeMillis())
|
||||
|
||||
/**
|
||||
* Сбрасывает локальные изменения после успешной синхронизации
|
||||
*/
|
||||
@Query("UPDATE messages SET syncStatus = 'SYNCED', isDeletedLocally = 0, isEditedLocally = 0, editedContent = NULL WHERE id = :messageId")
|
||||
suspend fun markAsSynced(messageId: String)
|
||||
|
||||
/**
|
||||
* Устанавливает статус FAILED для сообщений с ошибкой синхронизации
|
||||
*/
|
||||
@Query("UPDATE messages SET syncStatus = 'FAILED' WHERE id = :messageId")
|
||||
suspend fun markAsSyncFailed(messageId: String)
|
||||
|
||||
/**
|
||||
* Получает все сообщения, требующие синхронизации
|
||||
*/
|
||||
@Query("SELECT * FROM messages WHERE syncStatus = 'SYNCING' OR isDeletedLocally = 1 ORDER BY lastUpdated ASC")
|
||||
suspend fun getPendingSyncMessages(): List<MessageEntity>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE syncStatus = 'SYNCING' OR isDeletedLocally = 1 ORDER BY lastUpdated ASC")
|
||||
fun getPendingSyncMessagesFlow(): Flow<List<MessageEntity>>
|
||||
|
||||
/**
|
||||
* Получает сообщения с_failed статусом для повторной отправки
|
||||
*/
|
||||
@Query("SELECT * FROM messages WHERE syncStatus = 'FAILED' ORDER BY lastUpdated ASC")
|
||||
suspend fun getFailedSyncMessages(): List<MessageEntity>
|
||||
|
||||
// ==================== Утилиты ====================
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0")
|
||||
suspend fun getMessagesCount(chatId: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE chatId = :chatId AND isRead = 0 AND isDeletedLocally = 0")
|
||||
suspend fun getUnreadCount(chatId: String): Int
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM messages WHERE id = :id)")
|
||||
suspend fun exists(id: String): Boolean
|
||||
}
|
||||
|
||||
@Database(entities = [MessageEntity::class], version = 1)
|
||||
@Database(entities = [MessageEntity::class], version = 2)
|
||||
@TypeConverters(SyncStatusConverter::class)
|
||||
abstract class ChatDatabase : RoomDatabase() {
|
||||
abstract fun messageDao(): MessageDao
|
||||
|
||||
companion object {
|
||||
const val DATABASE_NAME = "knot_chat_database"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертер для Enum SyncStatus
|
||||
*/
|
||||
class SyncStatusConverter {
|
||||
@TypeConverter
|
||||
fun fromSyncStatus(status: SyncStatus): String = status.name
|
||||
|
||||
@TypeConverter
|
||||
fun toSyncStatus(value: String): SyncStatus = SyncStatus.valueOf(value)
|
||||
}
|
||||
|
||||
39
client-mobile/core/database/data/Migrations.kt
Normal file
39
client-mobile/core/database/data/Migrations.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
package core.database.data
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
/**
|
||||
* Миграция с версии 1 на версию 2
|
||||
* Добавляет поля для офлайн-синхронизации:
|
||||
* - syncStatus (TEXT, по умолчанию 'SYNCED')
|
||||
* - isDeletedLocally (INTEGER, по умолчанию 0)
|
||||
* - isEditedLocally (INTEGER, по умолчанию 0)
|
||||
* - editedContent (TEXT, nullable)
|
||||
* - lastUpdated (INTEGER, по умолчанию текущее время)
|
||||
*/
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем новые колонки для синхронизации
|
||||
database.execSQL("""
|
||||
ALTER TABLE messages ADD COLUMN syncStatus TEXT NOT NULL DEFAULT 'SYNCED'
|
||||
""".trimIndent())
|
||||
|
||||
database.execSQL("""
|
||||
ALTER TABLE messages ADD COLUMN isDeletedLocally INTEGER NOT NULL DEFAULT 0
|
||||
""".trimIndent())
|
||||
|
||||
database.execSQL("""
|
||||
ALTER TABLE messages ADD COLUMN isEditedLocally INTEGER NOT NULL DEFAULT 0
|
||||
""".trimIndent())
|
||||
|
||||
database.execSQL("""
|
||||
ALTER TABLE messages ADD COLUMN editedContent TEXT
|
||||
""".trimIndent())
|
||||
|
||||
// lastUpdated по умолчанию 0, будет обновлён при первой синхронизации
|
||||
database.execSQL("""
|
||||
ALTER TABLE messages ADD COLUMN lastUpdated INTEGER NOT NULL DEFAULT 0
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import androidx.room.Room
|
||||
import core.database.data.ChatDatabase
|
||||
import core.database.data.MessageDao
|
||||
import core.database.data.MIGRATION_1_2
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -21,8 +22,10 @@ object DatabaseModule {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
ChatDatabase::class.java,
|
||||
"chat_database"
|
||||
).fallbackToDestructiveMigration().build()
|
||||
ChatDatabase.DATABASE_NAME
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
||||
37
client-mobile/core/di/WorkManagerModule.kt
Normal file
37
client-mobile/core/di/WorkManagerModule.kt
Normal file
@@ -0,0 +1,37 @@
|
||||
package core.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* DI модуль для WorkManager
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object WorkManagerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWorkManager(
|
||||
@ApplicationContext context: Context,
|
||||
configuration: Configuration
|
||||
): WorkManager {
|
||||
WorkManager.initialize(context, configuration)
|
||||
return WorkManager.getInstance(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWorkManagerConfiguration(): Configuration {
|
||||
return Configuration.Builder()
|
||||
.setMinimumLoggingLevel(android.util.Log.INFO)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
sealed class NavEvent {
|
||||
data class OpenChat(val chatId: String) : NavEvent()
|
||||
data class OpenChat(val chatId: String, val chatName: String = "Chat") : NavEvent()
|
||||
object Logout : NavEvent()
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ class NavigationManager @Inject constructor() {
|
||||
private val _events = MutableSharedFlow<NavEvent>(extraBufferCapacity = 1)
|
||||
val events = _events
|
||||
|
||||
fun navigateToChat(chatId: String) {
|
||||
_events.tryEmit(NavEvent.OpenChat(chatId))
|
||||
fun navigateToChat(chatId: String, chatName: String = "Chat") {
|
||||
_events.tryEmit(NavEvent.OpenChat(chatId, chatName))
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
|
||||
@@ -61,7 +61,7 @@ fun AppNavigation(
|
||||
when (event) {
|
||||
is core.utils.NavEvent.OpenChat -> {
|
||||
if (authState.isAuthenticated) {
|
||||
navController.navigate(Screen.ChatDetail.createRoute(event.chatId, "Chat")) {
|
||||
navController.navigate(Screen.ChatDetail.createRoute(event.chatId, event.chatName)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
@@ -149,8 +149,10 @@ fun AppNavigation(
|
||||
ChatListScreen(
|
||||
viewModel = viewModel,
|
||||
storyViewModel = storyViewModel,
|
||||
onChatClick = { chatId ->
|
||||
navController.navigate(Screen.ChatDetail.createRoute(chatId, "Chat"))
|
||||
onChatClick = { chatId, chatName ->
|
||||
navController.navigate(Screen.ChatDetail.createRoute(chatId, chatName)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onStoryClick = { /* Story click logic */ }
|
||||
)
|
||||
@@ -183,7 +185,7 @@ fun AppNavigation(
|
||||
onContactClick = { id -> navController.navigate(Screen.Profile.createRoute(id)) },
|
||||
onSearchChange = { viewModel.onSearchChange(it) },
|
||||
onAddContact = { id -> viewModel.addContact(id) },
|
||||
onStartChat = { id -> viewModel.startChat(id) },
|
||||
onStartChat = { id, name -> viewModel.startChat(id, name) },
|
||||
onAcceptRequest = { id -> viewModel.acceptRequest(id) },
|
||||
onDeclineRequest = { id -> viewModel.declineRequest(id) }
|
||||
)
|
||||
@@ -205,7 +207,7 @@ fun AppNavigation(
|
||||
viewModel = viewModel,
|
||||
onEditProfile = { navController.navigate(Screen.EditProfile.route) },
|
||||
onBack = { navController.popBackStack() },
|
||||
onSendMessage = { id -> navController.navigate(Screen.ChatDetail.createRoute(id, "Chat")) }
|
||||
onSendMessage = { id, name -> navController.navigate(Screen.ChatDetail.createRoute(id, name)) }
|
||||
)
|
||||
}
|
||||
composable(Screen.EditProfile.route) {
|
||||
|
||||
@@ -35,7 +35,7 @@ fun ProfileScreen(
|
||||
profileId: String? = null, // null for own profile
|
||||
viewModel: ProfileViewModel = hiltViewModel(),
|
||||
onEditProfile: () -> Unit = {},
|
||||
onSendMessage: (String) -> Unit = {},
|
||||
onSendMessage: (String, String) -> Unit = { _, _ -> },
|
||||
onCall: (String) -> Unit = {},
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
@@ -108,7 +108,7 @@ fun ProfileScreen(
|
||||
birthday = profile?.birthday,
|
||||
isOwnProfile = isOwnProfile,
|
||||
isCallsEnabled = true,
|
||||
onSendMessage = { profile?.id?.let { id -> onSendMessage(id) } },
|
||||
onSendMessage = { profile?.let { p -> onSendMessage(p.id ?: "", p.displayName ?: p.username ?: "Chat") } },
|
||||
onCall = { profile?.id?.let { id -> onCall(id) } }
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user