feat: Simplify animations across multiple screens by replacing slide transitions with fade animations for improved user experience

This commit is contained in:
k1ngsterr1
2026-01-18 17:26:04 +05:00
parent 89e5f3cfa2
commit 61995e9286
16 changed files with 506 additions and 399 deletions

View File

@@ -23,8 +23,12 @@ import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -350,13 +354,33 @@ fun MainScreen(
onToggleTheme: () -> Unit = {},
onLogout: () -> Unit = {}
) {
val accountName = account?.name ?: "Account"
val accountPhone = account?.publicKey?.take(16)?.let {
// 🔥 КРИТИЧНО: Если account == null, показываем загрузку вместо мокового аккаунта
if (account == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
"Loading account...",
color = if (isDarkTheme) Color.White else Color.Black
)
}
}
return
}
val accountName = account.name
val accountPhone = account.publicKey.take(16).let {
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
} ?: "+7 775 9932587"
val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
val accountPrivateKey = account?.privateKey ?: ""
val privateKeyHash = account?.privateKeyHash ?: ""
}
val accountPublicKey = account.publicKey
val accountPrivateKey = account.privateKey
val privateKeyHash = account.privateKeyHash
// Состояние протокола для передачи в SearchScreen
val protocolState by ProtocolManager.state.collectAsState()
@@ -375,72 +399,48 @@ fun MainScreen(
val isExitingSearch = !targetState.second && initialState.second
when {
// 🚀 Вход в чат - быстрый slide справа (как Telegram/iOS)
// Новый экран полностью покрывает старый, никакой прозрачности
// 🚀 Вход в чат - плавный fade
isEnteringChat -> {
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh // 🔥 Быстрее для плавности
)
) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth / 5 }, // Меньше смещение
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh
)
fadeIn(
animationSpec = tween(200)
) togetherWith fadeOut(
animationSpec = tween(150)
)
}
// 🔙 Выход из чата - обратный slide
// 🔙 Выход из чата - плавный fade
isExitingChat -> {
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth / 5 },
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh
)
) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh
)
fadeIn(
animationSpec = tween(200)
) togetherWith fadeOut(
animationSpec = tween(150)
)
}
// 🔍 Вход в поиск - быстрый slide справа
// 🔍 Вход в Search - плавный fade
isEnteringSearch -> {
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh // 🔥 Быстрее для плавности
)
) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth / 5 }, // Меньше смещение
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh
)
fadeIn(
animationSpec = tween(200)
) togetherWith fadeOut(
animationSpec = tween(150)
)
}
// Выход из поиска - обратный slide
// 🔙 Выход из Search - плавный fade
isEnteringSearch -> {
fadeIn(
animationSpec = tween(200)
) togetherWith fadeOut(
animationSpec = tween(150)
)
}
// 🔙 Выход из Search - плавный fade
isExitingSearch -> {
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth / 5 },
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh
)
) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh
)
fadeIn(
animationSpec = tween(200)
) togetherWith fadeOut(
animationSpec = tween(150)
)
}

View File

@@ -66,6 +66,9 @@ class MessageRepository private constructor(private val context: Context) {
private val _newMessageEvents = MutableSharedFlow<String>(replay = 0, extraBufferCapacity = 10)
val newMessageEvents: SharedFlow<String> = _newMessageEvents.asSharedFlow()
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
private val requestedUserInfoKeys = mutableSetOf<String>()
// Текущий аккаунт
private var currentAccount: String? = null
private var currentPrivateKey: String? = null
@@ -97,6 +100,11 @@ class MessageRepository private constructor(private val context: Context) {
* Инициализация с текущим аккаунтом
*/
fun initialize(publicKey: String, privateKey: String) {
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
if (currentAccount != publicKey) {
requestedUserInfoKeys.clear()
}
currentAccount = publicKey
currentPrivateKey = privateKey
@@ -582,10 +590,17 @@ class MessageRepository private constructor(private val context: Context) {
/**
* Запросить информацию о пользователе с сервера
* 🔥 Защита от бесконечных запросов - каждый ключ запрашивается только один раз
*/
fun requestUserInfo(publicKey: String) {
val privateKey = currentPrivateKey ?: return
// 🔥 Не запрашиваем если уже запрашивали
if (requestedUserInfoKeys.contains(publicKey)) {
return
}
requestedUserInfoKeys.add(publicKey)
scope.launch {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val packet = PacketSearch().apply {

View File

@@ -106,7 +106,16 @@ data class DialogEntity(
val verified: Int = 0, // Верифицирован
@ColumnInfo(name = "i_have_sent", defaultValue = "0")
val iHaveSent: Int = 0 // Отправлял ли я сообщения в этот диалог (0/1)
val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1)
@ColumnInfo(name = "last_message_from_me", defaultValue = "0")
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
@ColumnInfo(name = "last_message_delivered", defaultValue = "0")
val lastMessageDelivered: Int = 0, // Статус доставки последнего сообщения (0=WAITING, 1=DELIVERED, 2=ERROR)
@ColumnInfo(name = "last_message_read", defaultValue = "0")
val lastMessageRead: Int = 0 // Прочитано последнее сообщение (0/1)
)
/**
@@ -465,7 +474,10 @@ interface DialogDao {
is_online,
last_seen,
verified,
i_have_sent
i_have_sent,
last_message_from_me,
last_message_delivered,
last_message_read
)
SELECT
:account AS account,
@@ -525,7 +537,31 @@ interface DialogDao {
(SELECT i_have_sent FROM dialogs WHERE account = :account AND opponent_key = :opponentKey),
0
)
END AS i_have_sent
END AS i_have_sent,
COALESCE(
(SELECT from_me FROM messages
WHERE account = :account
AND ((from_public_key = :opponentKey AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponentKey))
ORDER BY timestamp DESC LIMIT 1),
0
) AS last_message_from_me,
COALESCE(
(SELECT delivered FROM messages
WHERE account = :account
AND ((from_public_key = :opponentKey AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponentKey))
ORDER BY timestamp DESC LIMIT 1),
0
) AS last_message_delivered,
COALESCE(
(SELECT read FROM messages
WHERE account = :account
AND ((from_public_key = :opponentKey AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponentKey))
ORDER BY timestamp DESC LIMIT 1),
0
) AS last_message_read
WHERE EXISTS (
SELECT 1 FROM messages
WHERE account = :account
@@ -557,7 +593,10 @@ interface DialogDao {
is_online,
last_seen,
verified,
i_have_sent
i_have_sent,
last_message_from_me,
last_message_delivered,
last_message_read
)
SELECT
:account AS account,
@@ -598,7 +637,24 @@ interface DialogDao {
(SELECT verified FROM dialogs WHERE account = :account AND opponent_key = :account),
0
) AS verified,
1 AS i_have_sent
1 AS i_have_sent,
1 AS last_message_from_me,
COALESCE(
(SELECT delivered FROM messages
WHERE account = :account
AND from_public_key = :account
AND to_public_key = :account
ORDER BY timestamp DESC LIMIT 1),
0
) AS last_message_delivered,
COALESCE(
(SELECT read FROM messages
WHERE account = :account
AND from_public_key = :account
AND to_public_key = :account
ORDER BY timestamp DESC LIMIT 1),
0
) AS last_message_read
WHERE EXISTS (
SELECT 1 FROM messages
WHERE account = :account

View File

@@ -4,6 +4,8 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [
@@ -12,7 +14,7 @@ import androidx.room.RoomDatabase
DialogEntity::class,
BlacklistEntity::class
],
version = 4,
version = 5,
exportSchema = false
)
abstract class RosettaDatabase : RoomDatabase() {
@@ -24,6 +26,15 @@ abstract class RosettaDatabase : RoomDatabase() {
companion object {
@Volatile
private var INSTANCE: RosettaDatabase? = null
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
// Добавляем новые столбцы для индикаторов прочтения
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0")
}
}
fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE ?: synchronized(this) {
@@ -33,7 +44,8 @@ abstract class RosettaDatabase : RoomDatabase() {
"rosetta_secure.db"
)
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
.fallbackToDestructiveMigration() // Для разработки
.addMigrations(MIGRATION_4_5)
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена
.build()
INSTANCE = instance
instance

View File

@@ -72,7 +72,7 @@ object ProtocolManager {
private fun setupStateMonitoring() {
scope.launch {
getProtocol().state.collect { newState ->
addLog("📊 STATE MONITOR: Connection state changed to $newState")
addLog("📡 STATE CHANGED: $newState")
}
}
}
@@ -202,8 +202,7 @@ object ProtocolManager {
*/
fun getProtocol(): Protocol {
if (protocol == null) {
addLog("Creating new Protocol instance")
addLog("Server: $SERVER_ADDRESS")
addLog("🔌 Creating new Protocol instance for $SERVER_ADDRESS")
protocol = Protocol(SERVER_ADDRESS) { msg -> addLog(msg) }
}
return protocol!!
@@ -225,7 +224,7 @@ object ProtocolManager {
* Connect to server
*/
fun connect() {
addLog("🔌 CONNECT REQUESTED from ProtocolManager")
addLog("🔌 CONNECT REQUESTED - calling Protocol.connect()")
getProtocol().connect()
}
@@ -233,9 +232,7 @@ object ProtocolManager {
* Authenticate with server
*/
fun authenticate(publicKey: String, privateHash: String) {
addLog("🔐 AUTHENTICATE called from ProtocolManager")
addLog(" PublicKey: ${publicKey.take(30)}...")
addLog(" PrivateHash: ${privateHash.take(20)}...")
addLog("🔐 AUTHENTICATE REQUESTED for ${publicKey.take(16)}...")
getProtocol().startHandshake(publicKey, privateHash)
}

View File

@@ -159,10 +159,7 @@ fun ConfirmSeedPhraseScreen(
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(250)
)
enter = fadeIn(tween(300))
) {
Text(
text = "Confirm Backup",
@@ -176,10 +173,7 @@ fun ConfirmSeedPhraseScreen(
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically(
initialOffsetY = { -20 },
animationSpec = tween(500, delayMillis = 100)
)
enter = fadeIn(tween(400, delayMillis = 100))
) {
Text(
text = "Enter words #${wordsToConfirm[0].first + 1}, #${wordsToConfirm[1].first + 1}, #${wordsToConfirm[2].first + 1}, #${wordsToConfirm[3].first + 1}\nto confirm you've backed up your phrase.",
@@ -354,10 +348,7 @@ fun ConfirmSeedPhraseScreen(
// Continue Button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 600)
)
enter = fadeIn(tween(400, delayMillis = 600))
) {
Button(
onClick = {
@@ -408,10 +399,7 @@ private fun AnimatedConfirmWordItem(
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally(
initialOffsetX = { if (number <= 6) -50 else 50 },
animationSpec = tween(400, delayMillis = delay)
)
enter = fadeIn(tween(400, delayMillis = delay))
) {
if (isInput) {
ConfirmWordInputItem(

View File

@@ -78,10 +78,7 @@ fun ImportSeedPhraseScreen(
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(250)
)
enter = fadeIn(tween(300))
) {
Text(
text = "Import Account",
@@ -95,10 +92,7 @@ fun ImportSeedPhraseScreen(
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically(
initialOffsetY = { -20 },
animationSpec = tween(500, delayMillis = 100)
)
enter = fadeIn(tween(400, delayMillis = 100))
) {
Text(
text = "Enter your 12-word recovery phrase\nto restore your account.",
@@ -243,10 +237,7 @@ fun ImportSeedPhraseScreen(
// Import button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 600)
)
enter = fadeIn(tween(400, delayMillis = 600))
) {
Button(
onClick = {
@@ -302,10 +293,7 @@ private fun AnimatedWordInputItem(
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally(
initialOffsetX = { if (number <= 6) -50 else 50 },
animationSpec = tween(400, delayMillis = delay)
)
enter = fadeIn(tween(400, delayMillis = delay))
) {
WordInputItem(
number = number,

View File

@@ -80,10 +80,7 @@ fun SeedPhraseScreen(
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(250)
)
enter = fadeIn(tween(300))
) {
Text(
text = "Your Recovery Phrase",
@@ -97,10 +94,7 @@ fun SeedPhraseScreen(
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically(
initialOffsetY = { -20 },
animationSpec = tween(500, delayMillis = 100)
)
enter = fadeIn(tween(400, delayMillis = 100))
) {
Text(
text = "Write down these 12 words in order.\nYou'll need them to restore your account.",
@@ -213,10 +207,7 @@ fun SeedPhraseScreen(
// Continue button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 700)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 700)
)
enter = fadeIn(tween(400, delayMillis = 700))
) {
Button(
onClick = { onConfirm(seedPhrase) },
@@ -313,10 +304,7 @@ private fun AnimatedWordItem(
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally(
initialOffsetX = { if (number <= 6) -50 else 50 },
animationSpec = tween(400, delayMillis = delay)
)
enter = fadeIn(tween(400, delayMillis = delay))
) {
WordItem(
number = number,

View File

@@ -103,10 +103,7 @@ fun SelectAccountScreen(
// Header
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(250)
)
enter = fadeIn(tween(300))
) {
Column {
Row {
@@ -269,10 +266,7 @@ private fun AccountListItem(
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(200)) + slideInHorizontally(
initialOffsetX = { 50 },
animationSpec = tween(200)
)
enter = fadeIn(tween(300))
) {
Card(
modifier = Modifier
@@ -378,10 +372,7 @@ private fun AddAccountButton(
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(200)) + slideInHorizontally(
initialOffsetX = { -50 },
animationSpec = tween(200)
)
enter = fadeIn(tween(300))
) {
Card(
modifier = Modifier

View File

@@ -178,12 +178,7 @@ fun SetPasswordScreen(
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(500, delayMillis = 100)) +
slideInVertically(
initialOffsetY = { -20 },
animationSpec = tween(500, delayMillis = 100)
)
enter = fadeIn(tween(400, delayMillis = 100))
) {
Text(
text = "Protect Your Account",
@@ -214,12 +209,7 @@ fun SetPasswordScreen(
// Password Field
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(500, delayMillis = 300)) +
slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 300)
)
enter = fadeIn(tween(400, delayMillis = 300))
) {
OutlinedTextField(
value = password,
@@ -270,12 +260,7 @@ fun SetPasswordScreen(
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(400, delayMillis = 350)) +
slideInHorizontally(
initialOffsetX = { -30 },
animationSpec = tween(400, delayMillis = 350)
)
enter = fadeIn(tween(400, delayMillis = 350))
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
@@ -345,12 +330,7 @@ fun SetPasswordScreen(
// Confirm Password Field
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(500, delayMillis = 400)) +
slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 400)
)
enter = fadeIn(tween(400, delayMillis = 400))
) {
OutlinedTextField(
value = confirmPassword,
@@ -407,12 +387,7 @@ fun SetPasswordScreen(
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(400, delayMillis = 450)) +
slideInHorizontally(
initialOffsetX = { -30 },
animationSpec = tween(400, delayMillis = 450)
)
enter = fadeIn(tween(400, delayMillis = 450))
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -459,20 +434,8 @@ fun SetPasswordScreen(
// Info - hide when keyboard is visible
AnimatedVisibility(
visible = visible && !isKeyboardVisible,
enter =
fadeIn(tween(200)) +
slideInVertically(
initialOffsetY = { 30 },
animationSpec = tween(200)
) +
scaleIn(initialScale = 0.9f, animationSpec = tween(200)),
exit =
fadeOut(tween(150)) +
slideOutVertically(
targetOffsetY = { 30 },
animationSpec = tween(150)
) +
scaleOut(targetScale = 0.9f, animationSpec = tween(150))
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200))
) {
Row(
modifier =
@@ -504,12 +467,7 @@ fun SetPasswordScreen(
// Create Account Button
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(500, delayMillis = 600)) +
slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 600)
)
enter = fadeIn(tween(400, delayMillis = 600))
) {
Button(
onClick = {

View File

@@ -38,6 +38,7 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.ui.chats.getAvatarColor
import com.rosetta.messenger.ui.chats.getAvatarText
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -195,12 +196,7 @@ fun UnlockScreen(
// Title
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(600, delayMillis = 200)) +
slideInVertically(
initialOffsetY = { 30 },
animationSpec = tween(600, delayMillis = 200)
)
enter = fadeIn(tween(400, delayMillis = 200))
) {
Text(
text = "Welcome Back",
@@ -226,12 +222,7 @@ fun UnlockScreen(
// Account Selector Card
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(600, delayMillis = 350)) +
slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 350)
)
enter = fadeIn(tween(400, delayMillis = 350))
) {
Column {
// Account selector dropdown
@@ -511,12 +502,7 @@ fun UnlockScreen(
// Password Field
AnimatedVisibility(
visible = visible && !isDropdownExpanded,
enter =
fadeIn(tween(600, delayMillis = 400)) +
slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 400)
)
enter = fadeIn(tween(400, delayMillis = 400))
) {
OutlinedTextField(
value = password,
@@ -566,8 +552,8 @@ fun UnlockScreen(
// Error message
AnimatedVisibility(
visible = error != null,
enter = fadeIn() + slideInVertically { -10 },
exit = fadeOut()
enter = fadeIn(tween(200)),
exit = fadeOut(tween(150))
) {
Spacer(modifier = Modifier.height(8.dp))
Text(text = error ?: "", fontSize = 14.sp, color = Color(0xFFE53935))
@@ -578,12 +564,7 @@ fun UnlockScreen(
// Unlock Button
AnimatedVisibility(
visible = visible && !isDropdownExpanded,
enter =
fadeIn(tween(600, delayMillis = 500)) +
slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 500)
)
enter = fadeIn(tween(400, delayMillis = 500))
) {
Button(
onClick = {
@@ -636,13 +617,37 @@ fun UnlockScreen(
name = account.name
)
android.util.Log.d("UnlockScreen", "🔐 Starting connection and authentication...")
android.util.Log.d("UnlockScreen", " PublicKey: ${account.publicKey.take(16)}...")
android.util.Log.d("UnlockScreen", " PrivateKeyHash: ${privateKeyHash.take(16)}...")
// Connect to server and authenticate
ProtocolManager.connect()
// Give WebSocket time to connect before authenticating
kotlinx.coroutines.delay(500)
// 🔥 Ждем пока websocket подключится (CONNECTED state)
var waitAttempts = 0
while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) {
kotlinx.coroutines.delay(100)
waitAttempts++
}
android.util.Log.d("UnlockScreen", "🔌 Connection state after wait: ${ProtocolManager.state.value}")
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
error = "Failed to connect to server"
isUnlocking = false
return@launch
}
// Еще немного ждем для стабильности
kotlinx.coroutines.delay(300)
android.util.Log.d("UnlockScreen", "🔐 Starting authentication...")
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
accountManager.setCurrentAccount(account.publicKey)
android.util.Log.d("UnlockScreen", "✅ Unlock complete, calling onUnlocked callback")
onUnlocked(decryptedAccount)
} catch (e: Exception) {
error = "Failed to unlock: \${e.message}"
@@ -684,12 +689,7 @@ fun UnlockScreen(
// Create New Account button
AnimatedVisibility(
visible = visible && !isDropdownExpanded,
enter =
fadeIn(tween(600, delayMillis = 600)) +
slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 600)
)
enter = fadeIn(tween(400, delayMillis = 600))
) {
TextButton(onClick = onSwitchAccount) {
Icon(

View File

@@ -115,12 +115,7 @@ fun WelcomeScreen(
// Title
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(600, delayMillis = 200)) +
slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 200)
)
enter = fadeIn(tween(400, delayMillis = 200))
) {
Text(
text = "Your Keys,\nYour Messages",
@@ -137,12 +132,7 @@ fun WelcomeScreen(
// Subtitle
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(600, delayMillis = 300)) +
slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 300)
)
enter = fadeIn(tween(400, delayMillis = 300))
) {
Text(
text = "Secure messaging with\ncryptographic keys",
@@ -188,12 +178,7 @@ fun WelcomeScreen(
// Create Seed Button
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(600, delayMillis = 500)) +
slideInVertically(
initialOffsetY = { 100 },
animationSpec = tween(600, delayMillis = 500)
)
enter = fadeIn(tween(400, delayMillis = 500))
) {
Button(
onClick = onCreateSeed,
@@ -229,12 +214,7 @@ fun WelcomeScreen(
// Import Seed Button
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(600, delayMillis = 600)) +
slideInVertically(
initialOffsetY = { 100 },
animationSpec = tween(600, delayMillis = 600)
)
enter = fadeIn(tween(400, delayMillis = 600))
) {
TextButton(
onClick = onImportSeed,

View File

@@ -59,6 +59,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
@@ -474,8 +477,30 @@ fun ChatDetailScreen(
// 🔥 Обработка системной кнопки назад
BackHandler { hideKeyboardAndBack() }
// 🔥 Cleanup при выходе из экрана - только закрываем диалог
DisposableEffect(Unit) { onDispose { viewModel.closeDialog() } }
// 🔥 Lifecycle-aware отслеживание активности экрана
val lifecycleOwner = LocalLifecycleOwner.current
var isScreenActive by remember { mutableStateOf(true) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
isScreenActive = true
viewModel.setDialogActive(true)
}
Lifecycle.Event.ON_PAUSE -> {
isScreenActive = false
viewModel.setDialogActive(false)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
viewModel.closeDialog()
}
}
// Инициализируем ViewModel с ключами и открываем диалог
LaunchedEffect(user.publicKey) {
@@ -489,9 +514,9 @@ fun ChatDetailScreen(
com.rosetta.messenger.ui.components.EmojiCache.preload(context)
}
// Отмечаем сообщения как прочитанные когда они видны
LaunchedEffect(messages) {
if (messages.isNotEmpty()) {
// Отмечаем сообщения как прочитанные только когда экран активен (RESUMED)
LaunchedEffect(messages, isScreenActive) {
if (messages.isNotEmpty() && isScreenActive) {
viewModel.markVisibleMessagesAsRead()
}
}
@@ -855,28 +880,44 @@ fun ChatDetailScreen(
}
}
// Кнопка меню - открывает bottom sheet
IconButton(
onClick = {
// Закрываем клавиатуру перед открытием меню
keyboardController?.hide()
focusManager.clearFocus()
// Даём клавиатуре время закрыться перед показом
// bottom sheet
scope.launch {
delay(
150
) // Задержка для плавного закрытия клавиатуры
// Кнопка меню - открывает kebab menu
Box {
IconButton(
onClick = {
// Закрываем клавиатуру перед открытием меню
keyboardController?.hide()
focusManager.clearFocus()
showMenu = true
}
},
modifier = Modifier.size(48.dp).clip(CircleShape)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More",
tint = headerIconColor.copy(alpha = 0.6f),
modifier = Modifier.size(26.dp)
)
}
// 🔥 TELEGRAM-STYLE KEBAB MENU
KebabMenu(
expanded = showMenu,
onDismiss = { showMenu = false },
isDarkTheme = isDarkTheme,
isSavedMessages = isSavedMessages,
isBlocked = isBlocked,
onBlockClick = {
showMenu = false
showBlockConfirm = true
},
modifier = Modifier.size(48.dp).clip(CircleShape)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More",
tint = headerIconColor.copy(alpha = 0.6f),
modifier = Modifier.size(26.dp)
onUnblockClick = {
showMenu = false
showUnblockConfirm = true
},
onDeleteClick = {
showMenu = false
showDeleteConfirm = true
}
)
}
}
@@ -927,29 +968,8 @@ fun ChatDetailScreen(
AnimatedContent(
targetState = isSelectionMode,
transitionSpec = {
if (targetState) {
// Selection mode появляется: снизу вверх
slideInVertically(
animationSpec = tween(300, easing = TelegramEasing),
initialOffsetY = { it }
) + fadeIn(animationSpec = tween(200)) togetherWith
slideOutVertically(
animationSpec =
tween(300, easing = TelegramEasing),
targetOffsetY = { -it }
) + fadeOut(animationSpec = tween(150))
} else {
// Input bar возвращается: снизу вверх
slideInVertically(
animationSpec = tween(300, easing = TelegramEasing),
initialOffsetY = { it }
) + fadeIn(animationSpec = tween(200)) togetherWith
slideOutVertically(
animationSpec =
tween(300, easing = TelegramEasing),
targetOffsetY = { it }
) + fadeOut(animationSpec = tween(150))
}
fadeIn(animationSpec = tween(200)) togetherWith
fadeOut(animationSpec = tween(150))
},
label = "bottomBarContent"
) { selectionMode ->
@@ -1680,74 +1700,7 @@ fun ChatDetailScreen(
)
}
// <EFBFBD> Bottom Sheet меню (вместо popup menu)
if (showMenu) {
ModalBottomSheet(
onDismissRequest = { showMenu = false },
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
scrimColor = Color.Black.copy(alpha = 0.6f),
windowInsets =
WindowInsets(0, 0, 0, 0), // Перекрываем весь экран включая status bar
dragHandle = {
Box(
modifier =
Modifier.padding(vertical = 12.dp)
.width(36.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.3f)
else Color.Black.copy(alpha = 0.2f)
)
)
}
) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)) {
// Block/Unblock User
if (!isSavedMessages) {
BottomSheetMenuItem(
icon =
if (isBlocked) Icons.Default.CheckCircle
else Icons.Default.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = {
showMenu = false
if (isBlocked) {
showUnblockConfirm = true
} else {
showBlockConfirm = true
}
},
isDarkTheme = isDarkTheme,
tintColor = PrimaryBlue
)
Divider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp,
color =
if (isDarkTheme) Color.White.copy(alpha = 0.1f)
else Color.Black.copy(alpha = 0.08f)
)
}
// Delete Chat
BottomSheetMenuItem(
icon = Icons.Default.Delete,
text = "Delete Chat",
onClick = {
showMenu = false
showDeleteConfirm = true
},
isDarkTheme = isDarkTheme,
isDestructive = true
)
}
}
}
// <20>📨 Forward Chat Picker BottomSheet
// 📨 Forward Chat Picker BottomSheet
if (showForwardPicker) {
ForwardChatPickerBottomSheet(
dialogs = dialogsList,
@@ -3180,3 +3133,108 @@ private fun BottomSheetMenuItem(
Text(text = text, color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium)
}
}
/**
* 🔥 TELEGRAM-STYLE KEBAB MENU
* Красивое выпадающее меню с анимациями и современным дизайном
*/
@Composable
private fun KebabMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDarkTheme: Boolean,
isSavedMessages: Boolean,
isBlocked: Boolean,
onBlockClick: () -> Unit,
onUnblockClick: () -> Unit,
onDeleteClick: () -> Unit
) {
val menuBackgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
modifier = Modifier.width(220.dp),
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
// Block/Unblock User (не для Saved Messages)
if (!isSavedMessages) {
KebabMenuItem(
icon = if (isBlocked) Icons.Default.CheckCircle else Icons.Default.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = {
if (isBlocked) onUnblockClick() else onBlockClick()
},
tintColor = PrimaryBlue,
textColor = textColor
)
// Divider
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.height(0.5.dp)
.background(dividerColor)
)
}
// Delete Chat
KebabMenuItem(
icon = Icons.Default.Delete,
text = "Delete Chat",
onClick = onDeleteClick,
tintColor = Color(0xFFFF3B30),
textColor = Color(0xFFFF3B30)
)
}
}
/**
* 🔥 Элемент Kebab меню
*/
@Composable
private fun KebabMenuItem(
icon: ImageVector,
text: String,
onClick: () -> Unit,
tintColor: Color,
textColor: Color
) {
val interactionSource = remember { MutableInteractionSource() }
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tintColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Text(
text = text,
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
},
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
interactionSource = interactionSource,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
)
}

View File

@@ -140,6 +140,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Как currentDialogPublicKeyView в архиве
private var isDialogActive = false
// 🟢 Флаг что уже подписаны на онлайн статус собеседника
private var subscribedToOnlineStatus = false
init {
setupPacketListeners()
setupNewMessageListener()
@@ -351,6 +354,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isLoadingMessages = false
lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен!
// 📨 Применяем Forward сообщения СРАЗУ после сброса
@@ -390,6 +394,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isDialogActive = false
}
/**
* 🔥 Установить состояние активности диалога (вызывается при ON_RESUME/ON_PAUSE)
* Предотвращает отметку сообщений как прочитанных когда приложение в фоне
*/
fun setDialogActive(active: Boolean) {
isDialogActive = active
}
/**
* 🚀 СУПЕР-оптимизированная загрузка сообщений
* 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка
@@ -1428,8 +1440,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/**
* 🟢 Подписаться на онлайн статус собеседника
* 📁 Для Saved Messages - не подписываемся
* 🔥 Проверяем флаг чтобы не подписываться повторно
*/
fun subscribeToOnlineStatus() {
// 🔥 Если уже подписаны - не подписываемся повторно!
if (subscribedToOnlineStatus) return
val opponent = opponentKey ?: return
val privateKey = myPrivateKey ?: return
val account = myPublicKey ?: return
@@ -1439,6 +1455,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return
}
// 🔥 Устанавливаем флаг ДО отправки пакета
subscribedToOnlineStatus = true
viewModelScope.launch(Dispatchers.IO) {
try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -1460,6 +1479,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
super.onCleared()
lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
opponentKey = null
}
}

View File

@@ -235,6 +235,11 @@ fun ChatsListScreen(
}
*/
// Enable UI logs when status dialog is shown
LaunchedEffect(showStatusDialog) {
ProtocolManager.enableUILogs(showStatusDialog)
}
// Status dialog with logs
if (showStatusDialog) {
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
@@ -576,8 +581,12 @@ fun ChatsListScreen(
iconColor = menuIconColor,
textColor = textColor,
onClick = {
scope.launch { drawerState.close() }
onSavedMessagesClick()
scope.launch {
drawerState.close()
// Ждём завершения анимации закрытия drawer
kotlinx.coroutines.delay(250)
onSavedMessagesClick()
}
}
)
@@ -893,37 +902,8 @@ fun ChatsListScreen(
AnimatedContent(
targetState = showRequestsScreen,
transitionSpec = {
if (targetState) {
// Переход на Requests: быстрый slide + fade
(slideInHorizontally(
animationSpec =
tween(200, easing = FastOutSlowInEasing),
initialOffsetX = { it }
) + fadeIn(tween(150))) togetherWith
(slideOutHorizontally(
animationSpec =
tween(
200,
easing = FastOutSlowInEasing
),
targetOffsetX = { -it / 4 }
) + fadeOut(tween(100)))
} else {
// Возврат из Requests: slide out to right
(slideInHorizontally(
animationSpec =
tween(200, easing = FastOutSlowInEasing),
initialOffsetX = { -it / 4 }
) + fadeIn(tween(150))) togetherWith
(slideOutHorizontally(
animationSpec =
tween(
200,
easing = FastOutSlowInEasing
),
targetOffsetX = { it }
) + fadeOut(tween(100)))
}
fadeIn(animationSpec = tween(200)) togetherWith
fadeOut(animationSpec = tween(150))
},
label = "RequestsTransition"
) { isRequestsScreen ->
@@ -1662,11 +1642,63 @@ fun DialogItemContent(
modifier = Modifier.weight(1f)
)
Text(
text = formatTime(Date(dialog.lastMessageTimestamp)),
fontSize = 13.sp,
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
// Показываем статус только для исходящих сообщений
if (dialog.lastMessageFromMe == 1) {
when (dialog.lastMessageDelivered) {
2 -> {
// ERROR - показываем иконку ошибки
Icon(
imageVector = Icons.Outlined.ErrorOutline,
contentDescription = "Sending failed",
tint = Color(0xFFFF3B30), // iOS красный
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
1 -> {
// DELIVERED - две галочки
val checkmarkColor = if (dialog.lastMessageRead == 1) {
PrimaryBlue // прочитано - синие
} else {
secondaryTextColor.copy(alpha = 0.6f) // доставлено - серые
}
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
tint = checkmarkColor,
modifier = Modifier.size(14.dp)
)
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
tint = checkmarkColor,
modifier = Modifier.size(14.dp).offset(x = (-6).dp)
)
Spacer(modifier = Modifier.width(2.dp))
}
0 -> {
// WAITING - одна серая галочка (отправлено, ждём доставку)
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Text(
text = formatTime(Date(dialog.lastMessageTimestamp)),
fontSize = 13.sp,
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
)
}
}
Spacer(modifier = Modifier.height(4.dp))

View File

@@ -30,7 +30,10 @@ data class DialogUiModel(
val isOnline: Int,
val lastSeen: Long,
val verified: Int,
val isSavedMessages: Boolean = false // 📁 Флаг для Saved Messages (account == opponentKey)
val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
val lastMessageRead: Int = 0 // Прочитано (0/1)
)
/**
@@ -61,6 +64,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
private val requestedUserInfoKeys = mutableSetOf<String>()
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
private val subscribedOnlineKeys = mutableSetOf<String>()
// Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
@@ -97,6 +103,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (currentAccount == publicKey) {
return
}
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear()
currentAccount = publicKey
currentPrivateKey = privateKey
@@ -138,7 +148,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages = isSavedMessages // 📁 Saved Messages
isSavedMessages = isSavedMessages, // 📁 Saved Messages
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered = dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead
)
}
}
@@ -194,7 +207,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages = (dialog.account == dialog.opponentKey) // 📁 Saved Messages
isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered = dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead
)
}
}
@@ -216,17 +232,25 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/**
* 🟢 Подписаться на онлайн-статусы всех собеседников
* 🔥 Фильтруем уже подписанные ключи чтобы избежать бесконечного цикла
*/
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
if (opponentKeys.isEmpty()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) }
if (newKeys.isEmpty()) return // Все уже подписаны
// Добавляем в Set ДО отправки пакета чтобы избежать race condition
subscribedOnlineKeys.addAll(newKeys)
viewModelScope.launch(Dispatchers.IO) {
try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val packet = PacketOnlineSubscribe().apply {
this.privateKey = privateKeyHash
opponentKeys.forEach { key ->
newKeys.forEach { key ->
addPublicKey(key)
}
}