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:
@@ -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;
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
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 {
|
||||
|
||||
@@ -41,7 +40,8 @@ object CryptoManager {
|
||||
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)
|
||||
|
||||
@@ -53,20 +53,25 @@ object CryptoManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Получить или вычислить 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()
|
||||
@@ -74,9 +79,7 @@ object CryptoManager {
|
||||
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
|
||||
@@ -86,9 +89,7 @@ object CryptoManager {
|
||||
return mnemonicCode.toMnemonic(entropy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a seed phrase
|
||||
*/
|
||||
/** Validate a seed phrase */
|
||||
fun validateSeedPhrase(words: List<String>): Boolean {
|
||||
return try {
|
||||
val mnemonicCode = MnemonicCode.INSTANCE
|
||||
@@ -148,16 +149,16 @@ object CryptoManager {
|
||||
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)
|
||||
|
||||
@@ -166,13 +167,9 @@ object CryptoManager {
|
||||
|
||||
// ⚡ ВАЖНО: Используем СЖАТЫЙ формат (compressed=true) - 33 байта вместо 65
|
||||
// Это совместимо с crypto_new где используется: secp256k1.getPublicKey(..., true)
|
||||
val publicKeyHex = publicKeyPoint.getEncoded(true)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
val publicKeyHex = publicKeyPoint.getEncoded(true).joinToString("") { "%02x".format(it) }
|
||||
|
||||
val keyPair = KeyPairData(
|
||||
privateKey = privateKeyHex,
|
||||
publicKey = publicKeyHex
|
||||
)
|
||||
val keyPair = KeyPairData(privateKey = privateKeyHex, publicKey = publicKeyHex)
|
||||
|
||||
// Сохраняем в кэш (ограничиваем размер до 5 записей)
|
||||
keyPairCache[cacheKey] = keyPair
|
||||
@@ -184,12 +181,14 @@ object CryptoManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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")
|
||||
@@ -234,7 +233,13 @@ object CryptoManager {
|
||||
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")
|
||||
|
||||
@@ -261,7 +266,13 @@ 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")
|
||||
|
||||
@@ -302,7 +313,9 @@ object CryptoManager {
|
||||
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)
|
||||
@@ -324,9 +337,7 @@ object CryptoManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔐 Внутренняя функция расшифровки (без кэширования результата)
|
||||
*/
|
||||
/** 🔐 Внутренняя функция расшифровки (без кэширования результата) */
|
||||
private fun decryptWithPasswordInternal(encryptedData: String, password: String): String? {
|
||||
return try {
|
||||
// 🚀 Получаем кэшированный PBKDF2 ключ
|
||||
@@ -395,9 +406,7 @@ object CryptoManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -406,7 +415,8 @@ object CryptoManager {
|
||||
|
||||
return try {
|
||||
val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8)
|
||||
decoded.contains(":") && decoded.split(":").all { part ->
|
||||
decoded.contains(":") &&
|
||||
decoded.split(":").all { part ->
|
||||
part.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -485,7 +495,8 @@ object CryptoManager {
|
||||
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)
|
||||
@@ -493,7 +504,8 @@ object CryptoManager {
|
||||
|
||||
// Use x-coordinate of shared point as AES key (32 bytes)
|
||||
val sharedKeyBytes = sharedPoint.affineXCoord.encoded
|
||||
val sharedKey = if (sharedKeyBytes.size >= 32) {
|
||||
val sharedKey =
|
||||
if (sharedKeyBytes.size >= 32) {
|
||||
sharedKeyBytes.copyOfRange(sharedKeyBytes.size - 32, sharedKeyBytes.size)
|
||||
} else {
|
||||
// Pad with leading zeros if needed
|
||||
@@ -512,8 +524,12 @@ object CryptoManager {
|
||||
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)
|
||||
val normalizedPrivateKey =
|
||||
if (ephemeralPrivateKeyBytes.size > 32) {
|
||||
ephemeralPrivateKeyBytes.copyOfRange(
|
||||
ephemeralPrivateKeyBytes.size - 32,
|
||||
ephemeralPrivateKeyBytes.size
|
||||
)
|
||||
} else {
|
||||
ephemeralPrivateKeyBytes
|
||||
}
|
||||
@@ -546,7 +562,8 @@ object CryptoManager {
|
||||
|
||||
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")
|
||||
|
||||
@@ -558,13 +575,18 @@ object CryptoManager {
|
||||
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()
|
||||
@@ -587,11 +609,7 @@ object CryptoManager {
|
||||
/**
|
||||
* 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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,8 +105,8 @@ data class AvatarColors(val textColor: Color, val backgroundColor: Color)
|
||||
private val avatarColorCache = mutableMapOf<String, AvatarColors>()
|
||||
|
||||
/**
|
||||
* Определяет, является ли цвет светлым (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
|
||||
@@ -266,7 +265,10 @@ fun ChatsListScreen(
|
||||
// сообщений
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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,19 +462,25 @@ 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()
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(
|
||||
top = 16.dp,
|
||||
start = 20.dp,
|
||||
start =
|
||||
20.dp,
|
||||
end = 20.dp,
|
||||
bottom = 20.dp
|
||||
bottom =
|
||||
20.dp
|
||||
)
|
||||
) {
|
||||
// Avatar - используем AvatarImage
|
||||
@@ -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"
|
||||
@@ -690,7 +735,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.ArrowLeft,
|
||||
TablerIcons
|
||||
.ArrowLeft,
|
||||
contentDescription =
|
||||
"Back",
|
||||
tint =
|
||||
@@ -710,7 +756,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.Menu2,
|
||||
TablerIcons
|
||||
.Menu2,
|
||||
contentDescription =
|
||||
"Menu",
|
||||
tint =
|
||||
@@ -731,10 +778,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
||||
fontWeight =
|
||||
FontWeight
|
||||
.Bold,
|
||||
fontSize =
|
||||
20.sp,
|
||||
color =
|
||||
textColor
|
||||
fontSize = 20.sp,
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
// Rosetta title
|
||||
@@ -819,7 +864,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
||||
.AUTHENTICATED
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.Search,
|
||||
TablerIcons
|
||||
.Search,
|
||||
contentDescription =
|
||||
"Search",
|
||||
tint =
|
||||
@@ -843,8 +889,7 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults
|
||||
.topAppBarColors(
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor =
|
||||
backgroundColor,
|
||||
scrolledContainerColor =
|
||||
@@ -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)
|
||||
) {
|
||||
// <20> Используем комбинированное состояние для атомарного
|
||||
// обновления
|
||||
// Это предотвращает "дергание" 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)
|
||||
val dividerColor =
|
||||
remember(isDarkTheme) {
|
||||
if (isDarkTheme)
|
||||
Color(0xFF3A3A3A)
|
||||
else Color(0xFFE8E8E8)
|
||||
}
|
||||
val listBackgroundColor = remember(isDarkTheme) {
|
||||
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
val listBackgroundColor =
|
||||
remember(isDarkTheme) {
|
||||
if (isDarkTheme)
|
||||
Color(0xFF1A1A1A)
|
||||
else Color(0xFFF2F2F7)
|
||||
}
|
||||
// 🔥 Берем dialogs из chatsState для
|
||||
// консистентности
|
||||
// 📌 Сортируем: pinned сначала, потом по времени
|
||||
val currentDialogs = remember(chatsState.dialogs, pinnedChats) {
|
||||
chatsState.dialogs.sortedWith(
|
||||
compareByDescending<DialogUiModel> { 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<String?>(null) }
|
||||
// Telegram-style: only one item can be
|
||||
// swiped open at a time
|
||||
var swipedItemKey by remember {
|
||||
mutableStateOf<String?>(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,9 +1097,21 @@ 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 {
|
||||
@@ -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)
|
||||
@@ -1349,7 +1544,9 @@ fun ChatItem(
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
modifier = Modifier.weight(1f),
|
||||
enableLinks = false // 🔗 Ссылки не кликабельны в списке чатов
|
||||
enableLinks =
|
||||
false // 🔗 Ссылки не кликабельны в списке
|
||||
// чатов
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -1515,7 +1712,8 @@ fun SwipeableDialogItem(
|
||||
isPinned: Boolean = false,
|
||||
onPin: () -> Unit = {}
|
||||
) {
|
||||
val backgroundColor = if (isPinned) {
|
||||
val backgroundColor =
|
||||
if (isPinned) {
|
||||
if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED)
|
||||
} else {
|
||||
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
@@ -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 }
|
||||
val change =
|
||||
event.changes.firstOrNull {
|
||||
it.id == down.id
|
||||
}
|
||||
?: break
|
||||
if (change.changedToUpIgnoreConsumed()) 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
|
||||
val dist =
|
||||
kotlin.math.sqrt(
|
||||
totalDragX *
|
||||
totalDragX +
|
||||
totalDragY *
|
||||
totalDragY
|
||||
)
|
||||
if (dist < touchSlop) continue
|
||||
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,16 +2170,21 @@ fun DialogItemContent(
|
||||
}
|
||||
} else {
|
||||
// 🔥 Формируем displayName для инициалов в placeholder
|
||||
val avatarDisplayName = remember(
|
||||
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
|
||||
dialog.opponentTitle !=
|
||||
dialog.opponentKey &&
|
||||
!dialog.opponentTitle.startsWith(
|
||||
dialog.opponentKey.take(7)
|
||||
) -> dialog.opponentTitle
|
||||
dialog.opponentUsername.isNotEmpty() ->
|
||||
dialog.opponentUsername
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -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,8 +2352,11 @@ fun DialogItemContent(
|
||||
}
|
||||
}
|
||||
|
||||
val formattedTime = remember(dialog.lastMessageTimestamp) {
|
||||
formatTime(Date(dialog.lastMessageTimestamp))
|
||||
val formattedTime =
|
||||
remember(dialog.lastMessageTimestamp) {
|
||||
formatTime(
|
||||
Date(dialog.lastMessageTimestamp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = formattedTime,
|
||||
@@ -2107,12 +2380,18 @@ fun DialogItemContent(
|
||||
TypingIndicatorSmall()
|
||||
} else {
|
||||
// <20> Определяем что показывать - 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"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2132,18 +2411,23 @@ 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) {
|
||||
val unreadText =
|
||||
remember(dialog.unreadCount) {
|
||||
when {
|
||||
dialog.unreadCount > 999 -> "999+"
|
||||
dialog.unreadCount > 99 -> "99+"
|
||||
else -> dialog.unreadCount.toString()
|
||||
else ->
|
||||
dialog.unreadCount
|
||||
.toString()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -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,
|
||||
|
||||
@@ -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 - не нужно подписываться на свой собственный
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
33
baselineprofile/build.gradle.kts
Normal file
33
baselineprofile/build.gradle.kts
Normal file
@@ -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")
|
||||
}
|
||||
3
baselineprofile/src/main/AndroidManifest.xml
Normal file
3
baselineprofile/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "rosetta-android"
|
||||
include(":app")
|
||||
include(":baselineprofile")
|
||||
|
||||
Reference in New Issue
Block a user