feat: Enhance search functionality and user experience

- Added local account metadata handling in SearchScreen for improved "Saved Messages" search fallback.
- Updated search logic to include username and account name checks when searching for the user.
- Introduced search logging in SearchUsersViewModel for better debugging and tracking of search queries.
- Refactored image download process in AttachmentComponents to include detailed logging for debugging.
- Created AttachmentDownloadDebugLogger to manage and display download logs.
- Improved DeviceVerificationBanner UI for better user engagement during device verification.
- Adjusted OtherProfileScreen layout to enhance information visibility and user interaction.
- Updated network security configuration to include new Let's Encrypt certificate for CDN.
This commit is contained in:
2026-02-19 17:34:16 +05:00
parent cacd6dc029
commit 53d0e44ef8
26 changed files with 972 additions and 613 deletions

View File

@@ -0,0 +1,31 @@
package com.rosetta.messenger.ui.auth
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
internal suspend fun awaitAuthHandshakeState(
publicKey: String,
privateKeyHash: String,
attempts: Int = 2,
timeoutMs: Long = 25_000L
): ProtocolState? {
repeat(attempts) {
ProtocolManager.disconnect()
delay(200)
ProtocolManager.authenticate(publicKey, privateKeyHash)
val state = withTimeoutOrNull(timeoutMs) {
ProtocolManager.state.first {
it == ProtocolState.AUTHENTICATED ||
it == ProtocolState.DEVICE_VERIFICATION_REQUIRED
}
}
if (state != null) {
return state
}
}
return null
}

View File

@@ -1,6 +1,11 @@
package com.rosetta.messenger.ui.auth
import android.os.Build
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -15,6 +20,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -29,6 +35,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -54,11 +62,16 @@ fun DeviceConfirmScreen(
isDarkTheme: Boolean,
onExit: () -> Unit
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val cardBorderColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE8E8ED)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val backgroundTop = if (isDarkTheme) Color(0xFF17181D) else Color(0xFFF4F7FC)
val backgroundBottom = if (isDarkTheme) Color(0xFF121316) else Color(0xFFE9EEF7)
val cardColor = if (isDarkTheme) Color(0xFF23252B) else Color.White
val cardBorderColor = if (isDarkTheme) Color(0xFF343844) else Color(0xFFDCE4F0)
val textColor = if (isDarkTheme) Color(0xFFF2F3F5) else Color(0xFF1B1C1F)
val secondaryTextColor = if (isDarkTheme) Color(0xFFB2B5BD) else Color(0xFF6F7480)
val accentColor = if (isDarkTheme) Color(0xFF4A9FFF) else PrimaryBlue
val deviceCardColor = if (isDarkTheme) Color(0xFF1A1C22) else Color(0xFFF5F8FD)
val exitButtonColor = if (isDarkTheme) Color(0xFF3D2227) else Color(0xFFFFEAED)
val exitButtonTextColor = Color(0xFFFF5E61)
val onExitState by rememberUpdatedState(onExit)
val scope = rememberCoroutineScope()
@@ -92,7 +105,12 @@ fun DeviceConfirmScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.background(
brush =
Brush.verticalGradient(
colors = listOf(backgroundTop, backgroundBottom)
)
)
.navigationBarsPadding()
.padding(horizontal = 22.dp),
contentAlignment = Alignment.Center
@@ -102,98 +120,160 @@ fun DeviceConfirmScreen(
.fillMaxWidth()
.widthIn(max = 400.dp),
color = cardColor,
shape = RoundedCornerShape(24.dp),
shape = RoundedCornerShape(28.dp),
border = BorderStroke(1.dp, cardBorderColor)
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
modifier = Modifier.padding(horizontal = 22.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(128.dp)
)
Box(
modifier =
Modifier
.size(118.dp)
.clip(CircleShape)
.background(accentColor.copy(alpha = if (isDarkTheme) 0.16f else 0.1f)),
contentAlignment = Alignment.Center
) {
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(96.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = TablerIcons.DeviceMobile,
contentDescription = null,
tint = PrimaryBlue
tint = accentColor
)
Spacer(modifier = Modifier.size(6.dp))
Text(
text = "NEW DEVICE REQUEST",
color = PrimaryBlue,
color = accentColor,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Waiting for approval",
color = textColor,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Open Rosetta on your first device and approve this login request.",
color = secondaryTextColor,
fontSize = 14.sp,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(14.dp))
Text(
text = "\"$localDeviceName\" is waiting for approval",
color = textColor.copy(alpha = 0.9f),
fontSize = 13.sp,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "If you didn't request this login, tap Exit.",
color = secondaryTextColor,
fontSize = 12.sp,
text = "Waiting for approval",
color = textColor,
fontSize = 34.sp,
lineHeight = 38.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onExitState,
modifier = Modifier.height(42.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF3B30),
contentColor = Color.White
),
shape = RoundedCornerShape(12.dp)
Text(
text = "Open Rosetta on your first device and approve this login request.",
color = secondaryTextColor,
fontSize = 15.sp,
textAlign = TextAlign.Center,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(18.dp))
Surface(
modifier = Modifier.fillMaxWidth(),
color = deviceCardColor,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, cardBorderColor.copy(alpha = if (isDarkTheme) 0.7f else 1f))
) {
Text("Exit")
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = "Device waiting for approval",
color = secondaryTextColor,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = localDeviceName,
color = textColor,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Waiting for confirmation...",
color = secondaryTextColor,
fontSize = 11.sp
)
Button(
onClick = onExitState,
modifier =
Modifier
.fillMaxWidth()
.height(46.dp),
colors = ButtonDefaults.buttonColors(
containerColor = exitButtonColor,
contentColor = exitButtonTextColor
),
border = BorderStroke(1.dp, exitButtonTextColor.copy(alpha = 0.35f)),
shape = RoundedCornerShape(14.dp)
) {
Text(
text = "Exit",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(14.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Waiting for confirmation",
color = secondaryTextColor,
fontSize = 12.sp
)
Spacer(modifier = Modifier.size(8.dp))
WaitingDots(color = secondaryTextColor)
}
}
}
}
}
@Composable
private fun WaitingDots(color: Color) {
val transition = rememberInfiniteTransition(label = "waiting-dots")
val progress by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 1000, easing = FastOutSlowInEasing)
),
label = "waiting-dots-progress"
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(3) { index ->
val shifted = (progress + index * 0.22f) % 1f
val alpha = 0.3f + (1f - shifted) * 0.7f
Box(
modifier =
Modifier
.size(5.dp)
.clip(CircleShape)
.background(color.copy(alpha = alpha))
)
}
}
}

View File

@@ -29,7 +29,6 @@ import com.rosetta.messenger.crypto.CryptoManager
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.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
@@ -520,20 +519,26 @@ fun SetPasswordScreen(
)
accountManager.saveAccount(account)
accountManager.setCurrentAccount(keyPair.publicKey)
// 🔌 Connect to server and authenticate
val privateKeyHash =
CryptoManager.generatePrivateKeyHash(
keyPair.privateKey
)
ProtocolManager.connect()
// Give WebSocket time to connect before authenticating
kotlinx.coroutines.delay(500)
ProtocolManager.authenticate(
keyPair.publicKey,
privateKeyHash
)
val handshakeState =
awaitAuthHandshakeState(
keyPair.publicKey,
privateKeyHash
)
if (handshakeState == null) {
error =
"Failed to connect to server. Please try again."
isCreating = false
return@launch
}
accountManager.setCurrentAccount(keyPair.publicKey)
// Create DecryptedAccount to pass to callback
val decryptedAccount =

View File

@@ -43,19 +43,16 @@ import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.getAvatarColor
import com.rosetta.messenger.ui.chats.getAvatarText
import com.rosetta.messenger.ui.chats.utils.getInitials
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.launch
// Account model for dropdown
data class AccountItem(
@@ -116,33 +113,17 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
privateKey = decryptedPrivateKey,
seedPhrase = decryptedSeedPhrase,
privateKeyHash = privateKeyHash,
name = account.name
name = selectedAccount.name
)
// Connect to server
val connectStart = System.currentTimeMillis()
ProtocolManager.connect()
// Wait for websocket connection
val connected = withTimeoutOrNull(5000) {
ProtocolManager.state.first { it != ProtocolState.DISCONNECTED }
}
val connectTime = System.currentTimeMillis() - connectStart
if (connected == null) {
val handshakeState = awaitAuthHandshakeState(account.publicKey, privateKeyHash)
if (handshakeState == null) {
onError("Failed to connect to server")
onUnlocking(false)
return
}
kotlinx.coroutines.delay(300)
// Authenticate
val authStart = System.currentTimeMillis()
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
val authTime = System.currentTimeMillis() - authStart
accountManager.setCurrentAccount(account.publicKey)
val totalTime = System.currentTimeMillis() - totalStart
onSuccess(decryptedAccount)
} catch (e: Exception) {
onError("Failed to unlock: ${e.message}")
@@ -216,7 +197,11 @@ fun UnlockScreen(
val allAccounts = accountManager.getAllAccounts()
accounts =
allAccounts.map { acc ->
AccountItem(publicKey = acc.publicKey, name = acc.name, encryptedAccount = acc)
AccountItem(
publicKey = acc.publicKey,
name = resolveAccountDisplayName(acc.publicKey, acc.name, acc.username),
encryptedAccount = acc
)
}
// Find the target account - приоритет: selectedAccountId > lastLoggedKey > первый
@@ -359,6 +344,7 @@ fun UnlockScreen(
avatarRepository = avatarRepository,
size = 120.dp,
isDarkTheme = isDarkTheme,
displayName = selectedAccount!!.name,
shape = RoundedCornerShape(28.dp)
)
} else {
@@ -479,7 +465,8 @@ fun UnlockScreen(
publicKey = account.publicKey,
avatarRepository = avatarRepository,
size = 40.dp,
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
displayName = account.name
)
Spacer(modifier = Modifier.width(12.dp))