- Added AvatarRepository for handling avatar storage, retrieval, and delivery. - Created AvatarCacheEntity and AvatarDeliveryEntity for database storage. - Introduced PacketAvatar for P2P avatar transfer between clients. - Enhanced RosettaDatabase to include avatar-related tables and migration. - Developed AvatarFileManager for file operations related to avatars. - Implemented AvatarImage composable for displaying user avatars. - Updated ProfileScreen to support avatar selection and updating. - Added functionality for handling incoming avatar packets in ProtocolManager.
386 lines
12 KiB
Markdown
386 lines
12 KiB
Markdown
# 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 версия
|
||
|
||
❌ Не совместимо (другой протокол)
|