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

@@ -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
)
}
}
}
}
}