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()
|
val profileState by profileViewModel.state.collectAsState()
|
||||||
|
|
||||||
// AvatarRepository для работы с аватарами
|
// AvatarRepository для работы с аватарами
|
||||||
val avatarRepository = remember(accountPublicKey, accountPrivateKey) {
|
val avatarRepository = remember(accountPublicKey) {
|
||||||
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
|
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
|
||||||
val database = RosettaDatabase.getDatabase(context)
|
val database = RosettaDatabase.getDatabase(context)
|
||||||
AvatarRepository(
|
AvatarRepository(
|
||||||
context = context,
|
context = context,
|
||||||
avatarDao = database.avatarDao(),
|
avatarDao = database.avatarDao(),
|
||||||
currentPublicKey = accountPublicKey,
|
currentPublicKey = accountPublicKey
|
||||||
currentPrivateKey = accountPrivateKey,
|
|
||||||
protocolManager = ProtocolManager
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -782,7 +780,8 @@ fun MainScreen(
|
|||||||
showLogsScreen = true
|
showLogsScreen = true
|
||||||
},
|
},
|
||||||
viewModel = profileViewModel,
|
viewModel = profileViewModel,
|
||||||
avatarRepository = avatarRepository
|
avatarRepository = avatarRepository,
|
||||||
|
dialogDao = RosettaDatabase.getDatabase(context).dialogDao()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,29 +411,30 @@ object CryptoManager {
|
|||||||
*/
|
*/
|
||||||
fun encrypt(data: String, publicKeyHex: String): String {
|
fun encrypt(data: String, publicKeyHex: String): String {
|
||||||
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||||||
val keyPairGenerator = KeyPairGenerator.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME)
|
|
||||||
keyPairGenerator.initialize(ecSpec, SecureRandom())
|
|
||||||
|
|
||||||
// Generate ephemeral key pair
|
// Generate ephemeral private key (random 32 bytes)
|
||||||
val ephemeralKeyPair = keyPairGenerator.generateKeyPair()
|
val ephemeralPrivateKeyBytes = ByteArray(32)
|
||||||
val ephemeralPrivateKey = ephemeralKeyPair.private as org.bouncycastle.jce.interfaces.ECPrivateKey
|
SecureRandom().nextBytes(ephemeralPrivateKeyBytes)
|
||||||
val ephemeralPublicKey = ephemeralKeyPair.public as org.bouncycastle.jce.interfaces.ECPublicKey
|
val ephemeralPrivateKeyBigInt = BigInteger(1, ephemeralPrivateKeyBytes)
|
||||||
|
|
||||||
|
// Generate ephemeral public key from private key
|
||||||
|
val ephemeralPublicKeyPoint = ecSpec.g.multiply(ephemeralPrivateKeyBigInt)
|
||||||
|
|
||||||
// Parse recipient's public key
|
// Parse recipient's public key
|
||||||
val recipientPublicKeyBytes = publicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
val recipientPublicKeyBytes = publicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
val recipientPublicKeyPoint = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
|
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
|
// Compute shared secret using ECDH (ephemeralPrivateKey × recipientPublicKey)
|
||||||
val keyAgreement = javax.crypto.KeyAgreement.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME)
|
val sharedPoint = recipientPublicKeyPoint.multiply(ephemeralPrivateKeyBigInt).normalize()
|
||||||
keyAgreement.init(ephemeralPrivateKey)
|
|
||||||
keyAgreement.doPhase(recipientPublicKey, true)
|
|
||||||
val sharedSecret = keyAgreement.generateSecret()
|
|
||||||
|
|
||||||
// Use first 32 bytes (x-coordinate) as AES key
|
// Use x-coordinate of shared point as AES key (32 bytes)
|
||||||
val sharedKey = sharedSecret.copyOfRange(1, 33)
|
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")
|
val key = SecretKeySpec(sharedKey, "AES")
|
||||||
|
|
||||||
// Generate random IV
|
// Generate random IV
|
||||||
@@ -446,8 +447,7 @@ object CryptoManager {
|
|||||||
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
|
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
|
||||||
val encrypted = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
|
val encrypted = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
// Get ephemeral private key bytes
|
// Normalize ephemeral private key to 32 bytes
|
||||||
val ephemeralPrivateKeyBytes = ephemeralPrivateKey.d.toByteArray()
|
|
||||||
val normalizedPrivateKey = if (ephemeralPrivateKeyBytes.size > 32) {
|
val normalizedPrivateKey = if (ephemeralPrivateKeyBytes.size > 32) {
|
||||||
ephemeralPrivateKeyBytes.copyOfRange(ephemeralPrivateKeyBytes.size - 32, ephemeralPrivateKeyBytes.size)
|
ephemeralPrivateKeyBytes.copyOfRange(ephemeralPrivateKeyBytes.size - 32, ephemeralPrivateKeyBytes.size)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
/**
|
/**
|
||||||
* Entity для кэша аватаров - хранит пути к зашифрованным файлам
|
* Entity для кэша аватаров - хранит пути к зашифрованным файлам
|
||||||
* Совместимо с desktop версией (AvatarProvider)
|
* Совместимо с desktop версией (AvatarProvider)
|
||||||
|
*
|
||||||
|
* Desktop логика:
|
||||||
|
* - Аватары передаются как attachment в сообщениях (AttachmentType.AVATAR)
|
||||||
|
* - Локальное хранение: SQLite + зашифрованные файлы
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "avatar_cache",
|
tableName = "avatar_cache",
|
||||||
@@ -27,35 +31,12 @@ data class AvatarCacheEntity(
|
|||||||
val timestamp: Long // Unix timestamp
|
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 для работы с аватарами
|
||||||
*/
|
*/
|
||||||
@Dao
|
@Dao
|
||||||
interface AvatarDao {
|
interface AvatarDao {
|
||||||
|
|
||||||
// ============ Avatar Cache ============
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить все аватары пользователя (отсортированные по времени)
|
* Получить все аватары пользователя (отсортированные по времени)
|
||||||
*/
|
*/
|
||||||
@@ -81,7 +62,7 @@ interface AvatarDao {
|
|||||||
suspend fun insertAvatar(avatar: AvatarCacheEntity)
|
suspend fun insertAvatar(avatar: AvatarCacheEntity)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Удалить все аватары пользователя (при смене аватара)
|
* Удалить все аватары пользователя
|
||||||
*/
|
*/
|
||||||
@Query("DELETE FROM avatar_cache WHERE public_key = :publicKey")
|
@Query("DELETE FROM avatar_cache WHERE public_key = :publicKey")
|
||||||
suspend fun deleteAvatars(publicKey: String)
|
suspend fun deleteAvatars(publicKey: String)
|
||||||
@@ -100,30 +81,4 @@ interface AvatarDao {
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
suspend fun deleteOldAvatars(publicKey: String, keepCount: Int = 5)
|
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,
|
MessageEntity::class,
|
||||||
DialogEntity::class,
|
DialogEntity::class,
|
||||||
BlacklistEntity::class,
|
BlacklistEntity::class,
|
||||||
AvatarCacheEntity::class,
|
AvatarCacheEntity::class
|
||||||
AvatarDeliveryEntity::class
|
|
||||||
],
|
],
|
||||||
version = 7,
|
version = 8,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class RosettaDatabase : RoomDatabase() {
|
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 INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем таблицу для трекинга доставки аватаров
|
private val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||||
database.execSQL("""
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
CREATE TABLE IF NOT EXISTS avatar_delivery (
|
// Удаляем таблицу avatar_delivery (больше не нужна)
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
|
||||||
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)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +75,7 @@ abstract class RosettaDatabase : RoomDatabase() {
|
|||||||
"rosetta_secure.db"
|
"rosetta_secure.db"
|
||||||
)
|
)
|
||||||
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
|
.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() // Для разработки - только если миграция не найдена
|
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
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
|
* Push Notification Action
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -119,8 +119,7 @@ class Protocol(
|
|||||||
0x07 to { PacketRead() },
|
0x07 to { PacketRead() },
|
||||||
0x08 to { PacketDelivery() },
|
0x08 to { PacketDelivery() },
|
||||||
0x09 to { PacketChunk() },
|
0x09 to { PacketChunk() },
|
||||||
0x0B to { PacketTyping() },
|
0x0B to { PacketTyping() }
|
||||||
0x0C to { PacketAvatar() }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
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
|
package com.rosetta.messenger.repository
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
|
||||||
import com.rosetta.messenger.database.AvatarCacheEntity
|
import com.rosetta.messenger.database.AvatarCacheEntity
|
||||||
import com.rosetta.messenger.database.AvatarDao
|
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 com.rosetta.messenger.utils.AvatarFileManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Репозиторий для работы с аватарами
|
* Репозиторий для работы с аватарами
|
||||||
* Совместимо с desktop версией (AvatarProvider)
|
* Совместимо с desktop версией (AvatarProvider)
|
||||||
*
|
*
|
||||||
* Возможности:
|
* Desktop логика:
|
||||||
* - Загрузка/сохранение аватаров
|
* - Аватары передаются как attachment в сообщениях (AttachmentType.AVATAR)
|
||||||
* - P2P доставка аватаров (PacketAvatar 0x0C)
|
* - Локальное хранение: SQLite (avatar_cache) + зашифрованные файлы
|
||||||
* - Multi-layer кэширование (Memory + SQLite + Encrypted Files)
|
* - Memory cache для декодированных изображений
|
||||||
* - Трекинг доставки
|
*
|
||||||
* - Поддержка истории аватаров
|
* НЕТ отдельного P2P пакета для аватаров!
|
||||||
*/
|
*/
|
||||||
class AvatarRepository(
|
class AvatarRepository(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val avatarDao: AvatarDao,
|
private val avatarDao: AvatarDao,
|
||||||
private val currentPublicKey: String,
|
private val currentPublicKey: String
|
||||||
private val currentPrivateKey: String,
|
|
||||||
private val protocolManager: ProtocolManager
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "AvatarRepository"
|
private const val TAG = "AvatarRepository"
|
||||||
@@ -88,6 +80,8 @@ class AvatarRepository(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить полученный аватар от другого пользователя
|
* Сохранить полученный аватар от другого пользователя
|
||||||
|
* Вызывается при получении attachment с типом AVATAR в сообщении
|
||||||
|
*
|
||||||
* @param fromPublicKey Публичный ключ отправителя
|
* @param fromPublicKey Публичный ключ отправителя
|
||||||
* @param base64Image Base64-encoded изображение
|
* @param base64Image Base64-encoded изображение
|
||||||
*/
|
*/
|
||||||
@@ -116,14 +110,21 @@ class AvatarRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Изменить свой аватар
|
* Изменить свой аватар (сохранить локально)
|
||||||
|
* Отправка происходит через сообщение с attachment типа AVATAR
|
||||||
|
*
|
||||||
* @param base64Image Base64-encoded изображение
|
* @param base64Image Base64-encoded изображение
|
||||||
*/
|
*/
|
||||||
suspend fun changeMyAvatar(base64Image: String) {
|
suspend fun changeMyAvatar(base64Image: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
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)
|
val filePath = AvatarFileManager.saveAvatar(context, base64Image, currentPublicKey)
|
||||||
|
Log.d(TAG, "✅ Avatar file saved: $filePath")
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
val entity = AvatarCacheEntity(
|
val entity = AvatarCacheEntity(
|
||||||
@@ -132,146 +133,15 @@ class AvatarRepository(
|
|||||||
timestamp = System.currentTimeMillis()
|
timestamp = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
avatarDao.insertAvatar(entity)
|
avatarDao.insertAvatar(entity)
|
||||||
|
Log.d(TAG, "✅ Avatar inserted to DB")
|
||||||
// Очищаем трекинг доставки (новый аватар нужно доставить всем заново)
|
|
||||||
avatarDao.clearDeliveryForAccount(currentPublicKey)
|
|
||||||
|
|
||||||
// Очищаем старые аватары
|
// Очищаем старые аватары
|
||||||
avatarDao.deleteOldAvatars(currentPublicKey, MAX_AVATAR_HISTORY)
|
avatarDao.deleteOldAvatars(currentPublicKey, MAX_AVATAR_HISTORY)
|
||||||
|
|
||||||
Log.d(TAG, "Changed my avatar")
|
Log.d(TAG, "🎉 Avatar changed successfully!")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to change avatar", e)
|
Log.e(TAG, "❌ Failed to change avatar: ${e.message}", e)
|
||||||
}
|
throw 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -735,24 +735,28 @@ fun UnlockScreen(
|
|||||||
onUnlocking = { isUnlocking = it },
|
onUnlocking = { isUnlocking = it },
|
||||||
onError = { error = it },
|
onError = { error = it },
|
||||||
onSuccess = { decryptedAccount ->
|
onSuccess = { decryptedAccount ->
|
||||||
// If biometric is enabled, save password
|
// If biometric is enabled and password not saved yet, save password
|
||||||
if (biometricAvailable is BiometricAvailability.Available &&
|
if (biometricAvailable is BiometricAvailability.Available &&
|
||||||
isBiometricEnabled && activity != null) {
|
isBiometricEnabled && activity != null) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
biometricManager.encryptPassword(
|
// Check if password is already saved
|
||||||
activity = activity,
|
val hasPassword = biometricPrefs.hasEncryptedPassword(decryptedAccount.publicKey)
|
||||||
password = password,
|
if (!hasPassword) {
|
||||||
onSuccess = { encryptedPassword ->
|
biometricManager.encryptPassword(
|
||||||
scope.launch {
|
activity = activity,
|
||||||
biometricPrefs.saveEncryptedPassword(
|
password = password,
|
||||||
decryptedAccount.publicKey,
|
onSuccess = { encryptedPassword ->
|
||||||
encryptedPassword
|
scope.launch {
|
||||||
)
|
biometricPrefs.saveEncryptedPassword(
|
||||||
}
|
decryptedAccount.publicKey,
|
||||||
},
|
encryptedPassword
|
||||||
onError = { /* Ignore save errors */ },
|
)
|
||||||
onCancel = { /* User cancelled */ }
|
}
|
||||||
)
|
},
|
||||||
|
onError = { /* Ignore save errors */ },
|
||||||
|
onCancel = { /* User cancelled */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onUnlocked(decryptedAccount)
|
onUnlocked(decryptedAccount)
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ fun ProfileScreen(
|
|||||||
onNavigateToSafety: () -> Unit = {},
|
onNavigateToSafety: () -> Unit = {},
|
||||||
onNavigateToLogs: () -> Unit = {},
|
onNavigateToLogs: () -> Unit = {},
|
||||||
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
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 context = LocalContext.current
|
||||||
val activity = context as? FragmentActivity
|
val activity = context as? FragmentActivity
|
||||||
@@ -169,32 +170,46 @@ fun ProfileScreen(
|
|||||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetContent()
|
contract = ActivityResultContracts.GetContent()
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
|
Log.d(TAG, "🖼️ Image picker result: uri=$uri")
|
||||||
uri?.let {
|
uri?.let {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
|
Log.d(TAG, "📁 Reading image from URI: $uri")
|
||||||
// Читаем файл изображения
|
// Читаем файл изображения
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
val imageBytes = inputStream?.readBytes()
|
val imageBytes = inputStream?.readBytes()
|
||||||
inputStream?.close()
|
inputStream?.close()
|
||||||
|
|
||||||
|
Log.d(TAG, "📊 Image bytes read: ${imageBytes?.size ?: 0} bytes")
|
||||||
|
|
||||||
if (imageBytes != null) {
|
if (imageBytes != null) {
|
||||||
|
Log.d(TAG, "🔄 Converting to PNG Base64...")
|
||||||
// Конвертируем в PNG Base64 (кросс-платформенная совместимость)
|
// Конвертируем в PNG Base64 (кросс-платформенная совместимость)
|
||||||
val base64Png = withContext(Dispatchers.IO) {
|
val base64Png = withContext(Dispatchers.IO) {
|
||||||
AvatarFileManager.imagePrepareForNetworkTransfer(context, imageBytes)
|
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)
|
avatarRepository?.changeMyAvatar(base64Png)
|
||||||
|
|
||||||
|
Log.d(TAG, "🎉 Avatar update completed")
|
||||||
|
|
||||||
// Показываем успешное сообщение
|
// Показываем успешное сообщение
|
||||||
android.widget.Toast.makeText(
|
android.widget.Toast.makeText(
|
||||||
context,
|
context,
|
||||||
"Avatar updated successfully",
|
"Avatar updated successfully",
|
||||||
android.widget.Toast.LENGTH_SHORT
|
android.widget.Toast.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "❌ Image bytes are null")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to upload avatar", e)
|
Log.e(TAG, "❌ Failed to upload avatar", e)
|
||||||
android.widget.Toast.makeText(
|
android.widget.Toast.makeText(
|
||||||
context,
|
context,
|
||||||
"Failed to update avatar: ${e.message}",
|
"Failed to update avatar: ${e.message}",
|
||||||
@@ -202,7 +217,7 @@ fun ProfileScreen(
|
|||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} ?: Log.w(TAG, "⚠️ URI is null, image picker cancelled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Цвета в зависимости от темы
|
// Цвета в зависимости от темы
|
||||||
|
|||||||
@@ -31,15 +31,23 @@ object AvatarFileManager {
|
|||||||
* @return Путь к файлу (формат: "a/md5hash")
|
* @return Путь к файлу (формат: "a/md5hash")
|
||||||
*/
|
*/
|
||||||
fun saveAvatar(context: Context, base64Image: String, entityId: String): String {
|
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 версии
|
// Генерируем путь как в desktop версии
|
||||||
val filePath = generateMd5Path(base64Image, entityId)
|
val filePath = generateMd5Path(base64Image, entityId)
|
||||||
|
android.util.Log.d("AvatarFileManager", "🔗 Generated file path: $filePath")
|
||||||
|
|
||||||
// Шифруем данные с паролем "rosetta-a"
|
// Шифруем данные с паролем "rosetta-a"
|
||||||
|
android.util.Log.d("AvatarFileManager", "🔐 Encrypting with password...")
|
||||||
val encrypted = CryptoManager.encryptWithPassword(base64Image, AVATAR_PASSWORD)
|
val encrypted = CryptoManager.encryptWithPassword(base64Image, AVATAR_PASSWORD)
|
||||||
|
android.util.Log.d("AvatarFileManager", "✅ Encrypted length: ${encrypted.length}")
|
||||||
|
|
||||||
// Сохраняем в файловую систему
|
// Сохраняем в файловую систему
|
||||||
val dir = File(context.filesDir, AVATAR_DIR)
|
val dir = File(context.filesDir, AVATAR_DIR)
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
|
android.util.Log.d("AvatarFileManager", "📁 Base dir: ${dir.absolutePath}")
|
||||||
|
|
||||||
// Путь формата "a/md5hash" -> создаем подпапку "a"
|
// Путь формата "a/md5hash" -> создаем подпапку "a"
|
||||||
val parts = filePath.split("/")
|
val parts = filePath.split("/")
|
||||||
@@ -48,11 +56,14 @@ object AvatarFileManager {
|
|||||||
subDir.mkdirs()
|
subDir.mkdirs()
|
||||||
val file = File(subDir, parts[1])
|
val file = File(subDir, parts[1])
|
||||||
file.writeText(encrypted)
|
file.writeText(encrypted)
|
||||||
|
android.util.Log.d("AvatarFileManager", "💾 Saved to: ${file.absolutePath}")
|
||||||
} else {
|
} else {
|
||||||
val file = File(dir, filePath)
|
val file = File(dir, filePath)
|
||||||
file.writeText(encrypted)
|
file.writeText(encrypted)
|
||||||
|
android.util.Log.d("AvatarFileManager", "💾 Saved to: ${file.absolutePath}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("AvatarFileManager", "✅ Avatar saved successfully")
|
||||||
return filePath
|
return filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user