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:
k1ngsterr1
2026-01-23 03:04:27 +05:00
parent 6fdad7a4c1
commit b08bea2c14
12 changed files with 1670 additions and 18 deletions

View File

@@ -0,0 +1,172 @@
package com.rosetta.messenger.ui.components
import android.graphics.Bitmap
import android.util.Base64
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.AvatarColors
import com.rosetta.messenger.ui.chats.getAvatarColor
import com.rosetta.messenger.ui.chats.getAvatarText
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Composable для отображения аватара пользователя
* Совместимо с desktop версией (AvatarProvider)
*
* Приоритет отображения:
* 1. Реальный аватар из AvatarRepository (если есть)
* 2. Цветной placeholder с инициалами (fallback)
*
* @param publicKey Публичный ключ пользователя
* @param avatarRepository Репозиторий аватаров
* @param size Размер аватара
* @param isDarkTheme Темная тема
* @param onClick Обработчик клика (опционально)
* @param showOnlineIndicator Показывать индикатор онлайн
* @param isOnline Пользователь онлайн
*/
@Composable
fun AvatarImage(
publicKey: String,
avatarRepository: AvatarRepository?,
size: Dp = 40.dp,
isDarkTheme: Boolean,
onClick: (() -> Unit)? = null,
showOnlineIndicator: Boolean = false,
isOnline: Boolean = false
) {
// Получаем аватары из репозитория
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
// Состояние для bitmap
var bitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) }
// Декодируем первый аватар
LaunchedEffect(avatars) {
bitmap = if (avatars.isNotEmpty()) {
withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
}
} else {
null
}
}
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
} else {
Modifier
}
),
contentAlignment = Alignment.Center
) {
if (bitmap != null) {
// Отображаем реальный аватар
Image(
bitmap = bitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
// Fallback: цветной placeholder
AvatarPlaceholder(
publicKey = publicKey,
size = size,
isDarkTheme = isDarkTheme
)
}
// Индикатор онлайн
if (showOnlineIndicator && isOnline) {
OnlineIndicator(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(size / 4)
)
}
}
}
/**
* Цветной placeholder аватара с инициалами
* (используется как fallback если нет реального аватара)
*/
@Composable
fun AvatarPlaceholder(
publicKey: String,
size: Dp = 40.dp,
isDarkTheme: Boolean,
fontSize: TextUnit? = null
) {
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
val avatarText = getAvatarText(publicKey)
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = avatarText,
color = avatarColors.textColor,
fontSize = fontSize ?: (size.value / 2.5).sp,
fontWeight = FontWeight.Medium
)
}
}
/**
* Индикатор онлайн статуса
*/
@Composable
private fun OnlineIndicator(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(CircleShape)
.background(Color(0xFF4CAF50))
)
}
/**
* Composable для выбора аватара (Image Picker)
* Использует Android intent для выбора изображения
*/
@Composable
fun AvatarPicker(
onAvatarSelected: (String) -> Unit
) {
// TODO: Реализовать выбор изображения через ActivityResultContract
// 1. Использовать rememberLauncherForActivityResult с ActivityResultContracts.GetContent()
// 2. Конвертировать URI в ByteArray
// 3. Использовать AvatarFileManager.imagePrepareForNetworkTransfer()
// 4. Вызвать onAvatarSelected с Base64 PNG
}

View File

@@ -3,7 +3,10 @@ package com.rosetta.messenger.ui.settings
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
@@ -47,9 +50,14 @@ import androidx.fragment.app.FragmentActivity
import com.rosetta.messenger.biometric.BiometricAuthManager
import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.ui.components.AvatarImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
private const val TAG = "ProfileScreen"
@@ -135,7 +143,8 @@ fun ProfileScreen(
onNavigateToTheme: () -> Unit = {},
onNavigateToSafety: () -> Unit = {},
onNavigateToLogs: () -> Unit = {},
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: AvatarRepository? = null
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
@@ -156,6 +165,46 @@ fun ProfileScreen(
// Состояние меню аватара для установки фото профиля
var showAvatarMenu by remember { mutableStateOf(false) }
// Image picker launcher для выбора аватара
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
scope.launch {
try {
// Читаем файл изображения
val inputStream = context.contentResolver.openInputStream(uri)
val imageBytes = inputStream?.readBytes()
inputStream?.close()
if (imageBytes != null) {
// Конвертируем в PNG Base64 (кросс-платформенная совместимость)
val base64Png = withContext(Dispatchers.IO) {
AvatarFileManager.imagePrepareForNetworkTransfer(context, imageBytes)
}
// Сохраняем аватар через репозиторий
avatarRepository?.changeMyAvatar(base64Png)
// Показываем успешное сообщение
android.widget.Toast.makeText(
context,
"Avatar updated successfully",
android.widget.Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to upload avatar", e)
android.widget.Toast.makeText(
context,
"Failed to update avatar: ${e.message}",
android.widget.Toast.LENGTH_LONG
).show()
}
}
}
}
// Цвета в зависимости от темы
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
@@ -402,7 +451,11 @@ fun ProfileScreen(
},
isDarkTheme = isDarkTheme,
showAvatarMenu = showAvatarMenu,
onAvatarMenuChange = { showAvatarMenu = it }
onAvatarMenuChange = { showAvatarMenu = it },
onSetPhotoClick = {
imagePickerLauncher.launch("image/*")
},
avatarRepository = avatarRepository
)
}
}
@@ -422,7 +475,9 @@ private fun CollapsingProfileHeader(
onSave: () -> Unit,
isDarkTheme: Boolean,
showAvatarMenu: Boolean,
onAvatarMenuChange: (Boolean) -> Unit
onAvatarMenuChange: (Boolean) -> Unit,
onSetPhotoClick: () -> Unit,
avatarRepository: AvatarRepository?
) {
val density = LocalDensity.current
val configuration = LocalConfiguration.current
@@ -522,13 +577,13 @@ private fun CollapsingProfileHeader(
isDarkTheme = isDarkTheme,
onSetPhotoClick = {
onAvatarMenuChange(false)
// TODO: Реализовать выбор фото профиля
onSetPhotoClick()
}
)
}
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR - shrinks and moves up
// 👤 AVATAR - shrinks and moves up (with real avatar support)
// ═══════════════════════════════════════════════════════════
if (avatarSize > 1.dp) {
Box(
@@ -541,17 +596,36 @@ private fun CollapsingProfileHeader(
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.15f))
.padding(2.dp)
.clip(CircleShape)
.background(avatarColors.backgroundColor),
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
if (avatarFontSize > 1.sp) {
Text(
text = getInitials(name),
fontSize = avatarFontSize,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
// Используем AvatarImage если репозиторий доступен
if (avatarRepository != null) {
AvatarImage(
publicKey = publicKey,
avatarRepository = avatarRepository,
size = avatarSize - 4.dp,
isDarkTheme = false, // Header всегда светлый на цветном фоне
onClick = null,
showOnlineIndicator = false
)
} else {
// Fallback: цветной placeholder с инициалами
Box(
modifier = Modifier
.fillMaxSize()
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
if (avatarFontSize > 1.sp) {
Text(
text = getInitials(name),
fontSize = avatarFontSize,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
}
}
}
}