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? {
|
fun safeGitOutput(vararg args: String): String? {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
providers.exec { commandLine("git", *args) }.standardOutput.asText.get().trim().ifBlank { null }
|
providers
|
||||||
}.getOrNull()
|
.exec { commandLine("git", *args) }
|
||||||
|
.standardOutput
|
||||||
|
.asText
|
||||||
|
.get()
|
||||||
|
.trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
val versionBase = providers.gradleProperty("ROSETTA_VERSION_BASE").orElse("1.0")
|
val versionBase = providers.gradleProperty("ROSETTA_VERSION_BASE").orElse("1.0")
|
||||||
@@ -51,6 +58,7 @@ android {
|
|||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
@@ -74,9 +82,7 @@ android {
|
|||||||
composeOptions { kotlinCompilerExtensionVersion = "1.5.4" }
|
composeOptions { kotlinCompilerExtensionVersion = "1.5.4" }
|
||||||
packaging {
|
packaging {
|
||||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||||
jniLibs {
|
jniLibs { useLegacyPackaging = true }
|
||||||
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>;
|
@com.google.firebase.messaging.RemoteMessage$MessageNotificationKeys <fields>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SLF4J (used by BitcoinJ/other libs)
|
||||||
|
# ============================================================
|
||||||
|
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Kotlin & Coroutines
|
# Kotlin & Coroutines
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -88,6 +93,13 @@
|
|||||||
@androidx.compose.runtime.Composable <methods>;
|
@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
|
# Data Models
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -57,8 +57,11 @@ import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
class MainActivity : FragmentActivity() {
|
class MainActivity : FragmentActivity() {
|
||||||
private lateinit var preferencesManager: PreferencesManager
|
private lateinit var preferencesManager: PreferencesManager
|
||||||
@@ -386,10 +389,11 @@ class MainActivity : FragmentActivity() {
|
|||||||
|
|
||||||
/** 🔔 Инициализация Firebase Cloud Messaging */
|
/** 🔔 Инициализация Firebase Cloud Messaging */
|
||||||
private fun initializeFirebase() {
|
private fun initializeFirebase() {
|
||||||
|
lifecycleScope.launch(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
addFcmLog("🔔 Инициализация Firebase...")
|
addFcmLog("🔔 Инициализация Firebase...")
|
||||||
// Инициализируем Firebase
|
// Инициализируем Firebase (тяжёлая операция — не на Main)
|
||||||
FirebaseApp.initializeApp(this)
|
FirebaseApp.initializeApp(this@MainActivity)
|
||||||
addFcmLog("✅ Firebase инициализирован")
|
addFcmLog("✅ Firebase инициализирован")
|
||||||
|
|
||||||
// Получаем FCM токен
|
// Получаем FCM токен
|
||||||
@@ -419,6 +423,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
addFcmLog("❌ Ошибка Firebase: ${e.message}")
|
addFcmLog("❌ Ошибка Firebase: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Сохранить FCM токен в SharedPreferences */
|
/** Сохранить FCM токен в SharedPreferences */
|
||||||
private fun saveFcmToken(token: String) {
|
private fun saveFcmToken(token: String) {
|
||||||
@@ -446,15 +451,12 @@ class MainActivity : FragmentActivity() {
|
|||||||
addFcmLog("⏳ Ожидание аутентификации...")
|
addFcmLog("⏳ Ожидание аутентификации...")
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Ждем пока протокол станет AUTHENTICATED
|
// 🔥 КРИТИЧНО: Ждем пока протокол станет AUTHENTICATED
|
||||||
var waitAttempts = 0
|
val authenticated = withTimeoutOrNull(5000) {
|
||||||
while (ProtocolManager.state.value != ProtocolState.AUTHENTICATED &&
|
ProtocolManager.state.first { it == ProtocolState.AUTHENTICATED }
|
||||||
waitAttempts < 50) {
|
|
||||||
delay(100) // Ждем 100ms
|
|
||||||
waitAttempts++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) {
|
if (authenticated == null) {
|
||||||
addFcmLog("❌ Таймаут аутентификации (${waitAttempts * 100}ms)")
|
addFcmLog("❌ Таймаут аутентификации (5000ms)")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ object MessageCrypto {
|
|||||||
private const val XCHACHA_NONCE_SIZE = 24
|
private const val XCHACHA_NONCE_SIZE = 24
|
||||||
private const val POLY1305_TAG_SIZE = 16
|
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 {
|
init {
|
||||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||||
Security.addProvider(BouncyCastleProvider())
|
Security.addProvider(BouncyCastleProvider())
|
||||||
@@ -608,6 +612,13 @@ object MessageCrypto {
|
|||||||
* но PBEKeySpec в Java использует UTF-16! Поэтому используем ручную реализацию.
|
* но PBEKeySpec в Java использует UTF-16! Поэтому используем ручную реализацию.
|
||||||
*/
|
*/
|
||||||
private fun generatePBKDF2Key(password: String, salt: String = "rosetta", iterations: Int = 1000): ByteArray {
|
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
|
// Crypto-js: WordArray.create(password) использует UTF-8
|
||||||
val passwordBytes = password.toByteArray(Charsets.UTF_8)
|
val passwordBytes = password.toByteArray(Charsets.UTF_8)
|
||||||
return generatePBKDF2KeyFromBytes(passwordBytes, salt.toByteArray(Charsets.UTF_8), iterations)
|
return generatePBKDF2KeyFromBytes(passwordBytes, salt.toByteArray(Charsets.UTF_8), iterations)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.rosetta.messenger.network
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -29,6 +30,8 @@ data class TransportState(
|
|||||||
*/
|
*/
|
||||||
object TransportManager {
|
object TransportManager {
|
||||||
private const val TAG = "TransportManager"
|
private const val TAG = "TransportManager"
|
||||||
|
private const val MAX_RETRIES = 3
|
||||||
|
private const val INITIAL_BACKOFF_MS = 1000L
|
||||||
|
|
||||||
// Fallback transport server (CDN)
|
// Fallback transport server (CDN)
|
||||||
private const val FALLBACK_TRANSPORT_SERVER = "https://cdn.rosetta-im.com"
|
private const val FALLBACK_TRANSPORT_SERVER = "https://cdn.rosetta-im.com"
|
||||||
@@ -67,6 +70,24 @@ object TransportManager {
|
|||||||
return server
|
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)
|
_uploading.value = _uploading.value + TransportState(id, 0)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
withRetry {
|
||||||
// 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content]))
|
// 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content]))
|
||||||
val contentBytes = content.toByteArray(Charsets.UTF_8)
|
val contentBytes = content.toByteArray(Charsets.UTF_8)
|
||||||
val totalSize = contentBytes.size.toLong()
|
val totalSize = contentBytes.size.toLong()
|
||||||
@@ -141,7 +163,6 @@ object TransportManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
val errorBody = response.body?.string() ?: "No error body"
|
|
||||||
throw IOException("Upload failed: ${response.code}")
|
throw IOException("Upload failed: ${response.code}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +178,7 @@ object TransportManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tag
|
tag
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Удаляем из списка загрузок
|
// Удаляем из списка загрузок
|
||||||
_uploading.value = _uploading.value.filter { it.id != id }
|
_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) {
|
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) {
|
||||||
val server = getActiveServer()
|
val server = getActiveServer()
|
||||||
|
|
||||||
|
|
||||||
// Добавляем в список скачиваний
|
// Добавляем в список скачиваний
|
||||||
_downloading.value = _downloading.value + TransportState(id, 0)
|
_downloading.value = _downloading.value + TransportState(id, 0)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
withRetry {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("$server/d/$tag")
|
.url("$server/d/$tag")
|
||||||
.get()
|
.get()
|
||||||
@@ -201,13 +223,13 @@ object TransportManager {
|
|||||||
val content = response.body?.string()
|
val content = response.body?.string()
|
||||||
?: throw IOException("Empty response")
|
?: throw IOException("Empty response")
|
||||||
|
|
||||||
|
|
||||||
// Обновляем прогресс до 100%
|
// Обновляем прогресс до 100%
|
||||||
_downloading.value = _downloading.value.map {
|
_downloading.value = _downloading.value.map {
|
||||||
if (it.id == id) it.copy(progress = 100) else it
|
if (it.id == id) it.copy(progress = 100) else it
|
||||||
}
|
}
|
||||||
|
|
||||||
content
|
content
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Удаляем из списка скачиваний
|
// Удаляем из списка скачиваний
|
||||||
_downloading.value = _downloading.value.filter { it.id != id }
|
_downloading.value = _downloading.value.filter { it.id != id }
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
return runCatching {
|
return runCatching {
|
||||||
val accountManager = AccountManager(applicationContext)
|
val accountManager = AccountManager(applicationContext)
|
||||||
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
|
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||||
runBlocking {
|
runBlocking(Dispatchers.IO) {
|
||||||
PreferencesManager(applicationContext).isChatMuted(currentAccount, senderPublicKey)
|
PreferencesManager(applicationContext).isChatMuted(currentAccount, senderPublicKey)
|
||||||
}
|
}
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import com.rosetta.messenger.database.AvatarCacheEntity
|
|||||||
import com.rosetta.messenger.database.AvatarDao
|
import com.rosetta.messenger.database.AvatarDao
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.flow.*
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.Closeable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Репозиторий для работы с аватарами
|
* Репозиторий для работы с аватарами
|
||||||
@@ -23,20 +28,31 @@ class AvatarRepository(
|
|||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val avatarDao: AvatarDao,
|
private val avatarDao: AvatarDao,
|
||||||
private val currentPublicKey: String
|
private val currentPublicKey: String
|
||||||
) {
|
) : Closeable {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "AvatarRepository"
|
private const val TAG = "AvatarRepository"
|
||||||
private const val MAX_AVATAR_HISTORY = 5 // Хранить последние N аватаров
|
private const val MAX_AVATAR_HISTORY = 5 // Хранить последние N аватаров
|
||||||
|
private const val MAX_CACHE_SIZE = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repository scope для coroutines
|
// Repository scope для coroutines
|
||||||
|
private val supervisorJob = kotlinx.coroutines.SupervisorJob()
|
||||||
private val repositoryScope = kotlinx.coroutines.CoroutineScope(
|
private val repositoryScope = kotlinx.coroutines.CoroutineScope(
|
||||||
kotlinx.coroutines.SupervisorJob() + Dispatchers.IO
|
supervisorJob + Dispatchers.IO
|
||||||
)
|
)
|
||||||
|
|
||||||
// In-memory cache (как decodedAvatarsCache в desktop)
|
// In-memory LRU cache: publicKey -> (Flow, Job)
|
||||||
// publicKey -> Flow<List<AvatarInfo>>
|
// При вытеснении отменяем Job подписки на БД
|
||||||
private val memoryCache = mutableMapOf<String, MutableStateFlow<List<AvatarInfo>>>()
|
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 = только последний (для списков)
|
* @param allDecode true = вся история, false = только последний (для списков)
|
||||||
*/
|
*/
|
||||||
fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow<List<AvatarInfo>> {
|
fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow<List<AvatarInfo>> {
|
||||||
// Проверяем memory cache
|
// Проверяем LRU cache (accessOrder=true обновляет позицию при get)
|
||||||
if (memoryCache.containsKey(publicKey)) {
|
memoryCache[publicKey]?.let { return it.flow.asStateFlow() }
|
||||||
return memoryCache[publicKey]!!.asStateFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем новый flow для этого пользователя
|
// Создаем новый flow для этого пользователя
|
||||||
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
|
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
|
||||||
memoryCache[publicKey] = flow
|
|
||||||
|
|
||||||
// Подписываемся на изменения в БД с использованием repository scope
|
// Подписываемся на изменения в БД
|
||||||
avatarDao.getAvatars(publicKey)
|
val job = avatarDao.getAvatars(publicKey)
|
||||||
.onEach { entities ->
|
.onEach { entities ->
|
||||||
val avatars = if (allDecode) {
|
val avatars = if (allDecode) {
|
||||||
// Загружаем всю историю
|
// Параллельная загрузка всей истории
|
||||||
entities.mapNotNull { entity ->
|
coroutineScope {
|
||||||
loadAndDecryptAvatar(entity)
|
entities.map { entity -> async { loadAndDecryptAvatar(entity) } }
|
||||||
|
.awaitAll()
|
||||||
|
.filterNotNull()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Загружаем только последний
|
// Загружаем только последний
|
||||||
@@ -71,6 +86,7 @@ class AvatarRepository(
|
|||||||
}
|
}
|
||||||
.launchIn(repositoryScope)
|
.launchIn(repositoryScope)
|
||||||
|
|
||||||
|
memoryCache[publicKey] = CacheEntry(flow, job)
|
||||||
return flow.asStateFlow()
|
return flow.asStateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,13 +123,12 @@ class AvatarRepository(
|
|||||||
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY)
|
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY)
|
||||||
|
|
||||||
// 🔄 Обновляем memory cache если он существует
|
// 🔄 Обновляем memory cache если он существует
|
||||||
val cachedFlow = memoryCache[fromPublicKey]
|
val cached = memoryCache[fromPublicKey]
|
||||||
if (cachedFlow != null) {
|
if (cached != null) {
|
||||||
val avatarInfo = loadAndDecryptAvatar(entity)
|
val avatarInfo = loadAndDecryptAvatar(entity)
|
||||||
if (avatarInfo != null) {
|
if (avatarInfo != null) {
|
||||||
cachedFlow.value = listOf(avatarInfo)
|
cached.flow.value = listOf(avatarInfo)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -172,8 +187,8 @@ class AvatarRepository(
|
|||||||
// Удаляем из БД
|
// Удаляем из БД
|
||||||
avatarDao.deleteAllAvatars(currentPublicKey)
|
avatarDao.deleteAllAvatars(currentPublicKey)
|
||||||
|
|
||||||
// Очищаем memory cache
|
// Очищаем memory cache + отменяем Job
|
||||||
memoryCache.remove(currentPublicKey)
|
memoryCache.remove(currentPublicKey)?.job?.cancel()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw e
|
throw e
|
||||||
@@ -206,9 +221,15 @@ class AvatarRepository(
|
|||||||
* Очистить memory cache (для освобождения памяти)
|
* Очистить memory cache (для освобождения памяти)
|
||||||
*/
|
*/
|
||||||
fun clearMemoryCache() {
|
fun clearMemoryCache() {
|
||||||
|
memoryCache.values.forEach { it.job?.cancel() }
|
||||||
memoryCache.clear()
|
memoryCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
clearMemoryCache()
|
||||||
|
supervisorJob.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Предзагрузить системные аватары (для ботов/системных аккаунтов)
|
* Предзагрузить системные аватары (для ботов/системных аккаунтов)
|
||||||
* Аналогично desktop версии, которая использует hardcoded аватары
|
* Аналогично desktop версии, которая использует hardcoded аватары
|
||||||
@@ -216,14 +237,14 @@ class AvatarRepository(
|
|||||||
suspend fun preloadSystemAvatars(systemAccounts: Map<String, String>) {
|
suspend fun preloadSystemAvatars(systemAccounts: Map<String, String>) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
systemAccounts.forEach { (publicKey, base64Avatar) ->
|
systemAccounts.forEach { (publicKey, base64Avatar) ->
|
||||||
// Сохраняем только в memory cache, не в БД
|
// Сохраняем только в memory cache, не в БД (job=null — нет подписки)
|
||||||
val flow = MutableStateFlow(listOf(
|
val flow = MutableStateFlow(listOf(
|
||||||
AvatarInfo(
|
AvatarInfo(
|
||||||
base64Data = base64Avatar,
|
base64Data = base64Avatar,
|
||||||
timestamp = 0
|
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.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
// Account model for dropdown
|
// Account model for dropdown
|
||||||
data class AccountItem(
|
data class AccountItem(
|
||||||
@@ -122,13 +123,11 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
|||||||
ProtocolManager.connect()
|
ProtocolManager.connect()
|
||||||
|
|
||||||
// Wait for websocket connection
|
// Wait for websocket connection
|
||||||
var waitAttempts = 0
|
val connected = withTimeoutOrNull(5000) {
|
||||||
while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) {
|
ProtocolManager.state.first { it != ProtocolState.DISCONNECTED }
|
||||||
kotlinx.coroutines.delay(100)
|
|
||||||
waitAttempts++
|
|
||||||
}
|
}
|
||||||
val connectTime = System.currentTimeMillis() - connectStart
|
val connectTime = System.currentTimeMillis() - connectStart
|
||||||
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
|
if (connected == null) {
|
||||||
onError("Failed to connect to server")
|
onError("Failed to connect to server")
|
||||||
onUnlocking(false)
|
onUnlocking(false)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -47,11 +47,14 @@ import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
|||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
|
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 compose.icons.tablericons.*
|
import compose.icons.tablericons.*
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class Chat(
|
data class Chat(
|
||||||
@@ -185,15 +188,6 @@ fun ChatsListScreen(
|
|||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// 🔥 Перехватываем системный back gesture - не закрываем приложение
|
|
||||||
// Если drawer открыт - закрываем его, иначе игнорируем
|
|
||||||
BackHandler(enabled = true) {
|
|
||||||
if (drawerState.isOpen) {
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
}
|
|
||||||
// Если drawer закрыт - ничего не делаем (не выходим из приложения)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen
|
// 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen
|
||||||
// Используем DisposableEffect чтобы срабатывало при каждом появлении экрана
|
// Используем DisposableEffect чтобы срабатывало при каждом появлении экрана
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
@@ -287,6 +281,29 @@ fun ChatsListScreen(
|
|||||||
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
|
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||||
var dialogToUnblock 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)
|
// Реактивный set заблокированных пользователей из ViewModel (Room Flow)
|
||||||
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
||||||
|
|
||||||
@@ -811,12 +828,146 @@ fun ChatsListScreen(
|
|||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
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(
|
TopAppBar(
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (showRequestsScreen) {
|
if (showRequestsScreen) {
|
||||||
// Back button for
|
|
||||||
// Requests
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showRequestsScreen =
|
showRequestsScreen =
|
||||||
@@ -833,8 +984,6 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Menu button for
|
|
||||||
// main screen
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope
|
scope
|
||||||
@@ -870,7 +1019,6 @@ fun ChatsListScreen(
|
|||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
if (showRequestsScreen) {
|
if (showRequestsScreen) {
|
||||||
// Requests title
|
|
||||||
Text(
|
Text(
|
||||||
"Requests",
|
"Requests",
|
||||||
fontWeight =
|
fontWeight =
|
||||||
@@ -880,7 +1028,6 @@ fun ChatsListScreen(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Rosetta title or Connecting animation
|
|
||||||
if (protocolState == ProtocolState.AUTHENTICATED) {
|
if (protocolState == ProtocolState.AUTHENTICATED) {
|
||||||
Text(
|
Text(
|
||||||
"Rosetta",
|
"Rosetta",
|
||||||
@@ -903,8 +1050,6 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Search only on main
|
|
||||||
// screen
|
|
||||||
if (!showRequestsScreen) {
|
if (!showRequestsScreen) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -955,6 +1100,8 @@ fun ChatsListScreen(
|
|||||||
Color.White
|
Color.White
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
} // end else normal header
|
||||||
|
} // end Crossfade
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -1293,6 +1440,8 @@ fun ChatsListScreen(
|
|||||||
isBlocked,
|
isBlocked,
|
||||||
isSavedMessages =
|
isSavedMessages =
|
||||||
isSavedMessages,
|
isSavedMessages,
|
||||||
|
isMuted =
|
||||||
|
mutedChats.contains(dialog.opponentKey),
|
||||||
avatarRepository =
|
avatarRepository =
|
||||||
avatarRepository,
|
avatarRepository,
|
||||||
isDrawerOpen =
|
isDrawerOpen =
|
||||||
@@ -1303,6 +1452,8 @@ fun ChatsListScreen(
|
|||||||
isSwipedOpen =
|
isSwipedOpen =
|
||||||
swipedItemKey ==
|
swipedItemKey ==
|
||||||
dialog.opponentKey,
|
dialog.opponentKey,
|
||||||
|
isSelected =
|
||||||
|
selectedChatKeys.contains(dialog.opponentKey),
|
||||||
onSwipeStarted = {
|
onSwipeStarted = {
|
||||||
swipedItemKey =
|
swipedItemKey =
|
||||||
dialog.opponentKey
|
dialog.opponentKey
|
||||||
@@ -1315,6 +1466,13 @@ fun ChatsListScreen(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
// Toggle selection
|
||||||
|
selectedChatKeys = if (selectedChatKeys.contains(dialog.opponentKey))
|
||||||
|
selectedChatKeys - dialog.opponentKey
|
||||||
|
else
|
||||||
|
selectedChatKeys + dialog.opponentKey
|
||||||
|
} else {
|
||||||
swipedItemKey =
|
swipedItemKey =
|
||||||
null
|
null
|
||||||
val user =
|
val user =
|
||||||
@@ -1325,6 +1483,14 @@ fun ChatsListScreen(
|
|||||||
onUserSelect(
|
onUserSelect(
|
||||||
user
|
user
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
selectedChatKeys = if (selectedChatKeys.contains(dialog.opponentKey))
|
||||||
|
selectedChatKeys - dialog.opponentKey
|
||||||
|
else
|
||||||
|
selectedChatKeys + dialog.opponentKey
|
||||||
},
|
},
|
||||||
onDelete = {
|
onDelete = {
|
||||||
dialogToDelete =
|
dialogToDelete =
|
||||||
@@ -1676,6 +1842,7 @@ fun ChatItem(
|
|||||||
chat: Chat,
|
chat: Chat,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
|
isMuted: Boolean = false,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
@@ -1722,6 +1889,16 @@ fun ChatItem(
|
|||||||
modifier = Modifier.weight(1f)
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
// Read status
|
// Read status
|
||||||
Icon(
|
Icon(
|
||||||
@@ -1910,12 +2087,15 @@ fun SwipeableDialogItem(
|
|||||||
isTyping: Boolean = false,
|
isTyping: Boolean = false,
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isSavedMessages: Boolean = false,
|
isSavedMessages: Boolean = false,
|
||||||
|
isMuted: Boolean = false,
|
||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
isDrawerOpen: Boolean = false,
|
isDrawerOpen: Boolean = false,
|
||||||
isSwipedOpen: Boolean = false,
|
isSwipedOpen: Boolean = false,
|
||||||
|
isSelected: Boolean = false,
|
||||||
onSwipeStarted: () -> Unit = {},
|
onSwipeStarted: () -> Unit = {},
|
||||||
onSwipeClosed: () -> Unit = {},
|
onSwipeClosed: () -> Unit = {},
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit = {},
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onBlock: () -> Unit = {},
|
onBlock: () -> Unit = {},
|
||||||
onUnblock: () -> Unit = {},
|
onUnblock: () -> Unit = {},
|
||||||
@@ -1923,7 +2103,9 @@ fun SwipeableDialogItem(
|
|||||||
onPin: () -> Unit = {}
|
onPin: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val targetBackgroundColor =
|
val targetBackgroundColor =
|
||||||
if (isPinned) {
|
if (isSelected) {
|
||||||
|
if (isDarkTheme) Color(0xFF1A3A5C) else Color(0xFFD6EAFF)
|
||||||
|
} else if (isPinned) {
|
||||||
if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED)
|
if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED)
|
||||||
} else {
|
} else {
|
||||||
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
@@ -2098,6 +2280,7 @@ fun SwipeableDialogItem(
|
|||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
val velocityTracker = VelocityTracker()
|
val velocityTracker = VelocityTracker()
|
||||||
val touchSlop = viewConfiguration.touchSlop
|
val touchSlop = viewConfiguration.touchSlop
|
||||||
|
val longPressTimeoutMs = viewConfiguration.longPressTimeoutMillis
|
||||||
|
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down =
|
val down =
|
||||||
@@ -2114,6 +2297,99 @@ fun SwipeableDialogItem(
|
|||||||
var passedSlop = false
|
var passedSlop = false
|
||||||
var claimed = 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) {
|
while (true) {
|
||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
val change =
|
val change =
|
||||||
@@ -2121,137 +2397,36 @@ fun SwipeableDialogItem(
|
|||||||
it.id == down.id
|
it.id == down.id
|
||||||
}
|
}
|
||||||
?: break
|
?: break
|
||||||
if (change.changedToUpIgnoreConsumed()
|
if (change.changedToUpIgnoreConsumed()) break
|
||||||
) {
|
|
||||||
// Tap detected — finger went up before touchSlop
|
|
||||||
if (!passedSlop) {
|
|
||||||
change.consume()
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
val delta = change.positionChange()
|
val delta = change.positionChange()
|
||||||
totalDragX += delta.x
|
val newOffset = offsetX + delta.x
|
||||||
totalDragY += delta.y
|
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
|
||||||
|
|
||||||
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(
|
velocityTracker.addPosition(
|
||||||
change.uptimeMillis,
|
change.uptimeMillis,
|
||||||
change.position
|
change.position
|
||||||
)
|
)
|
||||||
change.consume()
|
change.consume()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Snap animation
|
// Phase 3: Snap animation
|
||||||
if (claimed) {
|
if (claimed) {
|
||||||
val velocity =
|
val velocity =
|
||||||
velocityTracker
|
velocityTracker
|
||||||
.calculateVelocity()
|
.calculateVelocity()
|
||||||
.x
|
.x
|
||||||
when {
|
when {
|
||||||
// Rightward fling — always
|
|
||||||
// close
|
|
||||||
velocity > 150f -> {
|
velocity > 150f -> {
|
||||||
offsetX = 0f
|
offsetX = 0f
|
||||||
onSwipeClosed()
|
onSwipeClosed()
|
||||||
}
|
}
|
||||||
// Strong leftward fling —
|
|
||||||
// always open
|
|
||||||
velocity < -300f -> {
|
velocity < -300f -> {
|
||||||
offsetX =
|
offsetX = -swipeWidthPx
|
||||||
-swipeWidthPx
|
|
||||||
}
|
}
|
||||||
// Past halfway — stay open
|
|
||||||
kotlin.math.abs(offsetX) >
|
kotlin.math.abs(offsetX) >
|
||||||
swipeWidthPx /
|
swipeWidthPx / 2 -> {
|
||||||
2 -> {
|
offsetX = -swipeWidthPx
|
||||||
offsetX =
|
|
||||||
-swipeWidthPx
|
|
||||||
}
|
}
|
||||||
// Less than halfway — close
|
|
||||||
else -> {
|
else -> {
|
||||||
offsetX = 0f
|
offsetX = 0f
|
||||||
onSwipeClosed()
|
onSwipeClosed()
|
||||||
@@ -2267,6 +2442,7 @@ fun SwipeableDialogItem(
|
|||||||
isTyping = isTyping,
|
isTyping = isTyping,
|
||||||
isPinned = isPinned,
|
isPinned = isPinned,
|
||||||
isBlocked = isBlocked,
|
isBlocked = isBlocked,
|
||||||
|
isMuted = isMuted,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onClick = null // Tap handled by parent pointerInput
|
onClick = null // Tap handled by parent pointerInput
|
||||||
)
|
)
|
||||||
@@ -2290,6 +2466,7 @@ fun DialogItemContent(
|
|||||||
isTyping: Boolean = false,
|
isTyping: Boolean = false,
|
||||||
isPinned: Boolean = false,
|
isPinned: Boolean = false,
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
|
isMuted: Boolean = false,
|
||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
onClick: (() -> Unit)? = null
|
onClick: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
@@ -2480,6 +2657,15 @@ fun DialogItemContent(
|
|||||||
modifier = Modifier.size(14.dp)
|
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(
|
Row(
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import android.graphics.Matrix
|
|||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import androidx.compose.animation.core.Animatable
|
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.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.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Image
|
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.boundsInWindow
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -53,6 +61,7 @@ import compose.icons.TablerIcons
|
|||||||
import compose.icons.tablericons.*
|
import compose.icons.tablericons.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
@@ -62,8 +71,8 @@ import kotlin.math.min
|
|||||||
private const val TAG = "AttachmentComponents"
|
private const val TAG = "AttachmentComponents"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔄 Анимированный текст с точками (Downloading... → Downloading. → Downloading.. → Downloading...)
|
* Анимированный текст с волнообразными точками.
|
||||||
* Как в Telegram - точки плавно появляются и исчезают
|
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AnimatedDotsText(
|
fun AnimatedDotsText(
|
||||||
@@ -72,36 +81,80 @@ fun AnimatedDotsText(
|
|||||||
fontSize: androidx.compose.ui.unit.TextUnit = 12.sp,
|
fontSize: androidx.compose.ui.unit.TextUnit = 12.sp,
|
||||||
fontWeight: FontWeight = FontWeight.Normal
|
fontWeight: FontWeight = FontWeight.Normal
|
||||||
) {
|
) {
|
||||||
var dotCount by remember { mutableIntStateOf(0) }
|
val infiniteTransition = rememberInfiniteTransition(label = "dots")
|
||||||
|
|
||||||
// Анимация точек: 0 → 1 → 2 → 3 → 0 → ...
|
val dot0 by infiniteTransition.animateFloat(
|
||||||
LaunchedEffect(Unit) {
|
initialValue = 0f,
|
||||||
while (true) {
|
targetValue = 0f,
|
||||||
delay(400) // Интервал между изменениями
|
animationSpec = infiniteRepeatable(
|
||||||
dotCount = (dotCount + 1) % 4
|
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 dotValues = listOf(dot0, dot1, dot2)
|
||||||
// Добавляем невидимые точки для фиксированной ширины текста
|
val bounceHeight = with(LocalDensity.current) { fontSize.toPx() * 0.35f }
|
||||||
val invisibleDots = ".".repeat(3 - dotCount)
|
|
||||||
|
|
||||||
Row {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = "$baseText$dots",
|
text = baseText,
|
||||||
fontSize = fontSize,
|
fontSize = fontSize,
|
||||||
fontWeight = fontWeight,
|
fontWeight = fontWeight,
|
||||||
color = color
|
color = color
|
||||||
)
|
)
|
||||||
// Невидимые точки для сохранения ширины
|
dotValues.forEach { progress ->
|
||||||
Text(
|
Text(
|
||||||
text = invisibleDots,
|
text = ".",
|
||||||
fontSize = fontSize,
|
fontSize = fontSize,
|
||||||
fontWeight = fontWeight,
|
fontWeight = fontWeight,
|
||||||
color = Color.Transparent
|
color = color.copy(alpha = 0.4f + 0.6f * progress),
|
||||||
|
modifier = Modifier.graphicsLayer {
|
||||||
|
translationY = -bounceHeight * progress
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🖼️ Глобальный LRU кэш для bitmap'ов изображений
|
* 🖼️ Глобальный LRU кэш для bitmap'ов изображений
|
||||||
@@ -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 get(key: String): Bitmap? = cache.get(key)
|
||||||
|
|
||||||
fun put(key: String, bitmap: Bitmap) {
|
fun put(key: String, bitmap: Bitmap) {
|
||||||
if (cache.get(key) == null) {
|
if (cache.get(key) == null) {
|
||||||
cache.put(key, bitmap)
|
cache.put(key, bitmap)
|
||||||
|
_updates.tryEmit(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +193,19 @@ object ImageBitmapCache {
|
|||||||
fun clear() {
|
fun clear() {
|
||||||
cache.evictAll()
|
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 {
|
private fun formatFileSize(bytes: Long): String {
|
||||||
return when {
|
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.chats.utils.*
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
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.rosetta.messenger.utils.AttachmentFileManager
|
||||||
import com.vanniktech.blurhash.BlurHash
|
import com.vanniktech.blurhash.BlurHash
|
||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
@@ -1202,68 +1200,29 @@ fun ReplyBubble(
|
|||||||
} catch (e: Exception) {}
|
} catch (e: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Retry: фото может загрузиться в кэш параллельно
|
// 5. Ждём пока другой composable загрузит фото в кэш
|
||||||
if (imageBitmap == null) {
|
if (imageBitmap == null) {
|
||||||
repeat(6) {
|
val awaited = ImageBitmapCache.awaitCached("img_${imageAttachment.id}")
|
||||||
kotlinx.coroutines.delay(500)
|
if (awaited != null) {
|
||||||
val retry = ImageBitmapCache.get("img_${imageAttachment.id}")
|
imageBitmap = awaited
|
||||||
if (retry != null) {
|
|
||||||
imageBitmap = retry
|
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 6. CDN download — для форвардов, где фото загружено на CDN
|
// 6. CDN download — для форвардов, где фото загружено на CDN
|
||||||
if (imageBitmap == null && imageAttachment.preview.isNotEmpty()) {
|
if (imageBitmap == null && imageAttachment.preview.isNotEmpty()) {
|
||||||
val downloadTag = getDownloadTag(imageAttachment.preview)
|
val downloadTag = getDownloadTag(imageAttachment.preview)
|
||||||
if (downloadTag.isNotEmpty()) {
|
val bitmap = downloadAndDecryptImage(
|
||||||
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,
|
attachmentId = imageAttachment.id,
|
||||||
publicKey = replyData.senderPublicKey,
|
downloadTag = downloadTag,
|
||||||
privateKey = replyData.recipientPrivateKey
|
chachaKey = chachaKey,
|
||||||
|
privateKey = privateKey,
|
||||||
|
cacheKey = "img_${imageAttachment.id}",
|
||||||
|
context = context,
|
||||||
|
senderPublicKey = replyData.senderPublicKey,
|
||||||
|
recipientPrivateKey = replyData.recipientPrivateKey
|
||||||
)
|
)
|
||||||
}
|
if (bitmap != null) imageBitmap = bitmap
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1609,54 +1568,26 @@ private fun ForwardedImagePreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
// CDN download — exactly like desktop useAttachment.ts
|
// CDN download — exactly like desktop useAttachment.ts
|
||||||
if (downloadTag.isNotEmpty() && chachaKey.isNotEmpty() && privateKey.isNotEmpty()) {
|
if (imageBitmap == null) {
|
||||||
try {
|
val bitmap = downloadAndDecryptImage(
|
||||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
attachmentId = attachment.id,
|
||||||
if (encryptedContent.isNotEmpty()) {
|
downloadTag = downloadTag,
|
||||||
// Desktop: decryptKeyFromSender → plainKeyAndNonce → decodeWithPassword
|
chachaKey = chachaKey,
|
||||||
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
privateKey = privateKey,
|
||||||
// decryptReplyBlob = exact same as desktop decodeWithPassword:
|
cacheKey = cacheKey,
|
||||||
// bytesToJsUtf8String(plainKeyAndNonce) → PBKDF2(password,'rosetta',SHA256,1000) → AES-CBC → inflate
|
context = context,
|
||||||
val decrypted = MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
senderPublicKey = senderPublicKey,
|
||||||
if (decrypted.isNotEmpty() && decrypted != encryptedContent) {
|
recipientPrivateKey = recipientPrivateKey
|
||||||
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
|
if (bitmap != null) imageBitmap = bitmap
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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)
|
// Ждём пока другой composable загрузит фото в кэш
|
||||||
if (imageBitmap == null) {
|
if (imageBitmap == null) {
|
||||||
repeat(5) {
|
ImageBitmapCache.awaitCached(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect
|
||||||
kotlinx.coroutines.delay(400)
|
|
||||||
ImageBitmapCache.get(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,9 +148,9 @@ fun SwipeBackContainer(
|
|||||||
alpha = currentAlpha
|
alpha = currentAlpha
|
||||||
}
|
}
|
||||||
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
||||||
.then(
|
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut) {
|
||||||
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput
|
||||||
Modifier.pointerInput(Unit) {
|
|
||||||
val velocityTracker = VelocityTracker()
|
val velocityTracker = VelocityTracker()
|
||||||
val touchSlop =
|
val touchSlop =
|
||||||
viewConfiguration.touchSlop *
|
viewConfiguration.touchSlop *
|
||||||
@@ -305,10 +305,6 @@ fun SwipeBackContainer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { content() }
|
) { content() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ fun OtherProfileScreen(
|
|||||||
}
|
}
|
||||||
val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
|
val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
|
||||||
val sharedPagerMinHeight = (screenHeightDp * 0.45f).coerceAtLeast(240.dp)
|
val sharedPagerMinHeight = (screenHeightDp * 0.45f).coerceAtLeast(240.dp)
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(showImageViewer) {
|
||||||
onSwipeBackEnabledChanged(selectedTab == OtherProfileTab.MEDIA)
|
onSwipeBackEnabledChanged(!showImageViewer)
|
||||||
}
|
}
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
|
|||||||
Reference in New Issue
Block a user