feat: Implement baseline profile generation and startup benchmarking

- Added baseline profile generator for Rosetta Messenger to optimize startup performance.
- Created startup benchmark tests to measure cold start times under different compilation modes.
- Introduced a new Gradle module for baseline profile and benchmark tests.
- Updated ChatsListViewModel to show loading skeleton while data is being fetched.
- Improved keyboard handling in MessageInputBar by using SHOW_IMPLICIT instead of SHOW_FORCED.
- Minor code cleanups and optimizations across various components.
This commit is contained in:
k1ngsterr1
2026-02-08 09:21:05 +05:00
parent 8b8c883a63
commit abe1a1a710
13 changed files with 1111 additions and 564 deletions

View File

@@ -1,47 +1,108 @@
# Baseline Profile для Rosetta Messenger
# Предкомпилирует критические функции при установке APK
# Baseline Profile for Rosetta Messenger
# AOT-compiles critical classes and methods at APK install time
# ============ Keyboard & Animation (приоритет #1) ============
# MessageInputBar - основной источник JIT лага
HSPLcom/rosetta/messenger/ui/chats/ChatDetailScreenKt;->MessageInputBar-c4CPeSU(Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ZJJJJLjava/util/List;ZLkotlin/jvm/functions/Function0;Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/focus/FocusRequester;Lapp/rosette/android/ui/keyboard/KeyboardTransitionCoordinator;Landroidx/compose/runtime/Composer;III)V
# AnimatedKeyboardTransition - fade анимация
HSPLapp/rosette/android/ui/keyboard/AnimatedKeyboardTransitionKt;->AnimatedKeyboardTransition(Lapp/rosette/android/ui/keyboard/KeyboardTransitionCoordinator;ZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
# KeyboardTransitionCoordinator - управление состоянием
HSPLapp/rosette/android/ui/keyboard/KeyboardTransitionCoordinator;->updateKeyboardHeight(F)V
HSPLapp/rosette/android/ui/keyboard/KeyboardTransitionCoordinator;->requestShowEmoji(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V
HSPLapp/rosette/android/ui/keyboard/KeyboardTransitionCoordinator;->requestShowKeyboard(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V
# ============ EmojiPicker (приоритет #2) ============
# OptimizedEmojiPicker - основной UI эмодзи
HSPLcom/rosetta/messenger/ui/components/emoji/OptimizedEmojiPickerKt;->OptimizedEmojiPicker(Lkotlin/jvm/functions/Function1;FLandroidx/compose/runtime/Composer;I)V
# EmojiPickerContent - рендеринг эмодзи
HSPLcom/rosetta/messenger/ui/components/emoji/OptimizedEmojiPickerKt;->EmojiPickerContent(Lkotlin/jvm/functions/Function1;FLandroidx/compose/runtime/Composer;I)V
# ============ ChatsListScreen (приоритет #3) ============
# DialogItem - вызывает JIT компиляцию (6101KB)
HSPLcom/rosetta/messenger/ui/chats/ChatsListScreenKt;->DialogItem(Lcom/rosetta/messenger/ui/chats/DialogUiModel;ZZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V
# Главный экран списка чатов
HSPLcom/rosetta/messenger/ui/chats/ChatsListScreenKt;->ChatsListScreen(ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V
# ============ ChatDetailScreen (приоритет #4) ============
# Главный экран чата
HSPLcom/rosetta/messenger/ui/chats/ChatDetailScreenKt;->ChatDetailScreen(Lcom/rosetta/messenger/network/SearchUser;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V
# MessageBubble - рендеринг сообщений
HSPLcom/rosetta/messenger/ui/chats/ChatDetailScreenKt;->MessageBubble(Lcom/rosetta/messenger/data/local/entity/Message;ZZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
# ============ Navigation (приоритет #5) ============
# MainActivity и навигация
# 1. APP STARTUP
HSPLcom/rosetta/messenger/RosettaApplication;->onCreate()V
HSPLcom/rosetta/messenger/MainActivity;->onCreate(Landroid/os/Bundle;)V
HSPLcom/rosetta/messenger/MainActivityKt;->MainScreen(Lcom/rosetta/messenger/data/DecryptedAccount;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V
# ============ Common Compose (приоритет #6) ============
# Часто используемые Compose компоненты
HSPLandroidx/compose/foundation/lazy/LazyListState;->scrollToItem(IILkotlin/coroutines/Continuation;)Ljava/lang/Object;
HSPLandroidx/compose/ui/text/input/TextFieldValue;-><init>(Ljava/lang/String;JLandroidx/compose/ui/text/TextRange;)V
HSPLandroidx/compose/animation/core/Animatable;->animateTo(Ljava/lang/Object;Landroidx/compose/animation/core/AnimationSpec;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
HSPLandroidx/compose/animation/AnimatedContentKt;->AnimatedContent(Ljava/lang/Object;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Alignment;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
# 2. CRYPTO
HSPLcom/rosetta/messenger/crypto/CryptoManager;->getPbkdf2Key(Ljava/lang/String;)Ljavax/crypto/spec/SecretKeySpec;
HSPLcom/rosetta/messenger/crypto/CryptoManager;->decryptWithPassword(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
HSPLcom/rosetta/messenger/crypto/CryptoManager;->decryptWithPasswordInternal(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
HSPLcom/rosetta/messenger/crypto/CryptoManager;->isOldFormat(Ljava/lang/String;)Z
HSPLcom/rosetta/messenger/crypto/CryptoManager;->decompress([B)[B
HSPLcom/rosetta/messenger/crypto/CryptoManager;->encryptWithPassword(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
HSPLcom/rosetta/messenger/crypto/CryptoManager;->compress([B)[B
HSPLcom/rosetta/messenger/crypto/CryptoManager;->clearCaches()V
HSPLcom/rosetta/messenger/crypto/MessageCrypto;->**(**)**
HSPLjavax/crypto/Cipher;->getInstance(Ljava/lang/String;)**
HSPLjavax/crypto/Cipher;->doFinal([B)**
HSPLjavax/crypto/SecretKeyFactory;->getInstance(Ljava/lang/String;)**
HSPLorg/bouncycastle/jce/**;->**(**)**
HSPLcom/google/crypto/tink/subtle/XChaCha20Poly1305;->**(**)**
# 3. DATABASE
HSPLcom/rosetta/messenger/database/RosettaDatabase;->**(**)**
HSPLcom/rosetta/messenger/database/RosettaDatabase_Impl;->**(**)**
HSPLcom/rosetta/messenger/database/DialogDao_Impl;->**(**)**
HSPLcom/rosetta/messenger/database/MessageDao_Impl;->**(**)**
HSPLandroidx/room/RoomDatabase;->**(**)**
HSPLandroidx/room/Room;->**(**)**
# 4. VIEWMODELS
HSPLcom/rosetta/messenger/ui/chats/ChatsListViewModel;->**(**)**
HSPLcom/rosetta/messenger/ui/chats/ChatViewModel;->**(**)**
# 5. COMPOSE UI
HSPLcom/rosetta/messenger/ui/chats/ChatsListScreenKt;->ChatsListScreen(**)**
HSPLcom/rosetta/messenger/ui/chats/ChatsListScreenKt;->DialogItem(**)**
HSPLcom/rosetta/messenger/ui/chats/ChatsListScreenKt;->ChatsListSkeleton(**)**
HSPLcom/rosetta/messenger/ui/chats/ChatsListScreenKt;->SkeletonDialogItem(**)**
HSPLcom/rosetta/messenger/ui/chats/ChatsListScreenKt;->SwipeableDialogItem(**)**
HSPLcom/rosetta/messenger/ui/chats/ChatDetailScreenKt;->ChatDetailScreen(**)**
HSPLcom/rosetta/messenger/ui/chats/ChatDetailScreenKt;->MessageBubble(**)**
HSPLcom/rosetta/messenger/ui/chats/ChatDetailScreenKt;->MessageInputBar-c4CPeSU(**)**
HSPLcom/rosetta/messenger/ui/chats/components/ChatDetailComponentsKt;->**(**)**
HSPLcom/rosetta/messenger/ui/chats/input/ChatDetailInputKt;->**(**)**
# 6. KEYBOARD
HSPLapp/rosette/android/ui/keyboard/AnimatedKeyboardTransitionKt;->AnimatedKeyboardTransition(**)**
HSPLapp/rosette/android/ui/keyboard/KeyboardTransitionCoordinator;->**(**)**
# 7. EMOJI
HSPLcom/rosetta/messenger/ui/components/emoji/OptimizedEmojiPickerKt;->**(**)**
HSPLcom/rosetta/messenger/ui/components/OptimizedEmojiCache;->**(**)**
HSPLcom/rosetta/messenger/ui/components/AppleEmojiEditTextKt;->**(**)**
# 8. AVATAR
HSPLcom/rosetta/messenger/ui/components/AvatarImageKt;->**(**)**
HSPLcom/rosetta/messenger/utils/AvatarBitmapCache;->**(**)**
HSPLcom/rosetta/messenger/utils/AvatarFileManager;->**(**)**
HSPLcom/rosetta/messenger/repository/AvatarRepository;->**(**)**
# 9. NAVIGATION
HSPLcom/rosetta/messenger/MainActivityKt;->MainScreen(**)**
HSPLcom/rosetta/messenger/ui/components/SwipeBackContainerKt;->SwipeBackContainer(**)**
# 10. NETWORK
HSPLcom/rosetta/messenger/data/MessageRepository;->**(**)**
HSPLcom/rosetta/messenger/network/ProtocolManager;->**(**)**
HSPLcom/rosetta/messenger/network/WebSocketManager;->**(**)**
# 11. COMPOSE FRAMEWORK
HSPLandroidx/compose/foundation/lazy/**;->**(**)**
HSPLandroidx/compose/runtime/**;->**(**)**
HSPLandroidx/compose/ui/**;->**(**)**
HSPLandroidx/compose/animation/**;->**(**)**
HSPLandroidx/compose/material3/**;->**(**)**
HSPLandroidx/compose/foundation/**;->**(**)**
# 12. COROUTINES
HSPLkotlinx/coroutines/**;->**(**)**
# 13. PROFILEINSTALLER
HSPLandroidx/profileinstaller/**;->**(**)**
# 14. LOTTIE
HSPLcom/airbnb/lottie/**;->**(**)**
# 15. COIL
HSPLcoil/**;->**(**)**
# CLASS PRELOADING
Lcom/rosetta/messenger/MainActivity;
Lcom/rosetta/messenger/RosettaApplication;
Lcom/rosetta/messenger/crypto/CryptoManager;
Lcom/rosetta/messenger/crypto/MessageCrypto;
Lcom/rosetta/messenger/database/RosettaDatabase;
Lcom/rosetta/messenger/database/RosettaDatabase_Impl;
Lcom/rosetta/messenger/database/DialogDao_Impl;
Lcom/rosetta/messenger/database/MessageDao_Impl;
Lcom/rosetta/messenger/ui/chats/ChatsListViewModel;
Lcom/rosetta/messenger/ui/chats/ChatViewModel;
Lcom/rosetta/messenger/data/MessageRepository;
Lcom/rosetta/messenger/network/ProtocolManager;
Lcom/rosetta/messenger/network/WebSocketManager;
Lcom/rosetta/messenger/ui/components/OptimizedEmojiCache;
Lcom/rosetta/messenger/utils/AvatarBitmapCache;
Lcom/rosetta/messenger/repository/AvatarRepository;

View File

@@ -1,94 +1,95 @@
package com.rosetta.messenger.crypto
import android.util.Base64
import com.google.crypto.tink.subtle.XChaCha20Poly1305
import java.io.ByteArrayOutputStream
import java.math.BigInteger
import java.security.*
import java.util.concurrent.ConcurrentHashMap
import java.util.zip.Deflater
import java.util.zip.Inflater
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
import org.bitcoinj.crypto.MnemonicCode
import org.bitcoinj.crypto.MnemonicException
import org.bouncycastle.jce.ECNamedCurveTable
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.jce.spec.ECPrivateKeySpec
import org.bouncycastle.jce.spec.ECPublicKeySpec
import java.math.BigInteger
import java.security.*
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
import android.util.Base64
import java.security.spec.PKCS8EncodedKeySpec
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.Inflater
import java.util.concurrent.ConcurrentHashMap
import com.google.crypto.tink.subtle.XChaCha20Poly1305
/**
* Cryptography module for Rosetta Messenger
* Implements BIP39 seed phrase generation and secp256k1 key derivation
* Cryptography module for Rosetta Messenger Implements BIP39 seed phrase generation and secp256k1
* key derivation
*/
object CryptoManager {
private const val PBKDF2_ITERATIONS = 1000
private const val KEY_SIZE = 256
private const val SALT = "rosetta"
// 🚀 ОПТИМИЗАЦИЯ: Кэш для генерации ключей (seedPhrase -> KeyPair)
private val keyPairCache = mutableMapOf<String, KeyPairData>()
private val privateKeyHashCache = mutableMapOf<String, String>()
// 🚀 КРИТИЧЕСКАЯ ОПТИМИЗАЦИЯ: Кэш для PBKDF2 производных ключей
// PBKDF2 с 1000 итерациями - очень тяжелая операция!
// Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз
private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>()
// 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной расшифровке
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
// расшифровке
private const val DECRYPTION_CACHE_SIZE = 2000
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
init {
// Add BouncyCastle provider for secp256k1 support
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(BouncyCastleProvider())
}
}
/**
* 🚀 Получить или вычислить PBKDF2 ключ для пароля (кэшируется)
* 🚀 Получить или вычислить PBKDF2 ключ для пароля (кэшируется) Public для pre-warm при логине
* (чтобы кэш был горячий к моменту дешифровки)
*/
private fun getPbkdf2Key(password: String): SecretKeySpec {
fun getPbkdf2Key(password: String): SecretKeySpec {
return pbkdf2KeyCache.getOrPut(password) {
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(Charsets.UTF_8), PBKDF2_ITERATIONS, KEY_SIZE)
val spec =
PBEKeySpec(
password.toCharArray(),
SALT.toByteArray(Charsets.UTF_8),
PBKDF2_ITERATIONS,
KEY_SIZE
)
val secretKey = factory.generateSecret(spec)
SecretKeySpec(secretKey.encoded, "AES")
}
}
/**
* 🧹 Очистить кэши при logout
*/
/** 🧹 Очистить кэши при logout */
fun clearCaches() {
pbkdf2KeyCache.clear()
decryptionCache.clear()
keyPairCache.clear()
privateKeyHashCache.clear()
}
/**
* Generate a new 12-word BIP39 seed phrase
*/
/** Generate a new 12-word BIP39 seed phrase */
fun generateSeedPhrase(): List<String> {
val secureRandom = SecureRandom()
val entropy = ByteArray(16) // 128 bits = 12 words
secureRandom.nextBytes(entropy)
val mnemonicCode = MnemonicCode.INSTANCE
return mnemonicCode.toMnemonic(entropy)
}
/**
* Validate a seed phrase
*/
/** Validate a seed phrase */
fun validateSeedPhrase(words: List<String>): Boolean {
return try {
val mnemonicCode = MnemonicCode.INSTANCE
@@ -98,15 +99,15 @@ object CryptoManager {
false
}
}
/**
* Convert seed phrase to private key (32 bytes hex string)
*
*
* Алгоритм (совместим с Desktop desktop-rosetta):
* 1. BIP39 mnemonicToSeed(phrase, "") → 64 байта (PBKDF2-SHA512, 2048 итераций)
* 2. Конвертация seed в hex-строку (128 символов)
* 3. SHA256(hexSeed) → 32 байта privateKey
*
*
* Desktop эквивалент (SetPassword.tsx + crypto.ts):
* ```js
* let seed = await mnemonicToSeed(phrase); // BIP39 → 64 bytes
@@ -118,96 +119,94 @@ object CryptoManager {
// Step 1: BIP39 mnemonicToSeed — PBKDF2-SHA512 with 2048 iterations
// passphrase = "" (empty), salt = "mnemonic" + passphrase
val bip39Seed = MnemonicCode.toSeed(seedPhrase, "")
// Step 2: Convert to hex string (128 chars for 64 bytes)
val hexSeed = bip39Seed.joinToString("") { "%02x".format(it) }
// Step 3: SHA256(hexSeed) — matches Desktop's sha256.create().update(hex).digest()
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(hexSeed.toByteArray(Charsets.UTF_8))
return hash.joinToString("") { "%02x".format(it) }
}
/**
* Generate key pair from seed phrase using secp256k1 curve
*
*
* Алгоритм (совместим с Desktop desktop-rosetta):
* - privateKey = SHA256(hex(BIP39_seed(mnemonic))) - 32 байта
* - publicKey = secp256k1.getPublicKey(privateKey, compressed=true) - 33 байта
*
*
* Desktop эквивалент (crypto.ts):
* ```js
* const privateKey = sha256.create().update(seed).digest().toHex().toString();
* const publicKey = secp256k1.getPublicKey(Buffer.from(privateKey, "hex"), true);
* ```
*
*
* Кэшируем результаты для избежания повторных вычислений
*/
fun generateKeyPairFromSeed(seedPhrase: List<String>): KeyPairData {
val cacheKey = seedPhrase.joinToString(" ")
// Проверяем кэш
keyPairCache[cacheKey]?.let { return it }
keyPairCache[cacheKey]?.let {
return it
}
// Генерируем приватный ключ через SHA256
val privateKeyHex = seedPhraseToPrivateKey(seedPhrase)
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// Преобразуем hex в bytes (32 байта)
val privateKeyBytes = privateKeyHex.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
val privateKeyBytes = privateKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
// Генерируем публичный ключ из приватного
val publicKeyPoint = ecSpec.g.multiply(privateKeyBigInt)
// ⚡ ВАЖНО: Используем СЖАТЫЙ формат (compressed=true) - 33 байта вместо 65
// Это совместимо с crypto_new где используется: secp256k1.getPublicKey(..., true)
val publicKeyHex = publicKeyPoint.getEncoded(true)
.joinToString("") { "%02x".format(it) }
val keyPair = KeyPairData(
privateKey = privateKeyHex,
publicKey = publicKeyHex
)
val publicKeyHex = publicKeyPoint.getEncoded(true).joinToString("") { "%02x".format(it) }
val keyPair = KeyPairData(privateKey = privateKeyHex, publicKey = publicKeyHex)
// Сохраняем в кэш (ограничиваем размер до 5 записей)
keyPairCache[cacheKey] = keyPair
if (keyPairCache.size > 5) {
keyPairCache.remove(keyPairCache.keys.first())
}
return keyPair
}
/**
* Generate private key hash for protocol (SHA256(privateKey + "rosetta"))
* 🚀 ОПТИМИЗАЦИЯ: Кэшируем хэши для избежания повторных вычислений
* Generate private key hash for protocol (SHA256(privateKey + "rosetta")) 🚀 ОПТИМИЗАЦИЯ:
* Кэшируем хэши для избежания повторных вычислений
*/
fun generatePrivateKeyHash(privateKey: String): String {
// Проверяем кэш
privateKeyHashCache[privateKey]?.let { return it }
privateKeyHashCache[privateKey]?.let {
return it
}
val data = (privateKey + SALT).toByteArray()
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(data)
val hashHex = hash.joinToString("") { "%02x".format(it) }
// Сохраняем в кэш
privateKeyHashCache[privateKey] = hashHex
if (privateKeyHashCache.size > 10) {
privateKeyHashCache.remove(privateKeyHashCache.keys.first())
}
return hashHex
}
/**
* Encrypt data with password using PBKDF2 + AES
*
*
* ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts):
* - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию
* - Salt: "rosetta"
@@ -222,38 +221,44 @@ object CryptoManager {
fun encryptWithPassword(data: String, password: String): String {
// Compress data (zlib deflate - совместимо с pako.deflate в JS)
val compressed = compress(data.toByteArray(Charsets.UTF_8))
val CHUNK_SIZE = 10 * 1024 * 1024 // 10MB
// Check if we need chunking
if (compressed.size > CHUNK_SIZE) {
// Chunk the compressed data
val chunks = compressed.toList().chunked(CHUNK_SIZE).map { it.toByteArray() }
val encryptedChunks = mutableListOf<String>()
for (chunk in chunks) {
// Derive key using PBKDF2-HMAC-SHA1
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(Charsets.UTF_8), PBKDF2_ITERATIONS, KEY_SIZE)
val spec =
PBEKeySpec(
password.toCharArray(),
SALT.toByteArray(Charsets.UTF_8),
PBKDF2_ITERATIONS,
KEY_SIZE
)
val secretKey = factory.generateSecret(spec)
val key = SecretKeySpec(secretKey.encoded, "AES")
// Generate random IV
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val ivSpec = IvParameterSpec(iv)
// Encrypt with AES
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
val encrypted = cipher.doFinal(chunk)
// Store as ivBase64:ctBase64
val ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP)
val ctBase64 = Base64.encodeToString(encrypted, Base64.NO_WRAP)
encryptedChunks.add("$ivBase64:$ctBase64")
}
// Return chunked format: "CHNK:" + chunks joined by "::"
return "CHNK:" + encryptedChunks.joinToString("::")
} else {
@@ -261,52 +266,60 @@ object CryptoManager {
// Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!)
// crypto-js по умолчанию использует SHA1 для PBKDF2
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(Charsets.UTF_8), PBKDF2_ITERATIONS, KEY_SIZE)
val spec =
PBEKeySpec(
password.toCharArray(),
SALT.toByteArray(Charsets.UTF_8),
PBKDF2_ITERATIONS,
KEY_SIZE
)
val secretKey = factory.generateSecret(spec)
val key = SecretKeySpec(secretKey.encoded, "AES")
// Generate random IV
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val ivSpec = IvParameterSpec(iv)
// Encrypt with AES
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
val encrypted = cipher.doFinal(compressed)
// Return iv:ciphertext in Base64
val ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP)
val ctBase64 = Base64.encodeToString(encrypted, Base64.NO_WRAP)
return "$ivBase64:$ctBase64"
}
}
/**
* Decrypt data with password
*
*
* ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts):
* - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию
* - Salt: "rosetta"
* - Iterations: 1000
* - Iterations: 1000
* - Key size: 256 bit
* - AES-256-CBC с PKCS5/PKCS7 padding
* - Decompression: zlib inflate (pako.inflate в JS)
* - Supports old format (base64-encoded hex "iv:ciphertext")
* - Supports new format (base64 "iv:ciphertext")
* - Supports chunked format ("CHNK:" + chunks joined by "::")
*
*
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
*/
fun decryptWithPassword(encryptedData: String, password: String): String? {
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
val cacheKey = "$password:$encryptedData"
decryptionCache[cacheKey]?.let { return it }
decryptionCache[cacheKey]?.let {
return it
}
return try {
val result = decryptWithPasswordInternal(encryptedData, password)
// 🚀 Сохраняем в кэш (lock-free)
if (result != null) {
// Ограничиваем размер кэша
@@ -317,106 +330,103 @@ object CryptoManager {
}
decryptionCache[cacheKey] = result
}
result
} catch (e: Exception) {
null
}
}
/**
* 🔐 Внутренняя функция расшифровки (без кэширования результата)
*/
/** 🔐 Внутренняя функция расшифровки (без кэширования результата) */
private fun decryptWithPasswordInternal(encryptedData: String, password: String): String? {
return try {
// 🚀 Получаем кэшированный PBKDF2 ключ
val key = getPbkdf2Key(password)
// Check for old format: base64-encoded string containing hex
if (isOldFormat(encryptedData)) {
val decoded = String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
val parts = decoded.split(":")
if (parts.size != 2) return null
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Decrypt with AES-256-CBC (используем кэшированный ключ!)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
return String(decrypted, Charsets.UTF_8)
}
// Check for chunked format
if (encryptedData.startsWith("CHNK:")) {
val chunkStrings = encryptedData.substring(5).split("::")
val decompressedParts = mutableListOf<ByteArray>()
for (chunkString in chunkStrings) {
val parts = chunkString.split(":")
if (parts.size != 2) return null
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
// Decrypt with AES-256-CBC (используем кэшированный ключ!)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
decompressedParts.add(decrypted)
}
// Concatenate all decrypted chunks
val allBytes = decompressedParts.fold(ByteArray(0)) { acc, arr -> acc + arr }
// Decompress the concatenated data
return String(decompress(allBytes), Charsets.UTF_8)
}
// New format: base64 "iv:ciphertext"
val parts = encryptedData.split(":")
if (parts.size != 2) return null
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
// Decrypt with AES-256-CBC (используем кэшированный ключ!)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
// Decompress (zlib inflate - совместимо с pako.inflate в JS)
String(decompress(decrypted), Charsets.UTF_8)
} catch (e: Exception) {
null
}
}
/**
* Check if data is in old format (base64-encoded hex with ":")
*/
/** Check if data is in old format (base64-encoded hex with ":") */
private fun isOldFormat(data: String): Boolean {
// 🚀 Fast path: new format always contains ':' at plaintext level (iv:ct)
// Old format is a single base64 blob without ':' in the encoded string
if (data.contains(':')) return false
if (data.startsWith("CHNK:")) return false
return try {
val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8)
decoded.contains(":") && decoded.split(":").all { part ->
part.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
}
decoded.contains(":") &&
decoded.split(":").all { part ->
part.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
}
} catch (e: Exception) {
false
}
}
/**
* RAW Deflate сжатие (без zlib header)
*
*
* ⚠️ ВАЖНО: nowrap=true для совместимости с pako.deflate() в JS!
* - pako.deflate() создаёт RAW deflate поток (без 2-byte zlib header)
* - Java Deflater() по умолчанию создаёт zlib поток (с header 78 9C)
@@ -427,7 +437,7 @@ object CryptoManager {
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true)
deflater.setInput(data)
deflater.finish()
val outputStream = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (!deflater.finished()) {
@@ -438,10 +448,10 @@ object CryptoManager {
outputStream.close()
return outputStream.toByteArray()
}
/**
* RAW Inflate декомпрессия (без zlib header)
*
*
* ⚠️ ВАЖНО: nowrap=true для совместимости с pako.inflate() в JS!
* - pako.inflate() ожидает RAW deflate поток
* - Java Inflater() по умолчанию ожидает zlib поток (с header)
@@ -451,7 +461,7 @@ object CryptoManager {
// nowrap=true = RAW inflate (совместимо с pako.inflate)
val inflater = Inflater(true)
inflater.setInput(data)
val outputStream = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (!inflater.finished()) {
@@ -462,10 +472,10 @@ object CryptoManager {
outputStream.close()
return outputStream.toByteArray()
}
/**
* Encrypt data using ECDH + AES
*
*
* Algorithm:
* 1. Generate ephemeral key pair
* 2. Compute shared secret using ECDH (ephemeralPrivateKey × recipientPublicKey)
@@ -475,61 +485,67 @@ object CryptoManager {
*/
fun encrypt(data: String, publicKeyHex: String): String {
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// 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 recipientPublicKeyBytes =
publicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val recipientPublicKeyPoint = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
// Compute shared secret using ECDH (ephemeralPrivateKey × recipientPublicKey)
val sharedPoint = recipientPublicKeyPoint.multiply(ephemeralPrivateKeyBigInt).normalize()
// 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 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
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val ivSpec = IvParameterSpec(iv)
// Encrypt with AES
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
val encrypted = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
// Normalize ephemeral private key to 32 bytes
val normalizedPrivateKey = if (ephemeralPrivateKeyBytes.size > 32) {
ephemeralPrivateKeyBytes.copyOfRange(ephemeralPrivateKeyBytes.size - 32, ephemeralPrivateKeyBytes.size)
} else {
ephemeralPrivateKeyBytes
}
val normalizedPrivateKey =
if (ephemeralPrivateKeyBytes.size > 32) {
ephemeralPrivateKeyBytes.copyOfRange(
ephemeralPrivateKeyBytes.size - 32,
ephemeralPrivateKeyBytes.size
)
} else {
ephemeralPrivateKeyBytes
}
// Return base64(iv:ciphertext:ephemeralPrivateKey)
val ivHex = iv.joinToString("") { "%02x".format(it) }
val ctHex = encrypted.joinToString("") { "%02x".format(it) }
val ephemeralPrivateKeyHex = normalizedPrivateKey.joinToString("") { "%02x".format(it) }
val combined = "$ivHex:$ctHex:$ephemeralPrivateKeyHex"
return Base64.encodeToString(combined.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
}
/**
* Decrypt data using ECDH + AES
*
*
* Algorithm:
* 1. Parse iv, ciphertext, and ephemeralPrivateKey from base64
* 2. Compute ephemeral public key from ephemeral private key
@@ -543,55 +559,57 @@ object CryptoManager {
val decoded = String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
val parts = decoded.split(":")
if (parts.size != 3) return null
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ephemeralPrivateKeyBytes = parts[2].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ephemeralPrivateKeyBytes =
parts[2].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// Compute ephemeral public key from ephemeral private key
val ephemeralPrivateKeyBigInt = BigInteger(1, ephemeralPrivateKeyBytes)
val ephemeralPublicKeyPoint = ecSpec.g.multiply(ephemeralPrivateKeyBigInt)
val ephemeralPublicKeySpec = ECPublicKeySpec(ephemeralPublicKeyPoint, ecSpec)
val keyFactory = KeyFactory.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME)
val ephemeralPublicKey = keyFactory.generatePublic(ephemeralPublicKeySpec)
// Parse private key
val privateKeyBytes = privateKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val privateKeyBytes =
privateKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
val privateKeySpec = ECPrivateKeySpec(privateKeyBigInt, ecSpec)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
// Compute shared secret using ECDH
val keyAgreement = javax.crypto.KeyAgreement.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME)
val keyAgreement =
javax.crypto.KeyAgreement.getInstance(
"ECDH",
BouncyCastleProvider.PROVIDER_NAME
)
keyAgreement.init(privateKey)
keyAgreement.doPhase(ephemeralPublicKey, true)
val sharedSecret = keyAgreement.generateSecret()
// Use first 32 bytes (x-coordinate) as AES key
val sharedKey = sharedSecret.copyOfRange(1, 33)
val key = SecretKeySpec(sharedKey, "AES")
// Decrypt with AES-256-CBC
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
String(decrypted, Charsets.UTF_8)
} catch (e: Exception) {
null
}
}
/**
* Encrypt data using XChaCha20-Poly1305
*
* Returns: {
* ciphertext: hex string,
* nonce: hex string (24 bytes),
* key: hex string (32 bytes)
* }
*
* Returns: { ciphertext: hex string, nonce: hex string (24 bytes), key: hex string (32 bytes) }
*/
fun chacha20Encrypt(data: String): ChaCha20Result {
// Generate random key (32 bytes) and nonce (24 bytes)
@@ -599,22 +617,22 @@ object CryptoManager {
val nonce = ByteArray(24)
SecureRandom().nextBytes(key)
SecureRandom().nextBytes(nonce)
// Encrypt using XChaCha20-Poly1305
val cipher = XChaCha20Poly1305(key)
val plaintext = data.toByteArray(Charsets.UTF_8)
val ciphertext = cipher.encrypt(nonce, plaintext)
return ChaCha20Result(
ciphertext = ciphertext.joinToString("") { "%02x".format(it) },
nonce = nonce.joinToString("") { "%02x".format(it) },
key = key.joinToString("") { "%02x".format(it) }
ciphertext = ciphertext.joinToString("") { "%02x".format(it) },
nonce = nonce.joinToString("") { "%02x".format(it) },
key = key.joinToString("") { "%02x".format(it) }
)
}
/**
* Decrypt data using XChaCha20-Poly1305
*
*
* @param ciphertextHex Hex-encoded ciphertext
* @param nonceHex Hex-encoded nonce (24 bytes)
* @param keyHex Hex-encoded key (32 bytes)
@@ -624,10 +642,10 @@ object CryptoManager {
val ciphertext = ciphertextHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val nonce = nonceHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val key = keyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val cipher = XChaCha20Poly1305(key)
val decrypted = cipher.decrypt(nonce, ciphertext)
String(decrypted, Charsets.UTF_8)
} catch (e: Exception) {
null
@@ -635,13 +653,6 @@ object CryptoManager {
}
}
data class KeyPairData(
val privateKey: String,
val publicKey: String
)
data class KeyPairData(val privateKey: String, val publicKey: String)
data class ChaCha20Result(
val ciphertext: String,
val nonce: String,
val key: String
)
data class ChaCha20Result(val ciphertext: String, val nonce: String, val key: String)

View File

@@ -367,10 +367,24 @@ interface DialogDao {
AND i_have_sent = 1
AND last_message_timestamp > 0
ORDER BY last_message_timestamp DESC
LIMIT 30
"""
)
fun getDialogsFlow(account: String): Flow<List<DialogEntity>>
/** Получить все диалоги с пагинацией */
@Query(
"""
SELECT * FROM dialogs
WHERE account = :account
AND i_have_sent = 1
AND last_message_timestamp > 0
ORDER BY last_message_timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getDialogsPaged(account: String, limit: Int, offset: Int): List<DialogEntity>
/**
* Получить requests - диалоги где нам писали, но мы не отвечали Исключает пустые диалоги (без
* сообщений)
@@ -382,6 +396,7 @@ interface DialogDao {
AND i_have_sent = 0
AND last_message_timestamp > 0
ORDER BY last_message_timestamp DESC
LIMIT 30
"""
)
fun getRequestsFlow(account: String): Flow<List<DialogEntity>>

View File

@@ -117,11 +117,18 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
return
}
// 🔥 Показываем skeleton пока данные грузятся
_isLoading.value = true
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear()
currentAccount = publicKey
currentPrivateKey = privateKey
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
// Подписываемся на обычные диалоги
@OptIn(FlowPreview::class)
viewModelScope.launch {
@@ -268,6 +275,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
// 🚀 Убираем skeleton после первой загрузки
if (_isLoading.value) _isLoading.value = false
// 🟢 Подписываемся на онлайн-статусы всех собеседников
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный

View File

@@ -25,7 +25,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -89,7 +89,6 @@ fun MessageInputBar(
inputFocusTrigger: Int = 0
) {
val hasReply = replyMessages.isNotEmpty()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -206,7 +205,8 @@ fun MessageInputBar(
showKeyboard = {
editTextView?.let { editText ->
editText.requestFocus()
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
@Suppress("DEPRECATION")
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
},
hideEmoji = { onToggleEmojiPicker(false) }

View File

@@ -214,7 +214,7 @@ private fun OnlineIndicator(modifier: Modifier = Modifier) {
*/
@Composable
fun AvatarPicker(
onAvatarSelected: (String) -> Unit
@Suppress("UNUSED_PARAMETER") onAvatarSelected: (String) -> Unit
) {
// TODO: Реализовать выбор изображения через ActivityResultContract
// 1. Использовать rememberLauncherForActivityResult с ActivityResultContracts.GetContent()

View File

@@ -129,7 +129,6 @@ object OptimizedEmojiCache {
isPreloading = true
// Берем первые N эмодзи из категории Smileys (самые популярные)
val smileyCategory = EMOJI_CATEGORIES.find { it.key == "Smileys" }
val smileysToPreload = emojisByCategory?.get("Smileys")
?.take(PRELOAD_COUNT)
?: emptyList()