diff --git a/AVATAR_IMPLEMENTATION.md b/AVATAR_IMPLEMENTATION.md deleted file mode 100644 index 99338aa..0000000 --- a/AVATAR_IMPLEMENTATION.md +++ /dev/null @@ -1,385 +0,0 @@ -# Avatar Implementation Guide - -## Обзор - -Реализована полная система аватаров для Rosetta Messenger Android, совместимая с desktop версией: - -- ✅ P2P доставка аватаров (PacketAvatar 0x0C) -- ✅ Multi-layer кэширование (Memory + SQLite + Encrypted Files) -- ✅ ChaCha20-Poly1305 шифрование для передачи -- ✅ Password-based шифрование для хранения -- ✅ Трекинг доставки аватаров -- ✅ UI компоненты (AvatarImage, AvatarPlaceholder) - -## Архитектура - -### 1. Crypto Layer (CryptoManager.kt) - -```kotlin -// Шифрование для P2P передачи -val result = CryptoManager.chacha20Encrypt(base64Image) -// result.key, result.nonce, result.ciphertext - -// Шифрование для локального хранения (пароль "rosetta-a") -val encrypted = CryptoManager.encryptWithPassword(data, "rosetta-a") -``` - -### 2. Database Layer - -- **avatar_cache**: Хранит пути к зашифрованным файлам -- **avatar_delivery**: Трекинг доставки (кому отправлен аватар) - -### 3. File Storage (AvatarFileManager.kt) - -```kotlin -// Сохранение -val path = AvatarFileManager.saveAvatar(context, base64Image, publicKey) -// Путь: "a/md5hash" - -// Чтение -val base64Image = AvatarFileManager.readAvatar(context, path) - -// Конвертация изображения в PNG Base64 -val base64Png = AvatarFileManager.imagePrepareForNetworkTransfer(context, imageBytes) -``` - -### 4. Repository Layer (AvatarRepository.kt) - -```kotlin -val avatarRepository = AvatarRepository( - context = context, - avatarDao = database.avatarDao(), - currentPublicKey = myPublicKey, - currentPrivateKey = myPrivateKey, - protocolManager = ProtocolManager -) - -// Получить аватары пользователя -val avatars: StateFlow> = avatarRepository.getAvatars(publicKey) - -// Изменить свой аватар -avatarRepository.changeMyAvatar(base64Image) - -// Отправить аватар контакту -avatarRepository.sendAvatarTo(contactPublicKey) - -// Обработать входящий аватар -avatarRepository.handleIncomingAvatar(packetAvatar) -``` - -### 5. Network Layer (PacketAvatar) - -```kotlin -class PacketAvatar : Packet() { - var privateKey: String = "" // Hash приватного ключа - var fromPublicKey: String = "" // Отправитель - var toPublicKey: String = "" // Получатель - var chachaKey: String = "" // RSA-encrypted ChaCha20 key+nonce - var blob: String = "" // ChaCha20-encrypted avatar data - - override fun getPacketId(): Int = 0x0C -} -``` - -### 6. UI Layer (AvatarImage.kt) - -```kotlin -@Composable -fun AvatarImage( - publicKey: String, - avatarRepository: AvatarRepository?, - size: Dp = 40.dp, - isDarkTheme: Boolean, - onClick: (() -> Unit)? = null, - showOnlineIndicator: Boolean = false, - isOnline: Boolean = false -) -``` - -## Интеграция - -### Шаг 1: Инициализация в Application/Activity - -```kotlin -class MainActivity : ComponentActivity() { - private lateinit var avatarRepository: AvatarRepository - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val database = RosettaDatabase.getDatabase(applicationContext) - - // После авторизации пользователя - avatarRepository = AvatarRepository( - context = applicationContext, - avatarDao = database.avatarDao(), - currentPublicKey = currentAccount.publicKey, - currentPrivateKey = currentAccount.privateKey, - protocolManager = ProtocolManager - ) - - // Передаем в ProtocolManager для обработки входящих пакетов - ProtocolManager.setAvatarRepository(avatarRepository) - } -} -``` - -### Шаг 2: Обновление ProtocolManager - -Добавьте в ProtocolManager: - -```kotlin -object ProtocolManager { - private var avatarRepository: AvatarRepository? = null - - fun setAvatarRepository(repository: AvatarRepository) { - avatarRepository = repository - } - - // В setupPacketHandlers() обработчик уже добавлен: - waitPacket(0x0C) { packet -> - scope.launch(Dispatchers.IO) { - avatarRepository?.handleIncomingAvatar(packet as PacketAvatar) - } - } -} -``` - -### Шаг 3: Использование в UI - -#### Отображение аватара - -```kotlin -@Composable -fun ChatListItem( - publicKey: String, - avatarRepository: AvatarRepository?, - isDarkTheme: Boolean -) { - Row { - AvatarImage( - publicKey = publicKey, - avatarRepository = avatarRepository, - size = 48.dp, - isDarkTheme = isDarkTheme, - showOnlineIndicator = true, - isOnline = user.isOnline - ) - // ... остальной контент - } -} -``` - -#### Смена аватара - -```kotlin -@Composable -fun ProfileScreen( - avatarRepository: AvatarRepository, - viewModel: ProfileViewModel -) { - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - uri?.let { - viewModel.uploadAvatar(it, avatarRepository) - } - } - - IconButton(onClick = { launcher.launch("image/*") }) { - Icon(Icons.Default.Edit, "Change Avatar") - } -} -``` - -#### ViewModel для загрузки - -```kotlin -class ProfileViewModel : ViewModel() { - fun uploadAvatar(uri: Uri, avatarRepository: AvatarRepository) { - viewModelScope.launch { - try { - // Читаем файл - val inputStream = context.contentResolver.openInputStream(uri) - val imageBytes = inputStream?.readBytes() - inputStream?.close() - - // Конвертируем в PNG Base64 - val base64Png = AvatarFileManager.imagePrepareForNetworkTransfer( - context, - imageBytes!! - ) - - // Сохраняем - avatarRepository.changeMyAvatar(base64Png) - - // Показываем успех - _uiState.value = _uiState.value.copy( - showSuccess = true, - successMessage = "Avatar updated" - ) - } catch (e: Exception) { - _uiState.value = _uiState.value.copy( - showError = true, - errorMessage = e.message - ) - } - } - } -} -``` - -### Шаг 4: Автоматическая доставка аватаров - -#### В ChatDetailScreen - -```kotlin -LaunchedEffect(opponentPublicKey) { - // Проверяем нужно ли отправить аватар - val isDelivered = avatarRepository.isAvatarDelivered(opponentPublicKey) - val hasAvatar = avatarRepository.getLatestAvatar(currentPublicKey) != null - - if (!isDelivered && hasAvatar) { - // Показываем prompt или отправляем автоматически - avatarRepository.sendAvatarTo(opponentPublicKey) - } -} -``` - -## Тестирование - -### 1. Локальное тестирование - -```kotlin -// В тестовом классе -@Test -fun testAvatarEncryptionRoundTrip() { - val originalData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" - - // Encrypt - val result = CryptoManager.chacha20Encrypt(originalData) - - // Decrypt - val decrypted = CryptoManager.chacha20Decrypt( - result.ciphertext, - result.nonce, - result.key - ) - - assertEquals(originalData, decrypted) -} - -@Test -fun testPasswordEncryption() { - val data = "test image data" - val password = "rosetta-a" - - val encrypted = CryptoManager.encryptWithPassword(data, password) - val decrypted = CryptoManager.decryptWithPassword(encrypted, password) - - assertEquals(data, decrypted) -} -``` - -### 2. P2P тестирование - -1. Установите приложение на 2 устройства/эмулятора -2. Авторизуйтесь с разными аккаунтами -3. Установите аватар на первом устройстве -4. Откройте чат с первым пользователем на втором устройстве -5. Проверьте что аватар отобразился - -### 3. Проверка в Logcat - -```bash -adb logcat -s Protocol:D AvatarRepository:D -``` - -Ожидаемые логи: - -``` -Protocol: 🖼️ Received avatar from 02a1b2c3... -AvatarRepository: Received avatar from 02a1b2c3... -AvatarRepository: Saved avatar for 02a1b2c3... -``` - -## Производительность - -### Memory Management - -- Memory cache ограничен (хранит только активные диалоги) -- Используйте `avatarRepository.clearMemoryCache()` при выходе из чата -- Файлы хранятся зашифрованными (экономия памяти) - -### Network - -- Аватары отправляются только 1 раз каждому контакту -- Используется трекинг доставки (avatar_delivery таблица) -- Chunking не реализован (лимит ~5MB на аватар) - -### Database - -- WAL mode включен для производительности -- Индексы на (public_key, timestamp) для быстрого поиска -- Автоматическое удаление старых аватаров (хранится MAX_AVATAR_HISTORY = 5) - -## Troubleshooting - -### Аватары не отображаются - -1. Проверьте что AvatarRepository инициализирован -2. Проверьте логи декрипции файлов -3. Проверьте наличие файлов в `context.filesDir/avatars/` - -### Аватары не отправляются - -1. Проверьте что PacketAvatar (0x0C) зарегистрирован в Protocol.kt -2. Проверьте что ProtocolManager имеет ссылку на AvatarRepository -3. Проверьте статус соединения (AUTHENTICATED) - -### Ошибки шифрования - -1. Проверьте версию Google Tink (должна быть 1.10.0+) -2. Проверьте что пароль "rosetta-a" используется везде -3. Проверьте совместимость с desktop (тестируйте кросс-платформенно) - -## Будущие улучшения - -### Приоритет Высокий - -- [ ] Интеграция AvatarRepository в MainActivity -- [ ] Image Picker в AvatarPicker composable -- [ ] Avatar upload UI в ProfileScreen -- [ ] Automatic delivery prompt (как в desktop) - -### Приоритет Средний - -- [ ] Avatar compression перед upload -- [ ] Multiple avatar sizes (thumbnail, full) -- [ ] Avatar history viewer в профиле -- [ ] Group avatars support - -### Приоритет Низкий - -- [ ] Chunking для больших файлов (>5MB) -- [ ] Coil disk cache integration -- [ ] Avatar delete functionality -- [ ] System avatars preloading - -## Совместимость - -### Desktop версия - -✅ ChaCha20-Poly1305 шифрование -✅ PBKDF2+AES для хранения -✅ MD5 path generation -✅ PacketAvatar структура -✅ Delivery tracking - -### React Native версия - -⚠️ Требует тестирования (скорее всего совместимо) - -### Telegram версия - -❌ Не совместимо (другой протокол) diff --git a/AVATAR_SUMMARY_RU.md b/AVATAR_SUMMARY_RU.md deleted file mode 100644 index 70d7662..0000000 --- a/AVATAR_SUMMARY_RU.md +++ /dev/null @@ -1,268 +0,0 @@ -# Реализация Аватаров - Краткая Сводка - -## ✅ Что Реализовано - -### 1. **Криптография** (CryptoManager.kt) - -- ✅ XChaCha20-Poly1305 для P2P передачи (уже было) -- ✅ PBKDF2+AES для локального хранения (уже было) -- ✅ Все совместимо с desktop версией - -### 2. **База Данных** (AvatarEntities.kt) - -```kotlin -// Две новые таблицы: -- avatar_cache: хранит пути к зашифрованным файлам -- avatar_delivery: трекинг доставки аватаров - -// Миграция 6 -> 7 добавлена в RosettaDatabase.kt -``` - -### 3. **Файловое Хранилище** (AvatarFileManager.kt) - -```kotlin -// Основные функции: -- saveAvatar() - сохранение с шифрованием -- readAvatar() - чтение и расшифровка -- imagePrepareForNetworkTransfer() - конвертация в PNG Base64 -- generateMd5Path() - генерация путей как в desktop -``` - -### 4. **Сетевой Протокол** (Packets.kt) - -```kotlin -// Новый пакет 0x0C -class PacketAvatar : Packet() { - var privateKey: String = "" // Hash отправителя - var fromPublicKey: String = "" // Кто отправил - var toPublicKey: String = "" // Кому отправил - var chachaKey: String = "" // RSA-encrypted ключ - var blob: String = "" // Зашифрованный аватар -} - -// Зарегистрирован в Protocol.kt и ProtocolManager.kt -``` - -### 5. **Репозиторий** (AvatarRepository.kt) - -```kotlin -// Главный класс для работы с аватарами: -- getAvatars() - получить с auto-refresh -- changeMyAvatar() - изменить свой аватар -- sendAvatarTo() - отправить контакту -- handleIncomingAvatar() - обработать входящий -- Memory cache + SQLite + Files (tri-layer caching) -``` - -### 6. **UI Компоненты** (AvatarImage.kt) - -```kotlin -@Composable -fun AvatarImage( - publicKey: String, - avatarRepository: AvatarRepository?, - size: Dp = 40.dp, - isDarkTheme: Boolean, - onClick: (() -> Unit)? = null, - showOnlineIndicator: Boolean = false, - isOnline: Boolean = false -) - -// Автоматически: -// - Показывает реальный аватар (если есть) -// - Fallback на цветной placeholder с инициалами -// - Индикатор онлайн (опционально) -``` - -## 📋 Что Нужно Доделать - -### Шаг 1: Интеграция в MainActivity - -```kotlin -// В onCreate после авторизации: -private lateinit var avatarRepository: AvatarRepository - -fun initializeAfterLogin(account: Account) { - val database = RosettaDatabase.getDatabase(applicationContext) - - avatarRepository = AvatarRepository( - context = applicationContext, - avatarDao = database.avatarDao(), - currentPublicKey = account.publicKey, - currentPrivateKey = account.privateKey, - protocolManager = ProtocolManager - ) -} -``` - -### Шаг 2: Обновить ProtocolManager - -```kotlin -// Добавить поле: -private var avatarRepository: AvatarRepository? = null - -// Добавить метод: -fun setAvatarRepository(repository: AvatarRepository) { - avatarRepository = repository -} - -// В setupPacketHandlers() заменить TODO на: -waitPacket(0x0C) { packet -> - scope.launch(Dispatchers.IO) { - avatarRepository?.handleIncomingAvatar(packet as PacketAvatar) - } -} -``` - -### Шаг 3: Использовать AvatarImage в UI - -Заменить старые аватары (например в ChatsListScreen.kt): - -```kotlin -// БЫЛО: -Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor) -) { - Text(avatarText, color = avatarColors.textColor) -} - -// СТАЛО: -AvatarImage( - publicKey = dialog.opponentKey, - avatarRepository = avatarRepository, - size = 48.dp, - isDarkTheme = isDarkTheme, - showOnlineIndicator = true, - isOnline = dialog.isOnline -) -``` - -### Шаг 4: Image Picker для Upload - -```kotlin -// В ProfileScreen добавить: -val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() -) { uri: Uri? -> - uri?.let { - viewModel.uploadAvatar(it, avatarRepository) - } -} - -IconButton(onClick = { launcher.launch("image/*") }) { - Icon(Icons.Default.CameraAlt, "Upload Avatar") -} - -// В ViewModel: -fun uploadAvatar(uri: Uri, avatarRepository: AvatarRepository) { - viewModelScope.launch { - val inputStream = context.contentResolver.openInputStream(uri) - val bytes = inputStream?.readBytes() - val base64Png = AvatarFileManager.imagePrepareForNetworkTransfer(context, bytes!!) - avatarRepository.changeMyAvatar(base64Png) - } -} -``` - -## 🎯 Места Для Интеграции - -### Высокий Приоритет - -1. **ChatsListScreen.kt** - аватары в списке диалогов -2. **ChatDetailScreen.kt** - аватар собеседника в шапке -3. **ProfileScreen.kt** - свой аватар + кнопка загрузки -4. **OtherProfileScreen.kt** - аватар другого пользователя -5. **SearchScreen.kt** - аватары в результатах поиска - -### Средний Приоритет - -6. **UnlockScreen.kt** - аватары аккаунтов -7. **ForwardChatPickerBottomSheet.kt** - аватары при пересылке -8. **SearchResultsList.kt** - аватары в списке - -## 🔧 Как Тестировать - -### Локально - -```bash -# 1. Пересобрать проект (миграция БД автоматически применится) -./gradlew clean build - -# 2. Установить на устройство -./gradlew installDebug - -# 3. Проверить логи -adb logcat -s Protocol:D AvatarRepository:D -``` - -### P2P тестирование - -1. Установить на 2 устройства (или эмулятора + реальное устройство) -2. Авторизоваться с разными аккаунтами -3. На первом устройстве загрузить аватар -4. На втором открыть чат с первым пользователем -5. Аватар должен автоматически доставиться и отобразиться - -### Кросс-платформенное тестирование - -1. Desktop (Electron) - загрузить аватар -2. Android - открыть чат с desktop пользователем -3. Проверить что аватар корректно отображается -4. И наоборот: Android → Desktop - -## 📊 Структура Файлов - -``` -rosetta-android/app/src/main/java/com/rosetta/messenger/ -├── crypto/ -│ └── CryptoManager.kt ✅ (ChaCha20, PBKDF2 уже были) -├── database/ -│ ├── AvatarEntities.kt ✅ НОВЫЙ -│ └── RosettaDatabase.kt ✅ ОБНОВЛЕН (миграция 6->7) -├── network/ -│ ├── Packets.kt ✅ ОБНОВЛЕН (PacketAvatar 0x0C) -│ ├── Protocol.kt ✅ ОБНОВЛЕН (регистрация 0x0C) -│ └── ProtocolManager.kt ✅ ОБНОВЛЕН (обработчик 0x0C) -├── repository/ -│ └── AvatarRepository.kt ✅ НОВЫЙ -├── ui/ -│ └── components/ -│ └── AvatarImage.kt ✅ НОВЫЙ -└── utils/ - └── AvatarFileManager.kt ✅ НОВЫЙ -``` - -## 🚀 Преимущества - -1. **Совместимость**: 100% совместимо с desktop версией -2. **Безопасность**: End-to-end шифрование (ChaCha20 + RSA) -3. **Производительность**: Tri-layer caching (Memory + SQLite + Files) -4. **Экономия трафика**: Delivery tracking (отправляется 1 раз) -5. **UX**: Автоматический fallback на цветные плейсхолдеры - -## 🐛 Известные Ограничения - -1. **Chunking не реализован** - лимит ~5MB на аватар (как в desktop) -2. **Coil интеграция** - пока напрямую через Bitmap (можно оптимизировать) -3. **Image Picker** - требует реализации в UI слое -4. **Group avatars** - пока не поддерживается (только personal) - -## 📚 Документация - -Полная документация: [AVATAR_IMPLEMENTATION.md](./AVATAR_IMPLEMENTATION.md) - -## 💡 Рекомендации - -1. **Начать с ChatsListScreen** - самый заметный эффект -2. **Добавить upload в ProfileScreen** - чтобы можно было загружать -3. **Тестировать кросс-платформенно** - главное преимущество системы -4. **Мониторить память** - использовать clearMemoryCache() при необходимости - ---- - -**Статус**: ✅ Готово к интеграции -**Версия БД**: 7 (миграция готова) -**Совместимость**: Desktop ✅, React Native ⚠️ (требует тестирования) diff --git a/CRASH_REPORTS_SYSTEM.md b/CRASH_REPORTS_SYSTEM.md deleted file mode 100644 index 159b061..0000000 --- a/CRASH_REPORTS_SYSTEM.md +++ /dev/null @@ -1,256 +0,0 @@ -# Система Crash Reports для Rosetta Android - -## 📋 Обзор - -Реализована система автоматического сохранения crash reports в приложении Rosetta Android. Теперь при каждом краше приложения информация о нем будет сохраняться в локальное хранилище для последующего анализа. - -## 🎯 Функциональность - -### Автоматический сбор - -- **Отлов крашей**: Все необработанные исключения автоматически перехватываются -- **Детальная информация**: Сохраняется полная информация о краше: - - Timestamp (дата и время) - - Информация о приложении (package, версия) - - Информация об устройстве (модель, Android версия) - - Thread информация - - Exception type и message - - Полный stack trace - - Вложенные causes (до 10 уровней) - -### Управление логами - -- **Просмотр**: Удобный UI для просмотра всех крашей -- **Детали**: Подробный просмотр каждого краша с возможностью копирования -- **Удаление**: Можно удалить отдельный краш или все разом -- **Автоочистка**: Хранится максимум 50 последних крашей - -## 📁 Файловая структура - -``` -rosetta-android/app/src/main/java/com/rosetta/messenger/ -├── RosettaApplication.kt # Application класс с инициализацией -├── utils/ -│ └── CrashReportManager.kt # Основной класс управления крашами -└── ui/ - └── crashlogs/ - └── CrashLogsScreen.kt # UI для просмотра логов -``` - -## 🚀 Как использовать - -### 1. Просмотр Crash Logs - -В приложении: - -1. Откройте **Профиль** (Settings) -2. Нажмите на **Crash Logs** -3. Увидите список всех крашей -4. Нажмите на краш для просмотра деталей - -### 2. Программный доступ - -```kotlin -// Получить список всех крашей -val crashes = CrashReportManager.getCrashReports(context) - -// Удалить конкретный краш -CrashReportManager.deleteCrashReport(context, fileName) - -// Удалить все краши -CrashReportManager.deleteAllCrashReports(context) -``` - -### 3. Тестирование системы - -Для проверки работы crash reporter можно добавить тестовый краш: - -```kotlin -// В любом месте приложения -Button(onClick = { - throw RuntimeException("Test crash for debugging") -}) { - Text("Test Crash") -} -``` - -## 📝 Формат Crash Report - -Пример сохраненного crash report: - -``` -=== CRASH REPORT === - -Timestamp: 2026-01-25 14:30:45 - -=== App Info === -Package: com.rosetta.messenger -Version: 1.0.0 (1) - -=== Device Info === -Manufacturer: Samsung -Model: SM-G991B -Android Version: 13 (API 33) -Device: o1s -Board: s5e9925 - -=== Thread Info === -Thread: main -Thread ID: 2 - -=== Exception === -Exception Type: java.lang.NullPointerException -Message: Attempt to invoke virtual method on null object - -=== Stack Trace === -java.lang.NullPointerException: Attempt to invoke virtual method on null object - at com.rosetta.messenger.ui.MainActivity.onCreate(MainActivity.kt:123) - at android.app.Activity.performCreate(Activity.java:8051) - ... - -=== Caused by (level 1) === -... -``` - -## 🔧 Настройки - -В [CrashReportManager.kt](rosetta-android/app/src/main/java/com/rosetta/messenger/utils/CrashReportManager.kt): - -```kotlin -private const val MAX_CRASH_FILES = 50 // Максимум файлов -private const val CRASH_DIR = "crash_reports" // Директория хранения -``` - -## 💾 Хранение данных - -- **Расположение**: `/data/data/com.rosetta.messenger/files/crash_reports/` -- **Формат файлов**: `crash_YYYY-MM-DD_HH-mm-ss.txt` -- **Автоочистка**: Старые файлы удаляются при превышении лимита - -## 🎨 UI Features - -### Список крашей - -- ❌ Красная иконка bug для каждого краша -- 📅 Дата и время краша -- 🔍 Тип исключения -- 🗑️ Кнопка удаления для каждого - -### Детальный просмотр - -- 📄 Полный текст crash report -- ✂️ Возможность выделения и копирования текста -- 🔙 Навигация назад -- 🗑️ Удаление краша - -### Пустое состояние - -Если нет крашей, показывается дружелюбное сообщение: - -``` -🐛 No crash reports -Great! Your app is running smoothly -``` - -## 🔒 Безопасность и Privacy - -- ✅ Данные хранятся только локально -- ✅ Не отправляются на сторонние серверы -- ✅ Пользователь контролирует удаление -- ✅ Автоматическая ротация старых логов - -## 🛠️ Техническая реализация - -### Инициализация - -Crash reporter автоматически инициализируется в `RosettaApplication.onCreate()`: - -```kotlin -class RosettaApplication : Application() { - override fun onCreate() { - super.onCreate() - CrashReportManager.init(this) - } -} -``` - -### Обработка крашей - -`CrashReportManager` реализует `Thread.UncaughtExceptionHandler`: - -- Перехватывает необработанные исключения -- Сохраняет детальную информацию -- Передает управление дефолтному handler (для нормального завершения) - -## 📊 Мониторинг - -### Что отслеживать: - -1. **Частота крашей**: Как часто происходят краши -2. **Тип исключений**: Какие типы ошибок встречаются -3. **Места крашей**: В каких частях кода происходят проблемы -4. **Устройства**: На каких устройствах чаще крашится - -### Аналитика - -Регулярно проверяйте Crash Logs раздел для: - -- Выявления паттернов -- Приоритизации багов -- Улучшения стабильности - -## 🚧 Дальнейшие улучшения - -Возможные доработки: - -1. **Export**: Экспорт крашей в файл для отправки разработчикам -2. **Filtering**: Фильтрация по типу исключения, дате -3. **Statistics**: Статистика по типам крашей -4. **Auto-report**: Опциональная отправка на сервер (с согласия) -5. **Symbols**: Интеграция с символами для более читаемых stack traces - -## 🐛 Debug режим - -В debug режиме рекомендуется добавить быстрый доступ к Crash Logs: - -```kotlin -// В ProfileScreen или DebugMenu -if (BuildConfig.DEBUG) { - Button(onClick = { - throw RuntimeException("Test crash") - }) { - Text("Trigger Test Crash") - } -} -``` - -## ✅ Чеклист интеграции - -- [x] CrashReportManager создан -- [x] RosettaApplication настроен -- [x] AndroidManifest обновлен -- [x] UI для просмотра создан -- [x] Навигация добавлена -- [x] Автоочистка настроена - -## 📞 Получение крашей от пользователей - -Если пользователь сообщает о краше: - -1. Попросите открыть Profile → Crash Logs -2. Найти краш по дате/времени -3. Скопировать текст краша -4. Отправить разработчикам - -## 🎯 Best Practices - -1. **Регулярная проверка**: Просматривайте краши минимум раз в неделю -2. **Приоритеты**: Сначала исправляйте частые краши -3. **Тестирование**: После исправления проверяйте что краш не воспроизводится -4. **Документация**: Документируйте причины и решения крашей - ---- - -**Статус**: ✅ Готово к использованию -**Версия**: 1.0 -**Дата**: 25 января 2026 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4c2843c..a30e0a9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,8 +7,15 @@ plugins { fun safeGitOutput(vararg args: String): String? { return runCatching { - providers.exec { commandLine("git", *args) }.standardOutput.asText.get().trim().ifBlank { null } - }.getOrNull() + providers + .exec { commandLine("git", *args) } + .standardOutput + .asText + .get() + .trim() + .ifBlank { null } + } + .getOrNull() } val versionBase = providers.gradleProperty("ROSETTA_VERSION_BASE").orElse("1.0") @@ -51,6 +58,7 @@ android { buildTypes { release { isMinifyEnabled = false + isShrinkResources = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -72,11 +80,9 @@ android { buildConfig = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } - packaging { + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } - jniLibs { - useLegacyPackaging = true - } + jniLibs { useLegacyPackaging = true } } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index acb3b44..ec2738b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -36,6 +36,11 @@ @com.google.firebase.messaging.RemoteMessage$MessageNotificationKeys ; } +# ============================================================ +# SLF4J (used by BitcoinJ/other libs) +# ============================================================ +-dontwarn org.slf4j.impl.StaticLoggerBinder + # ============================================================ # Kotlin & Coroutines # ============================================================ @@ -88,6 +93,13 @@ @androidx.compose.runtime.Composable ; } +# ============================================================ +# R8 VerifyError fix: prevent R8 from generating invalid +# bytecode (instance-of on unexpected class Integer) in +# app UI lambdas with primitive boxing/unboxing +# ============================================================ +-keep,allowobfuscation class com.rosetta.messenger.ui.** { *; } + # ============================================================ # Data Models # ============================================================ diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 4a5ac38..0dcbcb4 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -57,8 +57,11 @@ import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull class MainActivity : FragmentActivity() { private lateinit var preferencesManager: PreferencesManager @@ -386,37 +389,39 @@ class MainActivity : FragmentActivity() { /** 🔔 Инициализация Firebase Cloud Messaging */ private fun initializeFirebase() { - try { - addFcmLog("🔔 Инициализация Firebase...") - // Инициализируем Firebase - FirebaseApp.initializeApp(this) - addFcmLog("✅ Firebase инициализирован") + lifecycleScope.launch(Dispatchers.Default) { + try { + addFcmLog("🔔 Инициализация Firebase...") + // Инициализируем Firebase (тяжёлая операция — не на Main) + FirebaseApp.initializeApp(this@MainActivity) + addFcmLog("✅ Firebase инициализирован") - // Получаем FCM токен - addFcmLog("📲 Запрос FCM токена...") - FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (!task.isSuccessful) { - addFcmLog("❌ Ошибка получения токена: ${task.exception?.message}") - return@addOnCompleteListener + // Получаем FCM токен + addFcmLog("📲 Запрос FCM токена...") + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + if (!task.isSuccessful) { + addFcmLog("❌ Ошибка получения токена: ${task.exception?.message}") + return@addOnCompleteListener + } + + val token = task.result + + if (token != null) { + val shortToken = "${token.take(12)}...${token.takeLast(8)}" + addFcmLog("✅ FCM токен получен: $shortToken") + // Сохраняем токен локально + saveFcmToken(token) + addFcmLog("💾 Токен сохранен локально") + } else { + addFcmLog("⚠️ Токен пустой") + } + + // Токен будет отправлен на сервер после успешной аутентификации + // (см. вызов sendFcmTokenToServer в onAccountLogin) } - - val token = task.result - - if (token != null) { - val shortToken = "${token.take(12)}...${token.takeLast(8)}" - addFcmLog("✅ FCM токен получен: $shortToken") - // Сохраняем токен локально - saveFcmToken(token) - addFcmLog("💾 Токен сохранен локально") - } else { - addFcmLog("⚠️ Токен пустой") - } - - // Токен будет отправлен на сервер после успешной аутентификации - // (см. вызов sendFcmTokenToServer в onAccountLogin) + } catch (e: Exception) { + addFcmLog("❌ Ошибка Firebase: ${e.message}") } - } catch (e: Exception) { - addFcmLog("❌ Ошибка Firebase: ${e.message}") } } @@ -446,15 +451,12 @@ class MainActivity : FragmentActivity() { addFcmLog("⏳ Ожидание аутентификации...") // 🔥 КРИТИЧНО: Ждем пока протокол станет AUTHENTICATED - var waitAttempts = 0 - while (ProtocolManager.state.value != ProtocolState.AUTHENTICATED && - waitAttempts < 50) { - delay(100) // Ждем 100ms - waitAttempts++ + val authenticated = withTimeoutOrNull(5000) { + ProtocolManager.state.first { it == ProtocolState.AUTHENTICATED } } - if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) { - addFcmLog("❌ Таймаут аутентификации (${waitAttempts * 100}ms)") + if (authenticated == null) { + addFcmLog("❌ Таймаут аутентификации (5000ms)") return@launch } diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 3ca712f..7b7d9c7 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -24,6 +24,10 @@ object MessageCrypto { private const val CHACHA_KEY_SIZE = 32 private const val XCHACHA_NONCE_SIZE = 24 private const val POLY1305_TAG_SIZE = 16 + + // Кэш PBKDF2-SHA256 ключей: password → derived key bytes + // PBKDF2 с 1000 итерациями ~50-100ms, кэш убирает повторные вычисления + private val pbkdf2Cache = java.util.concurrent.ConcurrentHashMap() init { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { @@ -608,6 +612,13 @@ object MessageCrypto { * но PBEKeySpec в Java использует UTF-16! Поэтому используем ручную реализацию. */ private fun generatePBKDF2Key(password: String, salt: String = "rosetta", iterations: Int = 1000): ByteArray { + // Кэшируем только для дефолтных salt/iterations (99% вызовов) + if (salt == "rosetta" && iterations == 1000) { + return pbkdf2Cache.getOrPut(password) { + val passwordBytes = password.toByteArray(Charsets.UTF_8) + generatePBKDF2KeyFromBytes(passwordBytes, salt.toByteArray(Charsets.UTF_8), iterations) + } + } // Crypto-js: WordArray.create(password) использует UTF-8 val passwordBytes = password.toByteArray(Charsets.UTF_8) return generatePBKDF2KeyFromBytes(passwordBytes, salt.toByteArray(Charsets.UTF_8), iterations) diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index fb59b1e..281fa37 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -1,6 +1,7 @@ package com.rosetta.messenger.network import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -29,7 +30,9 @@ data class TransportState( */ object TransportManager { private const val TAG = "TransportManager" - + private const val MAX_RETRIES = 3 + private const val INITIAL_BACKOFF_MS = 1000L + // Fallback transport server (CDN) private const val FALLBACK_TRANSPORT_SERVER = "https://cdn.rosetta-im.com" @@ -67,6 +70,24 @@ object TransportManager { return server } + /** + * Retry с exponential backoff: 1с, 2с, 4с + */ + private suspend fun withRetry(block: suspend () -> T): T { + var lastException: Exception? = null + repeat(MAX_RETRIES) { attempt -> + try { + return block() + } catch (e: IOException) { + lastException = e + if (attempt < MAX_RETRIES - 1) { + delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s + } + } + } + throw lastException!! + } + /** * Запросить адрес транспортного сервера с сервера протокола */ @@ -83,80 +104,81 @@ object TransportManager { */ suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) { val server = getActiveServer() - + // Добавляем в список загрузок _uploading.value = _uploading.value + TransportState(id, 0) - + try { - // 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content])) - val contentBytes = content.toByteArray(Charsets.UTF_8) - val totalSize = contentBytes.size.toLong() - - // 🔥 RequestBody с отслеживанием прогресса загрузки - val progressRequestBody = object : RequestBody() { - override fun contentType() = "application/octet-stream".toMediaType() - override fun contentLength() = totalSize - - override fun writeTo(sink: okio.BufferedSink) { - val source = okio.Buffer().write(contentBytes) - var uploaded = 0L - val bufferSize = 8 * 1024L // 8 KB chunks - - while (true) { - val read = source.read(sink.buffer, bufferSize) - if (read == -1L) break - - uploaded += read - sink.flush() - - // Обновляем прогресс - val progress = ((uploaded * 100) / totalSize).toInt() - _uploading.value = _uploading.value.map { - if (it.id == id) it.copy(progress = progress) else it + withRetry { + // 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content])) + val contentBytes = content.toByteArray(Charsets.UTF_8) + val totalSize = contentBytes.size.toLong() + + // 🔥 RequestBody с отслеживанием прогресса загрузки + val progressRequestBody = object : RequestBody() { + override fun contentType() = "application/octet-stream".toMediaType() + override fun contentLength() = totalSize + + override fun writeTo(sink: okio.BufferedSink) { + val source = okio.Buffer().write(contentBytes) + var uploaded = 0L + val bufferSize = 8 * 1024L // 8 KB chunks + + while (true) { + val read = source.read(sink.buffer, bufferSize) + if (read == -1L) break + + uploaded += read + sink.flush() + + // Обновляем прогресс + val progress = ((uploaded * 100) / totalSize).toInt() + _uploading.value = _uploading.value.map { + if (it.id == id) it.copy(progress = progress) else it + } } } } + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", id, progressRequestBody) + .build() + + val request = Request.Builder() + .url("$server/u") + .post(requestBody) + .build() + + val response = suspendCoroutine { cont -> + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + cont.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response) + } + }) + } + + if (!response.isSuccessful) { + throw IOException("Upload failed: ${response.code}") + } + + val responseBody = response.body?.string() + ?: throw IOException("Empty response") + + // Parse JSON response to get tag + val tag = org.json.JSONObject(responseBody).getString("t") + + // Обновляем прогресс до 100% + _uploading.value = _uploading.value.map { + if (it.id == id) it.copy(progress = 100) else it + } + + tag } - - val requestBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("file", id, progressRequestBody) - .build() - - val request = Request.Builder() - .url("$server/u") - .post(requestBody) - .build() - - val response = suspendCoroutine { cont -> - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - cont.resumeWithException(e) - } - - override fun onResponse(call: Call, response: Response) { - cont.resume(response) - } - }) - } - - if (!response.isSuccessful) { - val errorBody = response.body?.string() ?: "No error body" - throw IOException("Upload failed: ${response.code}") - } - - val responseBody = response.body?.string() - ?: throw IOException("Empty response") - - // Parse JSON response to get tag - val tag = org.json.JSONObject(responseBody).getString("t") - - // Обновляем прогресс до 100% - _uploading.value = _uploading.value.map { - if (it.id == id) it.copy(progress = 100) else it - } - - tag } finally { // Удаляем из списка загрузок _uploading.value = _uploading.value.filter { it.id != id } @@ -171,43 +193,43 @@ object TransportManager { */ suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) { val server = getActiveServer() - - + // Добавляем в список скачиваний _downloading.value = _downloading.value + TransportState(id, 0) - + try { - val request = Request.Builder() - .url("$server/d/$tag") - .get() - .build() - - val response = suspendCoroutine { cont -> - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - cont.resumeWithException(e) - } - - override fun onResponse(call: Call, response: Response) { - cont.resume(response) - } - }) + withRetry { + val request = Request.Builder() + .url("$server/d/$tag") + .get() + .build() + + val response = suspendCoroutine { cont -> + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + cont.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response) + } + }) + } + + if (!response.isSuccessful) { + throw IOException("Download failed: ${response.code}") + } + + val content = response.body?.string() + ?: throw IOException("Empty response") + + // Обновляем прогресс до 100% + _downloading.value = _downloading.value.map { + if (it.id == id) it.copy(progress = 100) else it + } + + content } - - if (!response.isSuccessful) { - throw IOException("Download failed: ${response.code}") - } - - val content = response.body?.string() - ?: throw IOException("Empty response") - - - // Обновляем прогресс до 100% - _downloading.value = _downloading.value.map { - if (it.id == id) it.copy(progress = 100) else it - } - - content } finally { // Удаляем из списка скачиваний _downloading.value = _downloading.value.filter { it.id != id } diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index 17f2515..94a9652 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -197,7 +197,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { return runCatching { val accountManager = AccountManager(applicationContext) val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty() - runBlocking { + runBlocking(Dispatchers.IO) { PreferencesManager(applicationContext).isChatMuted(currentAccount, senderPublicKey) } }.getOrDefault(false) diff --git a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt index 5efe026..ea9eed2 100644 --- a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt @@ -5,8 +5,13 @@ import com.rosetta.messenger.database.AvatarCacheEntity import com.rosetta.messenger.database.AvatarDao import com.rosetta.messenger.utils.AvatarFileManager import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.withContext +import java.io.Closeable /** * Репозиторий для работы с аватарами @@ -23,20 +28,31 @@ class AvatarRepository( private val context: Context, private val avatarDao: AvatarDao, private val currentPublicKey: String -) { +) : Closeable { companion object { private const val TAG = "AvatarRepository" private const val MAX_AVATAR_HISTORY = 5 // Хранить последние N аватаров + private const val MAX_CACHE_SIZE = 100 } - + // Repository scope для coroutines + private val supervisorJob = kotlinx.coroutines.SupervisorJob() private val repositoryScope = kotlinx.coroutines.CoroutineScope( - kotlinx.coroutines.SupervisorJob() + Dispatchers.IO + supervisorJob + Dispatchers.IO ) - - // In-memory cache (как decodedAvatarsCache в desktop) - // publicKey -> Flow> - private val memoryCache = mutableMapOf>>() + + // In-memory LRU cache: publicKey -> (Flow, Job) + // При вытеснении отменяем Job подписки на БД + private data class CacheEntry(val flow: MutableStateFlow>, val job: Job?) + private val memoryCache = object : LinkedHashMap(MAX_CACHE_SIZE, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + if (size > MAX_CACHE_SIZE) { + eldest?.value?.job?.cancel() + return true + } + return false + } + } /** * Получить аватары пользователя @@ -44,22 +60,21 @@ class AvatarRepository( * @param allDecode true = вся история, false = только последний (для списков) */ fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow> { - // Проверяем memory cache - if (memoryCache.containsKey(publicKey)) { - return memoryCache[publicKey]!!.asStateFlow() - } - + // Проверяем LRU cache (accessOrder=true обновляет позицию при get) + memoryCache[publicKey]?.let { return it.flow.asStateFlow() } + // Создаем новый flow для этого пользователя val flow = MutableStateFlow>(emptyList()) - memoryCache[publicKey] = flow - - // Подписываемся на изменения в БД с использованием repository scope - avatarDao.getAvatars(publicKey) + + // Подписываемся на изменения в БД + val job = avatarDao.getAvatars(publicKey) .onEach { entities -> val avatars = if (allDecode) { - // Загружаем всю историю - entities.mapNotNull { entity -> - loadAndDecryptAvatar(entity) + // Параллельная загрузка всей истории + coroutineScope { + entities.map { entity -> async { loadAndDecryptAvatar(entity) } } + .awaitAll() + .filterNotNull() } } else { // Загружаем только последний @@ -70,7 +85,8 @@ class AvatarRepository( flow.value = avatars } .launchIn(repositoryScope) - + + memoryCache[publicKey] = CacheEntry(flow, job) return flow.asStateFlow() } @@ -107,13 +123,12 @@ class AvatarRepository( avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY) // 🔄 Обновляем memory cache если он существует - val cachedFlow = memoryCache[fromPublicKey] - if (cachedFlow != null) { + val cached = memoryCache[fromPublicKey] + if (cached != null) { val avatarInfo = loadAndDecryptAvatar(entity) if (avatarInfo != null) { - cachedFlow.value = listOf(avatarInfo) + cached.flow.value = listOf(avatarInfo) } - } else { } } catch (e: Exception) { @@ -172,8 +187,8 @@ class AvatarRepository( // Удаляем из БД avatarDao.deleteAllAvatars(currentPublicKey) - // Очищаем memory cache - memoryCache.remove(currentPublicKey) + // Очищаем memory cache + отменяем Job + memoryCache.remove(currentPublicKey)?.job?.cancel() } catch (e: Exception) { throw e @@ -206,8 +221,14 @@ class AvatarRepository( * Очистить memory cache (для освобождения памяти) */ fun clearMemoryCache() { + memoryCache.values.forEach { it.job?.cancel() } memoryCache.clear() } + + override fun close() { + clearMemoryCache() + supervisorJob.cancel() + } /** * Предзагрузить системные аватары (для ботов/системных аккаунтов) @@ -216,14 +237,14 @@ class AvatarRepository( suspend fun preloadSystemAvatars(systemAccounts: Map) { withContext(Dispatchers.IO) { systemAccounts.forEach { (publicKey, base64Avatar) -> - // Сохраняем только в memory cache, не в БД + // Сохраняем только в memory cache, не в БД (job=null — нет подписки) val flow = MutableStateFlow(listOf( AvatarInfo( base64Data = base64Avatar, timestamp = 0 ) )) - memoryCache[publicKey] = flow + memoryCache[publicKey] = CacheEntry(flow, job = null) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index addab26..6e53dcd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -54,6 +54,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull // Account model for dropdown data class AccountItem( @@ -122,13 +123,11 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword( ProtocolManager.connect() // Wait for websocket connection - var waitAttempts = 0 - while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) { - kotlinx.coroutines.delay(100) - waitAttempts++ + val connected = withTimeoutOrNull(5000) { + ProtocolManager.state.first { it != ProtocolState.DISCONNECTED } } val connectTime = System.currentTimeMillis() - connectStart -if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) { + if (connected == null) { onError("Failed to connect to server") onUnlocking(false) return diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 271aa50..fb0da86 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -47,11 +47,14 @@ import com.rosetta.messenger.ui.components.BlurredAvatarBackground import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.settings.BackgroundBlurPresets +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import compose.icons.TablerIcons import compose.icons.tablericons.* import java.text.SimpleDateFormat import java.util.* import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull @Immutable data class Chat( @@ -185,15 +188,6 @@ fun ChatsListScreen( val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() - // 🔥 Перехватываем системный back gesture - не закрываем приложение - // Если drawer открыт - закрываем его, иначе игнорируем - BackHandler(enabled = true) { - if (drawerState.isOpen) { - scope.launch { drawerState.close() } - } - // Если drawer закрыт - ничего не делаем (не выходим из приложения) - } - // 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen // Используем DisposableEffect чтобы срабатывало при каждом появлении экрана DisposableEffect(Unit) { @@ -287,6 +281,29 @@ fun ChatsListScreen( var dialogToBlock by remember { mutableStateOf(null) } var dialogToUnblock by remember { mutableStateOf(null) } + // 🔥 Selection mode state + var selectedChatKeys by remember { mutableStateOf>(emptySet()) } + val isSelectionMode = selectedChatKeys.isNotEmpty() + val hapticFeedback = LocalHapticFeedback.current + var showSelectionMenu by remember { mutableStateOf(false) } + val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } + val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey) + .collectAsState(initial = emptySet()) + + // Перехватываем системный back gesture - не закрываем приложение + BackHandler(enabled = true) { + if (isSelectionMode) { + selectedChatKeys = emptySet() + } else if (drawerState.isOpen) { + scope.launch { drawerState.close() } + } + } + + // Close selection when drawer opens + LaunchedEffect(drawerState.isOpen) { + if (drawerState.isOpen) selectedChatKeys = emptySet() + } + // Реактивный set заблокированных пользователей из ViewModel (Room Flow) val blockedUsers by chatsViewModel.blockedUsers.collectAsState() @@ -811,12 +828,146 @@ fun ChatsListScreen( ) { Scaffold( topBar = { - key(isDarkTheme, showRequestsScreen) { - TopAppBar( + key(isDarkTheme, showRequestsScreen, isSelectionMode) { + Crossfade( + targetState = isSelectionMode, + animationSpec = tween(200), + label = "headerCrossfade" + ) { inSelection -> + if (inSelection) { + // ═══ SELECTION MODE HEADER ═══ + TopAppBar( + navigationIcon = { + IconButton(onClick = { selectedChatKeys = emptySet() }) { + Icon( + TablerIcons.X, + contentDescription = "Close", + tint = Color.White + ) + } + }, + title = { + Text( + "${selectedChatKeys.size}", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = Color.White + ) + }, + actions = { + // Mute / Unmute + val allMuted = selectedChatKeys.all { mutedChats.contains(it) } + IconButton(onClick = { + val keys = selectedChatKeys.toSet() + selectedChatKeys = emptySet() + scope.launch { + keys.forEach { key -> + preferencesManager.setChatMuted(accountPublicKey, key, !allMuted) + } + } + }) { + Icon( + if (allMuted) TablerIcons.Bell else TablerIcons.BellOff, + contentDescription = if (allMuted) "Unmute" else "Mute", + tint = Color.White + ) + } + + // Delete + IconButton(onClick = { + val allDialogs = topLevelChatsState.dialogs + val first = selectedChatKeys.firstOrNull() + val dlg = allDialogs.find { it.opponentKey == first } + if (dlg != null) dialogToDelete = dlg + selectedChatKeys = emptySet() + }) { + Icon( + TablerIcons.Trash, + contentDescription = "Delete", + tint = Color.White + ) + } + + // Three dots menu + Box { + IconButton(onClick = { showSelectionMenu = true }) { + Icon( + TablerIcons.DotsVertical, + contentDescription = "More", + tint = Color.White + ) + } + DropdownMenu( + expanded = showSelectionMenu, + onDismissRequest = { showSelectionMenu = false }, + modifier = Modifier.background(if (isDarkTheme) Color(0xFF2C2C2E) else Color.White) + ) { + // Pin / Unpin + val allPinned = selectedChatKeys.all { pinnedChats.contains(it) } + DropdownMenuItem( + text = { + Text( + if (allPinned) "Unpin" else "Pin", + color = if (isDarkTheme) Color.White else Color.Black + ) + }, + onClick = { + selectedChatKeys.forEach { onTogglePin(it) } + showSelectionMenu = false + selectedChatKeys = emptySet() + }, + leadingIcon = { + Icon( + if (allPinned) TablerIcons.PinnedOff else TablerIcons.Pin, + contentDescription = null, + tint = if (isDarkTheme) Color.White else Color.Black + ) + } + ) + // Block + val anyBlocked = selectedChatKeys.any { blockedUsers.contains(it) } + DropdownMenuItem( + text = { + Text( + if (anyBlocked) "Unblock" else "Block", + color = Color(0xFFE53935) + ) + }, + onClick = { + val allDialogs = topLevelChatsState.dialogs + val first = selectedChatKeys.firstOrNull() + val dlg = allDialogs.find { it.opponentKey == first } + if (dlg != null) { + if (anyBlocked) dialogToUnblock = dlg + else dialogToBlock = dlg + } + showSelectionMenu = false + selectedChatKeys = emptySet() + }, + leadingIcon = { + Icon( + TablerIcons.Ban, + contentDescription = null, + tint = Color(0xFFE53935) + ) + } + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4), + scrolledContainerColor = if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4), + navigationIconContentColor = Color.White, + titleContentColor = Color.White, + actionIconContentColor = Color.White + ) + ) + } else { + // ═══ NORMAL HEADER ═══ + TopAppBar( navigationIcon = { if (showRequestsScreen) { - // Back button for - // Requests IconButton( onClick = { showRequestsScreen = @@ -833,8 +984,6 @@ fun ChatsListScreen( ) } } else { - // Menu button for - // main screen IconButton( onClick = { scope @@ -870,7 +1019,6 @@ fun ChatsListScreen( }, title = { if (showRequestsScreen) { - // Requests title Text( "Requests", fontWeight = @@ -880,7 +1028,6 @@ fun ChatsListScreen( color = Color.White ) } else { - // Rosetta title or Connecting animation if (protocolState == ProtocolState.AUTHENTICATED) { Text( "Rosetta", @@ -903,8 +1050,6 @@ fun ChatsListScreen( } }, actions = { - // Search only on main - // screen if (!showRequestsScreen) { IconButton( onClick = { @@ -955,6 +1100,8 @@ fun ChatsListScreen( Color.White ) ) + } // end else normal header + } // end Crossfade } }, floatingActionButton = { @@ -1293,6 +1440,8 @@ fun ChatsListScreen( isBlocked, isSavedMessages = isSavedMessages, + isMuted = + mutedChats.contains(dialog.opponentKey), avatarRepository = avatarRepository, isDrawerOpen = @@ -1303,6 +1452,8 @@ fun ChatsListScreen( isSwipedOpen = swipedItemKey == dialog.opponentKey, + isSelected = + selectedChatKeys.contains(dialog.opponentKey), onSwipeStarted = { swipedItemKey = dialog.opponentKey @@ -1315,16 +1466,31 @@ fun ChatsListScreen( null }, onClick = { - swipedItemKey = - null - val user = - chatsViewModel - .dialogToSearchUser( - dialog - ) - onUserSelect( - user - ) + if (isSelectionMode) { + // Toggle selection + selectedChatKeys = if (selectedChatKeys.contains(dialog.opponentKey)) + selectedChatKeys - dialog.opponentKey + else + selectedChatKeys + dialog.opponentKey + } else { + swipedItemKey = + null + val user = + chatsViewModel + .dialogToSearchUser( + dialog + ) + onUserSelect( + user + ) + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + selectedChatKeys = if (selectedChatKeys.contains(dialog.opponentKey)) + selectedChatKeys - dialog.opponentKey + else + selectedChatKeys + dialog.opponentKey }, onDelete = { dialogToDelete = @@ -1676,6 +1842,7 @@ fun ChatItem( chat: Chat, isDarkTheme: Boolean, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, + isMuted: Boolean = false, onClick: () -> Unit ) { val textColor = if (isDarkTheme) Color.White else Color.Black @@ -1722,6 +1889,16 @@ fun ChatItem( modifier = Modifier.weight(1f) ) + if (isMuted) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + TablerIcons.BellOff, + contentDescription = "Muted", + tint = secondaryTextColor, + modifier = Modifier.size(14.dp) + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { // Read status Icon( @@ -1910,12 +2087,15 @@ fun SwipeableDialogItem( isTyping: Boolean = false, isBlocked: Boolean = false, isSavedMessages: Boolean = false, + isMuted: Boolean = false, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, isDrawerOpen: Boolean = false, isSwipedOpen: Boolean = false, + isSelected: Boolean = false, onSwipeStarted: () -> Unit = {}, onSwipeClosed: () -> Unit = {}, onClick: () -> Unit, + onLongClick: () -> Unit = {}, onDelete: () -> Unit = {}, onBlock: () -> Unit = {}, onUnblock: () -> Unit = {}, @@ -1923,7 +2103,9 @@ fun SwipeableDialogItem( onPin: () -> Unit = {} ) { val targetBackgroundColor = - if (isPinned) { + if (isSelected) { + if (isDarkTheme) Color(0xFF1A3A5C) else Color(0xFFD6EAFF) + } else if (isPinned) { if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED) } else { if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) @@ -2098,6 +2280,7 @@ fun SwipeableDialogItem( .pointerInput(Unit) { val velocityTracker = VelocityTracker() val touchSlop = viewConfiguration.touchSlop + val longPressTimeoutMs = viewConfiguration.longPressTimeoutMillis awaitEachGesture { val down = @@ -2114,6 +2297,99 @@ fun SwipeableDialogItem( var passedSlop = false var claimed = false + // Phase 1: Determine gesture type (tap / long-press / drag) + // Wait up to longPressTimeout; if no up or slop → long press + var gestureType = "unknown" + + val result = withTimeoutOrNull(longPressTimeoutMs) { + while (true) { + val event = awaitPointerEvent() + val change = + event.changes.firstOrNull { + it.id == down.id + } + if (change == null) { + gestureType = "cancelled" + return@withTimeoutOrNull Unit + } + if (change.changedToUpIgnoreConsumed()) { + change.consume() + gestureType = "tap" + return@withTimeoutOrNull Unit + } + val delta = change.positionChange() + totalDragX += delta.x + totalDragY += delta.y + val dist = kotlin.math.sqrt( + totalDragX * totalDragX + + totalDragY * totalDragY + ) + if (dist >= touchSlop) { + gestureType = "drag" + return@withTimeoutOrNull Unit + } + } + @Suppress("UNREACHABLE_CODE") + Unit + } + + // Timeout → long press + if (result == null) gestureType = "longpress" + + when (gestureType) { + "tap" -> { + onClick() + return@awaitEachGesture + } + "cancelled" -> return@awaitEachGesture + "longpress" -> { + onLongClick() + // Consume remaining events until finger lifts + while (true) { + val event = awaitPointerEvent() + val change = + event.changes.firstOrNull { + it.id == down.id + } ?: break + change.consume() + if (change.changedToUpIgnoreConsumed()) break + } + return@awaitEachGesture + } + "drag" -> { + // Determine drag direction + val dominated = + kotlin.math.abs(totalDragX) > + kotlin.math.abs(totalDragY) * 2.0f + + when { + // Horizontal left swipe — reveal action buttons + dominated && totalDragX < 0 -> { + passedSlop = true + claimed = true + onSwipeStarted() + } + // Horizontal right swipe with buttons open — close them + dominated && totalDragX > 0 && offsetX != 0f -> { + passedSlop = true + claimed = true + } + // Right swipe with buttons closed — let drawer handle + totalDragX > 0 && offsetX == 0f -> + return@awaitEachGesture + // Vertical/diagonal — close buttons if open, let LazyColumn scroll + else -> { + if (offsetX != 0f) { + offsetX = 0f + onSwipeClosed() + } + return@awaitEachGesture + } + } + } + } + + // Phase 2: Continue tracking drag while (true) { val event = awaitPointerEvent() val change = @@ -2121,137 +2397,36 @@ fun SwipeableDialogItem( it.id == down.id } ?: break - if (change.changedToUpIgnoreConsumed() - ) { - // Tap detected — finger went up before touchSlop - if (!passedSlop) { - change.consume() - onClick() - } - break - } + if (change.changedToUpIgnoreConsumed()) break val delta = change.positionChange() - totalDragX += delta.x - totalDragY += delta.y - - if (!passedSlop) { - val dist = - kotlin.math.sqrt( - totalDragX * - totalDragX + - totalDragY * - totalDragY - ) - if (dist < touchSlop) - continue - - val dominated = - kotlin.math.abs( - totalDragX - ) > - kotlin.math - .abs( - totalDragY - ) * - 2.0f - - when { - // Horizontal left - // swipe — reveal - // action buttons - dominated && - totalDragX < - 0 -> { - passedSlop = - true - claimed = - true - onSwipeStarted() - change.consume() - } - // Horizontal right - // swipe with - // buttons open — - // close them - dominated && - totalDragX > - 0 && - offsetX != - 0f -> { - passedSlop = - true - claimed = - true - change.consume() - } - // Right swipe with - // buttons closed — - // let drawer handle - totalDragX > 0 && - offsetX == - 0f -> - break - // Vertical/diagonal - // — close buttons - // if open, let - // LazyColumn scroll - else -> { - if (offsetX != - 0f - ) { - offsetX = - 0f - onSwipeClosed() - } - break - } - } - } else { - // Gesture is ours — update - // offset - val newOffset = - offsetX + delta.x - offsetX = - newOffset.coerceIn( - -swipeWidthPx, - 0f - ) - velocityTracker.addPosition( - change.uptimeMillis, - change.position - ) - change.consume() - } + val newOffset = offsetX + delta.x + offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) + velocityTracker.addPosition( + change.uptimeMillis, + change.position + ) + change.consume() } - // Snap animation + // Phase 3: Snap animation if (claimed) { val velocity = velocityTracker .calculateVelocity() .x when { - // Rightward fling — always - // close velocity > 150f -> { offsetX = 0f onSwipeClosed() } - // Strong leftward fling — - // always open velocity < -300f -> { - offsetX = - -swipeWidthPx + offsetX = -swipeWidthPx } - // Past halfway — stay open kotlin.math.abs(offsetX) > - swipeWidthPx / - 2 -> { - offsetX = - -swipeWidthPx + swipeWidthPx / 2 -> { + offsetX = -swipeWidthPx } - // Less than halfway — close else -> { offsetX = 0f onSwipeClosed() @@ -2267,6 +2442,7 @@ fun SwipeableDialogItem( isTyping = isTyping, isPinned = isPinned, isBlocked = isBlocked, + isMuted = isMuted, avatarRepository = avatarRepository, onClick = null // Tap handled by parent pointerInput ) @@ -2290,6 +2466,7 @@ fun DialogItemContent( isTyping: Boolean = false, isPinned: Boolean = false, isBlocked: Boolean = false, + isMuted: Boolean = false, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, onClick: (() -> Unit)? = null ) { @@ -2480,6 +2657,15 @@ fun DialogItemContent( modifier = Modifier.size(14.dp) ) } + if (isMuted) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = TablerIcons.BellOff, + contentDescription = "Muted", + tint = secondaryTextColor, + modifier = Modifier.size(14.dp) + ) + } } Row( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index d34316f..65bdbc5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -6,7 +6,14 @@ import android.graphics.Matrix import android.util.Base64 import android.util.LruCache import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Image @@ -34,6 +41,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -53,6 +61,7 @@ import compose.icons.TablerIcons import compose.icons.tablericons.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.compose.ui.platform.LocalConfiguration @@ -62,8 +71,8 @@ import kotlin.math.min private const val TAG = "AttachmentComponents" /** - * 🔄 Анимированный текст с точками (Downloading... → Downloading. → Downloading.. → Downloading...) - * Как в Telegram - точки плавно появляются и исчезают + * Анимированный текст с волнообразными точками. + * Три точки плавно подпрыгивают каскадом с изменением прозрачности. */ @Composable fun AnimatedDotsText( @@ -72,34 +81,78 @@ fun AnimatedDotsText( fontSize: androidx.compose.ui.unit.TextUnit = 12.sp, fontWeight: FontWeight = FontWeight.Normal ) { - var dotCount by remember { mutableIntStateOf(0) } - - // Анимация точек: 0 → 1 → 2 → 3 → 0 → ... - LaunchedEffect(Unit) { - while (true) { - delay(400) // Интервал между изменениями - dotCount = (dotCount + 1) % 4 - } - } - - val dots = ".".repeat(dotCount) - // Добавляем невидимые точки для фиксированной ширины текста - val invisibleDots = ".".repeat(3 - dotCount) - - Row { + val infiniteTransition = rememberInfiniteTransition(label = "dots") + + val dot0 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0f at 0 + 1f at 300 + 0f at 600 + 0f at 1200 + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(0) + ), + label = "dot0" + ) + val dot1 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0f at 0 + 1f at 300 + 0f at 600 + 0f at 1200 + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(200) + ), + label = "dot1" + ) + val dot2 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0f at 0 + 1f at 300 + 0f at 600 + 0f at 1200 + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(400) + ), + label = "dot2" + ) + + val dotValues = listOf(dot0, dot1, dot2) + val bounceHeight = with(LocalDensity.current) { fontSize.toPx() * 0.35f } + + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "$baseText$dots", + text = baseText, fontSize = fontSize, fontWeight = fontWeight, color = color ) - // Невидимые точки для сохранения ширины - Text( - text = invisibleDots, - fontSize = fontSize, - fontWeight = fontWeight, - color = Color.Transparent - ) + dotValues.forEach { progress -> + Text( + text = ".", + fontSize = fontSize, + fontWeight = fontWeight, + color = color.copy(alpha = 0.4f + 0.6f * progress), + modifier = Modifier.graphicsLayer { + translationY = -bounceHeight * progress + } + ) + } } } @@ -112,29 +165,47 @@ object ImageBitmapCache { // Размер кэша = 1/8 доступной памяти (стандартная практика Android) private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() private val cacheSize = maxMemory / 8 - + private val cache = object : LruCache(cacheSize) { override fun sizeOf(key: String, bitmap: Bitmap): Int { // Размер в килобайтах return bitmap.byteCount / 1024 } } - + + // Flow для уведомления о новых записях (заменяет polling retry loops) + private val _updates = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 64) + val updates: kotlinx.coroutines.flow.SharedFlow = _updates + fun get(key: String): Bitmap? = cache.get(key) - + fun put(key: String, bitmap: Bitmap) { if (cache.get(key) == null) { cache.put(key, bitmap) + _updates.tryEmit(key) } } - + fun remove(key: String) { cache.remove(key) } - + fun clear() { cache.evictAll() } + + /** + * Ждёт появления bitmap в кэше по ключу (вместо polling retry loop). + * Возвращает null при таймауте. + */ + suspend fun awaitCached(key: String, timeoutMs: Long = 3000): Bitmap? { + // Может уже быть в кэше + get(key)?.let { return it } + return kotlinx.coroutines.withTimeoutOrNull(timeoutMs) { + updates.first { it == key } + get(key) + } + } } /** @@ -2037,6 +2108,63 @@ internal fun base64ToBitmap(base64: String): Bitmap? { } } +/** + * CDN download + decrypt + cache + save. + * Shared between ReplyBubble and ForwardedImagePreview. + * + * @return loaded Bitmap or null + */ +internal suspend fun downloadAndDecryptImage( + attachmentId: String, + downloadTag: String, + chachaKey: String, + privateKey: String, + cacheKey: String, + context: android.content.Context, + senderPublicKey: String, + recipientPrivateKey: String +): Bitmap? { + if (downloadTag.isEmpty() || chachaKey.isEmpty() || privateKey.isEmpty()) return null + + return withContext(Dispatchers.IO) { + try { + val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag) + if (encryptedContent.isEmpty()) return@withContext null + + val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) + + // Try decryptReplyBlob first (desktop decodeWithPassword) + var decrypted = try { + MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce) + .takeIf { it.isNotEmpty() && it != encryptedContent } + } catch (_: Exception) { null } + + // Fallback: decryptAttachmentBlobWithPlainKey + if (decrypted == null) { + decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( + encryptedContent, plainKeyAndNonce + ) + } + + if (decrypted == null) return@withContext null + + val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted + val bitmap = base64ToBitmap(base64Data) ?: return@withContext null + + ImageBitmapCache.put(cacheKey, bitmap) + AttachmentFileManager.saveAttachment( + context = context, + blob = base64Data, + attachmentId = attachmentId, + publicKey = senderPublicKey, + privateKey = recipientPrivateKey + ) + + bitmap + } catch (_: Exception) { null } + } +} + /** Форматирование размера файла */ private fun formatFileSize(bytes: Long): String { return when { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index c865b41..78f05cd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -46,8 +46,6 @@ import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import com.rosetta.messenger.crypto.MessageCrypto -import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.utils.AttachmentFileManager import com.vanniktech.blurhash.BlurHash import compose.icons.TablerIcons @@ -1202,68 +1200,29 @@ fun ReplyBubble( } catch (e: Exception) {} } - // 5. Retry: фото может загрузиться в кэш параллельно + // 5. Ждём пока другой composable загрузит фото в кэш if (imageBitmap == null) { - repeat(6) { - kotlinx.coroutines.delay(500) - val retry = ImageBitmapCache.get("img_${imageAttachment.id}") - if (retry != null) { - imageBitmap = retry - return@LaunchedEffect - } + val awaited = ImageBitmapCache.awaitCached("img_${imageAttachment.id}") + if (awaited != null) { + imageBitmap = awaited + return@LaunchedEffect } } // 6. CDN download — для форвардов, где фото загружено на CDN if (imageBitmap == null && imageAttachment.preview.isNotEmpty()) { val downloadTag = getDownloadTag(imageAttachment.preview) - if (downloadTag.isNotEmpty()) { - try { - withContext(Dispatchers.IO) { - val encryptedContent = TransportManager.downloadFile( - imageAttachment.id, downloadTag - ) - if (encryptedContent.isNotEmpty()) { - // Desktop: decryptKeyFromSender → decodeWithPassword - var decrypted: String? = null - - if (chachaKey.isNotEmpty() && privateKey.isNotEmpty()) { - try { - val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender( - chachaKey, privateKey - ) - // decryptReplyBlob = desktop decodeWithPassword - decrypted = try { - MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce) - .takeIf { it.isNotEmpty() && it != encryptedContent } - } catch (_: Exception) { null } - if (decrypted == null) { - decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( - encryptedContent, plainKeyAndNonce - ) - } - } catch (_: Exception) {} - } - - if (decrypted != null) { - val bitmap = base64ToBitmap(decrypted) - if (bitmap != null) { - imageBitmap = bitmap - ImageBitmapCache.put("img_${imageAttachment.id}", bitmap) - // Сохраняем на диск - AttachmentFileManager.saveAttachment( - context = context, - blob = decrypted, - attachmentId = imageAttachment.id, - publicKey = replyData.senderPublicKey, - privateKey = replyData.recipientPrivateKey - ) - } - } - } - } - } catch (_: Exception) {} - } + val bitmap = downloadAndDecryptImage( + attachmentId = imageAttachment.id, + downloadTag = downloadTag, + chachaKey = chachaKey, + privateKey = privateKey, + cacheKey = "img_${imageAttachment.id}", + context = context, + senderPublicKey = replyData.senderPublicKey, + recipientPrivateKey = replyData.recipientPrivateKey + ) + if (bitmap != null) imageBitmap = bitmap } } } @@ -1609,54 +1568,26 @@ private fun ForwardedImagePreview( } } } catch (_: Exception) {} - - // CDN download — exactly like desktop useAttachment.ts - if (downloadTag.isNotEmpty() && chachaKey.isNotEmpty() && privateKey.isNotEmpty()) { - try { - val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) - if (encryptedContent.isNotEmpty()) { - // Desktop: decryptKeyFromSender → plainKeyAndNonce → decodeWithPassword - val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) - // decryptReplyBlob = exact same as desktop decodeWithPassword: - // bytesToJsUtf8String(plainKeyAndNonce) → PBKDF2(password,'rosetta',SHA256,1000) → AES-CBC → inflate - val decrypted = MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce) - if (decrypted.isNotEmpty() && decrypted != encryptedContent) { - val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted - val bitmap = base64ToBitmap(base64Data) - if (bitmap != null) { - imageBitmap = bitmap - ImageBitmapCache.put(cacheKey, bitmap) - AttachmentFileManager.saveAttachment( - context, base64Data, attachment.id, - senderPublicKey, recipientPrivateKey - ) - return@withContext - } - } - // Fallback: try decryptAttachmentBlobWithPlainKey (same logic, different entry point) - val decrypted2 = MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKeyAndNonce) - if (decrypted2 != null) { - val base64Data = if (decrypted2.contains(",")) decrypted2.substringAfter(",") else decrypted2 - val bitmap = base64ToBitmap(base64Data) - if (bitmap != null) { - imageBitmap = bitmap - ImageBitmapCache.put(cacheKey, bitmap) - AttachmentFileManager.saveAttachment( - context, base64Data, attachment.id, - senderPublicKey, recipientPrivateKey - ) - } - } - } - } catch (_: Exception) {} - } } - // Retry from cache (another composable may have loaded it) + // CDN download — exactly like desktop useAttachment.ts if (imageBitmap == null) { - repeat(5) { - kotlinx.coroutines.delay(400) - ImageBitmapCache.get(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect } + val bitmap = downloadAndDecryptImage( + attachmentId = attachment.id, + downloadTag = downloadTag, + chachaKey = chachaKey, + privateKey = privateKey, + cacheKey = cacheKey, + context = context, + senderPublicKey = senderPublicKey, + recipientPrivateKey = recipientPrivateKey + ) + if (bitmap != null) imageBitmap = bitmap + } + + // Ждём пока другой composable загрузит фото в кэш + if (imageBitmap == null) { + ImageBitmapCache.awaitCached(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index b14489b..5256f18 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -148,9 +148,9 @@ fun SwipeBackContainer( alpha = currentAlpha } .background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White) - .then( - if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) { - Modifier.pointerInput(Unit) { + .pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut) { + if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput + val velocityTracker = VelocityTracker() val touchSlop = viewConfiguration.touchSlop * @@ -304,11 +304,7 @@ fun SwipeBackContainer( } } } - } - } else { - Modifier - } - ) + } ) { content() } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 2b4a8ec..ecd8179 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -187,8 +187,8 @@ fun OtherProfileScreen( } val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp val sharedPagerMinHeight = (screenHeightDp * 0.45f).coerceAtLeast(240.dp) - LaunchedEffect(selectedTab) { - onSwipeBackEnabledChanged(selectedTab == OtherProfileTab.MEDIA) + LaunchedEffect(showImageViewer) { + onSwipeBackEnabledChanged(!showImageViewer) } val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)