feat: Enhance avatar management with detailed logging and error handling
This commit is contained in:
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
// Цвета в зависимости от темы
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user