Refactor and optimize various components

- Updated RosettaFirebaseMessagingService to use IO dispatcher for blocking calls.
- Enhanced AvatarRepository with LRU caching and improved coroutine handling for avatar loading.
- Implemented timeout for websocket connection in UnlockScreen.
- Added selection mode functionality in ChatsListScreen with haptic feedback and improved UI for chat actions.
- Improved animated dots in AttachmentComponents for a smoother visual effect.
- Refactored image downloading and caching logic in ChatDetailComponents to streamline the process.
- Optimized SwipeBackContainer to simplify gesture handling.
- Adjusted swipe back behavior in OtherProfileScreen based on image viewer state.
This commit is contained in:
2026-02-12 15:38:30 +05:00
parent 263d00b783
commit ea537ccce1
16 changed files with 775 additions and 1370 deletions

View File

@@ -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<List<AvatarInfo>> = 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 версия
Не совместимо (другой протокол)

View File

@@ -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 ⚠️ (требует тестирования)

View File

@@ -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

View File

@@ -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"
@@ -74,9 +82,7 @@ android {
composeOptions { kotlinCompilerExtensionVersion = "1.5.4" }
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
jniLibs {
useLegacyPackaging = true
}
jniLibs { useLegacyPackaging = true }
}
}

View File

@@ -36,6 +36,11 @@
@com.google.firebase.messaging.RemoteMessage$MessageNotificationKeys <fields>;
}
# ============================================================
# SLF4J (used by BitcoinJ/other libs)
# ============================================================
-dontwarn org.slf4j.impl.StaticLoggerBinder
# ============================================================
# Kotlin & Coroutines
# ============================================================
@@ -88,6 +93,13 @@
@androidx.compose.runtime.Composable <methods>;
}
# ============================================================
# 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
# ============================================================

View File

@@ -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,10 +389,11 @@ class MainActivity : FragmentActivity() {
/** 🔔 Инициализация Firebase Cloud Messaging */
private fun initializeFirebase() {
lifecycleScope.launch(Dispatchers.Default) {
try {
addFcmLog("🔔 Инициализация Firebase...")
// Инициализируем Firebase
FirebaseApp.initializeApp(this)
// Инициализируем Firebase (тяжёлая операция — не на Main)
FirebaseApp.initializeApp(this@MainActivity)
addFcmLog("✅ Firebase инициализирован")
// Получаем FCM токен
@@ -419,6 +423,7 @@ class MainActivity : FragmentActivity() {
addFcmLog("❌ Ошибка Firebase: ${e.message}")
}
}
}
/** Сохранить FCM токен в SharedPreferences */
private fun saveFcmToken(token: String) {
@@ -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
}

View File

@@ -25,6 +25,10 @@ object MessageCrypto {
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<String, ByteArray>()
init {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(BouncyCastleProvider())
@@ -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)

View File

@@ -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,6 +30,8 @@ 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 <T> 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!!
}
/**
* Запросить адрес транспортного сервера с сервера протокола
*/
@@ -88,6 +109,7 @@ object TransportManager {
_uploading.value = _uploading.value + TransportState(id, 0)
try {
withRetry {
// 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content]))
val contentBytes = content.toByteArray(Charsets.UTF_8)
val totalSize = contentBytes.size.toLong()
@@ -141,7 +163,6 @@ object TransportManager {
}
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "No error body"
throw IOException("Upload failed: ${response.code}")
}
@@ -157,6 +178,7 @@ object TransportManager {
}
tag
}
} finally {
// Удаляем из списка загрузок
_uploading.value = _uploading.value.filter { it.id != id }
@@ -172,11 +194,11 @@ object TransportManager {
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer()
// Добавляем в список скачиваний
_downloading.value = _downloading.value + TransportState(id, 0)
try {
withRetry {
val request = Request.Builder()
.url("$server/d/$tag")
.get()
@@ -201,13 +223,13 @@ object TransportManager {
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 }

View File

@@ -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)

View File

@@ -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<List<AvatarInfo>>
private val memoryCache = mutableMapOf<String, MutableStateFlow<List<AvatarInfo>>>()
// In-memory LRU cache: publicKey -> (Flow, Job)
// При вытеснении отменяем Job подписки на БД
private data class CacheEntry(val flow: MutableStateFlow<List<AvatarInfo>>, val job: Job?)
private val memoryCache = object : LinkedHashMap<String, CacheEntry>(MAX_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, CacheEntry>?): 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<List<AvatarInfo>> {
// Проверяем 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<List<AvatarInfo>>(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 {
// Загружаем только последний
@@ -71,6 +86,7 @@ class AvatarRepository(
}
.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,9 +221,15 @@ class AvatarRepository(
* Очистить memory cache (для освобождения памяти)
*/
fun clearMemoryCache() {
memoryCache.values.forEach { it.job?.cancel() }
memoryCache.clear()
}
override fun close() {
clearMemoryCache()
supervisorJob.cancel()
}
/**
* Предзагрузить системные аватары (для ботов/системных аккаунтов)
* Аналогично desktop версии, которая использует hardcoded аватары
@@ -216,14 +237,14 @@ class AvatarRepository(
suspend fun preloadSystemAvatars(systemAccounts: Map<String, String>) {
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)
}
}
}

View File

@@ -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

View File

@@ -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<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
// 🔥 Selection mode state
var selectedChatKeys by remember { mutableStateOf<Set<String>>(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) {
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,6 +1466,13 @@ fun ChatsListScreen(
null
},
onClick = {
if (isSelectionMode) {
// Toggle selection
selectedChatKeys = if (selectedChatKeys.contains(dialog.opponentKey))
selectedChatKeys - dialog.opponentKey
else
selectedChatKeys + dialog.opponentKey
} else {
swipedItemKey =
null
val user =
@@ -1325,6 +1483,14 @@ fun ChatsListScreen(
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
)
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(

View File

@@ -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,35 +81,79 @@ fun AnimatedDotsText(
fontSize: androidx.compose.ui.unit.TextUnit = 12.sp,
fontWeight: FontWeight = FontWeight.Normal
) {
var dotCount by remember { mutableIntStateOf(0) }
val infiniteTransition = rememberInfiniteTransition(label = "dots")
// Анимация точек: 0 → 1 → 2 → 3 → 0 → ...
LaunchedEffect(Unit) {
while (true) {
delay(400) // Интервал между изменениями
dotCount = (dotCount + 1) % 4
}
}
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 dots = ".".repeat(dotCount)
// Добавляем невидимые точки для фиксированной ширины текста
val invisibleDots = ".".repeat(3 - dotCount)
val dotValues = listOf(dot0, dot1, dot2)
val bounceHeight = with(LocalDensity.current) { fontSize.toPx() * 0.35f }
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$baseText$dots",
text = baseText,
fontSize = fontSize,
fontWeight = fontWeight,
color = color
)
// Невидимые точки для сохранения ширины
dotValues.forEach { progress ->
Text(
text = invisibleDots,
text = ".",
fontSize = fontSize,
fontWeight = fontWeight,
color = Color.Transparent
color = color.copy(alpha = 0.4f + 0.6f * progress),
modifier = Modifier.graphicsLayer {
translationY = -bounceHeight * progress
}
)
}
}
}
/**
@@ -120,11 +173,16 @@ object ImageBitmapCache {
}
}
// Flow для уведомления о новых записях (заменяет polling retry loops)
private val _updates = kotlinx.coroutines.flow.MutableSharedFlow<String>(extraBufferCapacity = 64)
val updates: kotlinx.coroutines.flow.SharedFlow<String> = _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)
}
}
@@ -135,6 +193,19 @@ object ImageBitmapCache {
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 {

View File

@@ -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
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,
val bitmap = downloadAndDecryptImage(
attachmentId = imageAttachment.id,
publicKey = replyData.senderPublicKey,
privateKey = replyData.recipientPrivateKey
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
cacheKey = "img_${imageAttachment.id}",
context = context,
senderPublicKey = replyData.senderPublicKey,
recipientPrivateKey = replyData.recipientPrivateKey
)
}
}
}
}
} catch (_: Exception) {}
}
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
if (imageBitmap == null) {
val bitmap = downloadAndDecryptImage(
attachmentId = attachment.id,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
cacheKey = cacheKey,
context = context,
senderPublicKey = senderPublicKey,
recipientPrivateKey = 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) {}
}
if (bitmap != null) imageBitmap = bitmap
}
// Retry from cache (another composable may have loaded it)
// Ждём пока другой composable загрузит фото в кэш
if (imageBitmap == null) {
repeat(5) {
kotlinx.coroutines.delay(400)
ImageBitmapCache.get(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect }
ImageBitmapCache.awaitCached(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect
}
}
}

View File

@@ -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 *
@@ -305,10 +305,6 @@ fun SwipeBackContainer(
}
}
}
} else {
Modifier
}
)
) { content() }
}
}

View File

@@ -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)