feat: Implement baseline profile generation and startup benchmarking

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

View File

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

View File

@@ -1,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)

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View 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")
}

View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "rosetta-android"
include(":app")
include(":baselineprofile")