feat: Enhance avatar management with detailed logging and error handling

This commit is contained in:
k1ngsterr1
2026-01-24 00:26:23 +05:00
parent b08bea2c14
commit 1367864008
11 changed files with 107 additions and 324 deletions

View File

@@ -516,15 +516,13 @@ fun MainScreen(
val profileState by profileViewModel.state.collectAsState()
// AvatarRepository для работы с аватарами
val avatarRepository = remember(accountPublicKey, accountPrivateKey) {
val avatarRepository = remember(accountPublicKey) {
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
val database = RosettaDatabase.getDatabase(context)
AvatarRepository(
context = context,
avatarDao = database.avatarDao(),
currentPublicKey = accountPublicKey,
currentPrivateKey = accountPrivateKey,
protocolManager = ProtocolManager
currentPublicKey = accountPublicKey
)
} else {
null
@@ -782,7 +780,8 @@ fun MainScreen(
showLogsScreen = true
},
viewModel = profileViewModel,
avatarRepository = avatarRepository
avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao()
)
}
}

View File

@@ -411,29 +411,30 @@ object CryptoManager {
*/
fun encrypt(data: String, publicKeyHex: String): String {
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
val keyPairGenerator = KeyPairGenerator.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME)
keyPairGenerator.initialize(ecSpec, SecureRandom())
// Generate ephemeral key pair
val ephemeralKeyPair = keyPairGenerator.generateKeyPair()
val ephemeralPrivateKey = ephemeralKeyPair.private as org.bouncycastle.jce.interfaces.ECPrivateKey
val ephemeralPublicKey = ephemeralKeyPair.public as org.bouncycastle.jce.interfaces.ECPublicKey
// Generate ephemeral private key (random 32 bytes)
val ephemeralPrivateKeyBytes = ByteArray(32)
SecureRandom().nextBytes(ephemeralPrivateKeyBytes)
val ephemeralPrivateKeyBigInt = BigInteger(1, ephemeralPrivateKeyBytes)
// Generate ephemeral public key from private key
val ephemeralPublicKeyPoint = ecSpec.g.multiply(ephemeralPrivateKeyBigInt)
// Parse recipient's public key
val recipientPublicKeyBytes = publicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val recipientPublicKeyPoint = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
val recipientPublicKeySpec = ECPublicKeySpec(recipientPublicKeyPoint, ecSpec)
val keyFactory = KeyFactory.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME)
val recipientPublicKey = keyFactory.generatePublic(recipientPublicKeySpec)
// Compute shared secret using ECDH
val keyAgreement = javax.crypto.KeyAgreement.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME)
keyAgreement.init(ephemeralPrivateKey)
keyAgreement.doPhase(recipientPublicKey, true)
val sharedSecret = keyAgreement.generateSecret()
// Compute shared secret using ECDH (ephemeralPrivateKey × recipientPublicKey)
val sharedPoint = recipientPublicKeyPoint.multiply(ephemeralPrivateKeyBigInt).normalize()
// Use first 32 bytes (x-coordinate) as AES key
val sharedKey = sharedSecret.copyOfRange(1, 33)
// Use x-coordinate of shared point as AES key (32 bytes)
val sharedKeyBytes = sharedPoint.affineXCoord.encoded
val sharedKey = if (sharedKeyBytes.size >= 32) {
sharedKeyBytes.copyOfRange(sharedKeyBytes.size - 32, sharedKeyBytes.size)
} else {
// Pad with leading zeros if needed
ByteArray(32 - sharedKeyBytes.size) + sharedKeyBytes
}
val key = SecretKeySpec(sharedKey, "AES")
// Generate random IV
@@ -446,8 +447,7 @@ object CryptoManager {
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
val encrypted = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
// Get ephemeral private key bytes
val ephemeralPrivateKeyBytes = ephemeralPrivateKey.d.toByteArray()
// Normalize ephemeral private key to 32 bytes
val normalizedPrivateKey = if (ephemeralPrivateKeyBytes.size > 32) {
ephemeralPrivateKeyBytes.copyOfRange(ephemeralPrivateKeyBytes.size - 32, ephemeralPrivateKeyBytes.size)
} else {

View File

@@ -6,6 +6,10 @@ import kotlinx.coroutines.flow.Flow
/**
* Entity для кэша аватаров - хранит пути к зашифрованным файлам
* Совместимо с desktop версией (AvatarProvider)
*
* Desktop логика:
* - Аватары передаются как attachment в сообщениях (AttachmentType.AVATAR)
* - Локальное хранение: SQLite + зашифрованные файлы
*/
@Entity(
tableName = "avatar_cache",
@@ -27,35 +31,12 @@ data class AvatarCacheEntity(
val timestamp: Long // Unix timestamp
)
/**
* Entity для трекинга доставки аватаров
* Отслеживает кому уже был отправлен текущий аватар
*/
@Entity(
tableName = "avatar_delivery",
indices = [
Index(value = ["public_key", "account"], unique = true)
]
)
data class AvatarDeliveryEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "public_key")
val publicKey: String, // Публичный ключ получателя
@ColumnInfo(name = "account")
val account: String // Публичный ключ отправителя (мой аккаунт)
)
/**
* DAO для работы с аватарами
*/
@Dao
interface AvatarDao {
// ============ Avatar Cache ============
/**
* Получить все аватары пользователя (отсортированные по времени)
*/
@@ -81,7 +62,7 @@ interface AvatarDao {
suspend fun insertAvatar(avatar: AvatarCacheEntity)
/**
* Удалить все аватары пользователя (при смене аватара)
* Удалить все аватары пользователя
*/
@Query("DELETE FROM avatar_cache WHERE public_key = :publicKey")
suspend fun deleteAvatars(publicKey: String)
@@ -100,30 +81,4 @@ interface AvatarDao {
)
""")
suspend fun deleteOldAvatars(publicKey: String, keepCount: Int = 5)
// ============ Avatar Delivery ============
/**
* Проверить доставлен ли аватар контакту
*/
@Query("SELECT COUNT(*) > 0 FROM avatar_delivery WHERE public_key = :publicKey AND account = :account")
suspend fun isAvatarDelivered(publicKey: String, account: String): Boolean
/**
* Отметить аватар как доставленный
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun markAvatarDelivered(delivery: AvatarDeliveryEntity)
/**
* Удалить все записи о доставке для аккаунта (при смене аватара)
*/
@Query("DELETE FROM avatar_delivery WHERE account = :account")
suspend fun clearDeliveryForAccount(account: String)
/**
* Получить список контактов, которым доставлен аватар
*/
@Query("SELECT public_key FROM avatar_delivery WHERE account = :account")
suspend fun getDeliveredContacts(account: String): List<String>
}

View File

@@ -13,10 +13,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
MessageEntity::class,
DialogEntity::class,
BlacklistEntity::class,
AvatarCacheEntity::class,
AvatarDeliveryEntity::class
AvatarCacheEntity::class
],
version = 7,
version = 8,
exportSchema = false
)
abstract class RosettaDatabase : RoomDatabase() {
@@ -58,16 +57,13 @@ abstract class RosettaDatabase : RoomDatabase() {
)
""")
database.execSQL("CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)")
}
}
// Создаем таблицу для трекинга доставки аватаров
database.execSQL("""
CREATE TABLE IF NOT EXISTS avatar_delivery (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_key TEXT NOT NULL,
account TEXT NOT NULL
)
""")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_avatar_delivery_public_key_account ON avatar_delivery (public_key, account)")
private val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
// Удаляем таблицу avatar_delivery (больше не нужна)
database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
}
}
@@ -79,7 +75,7 @@ abstract class RosettaDatabase : RoomDatabase() {
"rosetta_secure.db"
)
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена
.build()
INSTANCE = instance

View File

@@ -479,59 +479,6 @@ class PacketPushToken : Packet() {
}
}
/**
* Avatar packet (ID: 0x0C)
* P2P доставка аватаров между клиентами
* Совместимо с desktop версией (AvatarProvider)
*
* Структура:
* - privateKey: Hash приватного ключа отправителя (для аутентификации)
* - fromPublicKey: Публичный ключ отправителя
* - toPublicKey: Публичный ключ получателя
* - chachaKey: RSA-encrypted ChaCha20 key+nonce (hex)
* - blob: ChaCha20-encrypted avatar data (hex, base64 изображение внутри)
*
* Процесс шифрования (отправка):
* 1. Генерируется случайный ChaCha20 key (32 байта) + nonce (24 байта)
* 2. Base64-изображение шифруется ChaCha20-Poly1305
* 3. ChaCha key+nonce конкатенируются и шифруются RSA публичным ключом получателя
* 4. Отправляется пакет с зашифрованными данными
*
* Процесс расшифровки (получение):
* 1. RSA-расшифровка chachaKey приватным ключом получателя
* 2. Извлечение key (32 байта) и nonce (24 байта)
* 3. ChaCha20-расшифровка blob
* 4. Получение base64-изображения
*/
class PacketAvatar : Packet() {
var privateKey: String = ""
var fromPublicKey: String = ""
var toPublicKey: String = ""
var chachaKey: String = "" // RSA-encrypted (hex)
var blob: String = "" // ChaCha20-encrypted (hex)
override fun getPacketId(): Int = 0x0C
override fun receive(stream: Stream) {
privateKey = stream.readString()
fromPublicKey = stream.readString()
toPublicKey = stream.readString()
chachaKey = stream.readString()
blob = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(privateKey)
stream.writeString(fromPublicKey)
stream.writeString(toPublicKey)
stream.writeString(chachaKey)
stream.writeString(blob)
return stream
}
}
/**
* Push Notification Action
*/

View File

@@ -119,8 +119,7 @@ class Protocol(
0x07 to { PacketRead() },
0x08 to { PacketDelivery() },
0x09 to { PacketChunk() },
0x0B to { PacketTyping() },
0x0C to { PacketAvatar() }
0x0B to { PacketTyping() }
)
init {

View File

@@ -174,19 +174,6 @@ object ProtocolManager {
}
}
}
// 🖼️ Обработчик аватаров (0x0C)
// P2P доставка аватаров между клиентами
waitPacket(0x0C) { packet ->
val avatarPacket = packet as PacketAvatar
android.util.Log.d("Protocol", "🖼️ Received avatar from ${avatarPacket.fromPublicKey.take(16)}...")
scope.launch(Dispatchers.IO) {
// TODO: Передавать avatarRepository через initialize()
// Пока что логируем для отладки
// avatarRepository?.handleIncomingAvatar(avatarPacket)
}
}
}
/**

View File

@@ -1,37 +1,29 @@
package com.rosetta.messenger.repository
import android.content.Context
import android.util.Base64
import android.util.Log
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.database.AvatarCacheEntity
import com.rosetta.messenger.database.AvatarDao
import com.rosetta.messenger.database.AvatarDeliveryEntity
import com.rosetta.messenger.network.PacketAvatar
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import java.security.MessageDigest
/**
* Репозиторий для работы с аватарами
* Совместимо с desktop версией (AvatarProvider)
*
* Возможности:
* - Загрузка/сохранение аватаров
* - P2P доставка аватаров (PacketAvatar 0x0C)
* - Multi-layer кэширование (Memory + SQLite + Encrypted Files)
* - Трекинг доставки
* - Поддержка истории аватаров
* Desktop логика:
* - Аватары передаются как attachment в сообщениях (AttachmentType.AVATAR)
* - Локальное хранение: SQLite (avatar_cache) + зашифрованные файлы
* - Memory cache для декодированных изображений
*
* НЕТ отдельного P2P пакета для аватаров!
*/
class AvatarRepository(
private val context: Context,
private val avatarDao: AvatarDao,
private val currentPublicKey: String,
private val currentPrivateKey: String,
private val protocolManager: ProtocolManager
private val currentPublicKey: String
) {
companion object {
private const val TAG = "AvatarRepository"
@@ -88,6 +80,8 @@ class AvatarRepository(
/**
* Сохранить полученный аватар от другого пользователя
* Вызывается при получении attachment с типом AVATAR в сообщении
*
* @param fromPublicKey Публичный ключ отправителя
* @param base64Image Base64-encoded изображение
*/
@@ -116,14 +110,21 @@ class AvatarRepository(
}
/**
* Изменить свой аватар
* Изменить свой аватар (сохранить локально)
* Отправка происходит через сообщение с attachment типа AVATAR
*
* @param base64Image Base64-encoded изображение
*/
suspend fun changeMyAvatar(base64Image: String) {
withContext(Dispatchers.IO) {
try {
Log.d(TAG, "🔄 changeMyAvatar called")
Log.d(TAG, "👤 Current public key: ${currentPublicKey.take(16)}...")
Log.d(TAG, "📊 Base64 image length: ${base64Image.length} chars")
// Сохраняем файл
val filePath = AvatarFileManager.saveAvatar(context, base64Image, currentPublicKey)
Log.d(TAG, "✅ Avatar file saved: $filePath")
// Сохраняем в БД
val entity = AvatarCacheEntity(
@@ -132,146 +133,15 @@ class AvatarRepository(
timestamp = System.currentTimeMillis()
)
avatarDao.insertAvatar(entity)
// Очищаем трекинг доставки (новый аватар нужно доставить всем заново)
avatarDao.clearDeliveryForAccount(currentPublicKey)
Log.d(TAG, "✅ Avatar inserted to DB")
// Очищаем старые аватары
avatarDao.deleteOldAvatars(currentPublicKey, MAX_AVATAR_HISTORY)
Log.d(TAG, "Changed my avatar")
Log.d(TAG, "🎉 Avatar changed successfully!")
} catch (e: Exception) {
Log.e(TAG, "Failed to change avatar", e)
}
}
}
/**
* Отправить свой аватар контакту через PacketAvatar
* @param toPublicKey Публичный ключ получателя
*/
suspend fun sendAvatarTo(toPublicKey: String) {
withContext(Dispatchers.IO) {
try {
// Проверяем, не доставлен ли уже
if (avatarDao.isAvatarDelivered(toPublicKey, currentPublicKey)) {
Log.d(TAG, "Avatar already delivered to $toPublicKey")
return@withContext
}
// Получаем свой аватар
val myAvatar = getLatestAvatar(currentPublicKey)
if (myAvatar == null) {
Log.d(TAG, "No avatar to send")
return@withContext
}
// Шифруем ChaCha20
val chachaResult = CryptoManager.chacha20Encrypt(myAvatar.base64Data)
// Объединяем key + nonce (32 + 24 = 56 байт)
val keyNonceHex = chachaResult.key + chachaResult.nonce
// Шифруем RSA (публичным ключом получателя)
val keyNonceBytes = keyNonceHex.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
val keyNonceBase64 = Base64.encodeToString(keyNonceBytes, Base64.NO_WRAP)
val encryptedKeyNonce = CryptoManager.encrypt(keyNonceBase64, toPublicKey)
// Создаем пакет
val packet = PacketAvatar().apply {
privateKey = CryptoManager.generatePrivateKeyHash(currentPrivateKey)
fromPublicKey = currentPublicKey
this.toPublicKey = toPublicKey
chachaKey = encryptedKeyNonce
blob = chachaResult.ciphertext
}
// Отправляем через протокол
protocolManager.sendPacket(packet)
// Отмечаем как доставленный
markAvatarDelivered(toPublicKey)
Log.d(TAG, "Sent avatar to $toPublicKey")
} catch (e: Exception) {
Log.e(TAG, "Failed to send avatar", e)
}
}
}
/**
* Отметить аватар как доставленный (без фактической отправки)
*/
suspend fun markAvatarDelivered(toPublicKey: String) {
withContext(Dispatchers.IO) {
try {
val entity = AvatarDeliveryEntity(
publicKey = toPublicKey,
account = currentPublicKey
)
avatarDao.markAvatarDelivered(entity)
} catch (e: Exception) {
Log.e(TAG, "Failed to mark avatar delivered", e)
}
}
}
/**
* Проверить доставлен ли аватар контакту
*/
suspend fun isAvatarDelivered(toPublicKey: String): Boolean {
return withContext(Dispatchers.IO) {
avatarDao.isAvatarDelivered(toPublicKey, currentPublicKey)
}
}
/**
* Обработать входящий PacketAvatar
*/
suspend fun handleIncomingAvatar(packet: PacketAvatar) {
withContext(Dispatchers.IO) {
try {
// Проверяем, что пакет адресован нам
if (packet.toPublicKey != currentPublicKey) {
Log.w(TAG, "Avatar packet not for us")
return@withContext
}
// Расшифровываем ChaCha key+nonce (RSA)
val keyNonceBase64 = CryptoManager.decrypt(packet.chachaKey, currentPrivateKey)
if (keyNonceBase64 == null) {
Log.e(TAG, "Failed to decrypt ChaCha key")
return@withContext
}
// Декодируем из Base64
val keyNonceBytes = Base64.decode(keyNonceBase64, Base64.NO_WRAP)
val keyNonceHex = keyNonceBytes.joinToString("") { "%02x".format(it) }
// Разделяем на key (32 байта = 64 hex) и nonce (24 байта = 48 hex)
if (keyNonceHex.length != 112) { // 64 + 48
Log.e(TAG, "Invalid key+nonce length: ${keyNonceHex.length}")
return@withContext
}
val keyHex = keyNonceHex.substring(0, 64)
val nonceHex = keyNonceHex.substring(64, 112)
// Расшифровываем blob (ChaCha20)
val base64Image = CryptoManager.chacha20Decrypt(packet.blob, nonceHex, keyHex)
if (base64Image == null) {
Log.e(TAG, "Failed to decrypt avatar blob")
return@withContext
}
// Сохраняем аватар
saveAvatar(packet.fromPublicKey, base64Image)
Log.d(TAG, "Received avatar from ${packet.fromPublicKey}")
} catch (e: Exception) {
Log.e(TAG, "Failed to handle incoming avatar", e)
Log.e(TAG, "Failed to change avatar: ${e.message}", e)
throw e
}
}
}

View File

@@ -735,10 +735,13 @@ fun UnlockScreen(
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->
// If biometric is enabled, save password
// If biometric is enabled and password not saved yet, save password
if (biometricAvailable is BiometricAvailability.Available &&
isBiometricEnabled && activity != null) {
scope.launch {
// Check if password is already saved
val hasPassword = biometricPrefs.hasEncryptedPassword(decryptedAccount.publicKey)
if (!hasPassword) {
biometricManager.encryptPassword(
activity = activity,
password = password,
@@ -755,6 +758,7 @@ fun UnlockScreen(
)
}
}
}
onUnlocked(decryptedAccount)
}
)

View File

@@ -144,7 +144,8 @@ fun ProfileScreen(
onNavigateToSafety: () -> Unit = {},
onNavigateToLogs: () -> Unit = {},
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: AvatarRepository? = null
avatarRepository: AvatarRepository? = null,
dialogDao: com.rosetta.messenger.database.DialogDao? = null
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
@@ -169,32 +170,46 @@ fun ProfileScreen(
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
Log.d(TAG, "🖼️ Image picker result: uri=$uri")
uri?.let {
scope.launch {
try {
Log.d(TAG, "📁 Reading image from URI: $uri")
// Читаем файл изображения
val inputStream = context.contentResolver.openInputStream(uri)
val imageBytes = inputStream?.readBytes()
inputStream?.close()
Log.d(TAG, "📊 Image bytes read: ${imageBytes?.size ?: 0} bytes")
if (imageBytes != null) {
Log.d(TAG, "🔄 Converting to PNG Base64...")
// Конвертируем в PNG Base64 (кросс-платформенная совместимость)
val base64Png = withContext(Dispatchers.IO) {
AvatarFileManager.imagePrepareForNetworkTransfer(context, imageBytes)
}
Log.d(TAG, "✅ Converted to Base64: ${base64Png.length} chars")
Log.d(TAG, "🔐 Avatar repository available: ${avatarRepository != null}")
Log.d(TAG, "👤 Current public key: ${accountPublicKey.take(16)}...")
// Сохраняем аватар через репозиторий
Log.d(TAG, "💾 Calling avatarRepository.changeMyAvatar()...")
avatarRepository?.changeMyAvatar(base64Png)
Log.d(TAG, "🎉 Avatar update completed")
// Показываем успешное сообщение
android.widget.Toast.makeText(
context,
"Avatar updated successfully",
android.widget.Toast.LENGTH_SHORT
).show()
} else {
Log.e(TAG, "❌ Image bytes are null")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to upload avatar", e)
Log.e(TAG, "Failed to upload avatar", e)
android.widget.Toast.makeText(
context,
"Failed to update avatar: ${e.message}",
@@ -202,7 +217,7 @@ fun ProfileScreen(
).show()
}
}
}
} ?: Log.w(TAG, "⚠️ URI is null, image picker cancelled")
}
// Цвета в зависимости от темы

View File

@@ -31,15 +31,23 @@ object AvatarFileManager {
* @return Путь к файлу (формат: "a/md5hash")
*/
fun saveAvatar(context: Context, base64Image: String, entityId: String): String {
android.util.Log.d("AvatarFileManager", "💾 saveAvatar called")
android.util.Log.d("AvatarFileManager", "📊 Base64 length: ${base64Image.length}")
android.util.Log.d("AvatarFileManager", "👤 Entity ID: ${entityId.take(16)}...")
// Генерируем путь как в desktop версии
val filePath = generateMd5Path(base64Image, entityId)
android.util.Log.d("AvatarFileManager", "🔗 Generated file path: $filePath")
// Шифруем данные с паролем "rosetta-a"
android.util.Log.d("AvatarFileManager", "🔐 Encrypting with password...")
val encrypted = CryptoManager.encryptWithPassword(base64Image, AVATAR_PASSWORD)
android.util.Log.d("AvatarFileManager", "✅ Encrypted length: ${encrypted.length}")
// Сохраняем в файловую систему
val dir = File(context.filesDir, AVATAR_DIR)
dir.mkdirs()
android.util.Log.d("AvatarFileManager", "📁 Base dir: ${dir.absolutePath}")
// Путь формата "a/md5hash" -> создаем подпапку "a"
val parts = filePath.split("/")
@@ -48,11 +56,14 @@ object AvatarFileManager {
subDir.mkdirs()
val file = File(subDir, parts[1])
file.writeText(encrypted)
android.util.Log.d("AvatarFileManager", "💾 Saved to: ${file.absolutePath}")
} else {
val file = File(dir, filePath)
file.writeText(encrypted)
android.util.Log.d("AvatarFileManager", "💾 Saved to: ${file.absolutePath}")
}
android.util.Log.d("AvatarFileManager", "✅ Avatar saved successfully")
return filePath
}