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
|
# Baseline Profile for Rosetta Messenger
|
||||||
# Предкомпилирует критические функции при установке APK
|
# AOT-compiles critical classes and methods at APK install time
|
||||||
|
|
||||||
# ============ Keyboard & Animation (приоритет #1) ============
|
# 1. APP STARTUP
|
||||||
# MessageInputBar - основной источник JIT лага
|
HSPLcom/rosetta/messenger/RosettaApplication;->onCreate()V
|
||||||
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 и навигация
|
|
||||||
HSPLcom/rosetta/messenger/MainActivity;->onCreate(Landroid/os/Bundle;)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) ============
|
# 2. CRYPTO
|
||||||
# Часто используемые Compose компоненты
|
HSPLcom/rosetta/messenger/crypto/CryptoManager;->getPbkdf2Key(Ljava/lang/String;)Ljavax/crypto/spec/SecretKeySpec;
|
||||||
HSPLandroidx/compose/foundation/lazy/LazyListState;->scrollToItem(IILkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
HSPLcom/rosetta/messenger/crypto/CryptoManager;->decryptWithPassword(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
|
||||||
HSPLandroidx/compose/ui/text/input/TextFieldValue;-><init>(Ljava/lang/String;JLandroidx/compose/ui/text/TextRange;)V
|
HSPLcom/rosetta/messenger/crypto/CryptoManager;->decryptWithPasswordInternal(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
|
||||||
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;
|
HSPLcom/rosetta/messenger/crypto/CryptoManager;->isOldFormat(Ljava/lang/String;)Z
|
||||||
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
|
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
|
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.MnemonicCode
|
||||||
import org.bitcoinj.crypto.MnemonicException
|
import org.bitcoinj.crypto.MnemonicException
|
||||||
import org.bouncycastle.jce.ECNamedCurveTable
|
import org.bouncycastle.jce.ECNamedCurveTable
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
import org.bouncycastle.jce.spec.ECPrivateKeySpec
|
import org.bouncycastle.jce.spec.ECPrivateKeySpec
|
||||||
import org.bouncycastle.jce.spec.ECPublicKeySpec
|
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
|
* Cryptography module for Rosetta Messenger Implements BIP39 seed phrase generation and secp256k1
|
||||||
* Implements BIP39 seed phrase generation and secp256k1 key derivation
|
* key derivation
|
||||||
*/
|
*/
|
||||||
object CryptoManager {
|
object CryptoManager {
|
||||||
|
|
||||||
@@ -41,7 +40,8 @@ object CryptoManager {
|
|||||||
private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>()
|
private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>()
|
||||||
|
|
||||||
// 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений
|
// 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений
|
||||||
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной расшифровке
|
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
||||||
|
// расшифровке
|
||||||
private const val DECRYPTION_CACHE_SIZE = 2000
|
private const val DECRYPTION_CACHE_SIZE = 2000
|
||||||
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
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) {
|
return pbkdf2KeyCache.getOrPut(password) {
|
||||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
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 secretKey = factory.generateSecret(spec)
|
||||||
SecretKeySpec(secretKey.encoded, "AES")
|
SecretKeySpec(secretKey.encoded, "AES")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 🧹 Очистить кэши при logout */
|
||||||
* 🧹 Очистить кэши при logout
|
|
||||||
*/
|
|
||||||
fun clearCaches() {
|
fun clearCaches() {
|
||||||
pbkdf2KeyCache.clear()
|
pbkdf2KeyCache.clear()
|
||||||
decryptionCache.clear()
|
decryptionCache.clear()
|
||||||
@@ -74,9 +79,7 @@ object CryptoManager {
|
|||||||
privateKeyHashCache.clear()
|
privateKeyHashCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Generate a new 12-word BIP39 seed phrase */
|
||||||
* Generate a new 12-word BIP39 seed phrase
|
|
||||||
*/
|
|
||||||
fun generateSeedPhrase(): List<String> {
|
fun generateSeedPhrase(): List<String> {
|
||||||
val secureRandom = SecureRandom()
|
val secureRandom = SecureRandom()
|
||||||
val entropy = ByteArray(16) // 128 bits = 12 words
|
val entropy = ByteArray(16) // 128 bits = 12 words
|
||||||
@@ -86,9 +89,7 @@ object CryptoManager {
|
|||||||
return mnemonicCode.toMnemonic(entropy)
|
return mnemonicCode.toMnemonic(entropy)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Validate a seed phrase */
|
||||||
* Validate a seed phrase
|
|
||||||
*/
|
|
||||||
fun validateSeedPhrase(words: List<String>): Boolean {
|
fun validateSeedPhrase(words: List<String>): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val mnemonicCode = MnemonicCode.INSTANCE
|
val mnemonicCode = MnemonicCode.INSTANCE
|
||||||
@@ -148,16 +149,16 @@ object CryptoManager {
|
|||||||
val cacheKey = seedPhrase.joinToString(" ")
|
val cacheKey = seedPhrase.joinToString(" ")
|
||||||
|
|
||||||
// Проверяем кэш
|
// Проверяем кэш
|
||||||
keyPairCache[cacheKey]?.let { return it }
|
keyPairCache[cacheKey]?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
// Генерируем приватный ключ через SHA256
|
// Генерируем приватный ключ через SHA256
|
||||||
val privateKeyHex = seedPhraseToPrivateKey(seedPhrase)
|
val privateKeyHex = seedPhraseToPrivateKey(seedPhrase)
|
||||||
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||||||
|
|
||||||
// Преобразуем hex в bytes (32 байта)
|
// Преобразуем hex в bytes (32 байта)
|
||||||
val privateKeyBytes = privateKeyHex.chunked(2)
|
val privateKeyBytes = privateKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
.map { it.toInt(16).toByte() }
|
|
||||||
.toByteArray()
|
|
||||||
|
|
||||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||||
|
|
||||||
@@ -166,13 +167,9 @@ object CryptoManager {
|
|||||||
|
|
||||||
// ⚡ ВАЖНО: Используем СЖАТЫЙ формат (compressed=true) - 33 байта вместо 65
|
// ⚡ ВАЖНО: Используем СЖАТЫЙ формат (compressed=true) - 33 байта вместо 65
|
||||||
// Это совместимо с crypto_new где используется: secp256k1.getPublicKey(..., true)
|
// Это совместимо с crypto_new где используется: secp256k1.getPublicKey(..., true)
|
||||||
val publicKeyHex = publicKeyPoint.getEncoded(true)
|
val publicKeyHex = publicKeyPoint.getEncoded(true).joinToString("") { "%02x".format(it) }
|
||||||
.joinToString("") { "%02x".format(it) }
|
|
||||||
|
|
||||||
val keyPair = KeyPairData(
|
val keyPair = KeyPairData(privateKey = privateKeyHex, publicKey = publicKeyHex)
|
||||||
privateKey = privateKeyHex,
|
|
||||||
publicKey = publicKeyHex
|
|
||||||
)
|
|
||||||
|
|
||||||
// Сохраняем в кэш (ограничиваем размер до 5 записей)
|
// Сохраняем в кэш (ограничиваем размер до 5 записей)
|
||||||
keyPairCache[cacheKey] = keyPair
|
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 {
|
fun generatePrivateKeyHash(privateKey: String): String {
|
||||||
// Проверяем кэш
|
// Проверяем кэш
|
||||||
privateKeyHashCache[privateKey]?.let { return it }
|
privateKeyHashCache[privateKey]?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
val data = (privateKey + SALT).toByteArray()
|
val data = (privateKey + SALT).toByteArray()
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
@@ -234,7 +233,13 @@ object CryptoManager {
|
|||||||
for (chunk in chunks) {
|
for (chunk in chunks) {
|
||||||
// Derive key using PBKDF2-HMAC-SHA1
|
// Derive key using PBKDF2-HMAC-SHA1
|
||||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
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 secretKey = factory.generateSecret(spec)
|
||||||
val key = SecretKeySpec(secretKey.encoded, "AES")
|
val key = SecretKeySpec(secretKey.encoded, "AES")
|
||||||
|
|
||||||
@@ -261,7 +266,13 @@ object CryptoManager {
|
|||||||
// Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!)
|
// Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!)
|
||||||
// crypto-js по умолчанию использует SHA1 для PBKDF2
|
// crypto-js по умолчанию использует SHA1 для PBKDF2
|
||||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
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 secretKey = factory.generateSecret(spec)
|
||||||
val key = SecretKeySpec(secretKey.encoded, "AES")
|
val key = SecretKeySpec(secretKey.encoded, "AES")
|
||||||
|
|
||||||
@@ -302,7 +313,9 @@ object CryptoManager {
|
|||||||
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
||||||
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
||||||
val cacheKey = "$password:$encryptedData"
|
val cacheKey = "$password:$encryptedData"
|
||||||
decryptionCache[cacheKey]?.let { return it }
|
decryptionCache[cacheKey]?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||||
@@ -324,9 +337,7 @@ object CryptoManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 🔐 Внутренняя функция расшифровки (без кэширования результата) */
|
||||||
* 🔐 Внутренняя функция расшифровки (без кэширования результата)
|
|
||||||
*/
|
|
||||||
private fun decryptWithPasswordInternal(encryptedData: String, password: String): String? {
|
private fun decryptWithPasswordInternal(encryptedData: String, password: String): String? {
|
||||||
return try {
|
return try {
|
||||||
// 🚀 Получаем кэшированный PBKDF2 ключ
|
// 🚀 Получаем кэшированный 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 {
|
private fun isOldFormat(data: String): Boolean {
|
||||||
// 🚀 Fast path: new format always contains ':' at plaintext level (iv:ct)
|
// 🚀 Fast path: new format always contains ':' at plaintext level (iv:ct)
|
||||||
// Old format is a single base64 blob without ':' in the encoded string
|
// Old format is a single base64 blob without ':' in the encoded string
|
||||||
@@ -406,7 +415,8 @@ object CryptoManager {
|
|||||||
|
|
||||||
return try {
|
return try {
|
||||||
val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8)
|
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' }
|
part.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -485,7 +495,8 @@ object CryptoManager {
|
|||||||
val ephemeralPublicKeyPoint = ecSpec.g.multiply(ephemeralPrivateKeyBigInt)
|
val ephemeralPublicKeyPoint = ecSpec.g.multiply(ephemeralPrivateKeyBigInt)
|
||||||
|
|
||||||
// Parse recipient's public key
|
// Parse recipient's public key
|
||||||
val recipientPublicKeyBytes = publicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
val recipientPublicKeyBytes =
|
||||||
|
publicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
val recipientPublicKeyPoint = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
|
val recipientPublicKeyPoint = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
|
||||||
|
|
||||||
// Compute shared secret using ECDH (ephemeralPrivateKey × recipientPublicKey)
|
// Compute shared secret using ECDH (ephemeralPrivateKey × recipientPublicKey)
|
||||||
@@ -493,7 +504,8 @@ object CryptoManager {
|
|||||||
|
|
||||||
// Use x-coordinate of shared point as AES key (32 bytes)
|
// Use x-coordinate of shared point as AES key (32 bytes)
|
||||||
val sharedKeyBytes = sharedPoint.affineXCoord.encoded
|
val sharedKeyBytes = sharedPoint.affineXCoord.encoded
|
||||||
val sharedKey = if (sharedKeyBytes.size >= 32) {
|
val sharedKey =
|
||||||
|
if (sharedKeyBytes.size >= 32) {
|
||||||
sharedKeyBytes.copyOfRange(sharedKeyBytes.size - 32, sharedKeyBytes.size)
|
sharedKeyBytes.copyOfRange(sharedKeyBytes.size - 32, sharedKeyBytes.size)
|
||||||
} else {
|
} else {
|
||||||
// Pad with leading zeros if needed
|
// Pad with leading zeros if needed
|
||||||
@@ -512,8 +524,12 @@ object CryptoManager {
|
|||||||
val encrypted = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
|
val encrypted = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
// Normalize ephemeral private key to 32 bytes
|
// Normalize ephemeral private key to 32 bytes
|
||||||
val normalizedPrivateKey = if (ephemeralPrivateKeyBytes.size > 32) {
|
val normalizedPrivateKey =
|
||||||
ephemeralPrivateKeyBytes.copyOfRange(ephemeralPrivateKeyBytes.size - 32, ephemeralPrivateKeyBytes.size)
|
if (ephemeralPrivateKeyBytes.size > 32) {
|
||||||
|
ephemeralPrivateKeyBytes.copyOfRange(
|
||||||
|
ephemeralPrivateKeyBytes.size - 32,
|
||||||
|
ephemeralPrivateKeyBytes.size
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
ephemeralPrivateKeyBytes
|
ephemeralPrivateKeyBytes
|
||||||
}
|
}
|
||||||
@@ -546,7 +562,8 @@ object CryptoManager {
|
|||||||
|
|
||||||
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
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 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")
|
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||||||
|
|
||||||
@@ -558,13 +575,18 @@ object CryptoManager {
|
|||||||
val ephemeralPublicKey = keyFactory.generatePublic(ephemeralPublicKeySpec)
|
val ephemeralPublicKey = keyFactory.generatePublic(ephemeralPublicKeySpec)
|
||||||
|
|
||||||
// Parse private key
|
// 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 privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||||
val privateKeySpec = ECPrivateKeySpec(privateKeyBigInt, ecSpec)
|
val privateKeySpec = ECPrivateKeySpec(privateKeyBigInt, ecSpec)
|
||||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
||||||
|
|
||||||
// Compute shared secret using ECDH
|
// 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.init(privateKey)
|
||||||
keyAgreement.doPhase(ephemeralPublicKey, true)
|
keyAgreement.doPhase(ephemeralPublicKey, true)
|
||||||
val sharedSecret = keyAgreement.generateSecret()
|
val sharedSecret = keyAgreement.generateSecret()
|
||||||
@@ -587,11 +609,7 @@ object CryptoManager {
|
|||||||
/**
|
/**
|
||||||
* Encrypt data using XChaCha20-Poly1305
|
* Encrypt data using XChaCha20-Poly1305
|
||||||
*
|
*
|
||||||
* Returns: {
|
* Returns: { ciphertext: hex string, nonce: hex string (24 bytes), key: hex string (32 bytes) }
|
||||||
* ciphertext: hex string,
|
|
||||||
* nonce: hex string (24 bytes),
|
|
||||||
* key: hex string (32 bytes)
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
fun chacha20Encrypt(data: String): ChaCha20Result {
|
fun chacha20Encrypt(data: String): ChaCha20Result {
|
||||||
// Generate random key (32 bytes) and nonce (24 bytes)
|
// Generate random key (32 bytes) and nonce (24 bytes)
|
||||||
@@ -635,13 +653,6 @@ object CryptoManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class KeyPairData(
|
data class KeyPairData(val privateKey: String, val publicKey: String)
|
||||||
val privateKey: String,
|
|
||||||
val publicKey: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChaCha20Result(
|
data class ChaCha20Result(val ciphertext: String, val nonce: String, val key: String)
|
||||||
val ciphertext: String,
|
|
||||||
val nonce: String,
|
|
||||||
val key: String
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -367,10 +367,24 @@ interface DialogDao {
|
|||||||
AND i_have_sent = 1
|
AND i_have_sent = 1
|
||||||
AND last_message_timestamp > 0
|
AND last_message_timestamp > 0
|
||||||
ORDER BY last_message_timestamp DESC
|
ORDER BY last_message_timestamp DESC
|
||||||
|
LIMIT 30
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun getDialogsFlow(account: String): Flow<List<DialogEntity>>
|
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 - диалоги где нам писали, но мы не отвечали Исключает пустые диалоги (без
|
* Получить requests - диалоги где нам писали, но мы не отвечали Исключает пустые диалоги (без
|
||||||
* сообщений)
|
* сообщений)
|
||||||
@@ -382,6 +396,7 @@ interface DialogDao {
|
|||||||
AND i_have_sent = 0
|
AND i_have_sent = 0
|
||||||
AND last_message_timestamp > 0
|
AND last_message_timestamp > 0
|
||||||
ORDER BY last_message_timestamp DESC
|
ORDER BY last_message_timestamp DESC
|
||||||
|
LIMIT 30
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun getRequestsFlow(account: String): Flow<List<DialogEntity>>
|
fun getRequestsFlow(account: String): Flow<List<DialogEntity>>
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
package com.rosetta.messenger.ui.chats
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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 androidx.compose.material3.*
|
||||||
import compose.icons.TablerIcons
|
|
||||||
import compose.icons.tablericons.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
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.changedToUpIgnoreConsumed
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.positionChange
|
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.data.RecentSearchesManager
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
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.chats.components.AnimatedDotsText
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
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.onboarding.PrimaryBlue
|
||||||
|
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.*
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -106,8 +105,8 @@ data class AvatarColors(val textColor: Color, val backgroundColor: Color)
|
|||||||
private val avatarColorCache = mutableMapOf<String, AvatarColors>()
|
private val avatarColorCache = mutableMapOf<String, AvatarColors>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Определяет, является ли цвет светлым (true) или темным (false)
|
* Определяет, является ли цвет светлым (true) или темным (false) Использует формулу relative
|
||||||
* Использует формулу relative luminance из WCAG
|
* luminance из WCAG
|
||||||
*/
|
*/
|
||||||
fun isColorLight(color: Color): Boolean {
|
fun isColorLight(color: Color): Boolean {
|
||||||
val luminance = 0.299f * color.red + 0.587f * color.green + 0.114f * color.blue
|
val luminance = 0.299f * color.red + 0.587f * color.green + 0.114f * color.blue
|
||||||
@@ -266,7 +265,10 @@ fun ChatsListScreen(
|
|||||||
// сообщений
|
// сообщений
|
||||||
val initStart = System.currentTimeMillis()
|
val initStart = System.currentTimeMillis()
|
||||||
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
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(
|
||||||
text = "Disconnected",
|
text = "Disconnected",
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight =
|
||||||
|
FontWeight.Medium,
|
||||||
color = textColor
|
color = textColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -369,14 +372,16 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
baseText = "Connecting",
|
baseText = "Connecting",
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight =
|
||||||
|
FontWeight.Medium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ProtocolState.CONNECTED -> {
|
ProtocolState.CONNECTED -> {
|
||||||
Text(
|
Text(
|
||||||
text = "Connected",
|
text = "Connected",
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight =
|
||||||
|
FontWeight.Medium,
|
||||||
color = textColor
|
color = textColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -385,14 +390,16 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
baseText = "Authenticating",
|
baseText = "Authenticating",
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight =
|
||||||
|
FontWeight.Medium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ProtocolState.AUTHENTICATED -> {
|
ProtocolState.AUTHENTICATED -> {
|
||||||
Text(
|
Text(
|
||||||
text = "Authenticated",
|
text = "Authenticated",
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight =
|
||||||
|
FontWeight.Medium,
|
||||||
color = textColor
|
color = textColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -444,11 +451,10 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
val headerColor = avatarColors.backgroundColor
|
val headerColor = avatarColors.backgroundColor
|
||||||
|
|
||||||
// Header с размытым фоном аватарки
|
// Header с размытым фоном аватарки
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 🎨 BLURRED AVATAR BACKGROUND (на всю область header)
|
// 🎨 BLURRED AVATAR BACKGROUND (на всю
|
||||||
|
// область header)
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
BlurredAvatarBackground(
|
BlurredAvatarBackground(
|
||||||
publicKey = accountPublicKey,
|
publicKey = accountPublicKey,
|
||||||
@@ -456,19 +462,25 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
fallbackColor = headerColor,
|
fallbackColor = headerColor,
|
||||||
blurRadius = 40f,
|
blurRadius = 40f,
|
||||||
alpha = 0.6f,
|
alpha = 0.6f,
|
||||||
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId)
|
overlayColors =
|
||||||
|
BackgroundBlurPresets
|
||||||
|
.getOverlayColors(
|
||||||
|
backgroundBlurColorId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Content поверх фона
|
// Content поверх фона
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(
|
.padding(
|
||||||
top = 16.dp,
|
top = 16.dp,
|
||||||
start = 20.dp,
|
start =
|
||||||
|
20.dp,
|
||||||
end = 20.dp,
|
end = 20.dp,
|
||||||
bottom = 20.dp
|
bottom =
|
||||||
|
20.dp
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Avatar - используем AvatarImage
|
// Avatar - используем AvatarImage
|
||||||
@@ -492,11 +504,18 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
Alignment.Center
|
Alignment.Center
|
||||||
) {
|
) {
|
||||||
AvatarImage(
|
AvatarImage(
|
||||||
publicKey = accountPublicKey,
|
publicKey =
|
||||||
avatarRepository = avatarRepository,
|
accountPublicKey,
|
||||||
|
avatarRepository =
|
||||||
|
avatarRepository,
|
||||||
size = 66.dp,
|
size = 66.dp,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme =
|
||||||
displayName = accountName.ifEmpty { accountUsername } // 🔥 Для инициалов
|
isDarkTheme,
|
||||||
|
displayName =
|
||||||
|
accountName
|
||||||
|
.ifEmpty {
|
||||||
|
accountUsername
|
||||||
|
} // 🔥 Для инициалов
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,19 +531,44 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
Text(
|
Text(
|
||||||
text = accountName,
|
text = accountName,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight =
|
||||||
color = if (isDarkTheme) Color.White else Color.Black
|
FontWeight
|
||||||
|
.SemiBold,
|
||||||
|
color =
|
||||||
|
if (isDarkTheme
|
||||||
|
)
|
||||||
|
Color.White
|
||||||
|
else
|
||||||
|
Color.Black
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Username display (below name)
|
// Username display (below name)
|
||||||
if (accountUsername.isNotEmpty()) {
|
if (accountUsername.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(
|
||||||
|
modifier =
|
||||||
|
Modifier.height(
|
||||||
|
4.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"@$accountUsername",
|
"@$accountUsername",
|
||||||
fontSize = 13.sp,
|
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 = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
drawerState.close()
|
drawerState.close()
|
||||||
kotlinx.coroutines.delay(100)
|
kotlinx.coroutines
|
||||||
|
.delay(100)
|
||||||
onProfileClick()
|
onProfileClick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -590,7 +635,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
drawerState.close()
|
drawerState.close()
|
||||||
kotlinx.coroutines.delay(100)
|
kotlinx.coroutines
|
||||||
|
.delay(100)
|
||||||
onSettingsClick()
|
onSettingsClick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,8 +647,7 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
icon =
|
icon =
|
||||||
if (isDarkTheme)
|
if (isDarkTheme)
|
||||||
TablerIcons.Sun
|
TablerIcons.Sun
|
||||||
else
|
else TablerIcons.Moon,
|
||||||
TablerIcons.Moon,
|
|
||||||
text =
|
text =
|
||||||
if (isDarkTheme)
|
if (isDarkTheme)
|
||||||
"Light Mode"
|
"Light Mode"
|
||||||
@@ -690,7 +735,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
TablerIcons.ArrowLeft,
|
TablerIcons
|
||||||
|
.ArrowLeft,
|
||||||
contentDescription =
|
contentDescription =
|
||||||
"Back",
|
"Back",
|
||||||
tint =
|
tint =
|
||||||
@@ -710,7 +756,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
TablerIcons.Menu2,
|
TablerIcons
|
||||||
|
.Menu2,
|
||||||
contentDescription =
|
contentDescription =
|
||||||
"Menu",
|
"Menu",
|
||||||
tint =
|
tint =
|
||||||
@@ -731,10 +778,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
fontWeight =
|
fontWeight =
|
||||||
FontWeight
|
FontWeight
|
||||||
.Bold,
|
.Bold,
|
||||||
fontSize =
|
fontSize = 20.sp,
|
||||||
20.sp,
|
color = textColor
|
||||||
color =
|
|
||||||
textColor
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Rosetta title
|
// Rosetta title
|
||||||
@@ -819,7 +864,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
.AUTHENTICATED
|
.AUTHENTICATED
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
TablerIcons.Search,
|
TablerIcons
|
||||||
|
.Search,
|
||||||
contentDescription =
|
contentDescription =
|
||||||
"Search",
|
"Search",
|
||||||
tint =
|
tint =
|
||||||
@@ -843,8 +889,7 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
TopAppBarDefaults
|
TopAppBarDefaults.topAppBarColors(
|
||||||
.topAppBarColors(
|
|
||||||
containerColor =
|
containerColor =
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
scrolledContainerColor =
|
scrolledContainerColor =
|
||||||
@@ -890,13 +935,19 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
containerColor = backgroundColor
|
containerColor = backgroundColor
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
// Main content
|
// Main content
|
||||||
Box(modifier = Modifier.fillMaxSize().background(backgroundColor).padding(paddingValues)) {
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
// <20> Используем комбинированное состояние для атомарного
|
// <20> Используем комбинированное состояние для атомарного
|
||||||
// обновления
|
// обновления
|
||||||
// Это предотвращает "дергание" UI когда dialogs и requests
|
// Это предотвращает "дергание" UI когда dialogs и requests
|
||||||
// обновляются
|
// обновляются
|
||||||
// независимо
|
// независимо
|
||||||
val chatsState by chatsViewModel.chatsState.collectAsState()
|
val chatsState by chatsViewModel.chatsState.collectAsState()
|
||||||
|
val isLoading by chatsViewModel.isLoading.collectAsState()
|
||||||
val requests = chatsState.requests
|
val requests = chatsState.requests
|
||||||
val requestsCount = chatsState.requestsCount
|
val requestsCount = chatsState.requestsCount
|
||||||
|
|
||||||
@@ -946,6 +997,9 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
onUserSelect(user)
|
onUserSelect(user)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
} else if (isLoading) {
|
||||||
|
// 🚀 Shimmer skeleton пока данные грузятся
|
||||||
|
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
||||||
} else if (shouldShowEmptyState) {
|
} else if (shouldShowEmptyState) {
|
||||||
// 🔥 Empty state - показываем только если
|
// 🔥 Empty state - показываем только если
|
||||||
// контент НЕ был показан ранее
|
// контент НЕ был показан ранее
|
||||||
@@ -955,25 +1009,47 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Show dialogs list
|
// Show dialogs list
|
||||||
val dividerColor = remember(isDarkTheme) {
|
val dividerColor =
|
||||||
if (isDarkTheme) Color(0xFF3A3A3A)
|
remember(isDarkTheme) {
|
||||||
|
if (isDarkTheme)
|
||||||
|
Color(0xFF3A3A3A)
|
||||||
else Color(0xFFE8E8E8)
|
else Color(0xFFE8E8E8)
|
||||||
}
|
}
|
||||||
val listBackgroundColor = remember(isDarkTheme) {
|
val listBackgroundColor =
|
||||||
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
remember(isDarkTheme) {
|
||||||
|
if (isDarkTheme)
|
||||||
|
Color(0xFF1A1A1A)
|
||||||
|
else Color(0xFFF2F2F7)
|
||||||
}
|
}
|
||||||
// 🔥 Берем dialogs из chatsState для
|
// 🔥 Берем dialogs из chatsState для
|
||||||
// консистентности
|
// консистентности
|
||||||
// 📌 Сортируем: pinned сначала, потом по времени
|
// 📌 Сортируем: pinned сначала, потом по
|
||||||
val currentDialogs = remember(chatsState.dialogs, pinnedChats) {
|
// времени
|
||||||
chatsState.dialogs.sortedWith(
|
val currentDialogs =
|
||||||
compareByDescending<DialogUiModel> { pinnedChats.contains(it.opponentKey) }
|
remember(
|
||||||
.thenByDescending { it.lastMessageTimestamp }
|
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
|
// Telegram-style: only one item can be
|
||||||
var swipedItemKey by remember { mutableStateOf<String?>(null) }
|
// swiped open at a time
|
||||||
|
var swipedItemKey by remember {
|
||||||
|
mutableStateOf<String?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
// Close swiped item when drawer opens
|
// Close swiped item when drawer opens
|
||||||
LaunchedEffect(drawerState.isOpen) {
|
LaunchedEffect(drawerState.isOpen) {
|
||||||
@@ -983,9 +1059,11 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.background(listBackgroundColor)
|
.background(
|
||||||
|
listBackgroundColor
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
if (requestsCount > 0) {
|
if (requestsCount > 0) {
|
||||||
item(
|
item(
|
||||||
@@ -1019,9 +1097,21 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
val isSavedMessages =
|
val isSavedMessages =
|
||||||
dialog.opponentKey ==
|
dialog.opponentKey ==
|
||||||
accountPublicKey
|
accountPublicKey
|
||||||
val isBlocked = blockedUsers.contains(dialog.opponentKey)
|
val isBlocked =
|
||||||
val isTyping by remember(dialog.opponentKey) {
|
blockedUsers
|
||||||
derivedStateOf { typingUsers.contains(dialog.opponentKey) }
|
.contains(
|
||||||
|
dialog.opponentKey
|
||||||
|
)
|
||||||
|
val isTyping by
|
||||||
|
remember(
|
||||||
|
dialog.opponentKey
|
||||||
|
) {
|
||||||
|
derivedStateOf {
|
||||||
|
typingUsers
|
||||||
|
.contains(
|
||||||
|
dialog.opponentKey
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -1038,17 +1128,28 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
isSavedMessages,
|
isSavedMessages,
|
||||||
avatarRepository =
|
avatarRepository =
|
||||||
avatarRepository,
|
avatarRepository,
|
||||||
isDrawerOpen = drawerState.isOpen || drawerState.isAnimationRunning,
|
isDrawerOpen =
|
||||||
|
drawerState
|
||||||
|
.isOpen ||
|
||||||
|
drawerState
|
||||||
|
.isAnimationRunning,
|
||||||
isSwipedOpen =
|
isSwipedOpen =
|
||||||
swipedItemKey == dialog.opponentKey,
|
swipedItemKey ==
|
||||||
|
dialog.opponentKey,
|
||||||
onSwipeStarted = {
|
onSwipeStarted = {
|
||||||
swipedItemKey = dialog.opponentKey
|
swipedItemKey =
|
||||||
|
dialog.opponentKey
|
||||||
},
|
},
|
||||||
onSwipeClosed = {
|
onSwipeClosed = {
|
||||||
if (swipedItemKey == dialog.opponentKey) swipedItemKey = null
|
if (swipedItemKey ==
|
||||||
|
dialog.opponentKey
|
||||||
|
)
|
||||||
|
swipedItemKey =
|
||||||
|
null
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
swipedItemKey = null
|
swipedItemKey =
|
||||||
|
null
|
||||||
val user =
|
val user =
|
||||||
chatsViewModel
|
chatsViewModel
|
||||||
.dialogToSearchUser(
|
.dialogToSearchUser(
|
||||||
@@ -1070,8 +1171,16 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
dialogToUnblock =
|
dialogToUnblock =
|
||||||
dialog
|
dialog
|
||||||
},
|
},
|
||||||
isPinned = pinnedChats.contains(dialog.opponentKey),
|
isPinned =
|
||||||
onPin = { onTogglePin(dialog.opponentKey) }
|
pinnedChats
|
||||||
|
.contains(
|
||||||
|
dialog.opponentKey
|
||||||
|
),
|
||||||
|
onPin = {
|
||||||
|
onTogglePin(
|
||||||
|
dialog.opponentKey
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 СЕПАРАТОР -
|
// 🔥 СЕПАРАТОР -
|
||||||
@@ -1223,6 +1332,92 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
} // Close Box
|
} // 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
|
@Composable
|
||||||
private fun EmptyChatsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
|
private fun EmptyChatsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
@@ -1349,7 +1544,9 @@ fun ChatItem(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = android.text.TextUtils.TruncateAt.END,
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
enableLinks = false // 🔗 Ссылки не кликабельны в списке чатов
|
enableLinks =
|
||||||
|
false // 🔗 Ссылки не кликабельны в списке
|
||||||
|
// чатов
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -1515,7 +1712,8 @@ fun SwipeableDialogItem(
|
|||||||
isPinned: Boolean = false,
|
isPinned: Boolean = false,
|
||||||
onPin: () -> Unit = {}
|
onPin: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isPinned) {
|
val backgroundColor =
|
||||||
|
if (isPinned) {
|
||||||
if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED)
|
if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED)
|
||||||
} else {
|
} else {
|
||||||
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
@@ -1546,7 +1744,13 @@ fun SwipeableDialogItem(
|
|||||||
label = "swipeOffset"
|
label = "swipeOffset"
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth().height(itemHeight).background(backgroundColor).clipToBounds()) {
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.height(itemHeight)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.clipToBounds()
|
||||||
|
) {
|
||||||
// 1. КНОПКИ - позиционированы справа, всегда видны при свайпе
|
// 1. КНОПКИ - позиционированы справа, всегда видны при свайпе
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -1582,8 +1786,7 @@ fun SwipeableDialogItem(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text =
|
text = if (isPinned) "Unpin" else "Pin",
|
||||||
if (isPinned) "Unpin" else "Pin",
|
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
@@ -1615,8 +1818,7 @@ fun SwipeableDialogItem(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector =
|
imageVector =
|
||||||
if (isBlocked)
|
if (isBlocked) TablerIcons.LockOpen
|
||||||
TablerIcons.LockOpen
|
|
||||||
else TablerIcons.Ban,
|
else TablerIcons.Ban,
|
||||||
contentDescription =
|
contentDescription =
|
||||||
if (isBlocked) "Unblock"
|
if (isBlocked) "Unblock"
|
||||||
@@ -1682,7 +1884,10 @@ fun SwipeableDialogItem(
|
|||||||
val touchSlop = viewConfiguration.touchSlop
|
val touchSlop = viewConfiguration.touchSlop
|
||||||
|
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
val down =
|
||||||
|
awaitFirstDown(
|
||||||
|
requireUnconsumed = false
|
||||||
|
)
|
||||||
|
|
||||||
// Don't handle swipes when drawer is open
|
// Don't handle swipes when drawer is open
|
||||||
if (isDrawerOpen) return@awaitEachGesture
|
if (isDrawerOpen) return@awaitEachGesture
|
||||||
@@ -1695,52 +1900,101 @@ fun SwipeableDialogItem(
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
val change = event.changes.firstOrNull { it.id == down.id }
|
val change =
|
||||||
|
event.changes.firstOrNull {
|
||||||
|
it.id == down.id
|
||||||
|
}
|
||||||
?: break
|
?: break
|
||||||
if (change.changedToUpIgnoreConsumed()) break
|
if (change.changedToUpIgnoreConsumed()
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
val delta = change.positionChange()
|
val delta = change.positionChange()
|
||||||
totalDragX += delta.x
|
totalDragX += delta.x
|
||||||
totalDragY += delta.y
|
totalDragY += delta.y
|
||||||
|
|
||||||
if (!passedSlop) {
|
if (!passedSlop) {
|
||||||
val dist = kotlin.math.sqrt(
|
val dist =
|
||||||
totalDragX * totalDragX + totalDragY * totalDragY
|
kotlin.math.sqrt(
|
||||||
|
totalDragX *
|
||||||
|
totalDragX +
|
||||||
|
totalDragY *
|
||||||
|
totalDragY
|
||||||
)
|
)
|
||||||
if (dist < touchSlop) continue
|
if (dist < touchSlop)
|
||||||
|
continue
|
||||||
|
|
||||||
val dominated = kotlin.math.abs(totalDragX) >
|
val dominated =
|
||||||
kotlin.math.abs(totalDragY) * 2.0f
|
kotlin.math.abs(
|
||||||
|
totalDragX
|
||||||
|
) >
|
||||||
|
kotlin.math
|
||||||
|
.abs(
|
||||||
|
totalDragY
|
||||||
|
) *
|
||||||
|
2.0f
|
||||||
|
|
||||||
when {
|
when {
|
||||||
// Horizontal left swipe — reveal action buttons
|
// Horizontal left
|
||||||
dominated && totalDragX < 0 -> {
|
// swipe — reveal
|
||||||
passedSlop = true
|
// action buttons
|
||||||
claimed = true
|
dominated &&
|
||||||
|
totalDragX <
|
||||||
|
0 -> {
|
||||||
|
passedSlop =
|
||||||
|
true
|
||||||
|
claimed =
|
||||||
|
true
|
||||||
onSwipeStarted()
|
onSwipeStarted()
|
||||||
change.consume()
|
change.consume()
|
||||||
}
|
}
|
||||||
// Horizontal right swipe with buttons open — close them
|
// Horizontal right
|
||||||
dominated && totalDragX > 0 && offsetX != 0f -> {
|
// swipe with
|
||||||
passedSlop = true
|
// buttons open —
|
||||||
claimed = true
|
// close them
|
||||||
|
dominated &&
|
||||||
|
totalDragX >
|
||||||
|
0 &&
|
||||||
|
offsetX !=
|
||||||
|
0f -> {
|
||||||
|
passedSlop =
|
||||||
|
true
|
||||||
|
claimed =
|
||||||
|
true
|
||||||
change.consume()
|
change.consume()
|
||||||
}
|
}
|
||||||
// Right swipe with buttons closed — let drawer handle
|
// Right swipe with
|
||||||
totalDragX > 0 && offsetX == 0f -> break
|
// buttons closed —
|
||||||
// Vertical/diagonal — close buttons if open, let LazyColumn scroll
|
// let drawer handle
|
||||||
|
totalDragX > 0 &&
|
||||||
|
offsetX ==
|
||||||
|
0f ->
|
||||||
|
break
|
||||||
|
// Vertical/diagonal
|
||||||
|
// — close buttons
|
||||||
|
// if open, let
|
||||||
|
// LazyColumn scroll
|
||||||
else -> {
|
else -> {
|
||||||
if (offsetX != 0f) {
|
if (offsetX !=
|
||||||
offsetX = 0f
|
0f
|
||||||
|
) {
|
||||||
|
offsetX =
|
||||||
|
0f
|
||||||
onSwipeClosed()
|
onSwipeClosed()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Gesture is ours — update offset
|
// Gesture is ours — update
|
||||||
val newOffset = offsetX + delta.x
|
// offset
|
||||||
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
|
val newOffset =
|
||||||
|
offsetX + delta.x
|
||||||
|
offsetX =
|
||||||
|
newOffset.coerceIn(
|
||||||
|
-swipeWidthPx,
|
||||||
|
0f
|
||||||
|
)
|
||||||
velocityTracker.addPosition(
|
velocityTracker.addPosition(
|
||||||
change.uptimeMillis,
|
change.uptimeMillis,
|
||||||
change.position
|
change.position
|
||||||
@@ -1751,20 +2005,29 @@ fun SwipeableDialogItem(
|
|||||||
|
|
||||||
// Snap animation
|
// Snap animation
|
||||||
if (claimed) {
|
if (claimed) {
|
||||||
val velocity = velocityTracker.calculateVelocity().x
|
val velocity =
|
||||||
|
velocityTracker
|
||||||
|
.calculateVelocity()
|
||||||
|
.x
|
||||||
when {
|
when {
|
||||||
// Rightward fling — always close
|
// Rightward fling — always
|
||||||
|
// close
|
||||||
velocity > 150f -> {
|
velocity > 150f -> {
|
||||||
offsetX = 0f
|
offsetX = 0f
|
||||||
onSwipeClosed()
|
onSwipeClosed()
|
||||||
}
|
}
|
||||||
// Strong leftward fling — always open
|
// Strong leftward fling —
|
||||||
|
// always open
|
||||||
velocity < -300f -> {
|
velocity < -300f -> {
|
||||||
offsetX = -swipeWidthPx
|
offsetX =
|
||||||
|
-swipeWidthPx
|
||||||
}
|
}
|
||||||
// Past halfway — stay open
|
// Past halfway — stay open
|
||||||
kotlin.math.abs(offsetX) > swipeWidthPx / 2 -> {
|
kotlin.math.abs(offsetX) >
|
||||||
offsetX = -swipeWidthPx
|
swipeWidthPx /
|
||||||
|
2 -> {
|
||||||
|
offsetX =
|
||||||
|
-swipeWidthPx
|
||||||
}
|
}
|
||||||
// Less than halfway — close
|
// Less than halfway — close
|
||||||
else -> {
|
else -> {
|
||||||
@@ -1907,16 +2170,21 @@ fun DialogItemContent(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 🔥 Формируем displayName для инициалов в placeholder
|
// 🔥 Формируем displayName для инициалов в placeholder
|
||||||
val avatarDisplayName = remember(
|
val avatarDisplayName =
|
||||||
|
remember(
|
||||||
dialog.opponentTitle,
|
dialog.opponentTitle,
|
||||||
dialog.opponentKey,
|
dialog.opponentKey,
|
||||||
dialog.opponentUsername
|
dialog.opponentUsername
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
dialog.opponentTitle.isNotEmpty() &&
|
dialog.opponentTitle.isNotEmpty() &&
|
||||||
dialog.opponentTitle != dialog.opponentKey &&
|
dialog.opponentTitle !=
|
||||||
!dialog.opponentTitle.startsWith(dialog.opponentKey.take(7)) -> dialog.opponentTitle
|
dialog.opponentKey &&
|
||||||
dialog.opponentUsername.isNotEmpty() -> dialog.opponentUsername
|
!dialog.opponentTitle.startsWith(
|
||||||
|
dialog.opponentKey.take(7)
|
||||||
|
) -> dialog.opponentTitle
|
||||||
|
dialog.opponentUsername.isNotEmpty() ->
|
||||||
|
dialog.opponentUsername
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2008,7 +2276,8 @@ fun DialogItemContent(
|
|||||||
// ERROR - показываем иконку ошибки
|
// ERROR - показываем иконку ошибки
|
||||||
Icon(
|
Icon(
|
||||||
imageVector =
|
imageVector =
|
||||||
TablerIcons.AlertCircle,
|
TablerIcons
|
||||||
|
.AlertCircle,
|
||||||
contentDescription =
|
contentDescription =
|
||||||
"Sending failed",
|
"Sending failed",
|
||||||
tint =
|
tint =
|
||||||
@@ -2064,7 +2333,8 @@ fun DialogItemContent(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector =
|
imageVector =
|
||||||
TablerIcons.Clock,
|
TablerIcons.Clock,
|
||||||
contentDescription = "Sending",
|
contentDescription =
|
||||||
|
"Sending",
|
||||||
tint =
|
tint =
|
||||||
secondaryTextColor
|
secondaryTextColor
|
||||||
.copy(
|
.copy(
|
||||||
@@ -2082,8 +2352,11 @@ fun DialogItemContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val formattedTime = remember(dialog.lastMessageTimestamp) {
|
val formattedTime =
|
||||||
formatTime(Date(dialog.lastMessageTimestamp))
|
remember(dialog.lastMessageTimestamp) {
|
||||||
|
formatTime(
|
||||||
|
Date(dialog.lastMessageTimestamp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = formattedTime,
|
text = formattedTime,
|
||||||
@@ -2107,12 +2380,18 @@ fun DialogItemContent(
|
|||||||
TypingIndicatorSmall()
|
TypingIndicatorSmall()
|
||||||
} else {
|
} else {
|
||||||
// <20> Определяем что показывать - attachment или текст
|
// <20> Определяем что показывать - attachment или текст
|
||||||
val displayText = when {
|
val displayText =
|
||||||
dialog.lastMessageAttachmentType == "Photo" -> "Photo"
|
when {
|
||||||
dialog.lastMessageAttachmentType == "File" -> "File"
|
dialog.lastMessageAttachmentType ==
|
||||||
dialog.lastMessageAttachmentType == "Avatar" -> "Avatar"
|
"Photo" -> "Photo"
|
||||||
dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message"
|
dialog.lastMessageAttachmentType ==
|
||||||
dialog.lastMessage.isEmpty() -> "No messages"
|
"File" -> "File"
|
||||||
|
dialog.lastMessageAttachmentType ==
|
||||||
|
"Avatar" -> "Avatar"
|
||||||
|
dialog.lastMessageAttachmentType ==
|
||||||
|
"Forwarded" -> "Forwarded message"
|
||||||
|
dialog.lastMessage.isEmpty() ->
|
||||||
|
"No messages"
|
||||||
else -> dialog.lastMessage
|
else -> dialog.lastMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2132,18 +2411,23 @@ fun DialogItemContent(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = android.text.TextUtils.TruncateAt.END,
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
enableLinks = false // 🔗 Ссылки не кликабельны в списке чатов
|
enableLinks =
|
||||||
|
false // 🔗 Ссылки не кликабельны в списке
|
||||||
|
// чатов
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unread badge
|
// Unread badge
|
||||||
if (dialog.unreadCount > 0) {
|
if (dialog.unreadCount > 0) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
val unreadText = remember(dialog.unreadCount) {
|
val unreadText =
|
||||||
|
remember(dialog.unreadCount) {
|
||||||
when {
|
when {
|
||||||
dialog.unreadCount > 999 -> "999+"
|
dialog.unreadCount > 999 -> "999+"
|
||||||
dialog.unreadCount > 99 -> "99+"
|
dialog.unreadCount > 99 -> "99+"
|
||||||
else -> dialog.unreadCount.toString()
|
else ->
|
||||||
|
dialog.unreadCount
|
||||||
|
.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@@ -2289,7 +2573,11 @@ fun RequestsScreen(
|
|||||||
} else {
|
} else {
|
||||||
// Requests list
|
// Requests list
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(requests, key = { it.opponentKey }, contentType = { "request" }) { request ->
|
items(
|
||||||
|
requests,
|
||||||
|
key = { it.opponentKey },
|
||||||
|
contentType = { "request" }
|
||||||
|
) { request ->
|
||||||
DialogItemContent(
|
DialogItemContent(
|
||||||
dialog = request,
|
dialog = request,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
|||||||
@@ -117,11 +117,18 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 Показываем skeleton пока данные грузятся
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||||
requestedUserInfoKeys.clear()
|
requestedUserInfoKeys.clear()
|
||||||
|
|
||||||
currentAccount = publicKey
|
currentAccount = publicKey
|
||||||
currentPrivateKey = privateKey
|
currentPrivateKey = privateKey
|
||||||
|
|
||||||
|
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
|
||||||
|
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
|
||||||
|
|
||||||
// Подписываемся на обычные диалоги
|
// Подписываемся на обычные диалоги
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -268,6 +275,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
.collect { decryptedDialogs ->
|
.collect { decryptedDialogs ->
|
||||||
_dialogs.value = decryptedDialogs
|
_dialogs.value = decryptedDialogs
|
||||||
|
// 🚀 Убираем skeleton после первой загрузки
|
||||||
|
if (_isLoading.value) _isLoading.value = false
|
||||||
|
|
||||||
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
||||||
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
|
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -89,7 +89,6 @@ fun MessageInputBar(
|
|||||||
inputFocusTrigger: Int = 0
|
inputFocusTrigger: Int = 0
|
||||||
) {
|
) {
|
||||||
val hasReply = replyMessages.isNotEmpty()
|
val hasReply = replyMessages.isNotEmpty()
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -206,7 +205,8 @@ fun MessageInputBar(
|
|||||||
showKeyboard = {
|
showKeyboard = {
|
||||||
editTextView?.let { editText ->
|
editTextView?.let { editText ->
|
||||||
editText.requestFocus()
|
editText.requestFocus()
|
||||||
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
|
@Suppress("DEPRECATION")
|
||||||
|
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hideEmoji = { onToggleEmojiPicker(false) }
|
hideEmoji = { onToggleEmojiPicker(false) }
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ private fun OnlineIndicator(modifier: Modifier = Modifier) {
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AvatarPicker(
|
fun AvatarPicker(
|
||||||
onAvatarSelected: (String) -> Unit
|
@Suppress("UNUSED_PARAMETER") onAvatarSelected: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
// TODO: Реализовать выбор изображения через ActivityResultContract
|
// TODO: Реализовать выбор изображения через ActivityResultContract
|
||||||
// 1. Использовать rememberLauncherForActivityResult с ActivityResultContracts.GetContent()
|
// 1. Использовать rememberLauncherForActivityResult с ActivityResultContracts.GetContent()
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ object OptimizedEmojiCache {
|
|||||||
isPreloading = true
|
isPreloading = true
|
||||||
|
|
||||||
// Берем первые N эмодзи из категории Smileys (самые популярные)
|
// Берем первые N эмодзи из категории Smileys (самые популярные)
|
||||||
val smileyCategory = EMOJI_CATEGORIES.find { it.key == "Smileys" }
|
|
||||||
val smileysToPreload = emojisByCategory?.get("Smileys")
|
val smileysToPreload = emojisByCategory?.get("Smileys")
|
||||||
?.take(PRELOAD_COUNT)
|
?.take(PRELOAD_COUNT)
|
||||||
?: emptyList()
|
?: 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"
|
rootProject.name = "rosetta-android"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":baselineprofile")
|
||||||
|
|||||||
Reference in New Issue
Block a user