From abe1a1a710cb505d9e1ef2b5824709a2bb51580c Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 8 Feb 2026 09:21:05 +0500 Subject: [PATCH] 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. --- app/src/main/baseline-prof.txt | 149 ++- .../rosetta/messenger/crypto/CryptoManager.kt | 397 ++++---- .../messenger/database/MessageEntities.kt | 15 + .../messenger/ui/chats/ChatsListScreen.kt | 932 ++++++++++++------ .../messenger/ui/chats/ChatsListViewModel.kt | 9 + .../ui/chats/input/ChatDetailInput.kt | 6 +- .../messenger/ui/components/AvatarImage.kt | 2 +- .../ui/components/OptimizedEmojiCache.kt | 1 - baselineprofile/build.gradle.kts | 33 + baselineprofile/src/main/AndroidManifest.xml | 3 + .../BaselineProfileGenerator.kt | 66 ++ .../baselineprofile/StartupBenchmark.kt | 61 ++ settings.gradle.kts | 1 + 13 files changed, 1111 insertions(+), 564 deletions(-) create mode 100644 baselineprofile/build.gradle.kts create mode 100644 baselineprofile/src/main/AndroidManifest.xml create mode 100644 baselineprofile/src/main/java/com/rosetta/messenger/baselineprofile/BaselineProfileGenerator.kt create mode 100644 baselineprofile/src/main/java/com/rosetta/messenger/baselineprofile/StartupBenchmark.kt diff --git a/app/src/main/baseline-prof.txt b/app/src/main/baseline-prof.txt index 9e5d82a..52bf4b4 100644 --- a/app/src/main/baseline-prof.txt +++ b/app/src/main/baseline-prof.txt @@ -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;->(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; diff --git a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt index 0c12afb..171e7a5 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt @@ -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() private val privateKeyHashCache = mutableMapOf() - + // 🚀 КРИТИЧЕСКАЯ ОПТИМИЗАЦИЯ: Кэш для PBKDF2 производных ключей // PBKDF2 с 1000 итерациями - очень тяжелая операция! // Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз private val pbkdf2KeyCache = mutableMapOf() - + // 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений - // ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной расшифровке + // ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной + // расшифровке private const val DECRYPTION_CACHE_SIZE = 2000 private val decryptionCache = ConcurrentHashMap(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 { 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): 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): 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() - + 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() - + 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) diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 5938b78..8579498 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -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> + /** Получить все диалоги с пагинацией */ + @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 + /** * Получить 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> diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 38150a0..bd21ef3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -1,23 +1,18 @@ package com.rosetta.messenger.ui.chats import android.content.Context +import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.platform.LocalContext -import com.rosetta.messenger.repository.AvatarRepository -import com.rosetta.messenger.ui.components.AvatarImage import androidx.compose.material3.* -import compose.icons.TablerIcons -import compose.icons.tablericons.* import androidx.compose.runtime.* import androidx.compose.runtime.Immutable import androidx.compose.runtime.saveable.rememberSaveable @@ -26,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange @@ -43,13 +39,16 @@ import com.rosetta.messenger.R import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState +import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.components.AnimatedDotsText import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.BlurredAvatarBackground import com.rosetta.messenger.ui.components.VerifiedBadge -import com.rosetta.messenger.ui.settings.BackgroundBlurPresets import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import com.rosetta.messenger.ui.settings.BackgroundBlurPresets +import compose.icons.TablerIcons +import compose.icons.tablericons.* import java.text.SimpleDateFormat import java.util.* import kotlinx.coroutines.launch @@ -106,12 +105,12 @@ data class AvatarColors(val textColor: Color, val backgroundColor: Color) private val avatarColorCache = mutableMapOf() /** - * Определяет, является ли цвет светлым (true) или темным (false) - * Использует формулу relative luminance из WCAG + * Определяет, является ли цвет светлым (true) или темным (false) Использует формулу relative + * luminance из WCAG */ fun isColorLight(color: Color): Boolean { - val luminance = 0.299f * color.red + 0.587f * color.green + 0.114f * color.blue - return luminance > 0.5f + val luminance = 0.299f * color.red + 0.587f * color.green + 0.114f * color.blue + return luminance > 0.5f } fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors { @@ -184,7 +183,7 @@ fun ChatsListScreen( val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() - + // 🔥 Перехватываем системный back gesture - не закрываем приложение // Если drawer открыт - закрываем его, иначе игнорируем BackHandler(enabled = true) { @@ -259,14 +258,17 @@ fun ChatsListScreen( if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { val launchStart = System.currentTimeMillis() chatsViewModel.setAccount(accountPublicKey, accountPrivateKey) -// Устанавливаем аккаунт для RecentSearchesManager + // Устанавливаем аккаунт для RecentSearchesManager RecentSearchesManager.setAccount(accountPublicKey) - + // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих // сообщений val initStart = System.currentTimeMillis() ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey) -android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms") + android.util.Log.d( + "ChatsListScreen", + "✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms" + ) } } @@ -360,7 +362,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren Text( text = "Disconnected", fontSize = 16.sp, - fontWeight = FontWeight.Medium, + fontWeight = + FontWeight.Medium, color = textColor ) } @@ -369,14 +372,16 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren baseText = "Connecting", color = textColor, fontSize = 16.sp, - fontWeight = FontWeight.Medium + fontWeight = + FontWeight.Medium ) } ProtocolState.CONNECTED -> { Text( text = "Connected", fontSize = 16.sp, - fontWeight = FontWeight.Medium, + fontWeight = + FontWeight.Medium, color = textColor ) } @@ -385,14 +390,16 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren baseText = "Authenticating", color = textColor, fontSize = 16.sp, - fontWeight = FontWeight.Medium + fontWeight = + FontWeight.Medium ) } ProtocolState.AUTHENTICATED -> { Text( text = "Authenticated", fontSize = 16.sp, - fontWeight = FontWeight.Medium, + fontWeight = + FontWeight.Medium, color = textColor ) } @@ -417,7 +424,7 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) { ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = true, // 🔥 Явно включаем свайп для открытия drawer + gesturesEnabled = true, // 🔥 Явно включаем свайп для открытия drawer drawerContent = { ModalDrawerSheet( drawerContainerColor = Color.Transparent, @@ -444,11 +451,10 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren val headerColor = avatarColors.backgroundColor // Header с размытым фоном аватарки - Box( - modifier = Modifier.fillMaxWidth() - ) { + Box(modifier = Modifier.fillMaxWidth()) { // ═══════════════════════════════════════════════════════════ - // 🎨 BLURRED AVATAR BACKGROUND (на всю область header) + // 🎨 BLURRED AVATAR BACKGROUND (на всю + // область header) // ═══════════════════════════════════════════════════════════ BlurredAvatarBackground( publicKey = accountPublicKey, @@ -456,20 +462,26 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren fallbackColor = headerColor, blurRadius = 40f, alpha = 0.6f, - overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId) + overlayColors = + BackgroundBlurPresets + .getOverlayColors( + backgroundBlurColorId + ) ) - + // Content поверх фона Column( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding( - top = 16.dp, - start = 20.dp, - end = 20.dp, - bottom = 20.dp - ) + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .padding( + top = 16.dp, + start = + 20.dp, + end = 20.dp, + bottom = + 20.dp + ) ) { // Avatar - используем AvatarImage Box( @@ -492,11 +504,18 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren Alignment.Center ) { AvatarImage( - publicKey = accountPublicKey, - avatarRepository = avatarRepository, + publicKey = + accountPublicKey, + avatarRepository = + avatarRepository, size = 66.dp, - isDarkTheme = isDarkTheme, - displayName = accountName.ifEmpty { accountUsername } // 🔥 Для инициалов + isDarkTheme = + isDarkTheme, + displayName = + accountName + .ifEmpty { + accountUsername + } // 🔥 Для инициалов ) } @@ -512,19 +531,44 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren Text( text = accountName, fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = if (isDarkTheme) Color.White else Color.Black + fontWeight = + FontWeight + .SemiBold, + color = + if (isDarkTheme + ) + Color.White + else + Color.Black ) } // Username display (below name) if (accountUsername.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer( + modifier = + Modifier.height( + 4.dp + ) + ) Text( text = "@$accountUsername", fontSize = 13.sp, - color = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color.Black.copy(alpha = 0.7f) + color = + if (isDarkTheme + ) + Color.White + .copy( + alpha = + 0.7f + ) + else + Color.Black + .copy( + alpha = + 0.7f + ) ) } } @@ -554,7 +598,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren onClick = { scope.launch { drawerState.close() - kotlinx.coroutines.delay(100) + kotlinx.coroutines + .delay(100) onProfileClick() } } @@ -590,7 +635,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren onClick = { scope.launch { drawerState.close() - kotlinx.coroutines.delay(100) + kotlinx.coroutines + .delay(100) onSettingsClick() } } @@ -601,8 +647,7 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren icon = if (isDarkTheme) TablerIcons.Sun - else - TablerIcons.Moon, + else TablerIcons.Moon, text = if (isDarkTheme) "Light Mode" @@ -677,57 +722,75 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren ) { Scaffold( topBar = { - key(isDarkTheme, showRequestsScreen) { - TopAppBar( - navigationIcon = { - if (showRequestsScreen) { - // Back button for - // Requests - IconButton( - onClick = { - showRequestsScreen = - false - } - ) { - Icon( - TablerIcons.ArrowLeft, - contentDescription = - "Back", - tint = - PrimaryBlue - ) - } - } else { - // Menu button for - // main screen - IconButton( - onClick = { - scope - .launch { - drawerState - .open() - } - } - ) { - Icon( - TablerIcons.Menu2, - contentDescription = - "Menu", - tint = - textColor - .copy( - alpha = - 0.6f - ) - ) + key(isDarkTheme, showRequestsScreen) { + TopAppBar( + navigationIcon = { + if (showRequestsScreen) { + // Back button for + // Requests + IconButton( + onClick = { + showRequestsScreen = + false } + ) { + Icon( + TablerIcons + .ArrowLeft, + contentDescription = + "Back", + tint = + PrimaryBlue + ) } - }, - title = { - if (showRequestsScreen) { - // Requests title + } else { + // Menu button for + // main screen + IconButton( + onClick = { + scope + .launch { + drawerState + .open() + } + } + ) { + Icon( + TablerIcons + .Menu2, + contentDescription = + "Menu", + tint = + textColor + .copy( + alpha = + 0.6f + ) + ) + } + } + }, + title = { + if (showRequestsScreen) { + // Requests title + Text( + "Requests", + fontWeight = + FontWeight + .Bold, + fontSize = 20.sp, + color = textColor + ) + } else { + // Rosetta title + // with status + Row( + verticalAlignment = + Alignment + .CenterVertically + ) { Text( - "Requests", + "Rosetta", fontWeight = FontWeight .Bold, @@ -736,128 +799,110 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren color = textColor ) - } else { - // Rosetta title - // with status - Row( - verticalAlignment = - Alignment - .CenterVertically - ) { - Text( - "Rosetta", - fontWeight = - FontWeight - .Bold, - fontSize = - 20.sp, - color = - textColor - ) - Spacer( - modifier = - Modifier.width( - 8.dp + Spacer( + modifier = + Modifier.width( + 8.dp + ) + ) + Box( + modifier = + Modifier.size( + 10.dp ) - ) - Box( - modifier = - Modifier.size( - 10.dp - ) - .clip( - CircleShape - ) - .background( - when (protocolState - ) { - ProtocolState - .AUTHENTICATED -> - Color( - 0xFF4CAF50 - ) - ProtocolState - .CONNECTING, - ProtocolState - .CONNECTED, - ProtocolState - .HANDSHAKING -> - Color( - 0xFFFFC107 - ) - ProtocolState - .DISCONNECTED -> - Color( - 0xFFF44336 - ) - } - ) - .clickable { - showStatusDialog = - true + .clip( + CircleShape + ) + .background( + when (protocolState + ) { + ProtocolState + .AUTHENTICATED -> + Color( + 0xFF4CAF50 + ) + ProtocolState + .CONNECTING, + ProtocolState + .CONNECTED, + ProtocolState + .HANDSHAKING -> + Color( + 0xFFFFC107 + ) + ProtocolState + .DISCONNECTED -> + Color( + 0xFFF44336 + ) } - ) - } + ) + .clickable { + showStatusDialog = + true + } + ) } - }, - actions = { - // Search only on main - // screen - if (!showRequestsScreen) { - IconButton( - onClick = { + } + }, + actions = { + // Search only on main + // screen + if (!showRequestsScreen) { + IconButton( + onClick = { + if (protocolState == + ProtocolState + .AUTHENTICATED + ) { + onSearchClick() + } + }, + enabled = + protocolState == + ProtocolState + .AUTHENTICATED + ) { + Icon( + TablerIcons + .Search, + contentDescription = + "Search", + tint = if (protocolState == ProtocolState .AUTHENTICATED - ) { - onSearchClick() - } - }, - enabled = - protocolState == - ProtocolState - .AUTHENTICATED - ) { - Icon( - TablerIcons.Search, - contentDescription = - "Search", - tint = - if (protocolState == - ProtocolState - .AUTHENTICATED - ) - textColor - .copy( - alpha = - 0.6f - ) - else - textColor - .copy( - alpha = - 0.5f - ) - ) - } - } - }, - colors = - TopAppBarDefaults - .topAppBarColors( - containerColor = - backgroundColor, - scrolledContainerColor = - backgroundColor, - navigationIconContentColor = - textColor, - titleContentColor = - textColor, - actionIconContentColor = - textColor + ) + textColor + .copy( + alpha = + 0.6f + ) + else + textColor + .copy( + alpha = + 0.5f + ) ) - ) - } + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = + backgroundColor, + scrolledContainerColor = + backgroundColor, + navigationIconContentColor = + textColor, + titleContentColor = + textColor, + actionIconContentColor = + textColor + ) + ) + } }, floatingActionButton = { AnimatedVisibility( @@ -890,13 +935,19 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren containerColor = backgroundColor ) { paddingValues -> // Main content - Box(modifier = Modifier.fillMaxSize().background(backgroundColor).padding(paddingValues)) { + Box( + modifier = + Modifier.fillMaxSize() + .background(backgroundColor) + .padding(paddingValues) + ) { // � Используем комбинированное состояние для атомарного // обновления // Это предотвращает "дергание" UI когда dialogs и requests // обновляются // независимо val chatsState by chatsViewModel.chatsState.collectAsState() + val isLoading by chatsViewModel.isLoading.collectAsState() val requests = chatsState.requests val requestsCount = chatsState.requestsCount @@ -946,6 +997,9 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren onUserSelect(user) } ) + } else if (isLoading) { + // 🚀 Shimmer skeleton пока данные грузятся + ChatsListSkeleton(isDarkTheme = isDarkTheme) } else if (shouldShowEmptyState) { // 🔥 Empty state - показываем только если // контент НЕ был показан ранее @@ -955,25 +1009,47 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren ) } else { // Show dialogs list - val dividerColor = remember(isDarkTheme) { - if (isDarkTheme) Color(0xFF3A3A3A) - else Color(0xFFE8E8E8) - } - val listBackgroundColor = remember(isDarkTheme) { - if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) - } + val dividerColor = + remember(isDarkTheme) { + if (isDarkTheme) + Color(0xFF3A3A3A) + else Color(0xFFE8E8E8) + } + val listBackgroundColor = + remember(isDarkTheme) { + if (isDarkTheme) + Color(0xFF1A1A1A) + else Color(0xFFF2F2F7) + } // 🔥 Берем dialogs из chatsState для // консистентности - // 📌 Сортируем: pinned сначала, потом по времени - val currentDialogs = remember(chatsState.dialogs, pinnedChats) { - chatsState.dialogs.sortedWith( - compareByDescending { pinnedChats.contains(it.opponentKey) } - .thenByDescending { it.lastMessageTimestamp } - ) - } + // 📌 Сортируем: pinned сначала, потом по + // времени + val currentDialogs = + remember( + chatsState.dialogs, + pinnedChats + ) { + chatsState.dialogs + .sortedWith( + compareByDescending< + DialogUiModel> { + pinnedChats + .contains( + it.opponentKey + ) + } + .thenByDescending { + it.lastMessageTimestamp + } + ) + } - // Telegram-style: only one item can be swiped open at a time - var swipedItemKey by remember { mutableStateOf(null) } + // Telegram-style: only one item can be + // swiped open at a time + var swipedItemKey by remember { + mutableStateOf(null) + } // Close swiped item when drawer opens LaunchedEffect(drawerState.isOpen) { @@ -983,9 +1059,11 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren } LazyColumn( - modifier = Modifier - .fillMaxSize() - .background(listBackgroundColor) + modifier = + Modifier.fillMaxSize() + .background( + listBackgroundColor + ) ) { if (requestsCount > 0) { item( @@ -1019,10 +1097,22 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren val isSavedMessages = dialog.opponentKey == accountPublicKey - val isBlocked = blockedUsers.contains(dialog.opponentKey) - val isTyping by remember(dialog.opponentKey) { - derivedStateOf { typingUsers.contains(dialog.opponentKey) } - } + val isBlocked = + blockedUsers + .contains( + dialog.opponentKey + ) + val isTyping by + remember( + dialog.opponentKey + ) { + derivedStateOf { + typingUsers + .contains( + dialog.opponentKey + ) + } + } Column { SwipeableDialogItem( @@ -1038,17 +1128,28 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren isSavedMessages, avatarRepository = avatarRepository, - isDrawerOpen = drawerState.isOpen || drawerState.isAnimationRunning, + isDrawerOpen = + drawerState + .isOpen || + drawerState + .isAnimationRunning, isSwipedOpen = - swipedItemKey == dialog.opponentKey, + swipedItemKey == + dialog.opponentKey, onSwipeStarted = { - swipedItemKey = dialog.opponentKey + swipedItemKey = + dialog.opponentKey }, onSwipeClosed = { - if (swipedItemKey == dialog.opponentKey) swipedItemKey = null + if (swipedItemKey == + dialog.opponentKey + ) + swipedItemKey = + null }, onClick = { - swipedItemKey = null + swipedItemKey = + null val user = chatsViewModel .dialogToSearchUser( @@ -1070,8 +1171,16 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren dialogToUnblock = dialog }, - isPinned = pinnedChats.contains(dialog.opponentKey), - onPin = { onTogglePin(dialog.opponentKey) } + isPinned = + pinnedChats + .contains( + dialog.opponentKey + ), + onPin = { + onTogglePin( + dialog.opponentKey + ) + } ) // 🔥 СЕПАРАТОР - @@ -1223,6 +1332,92 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren } // Close Box } +/** + * 🚀 Shimmer skeleton для списка чатов — показывается пока данные грузятся Имитирует 10 строк + * диалогов: аватар + 2 строки текста + */ +@Composable +private fun ChatsListSkeleton(isDarkTheme: Boolean) { + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) + val shimmerBase = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0) + val shimmerHighlight = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFF0F0F0) + + val transition = rememberInfiniteTransition(label = "shimmer") + val shimmerProgress by + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = + tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmerAlpha" + ) + val shimmerColor = lerp(shimmerBase, shimmerHighlight, shimmerProgress) + + LazyColumn( + modifier = Modifier.fillMaxSize().background(backgroundColor), + userScrollEnabled = false + ) { + items(10) { + SkeletonDialogItem(shimmerColor = shimmerColor, isDarkTheme = isDarkTheme) + } + } +} + +@Composable +private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) { + val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar placeholder + Box(modifier = Modifier.size(56.dp).clip(CircleShape).background(shimmerColor)) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + // Name placeholder + Box( + modifier = + Modifier.fillMaxWidth(0.45f) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerColor) + ) + Spacer(modifier = Modifier.height(8.dp)) + // Message placeholder + Box( + modifier = + Modifier.fillMaxWidth(0.7f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerColor) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Time placeholder + Box( + modifier = + Modifier.width(36.dp) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerColor) + ) + } + Divider( + color = dividerColor, + thickness = 0.5.dp, + modifier = Modifier.padding(start = 84.dp) + ) +} + @Composable private fun EmptyChatsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) { val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) @@ -1296,7 +1491,7 @@ fun ChatItem( isDarkTheme = isDarkTheme, showOnlineIndicator = true, isOnline = chat.isOnline, - displayName = chat.name // 🔥 Для инициалов + displayName = chat.name // 🔥 Для инициалов ) Spacer(modifier = Modifier.width(12.dp)) @@ -1349,8 +1544,10 @@ fun ChatItem( maxLines = 1, overflow = android.text.TextUtils.TruncateAt.END, modifier = Modifier.weight(1f), - enableLinks = false // 🔗 Ссылки не кликабельны в списке чатов - ) + enableLinks = + false // 🔗 Ссылки не кликабельны в списке + // чатов + ) Row(verticalAlignment = Alignment.CenterVertically) { // Pin icon @@ -1515,11 +1712,12 @@ fun SwipeableDialogItem( isPinned: Boolean = false, onPin: () -> Unit = {} ) { - val backgroundColor = if (isPinned) { - if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED) - } else { - if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) - } + val backgroundColor = + if (isPinned) { + if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED) + } else { + if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) + } var offsetX by remember { mutableStateOf(0f) } // 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete) val buttonCount = if (isSavedMessages) 2 else 3 @@ -1546,7 +1744,13 @@ fun SwipeableDialogItem( label = "swipeOffset" ) - Box(modifier = Modifier.fillMaxWidth().height(itemHeight).background(backgroundColor).clipToBounds()) { + Box( + modifier = + Modifier.fillMaxWidth() + .height(itemHeight) + .background(backgroundColor) + .clipToBounds() + ) { // 1. КНОПКИ - позиционированы справа, всегда видны при свайпе Row( modifier = @@ -1582,8 +1786,7 @@ fun SwipeableDialogItem( ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = - if (isPinned) "Unpin" else "Pin", + text = if (isPinned) "Unpin" else "Pin", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold @@ -1615,8 +1818,7 @@ fun SwipeableDialogItem( ) { Icon( imageVector = - if (isBlocked) - TablerIcons.LockOpen + if (isBlocked) TablerIcons.LockOpen else TablerIcons.Ban, contentDescription = if (isBlocked) "Unblock" @@ -1682,7 +1884,10 @@ fun SwipeableDialogItem( val touchSlop = viewConfiguration.touchSlop awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) + val down = + awaitFirstDown( + requireUnconsumed = false + ) // Don't handle swipes when drawer is open if (isDrawerOpen) return@awaitEachGesture @@ -1695,52 +1900,101 @@ fun SwipeableDialogItem( while (true) { val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } - ?: break - if (change.changedToUpIgnoreConsumed()) break + val change = + event.changes.firstOrNull { + it.id == down.id + } + ?: break + if (change.changedToUpIgnoreConsumed() + ) + break val delta = change.positionChange() totalDragX += delta.x totalDragY += delta.y if (!passedSlop) { - val dist = kotlin.math.sqrt( - totalDragX * totalDragX + totalDragY * totalDragY - ) - if (dist < touchSlop) continue + val dist = + kotlin.math.sqrt( + totalDragX * + totalDragX + + totalDragY * + totalDragY + ) + if (dist < touchSlop) + continue - val dominated = kotlin.math.abs(totalDragX) > - kotlin.math.abs(totalDragY) * 2.0f + val dominated = + kotlin.math.abs( + totalDragX + ) > + kotlin.math + .abs( + totalDragY + ) * + 2.0f when { - // Horizontal left swipe — reveal action buttons - dominated && totalDragX < 0 -> { - passedSlop = true - claimed = true + // Horizontal left + // swipe — reveal + // action buttons + dominated && + totalDragX < + 0 -> { + passedSlop = + true + claimed = + true onSwipeStarted() change.consume() } - // Horizontal right swipe with buttons open — close them - dominated && totalDragX > 0 && offsetX != 0f -> { - passedSlop = true - claimed = true + // Horizontal right + // swipe with + // buttons open — + // close them + dominated && + totalDragX > + 0 && + offsetX != + 0f -> { + passedSlop = + true + claimed = + true change.consume() } - // Right swipe with buttons closed — let drawer handle - totalDragX > 0 && offsetX == 0f -> break - // Vertical/diagonal — close buttons if open, let LazyColumn scroll + // Right swipe with + // buttons closed — + // let drawer handle + totalDragX > 0 && + offsetX == + 0f -> + break + // Vertical/diagonal + // — close buttons + // if open, let + // LazyColumn scroll else -> { - if (offsetX != 0f) { - offsetX = 0f + if (offsetX != + 0f + ) { + offsetX = + 0f onSwipeClosed() } break } } } else { - // Gesture is ours — update offset - val newOffset = offsetX + delta.x - offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) + // Gesture is ours — update + // offset + val newOffset = + offsetX + delta.x + offsetX = + newOffset.coerceIn( + -swipeWidthPx, + 0f + ) velocityTracker.addPosition( change.uptimeMillis, change.position @@ -1751,20 +2005,29 @@ fun SwipeableDialogItem( // Snap animation if (claimed) { - val velocity = velocityTracker.calculateVelocity().x + val velocity = + velocityTracker + .calculateVelocity() + .x when { - // Rightward fling — always close + // Rightward fling — always + // close velocity > 150f -> { offsetX = 0f onSwipeClosed() } - // Strong leftward fling — always open + // Strong leftward fling — + // always open velocity < -300f -> { - offsetX = -swipeWidthPx + offsetX = + -swipeWidthPx } // Past halfway — stay open - kotlin.math.abs(offsetX) > swipeWidthPx / 2 -> { - offsetX = -swipeWidthPx + kotlin.math.abs(offsetX) > + swipeWidthPx / + 2 -> { + offsetX = + -swipeWidthPx } // Less than halfway — close else -> { @@ -1907,19 +2170,24 @@ fun DialogItemContent( } } else { // 🔥 Формируем displayName для инициалов в placeholder - val avatarDisplayName = remember( - dialog.opponentTitle, - dialog.opponentKey, - dialog.opponentUsername - ) { - when { - dialog.opponentTitle.isNotEmpty() && - dialog.opponentTitle != dialog.opponentKey && - !dialog.opponentTitle.startsWith(dialog.opponentKey.take(7)) -> dialog.opponentTitle - dialog.opponentUsername.isNotEmpty() -> dialog.opponentUsername - else -> null + val avatarDisplayName = + remember( + dialog.opponentTitle, + dialog.opponentKey, + dialog.opponentUsername + ) { + when { + dialog.opponentTitle.isNotEmpty() && + dialog.opponentTitle != + dialog.opponentKey && + !dialog.opponentTitle.startsWith( + dialog.opponentKey.take(7) + ) -> dialog.opponentTitle + dialog.opponentUsername.isNotEmpty() -> + dialog.opponentUsername + else -> null + } } - } com.rosetta.messenger.ui.components.AvatarImage( publicKey = dialog.opponentKey, avatarRepository = avatarRepository, @@ -2008,7 +2276,8 @@ fun DialogItemContent( // ERROR - показываем иконку ошибки Icon( imageVector = - TablerIcons.AlertCircle, + TablerIcons + .AlertCircle, contentDescription = "Sending failed", tint = @@ -2064,7 +2333,8 @@ fun DialogItemContent( Icon( imageVector = TablerIcons.Clock, - contentDescription = "Sending", + contentDescription = + "Sending", tint = secondaryTextColor .copy( @@ -2082,9 +2352,12 @@ fun DialogItemContent( } } - val formattedTime = remember(dialog.lastMessageTimestamp) { - formatTime(Date(dialog.lastMessageTimestamp)) - } + val formattedTime = + remember(dialog.lastMessageTimestamp) { + formatTime( + Date(dialog.lastMessageTimestamp) + ) + } Text( text = formattedTime, fontSize = 13.sp, @@ -2107,15 +2380,21 @@ fun DialogItemContent( TypingIndicatorSmall() } else { // � Определяем что показывать - attachment или текст - val displayText = when { - dialog.lastMessageAttachmentType == "Photo" -> "Photo" - dialog.lastMessageAttachmentType == "File" -> "File" - dialog.lastMessageAttachmentType == "Avatar" -> "Avatar" - dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message" - dialog.lastMessage.isEmpty() -> "No messages" - else -> dialog.lastMessage - } - + val displayText = + when { + dialog.lastMessageAttachmentType == + "Photo" -> "Photo" + dialog.lastMessageAttachmentType == + "File" -> "File" + dialog.lastMessageAttachmentType == + "Avatar" -> "Avatar" + dialog.lastMessageAttachmentType == + "Forwarded" -> "Forwarded message" + dialog.lastMessage.isEmpty() -> + "No messages" + else -> dialog.lastMessage + } + // 🔥 Используем AppleEmojiText для отображения эмодзи // Если есть непрочитанные - текст темнее AppleEmojiText( @@ -2132,20 +2411,25 @@ fun DialogItemContent( maxLines = 1, overflow = android.text.TextUtils.TruncateAt.END, modifier = Modifier.weight(1f), - enableLinks = false // 🔗 Ссылки не кликабельны в списке чатов - ) + enableLinks = + false // 🔗 Ссылки не кликабельны в списке + // чатов + ) } // Unread badge if (dialog.unreadCount > 0) { Spacer(modifier = Modifier.width(8.dp)) - val unreadText = remember(dialog.unreadCount) { - when { - dialog.unreadCount > 999 -> "999+" - dialog.unreadCount > 99 -> "99+" - else -> dialog.unreadCount.toString() + val unreadText = + remember(dialog.unreadCount) { + when { + dialog.unreadCount > 999 -> "999+" + dialog.unreadCount > 99 -> "99+" + else -> + dialog.unreadCount + .toString() + } } - } Box( modifier = Modifier.height(22.dp) @@ -2289,7 +2573,11 @@ fun RequestsScreen( } else { // Requests list LazyColumn(modifier = Modifier.fillMaxSize()) { - items(requests, key = { it.opponentKey }, contentType = { "request" }) { request -> + items( + requests, + key = { it.opponentKey }, + contentType = { "request" } + ) { request -> DialogItemContent( dialog = request, isDarkTheme = isDarkTheme, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 22ca177..0edb0bf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -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 - не нужно подписываться на свой собственный diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 68a1632..171db3c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -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) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 57b38fb..bab951f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt index 8c13ff5..ba991b7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt @@ -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() diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts new file mode 100644 index 0000000..6e477ef --- /dev/null +++ b/baselineprofile/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id("com.android.test") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.rosetta.messenger.baselineprofile" + compileSdk = 34 + + defaultConfig { + minSdk = 28 // Baseline Profiles require API 28+ + targetSdk = 34 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + targetProjectPath = ":app" +} + +dependencies { + implementation("androidx.test.ext:junit:1.1.5") + implementation("androidx.test.espresso:espresso-core:3.5.1") + implementation("androidx.test.uiautomator:uiautomator:2.2.0") + implementation("androidx.benchmark:benchmark-macro-junit4:1.2.2") +} diff --git a/baselineprofile/src/main/AndroidManifest.xml b/baselineprofile/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/baselineprofile/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/baselineprofile/src/main/java/com/rosetta/messenger/baselineprofile/BaselineProfileGenerator.kt b/baselineprofile/src/main/java/com/rosetta/messenger/baselineprofile/BaselineProfileGenerator.kt new file mode 100644 index 0000000..931de90 --- /dev/null +++ b/baselineprofile/src/main/java/com/rosetta/messenger/baselineprofile/BaselineProfileGenerator.kt @@ -0,0 +1,66 @@ +package com.rosetta.messenger.baselineprofile + +import androidx.benchmark.macro.ExperimentalBaselineProfilesApi +import androidx.benchmark.macro.junit4.BaselineProfileRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Baseline Profile Generator для Rosetta Messenger + * + * Запуск: + * ./gradlew :baselineprofile:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.rosetta.messenger.baselineprofile.BaselineProfileGenerator + * + * Результат: + * Профиль будет сгенерирован и сохранён — скопируйте его в app/src/main/baseline-prof.txt + * + * Требования: + * - Эмулятор или устройство API 28+ (лучше userdebug/rooted) + * - Приложение должно быть установлено + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class BaselineProfileGenerator { + + @get:Rule + val baselineProfileRule = BaselineProfileRule() + + @Test + fun generateBaselineProfile() = baselineProfileRule.collectBaselineProfile( + packageName = "com.rosetta.messenger" + ) { + // 1. Cold start — запуск приложения + pressHome() + startActivityAndWait() + + // 2. Ждём загрузки (Splash → Auth/Main) + device.waitForIdle() + Thread.sleep(3000) // Ждём splash + auth flow + + // 3. Если попали на экран логина — пропускаем (профиль соберёт auth UI) + // Если попали на список чатов — скроллим + val chatList = device.wait(Until.hasObject(By.scrollable(true)), 5000) + if (chatList) { + // Скролл списка чатов вниз и обратно + val scrollable = device.findObject(By.scrollable(true)) + scrollable?.let { + it.scroll(androidx.test.uiautomator.Direction.DOWN, 2.0f) + device.waitForIdle() + Thread.sleep(500) + + it.scroll(androidx.test.uiautomator.Direction.UP, 2.0f) + device.waitForIdle() + Thread.sleep(500) + } + } + + // 4. Ждём перед завершением + device.waitForIdle() + Thread.sleep(1000) + } +} diff --git a/baselineprofile/src/main/java/com/rosetta/messenger/baselineprofile/StartupBenchmark.kt b/baselineprofile/src/main/java/com/rosetta/messenger/baselineprofile/StartupBenchmark.kt new file mode 100644 index 0000000..fd9f3f9 --- /dev/null +++ b/baselineprofile/src/main/java/com/rosetta/messenger/baselineprofile/StartupBenchmark.kt @@ -0,0 +1,61 @@ +package com.rosetta.messenger.baselineprofile + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Startup Benchmark для замера холодного старта Rosetta + * + * Запуск: + * ./gradlew :baselineprofile:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.rosetta.messenger.baselineprofile.StartupBenchmark + * + * Сравнивает 3 режима компиляции: + * - None (интерпретатор) — worst case + * - BaselineProfile — с нашими профилями + * - Full (полная AOT) — best case + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class StartupBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun startupNoCompilation() = startup(CompilationMode.None()) + + @Test + fun startupBaselineProfile() = startup(CompilationMode.Partial()) + + @Test + fun startupFullCompilation() = startup(CompilationMode.Full()) + + private fun startup(compilationMode: CompilationMode) { + benchmarkRule.measureRepeated( + packageName = "com.rosetta.messenger", + metrics = listOf(StartupTimingMetric(), FrameTimingMetric()), + compilationMode = compilationMode, + iterations = 5, + startupMode = StartupMode.COLD, + setupBlock = { + pressHome() + } + ) { + startActivityAndWait() + + // Ждём появления контента (чат-лист или auth) + device.waitForIdle() + Thread.sleep(2000) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2f3f047..d58f3f1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,3 +16,4 @@ dependencyResolutionManagement { rootProject.name = "rosetta-android" include(":app") +include(":baselineprofile")