feat: Implement avatar management system with P2P delivery
- 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.
This commit is contained in:
385
AVATAR_IMPLEMENTATION.md
Normal file
385
AVATAR_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# 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 версия
|
||||
|
||||
❌ Не совместимо (другой протокол)
|
||||
Reference in New Issue
Block a user