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:
@@ -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 версия
|
||||
|
||||
❌ Не совместимо (другой протокол)
|
||||
@@ -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 ⚠️ (требует тестирования)
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -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
|
||||
# ============================================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, ByteArray>()
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 <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!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Запросить адрес транспортного сервера с сервера протокола
|
||||
*/
|
||||
@@ -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<Response> { 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<Response> { 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<Response> { 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<Response> { 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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
// Загружаем только последний
|
||||
@@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
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(
|
||||
|
||||
@@ -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<String, Bitmap>(cacheSize) {
|
||||
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
||||
// Размер в килобайтах
|
||||
return bitmap.byteCount / 1024
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user