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

View File

@@ -69,7 +69,6 @@ import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.*
@@ -339,14 +338,6 @@ fun ChatDetailScreen(
var showDeleteConfirm by remember { mutableStateOf(false) }
var showBlockConfirm by remember { mutableStateOf(false) }
var showUnblockConfirm by remember { mutableStateOf(false) }
var showDebugLogs by remember { mutableStateOf(false) }
// Debug logs из ProtocolManager
val debugLogs by ProtocolManager.debugLogs.collectAsState()
// Включаем UI логи только когда открыт bottom sheet
LaunchedEffect(showDebugLogs) { ProtocolManager.enableUILogs(showDebugLogs) }
// Наблюдаем за статусом блокировки в реальном времени через Flow
val isBlocked by
database.blacklistDao()
@@ -462,6 +453,7 @@ fun ChatDetailScreen(
Lifecycle.Event.ON_RESUME -> {
isScreenActive = true
viewModel.setDialogActive(true)
viewModel.markVisibleMessagesAsRead()
// 🔥 Убираем уведомление этого чата из шторки
com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey)
@@ -488,6 +480,7 @@ fun ChatDetailScreen(
LaunchedEffect(user.publicKey, forwardTrigger) {
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
viewModel.openDialog(user.publicKey, user.title, user.username)
viewModel.markVisibleMessagesAsRead()
// 🔥 Убираем уведомление этого чата из шторки при заходе
com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey)
@@ -1143,12 +1136,6 @@ fun ChatDetailScreen(
false
showDeleteConfirm =
true
},
onLogsClick = {
showMenu =
false
showDebugLogs =
true
}
)
}
@@ -1535,7 +1522,7 @@ fun ChatDetailScreen(
}
}
}
} else {
} else if (!isSystemAccount) {
// INPUT BAR
Column {
MessageInputBar(

View File

@@ -45,13 +45,14 @@ import com.rosetta.messenger.R
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.network.DeviceEntry
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.chats.components.DebugLogsBottomSheet
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
@@ -261,7 +262,6 @@ fun ChatsListScreen(
// Protocol connection state
val protocolState by ProtocolManager.state.collectAsState()
val syncLogs by ProtocolManager.debugLogs.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// 🔥 Пользователи, которые сейчас печатают
@@ -288,10 +288,6 @@ fun ChatsListScreen(
// Status dialog state
var showStatusDialog by remember { mutableStateOf(false) }
var showSyncLogs by remember { mutableStateOf(false) }
// Включаем UI логи только когда открыт bottom sheet, чтобы не перегружать композицию
LaunchedEffect(showSyncLogs) { ProtocolManager.enableUILogs(showSyncLogs) }
// 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) }
@@ -667,12 +663,15 @@ fun ChatsListScreen(
exit = shrinkVertically(animationSpec = tween(250)) + fadeOut(animationSpec = tween(200))
) {
Column(modifier = Modifier.fillMaxWidth()) {
// All accounts list
allAccounts.forEach { account ->
// All accounts list (max 5 like Telegram sidebar behavior)
allAccounts.take(5).forEach { account ->
val isCurrentAccount = account.publicKey == accountPublicKey
val displayName = account.name.ifEmpty {
account.username ?: account.publicKey.take(8)
}
val displayName =
resolveAccountDisplayName(
account.publicKey,
account.name,
account.username
)
Row(
modifier =
Modifier.fillMaxWidth()
@@ -1196,18 +1195,6 @@ fun ChatsListScreen(
},
actions = {
if (!showRequestsScreen) {
IconButton(
onClick = {
showSyncLogs = true
}
) {
Icon(
TablerIcons.Bug,
contentDescription = "Sync logs",
tint = Color.White.copy(alpha = 0.92f)
)
}
IconButton(
onClick = {
if (protocolState ==
@@ -1561,8 +1548,6 @@ fun ChatsListScreen(
DeviceVerificationBanner(
device = pendingDevice,
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
avatarRepository = avatarRepository,
onAccept = {
deviceResolveRequest =
pendingDevice to
@@ -1627,6 +1612,9 @@ fun ChatsListScreen(
val isSavedMessages =
dialog.opponentKey ==
accountPublicKey
val isSystemSafeDialog =
dialog.opponentKey ==
MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val isBlocked =
blockedUsers
.contains(
@@ -1734,6 +1722,8 @@ fun ChatsListScreen(
.contains(
dialog.opponentKey
),
swipeEnabled =
!isSystemSafeDialog,
onPin = {
onTogglePin(
dialog.opponentKey
@@ -1920,15 +1910,6 @@ fun ChatsListScreen(
)
}
if (showSyncLogs) {
DebugLogsBottomSheet(
logs = syncLogs,
isDarkTheme = isDarkTheme,
onDismiss = { showSyncLogs = false },
onClearLogs = { ProtocolManager.clearLogs() }
)
}
} // Close Box
}
@@ -2482,6 +2463,7 @@ fun SwipeableDialogItem(
isTyping: Boolean = false,
isBlocked: Boolean = false,
isSavedMessages: Boolean = false,
swipeEnabled: Boolean = true,
isMuted: Boolean = false,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
isDrawerOpen: Boolean = false,
@@ -2513,7 +2495,10 @@ fun SwipeableDialogItem(
)
var offsetX by remember { mutableStateOf(0f) }
// 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete)
val buttonCount = if (isSavedMessages) 2 else 3
val buttonCount =
if (!swipeEnabled) 0
else if (isSavedMessages) 2
else 3
val swipeWidthDp = (buttonCount * 80).dp
val density = androidx.compose.ui.platform.LocalDensity.current
val swipeWidthPx = with(density) { swipeWidthDp.toPx() }
@@ -2545,6 +2530,7 @@ fun SwipeableDialogItem(
.clipToBounds()
) {
// 1. КНОПКИ - позиционированы справа, всегда видны при свайпе
if (swipeEnabled) {
Row(
modifier =
Modifier.align(Alignment.CenterEnd)
@@ -2665,6 +2651,7 @@ fun SwipeableDialogItem(
}
}
}
}
// 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе
// 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные callbacks
@@ -2762,7 +2749,7 @@ fun SwipeableDialogItem(
when {
// Horizontal left swipe — reveal action buttons
dominated && totalDragX < 0 -> {
swipeEnabled && dominated && totalDragX < 0 -> {
passedSlop = true
claimed = true
onSwipeStarted()
@@ -3073,12 +3060,31 @@ fun DialogItemContent(
// 📁 Для Saved Messages ВСЕГДА показываем синие двойные
// галочки (прочитано)
if (dialog.isSavedMessages) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(16.dp)
)
Box(
modifier = Modifier.width(20.dp).height(16.dp)
) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = PrimaryBlue,
modifier =
Modifier.size(16.dp)
.align(
Alignment.CenterStart
)
)
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = PrimaryBlue,
modifier =
Modifier.size(16.dp)
.align(
Alignment.CenterStart
)
.offset(x = 5.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
} else if (dialog.lastMessageFromMe == 1) {
// Показываем статус только для исходящих сообщений
@@ -3116,14 +3122,44 @@ fun DialogItemContent(
3 -> {
// READ (delivered=3) - две синие
// галочки
Icon(
painter =
TelegramIcons.Done,
contentDescription = null,
tint = PrimaryBlue,
Box(
modifier =
Modifier.size(16.dp)
)
Modifier.width(20.dp)
.height(16.dp)
) {
Icon(
painter =
TelegramIcons.Done,
contentDescription =
null,
tint = PrimaryBlue,
modifier =
Modifier.size(
16.dp
)
.align(
Alignment.CenterStart
)
)
Icon(
painter =
TelegramIcons.Done,
contentDescription =
null,
tint = PrimaryBlue,
modifier =
Modifier.size(
16.dp
)
.align(
Alignment.CenterStart
)
.offset(
x =
5.dp
)
)
}
Spacer(
modifier =
Modifier.width(4.dp)

View File

@@ -38,7 +38,9 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser
@@ -96,6 +98,8 @@ fun SearchScreen(
val searchQuery by searchViewModel.searchQuery.collectAsState()
val searchResults by searchViewModel.searchResults.collectAsState()
val isSearching by searchViewModel.isSearching.collectAsState()
var ownAccountName by remember(currentUserPublicKey) { mutableStateOf("") }
var ownAccountUsername by remember(currentUserPublicKey) { mutableStateOf("") }
// Easter egg: navigate to CrashLogs when typing "rosettadev1"
LaunchedEffect(searchQuery) {
@@ -108,6 +112,24 @@ fun SearchScreen(
// Always reset query/results when leaving Search screen (back/swipe/navigation).
DisposableEffect(Unit) { onDispose { searchViewModel.clearSearchQuery() } }
// Keep private key hash in sync with active account.
LaunchedEffect(privateKeyHash) {
searchViewModel.setPrivateKeyHash(privateKeyHash)
}
// Keep own account metadata for local "Saved Messages" search fallback.
LaunchedEffect(currentUserPublicKey) {
if (currentUserPublicKey.isBlank()) {
ownAccountName = ""
ownAccountUsername = ""
return@LaunchedEffect
}
val account = AccountManager(context).getAccount(currentUserPublicKey)
ownAccountName = account?.name?.trim().orEmpty()
ownAccountUsername = account?.username?.trim().orEmpty()
}
// Recent users - отложенная подписка
val recentUsers by RecentSearchesManager.recentUsers.collectAsState()
@@ -150,11 +172,6 @@ fun SearchScreen(
RecentSearchesManager.setAccount(currentUserPublicKey)
}
// Устанавливаем privateKeyHash
if (privateKeyHash.isNotEmpty()) {
searchViewModel.setPrivateKeyHash(privateKeyHash)
}
// Автофокус с небольшой задержкой
kotlinx.coroutines.delay(100)
try {
@@ -314,15 +331,22 @@ fun SearchScreen(
} else {
// Search Results
// Проверяем, не ищет ли пользователь сам себя (Saved Messages)
val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase()
val normalizedPublicKey = currentUserPublicKey.lowercase()
val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase()
val normalizedName = ownAccountName.trim().lowercase()
val hasValidOwnName =
ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName)
val isSavedMessagesSearch =
searchQuery.trim().let { query ->
query.equals(currentUserPublicKey, ignoreCase = true) ||
query.equals(currentUserPublicKey.take(8), ignoreCase = true) ||
query.equals(
currentUserPublicKey.takeLast(8),
ignoreCase = true
)
}
normalizedQuery.isNotEmpty() &&
(normalizedPublicKey == normalizedQuery ||
normalizedPublicKey.startsWith(normalizedQuery) ||
normalizedPublicKey.take(8) == normalizedQuery ||
normalizedPublicKey.takeLast(8) == normalizedQuery ||
(normalizedUsername.isNotEmpty() &&
normalizedUsername.startsWith(normalizedQuery)) ||
(hasValidOwnName &&
normalizedName.startsWith(normalizedQuery)))
// Если ищем себя - показываем Saved Messages как первый результат
val resultsWithSavedMessages =

View File

@@ -4,8 +4,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -13,8 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
private const val TAG = "SearchUsersVM"
/**
* ViewModel для поиска пользователей через протокол
* Работает аналогично SearchBar в React Native приложении
@@ -33,38 +33,30 @@ class SearchUsersViewModel : ViewModel() {
private val _isSearchExpanded = MutableStateFlow(false)
val isSearchExpanded: StateFlow<Boolean> = _isSearchExpanded.asStateFlow()
private val _searchLogs = MutableStateFlow<List<String>>(emptyList())
val searchLogs: StateFlow<List<String>> = _searchLogs.asStateFlow()
// Приватные переменные
private var searchJob: Job? = null
private var lastSearchedText: String = ""
private var privateKeyHash: String = ""
private val timeFormatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
// Callback для обработки ответа поиска
private val searchPacketHandler: (com.rosetta.messenger.network.Packet) -> Unit = handler@{ packet ->
if (packet is PacketSearch) {
// 🔥 ВАЖНО: Игнорируем ответы с пустым search или не соответствующие нашему запросу
// Сервер может слать много пакетов 0x03 по разным причинам
val currentQuery = lastSearchedText
val responseSearch = packet.search
// Принимаем ответ только если:
// 1. search в ответе совпадает с нашим запросом, ИЛИ
// 2. search пустой но мы ждём ответ (lastSearchedText не пустой)
// НО: если search пустой и мы НЕ ждём ответ - игнорируем
if (responseSearch.isEmpty() && currentQuery.isEmpty()) {
logSearch(
"📥 PacketSearch response: search='${packet.search}', users=${packet.users.size}"
)
// Desktop parity: любой ответ PacketSearch обновляет результаты
// пока в поле есть активный поисковый запрос.
if (_searchQuery.value.trim().isEmpty()) {
logSearch("⏭ Ignored response: query is empty")
return@handler
}
// Если search не пустой и не совпадает с нашим запросом - игнорируем
if (responseSearch.isNotEmpty() && responseSearch != currentQuery) {
return@handler
}
packet.users.forEachIndexed { index, user ->
}
_searchResults.value = packet.users
_isSearching.value = false
logSearch("✅ Results updated")
}
}
@@ -84,7 +76,14 @@ class SearchUsersViewModel : ViewModel() {
* Установить приватный ключ для поиска
*/
fun setPrivateKeyHash(hash: String) {
privateKeyHash = hash
privateKeyHash = hash.trim()
val shortHash =
if (privateKeyHash.length > 12) {
"${privateKeyHash.take(8)}...${privateKeyHash.takeLast(4)}"
} else {
privateKeyHash
}
logSearch("🔑 privateKeyHash set: $shortHash")
}
/**
@@ -92,53 +91,51 @@ class SearchUsersViewModel : ViewModel() {
* Аналогично handleSearch в React Native
*/
fun onSearchQueryChange(query: String) {
_searchQuery.value = query
val normalizedQuery = sanitizeSearchInput(query)
_searchQuery.value = normalizedQuery
logSearch("⌨️ Query changed: '$query' -> '$normalizedQuery'")
// Отменяем предыдущий поиск
searchJob?.cancel()
// Если пустой запрос - очищаем результаты
if (query.trim().isEmpty()) {
if (normalizedQuery.trim().isEmpty()) {
_searchResults.value = emptyList()
_isSearching.value = false
lastSearchedText = ""
logSearch("🧹 Cleared results: empty query")
return
}
// Если текст уже был найден - не повторяем поиск
if (query == lastSearchedText) {
return
}
// Показываем индикатор загрузки
_isSearching.value = true
logSearch("⏳ Debounce started (1000ms)")
// Запускаем поиск с задержкой 1 секунда (как в React Native)
searchJob = viewModelScope.launch {
delay(1000) // debounce
// Проверяем состояние протокола
if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) {
_isSearching.value = false
return@launch
}
// Проверяем, не изменился ли запрос
if (query != _searchQuery.value) {
if (normalizedQuery != _searchQuery.value) {
logSearch("⏭ Skip send: query changed during debounce")
return@launch
}
val effectivePrivateHash =
privateKeyHash.ifBlank { ProtocolManager.getProtocol().getPrivateHash().orEmpty() }
if (effectivePrivateHash.isBlank()) {
_isSearching.value = false
logSearch("❌ Skip send: private hash is empty")
return@launch
}
lastSearchedText = query
// Создаем и отправляем пакет поиска
val packetSearch = PacketSearch().apply {
this.privateKey = privateKeyHash
this.search = query
this.privateKey = effectivePrivateHash
this.search = normalizedQuery
}
ProtocolManager.sendPacket(packetSearch)
logSearch("📤 PacketSearch sent: '$normalizedQuery'")
}
}
@@ -157,8 +154,8 @@ class SearchUsersViewModel : ViewModel() {
_searchQuery.value = ""
_searchResults.value = emptyList()
_isSearching.value = false
lastSearchedText = ""
searchJob?.cancel()
logSearch("↩️ Search collapsed")
}
/**
@@ -168,7 +165,18 @@ class SearchUsersViewModel : ViewModel() {
_searchQuery.value = ""
_searchResults.value = emptyList()
_isSearching.value = false
lastSearchedText = ""
searchJob?.cancel()
logSearch("🧹 Query cleared")
}
fun clearSearchLogs() {
_searchLogs.value = emptyList()
}
private fun logSearch(message: String) {
val timestamp = synchronized(timeFormatter) { timeFormatter.format(Date()) }
_searchLogs.value = (_searchLogs.value + "[$timestamp] $message").takeLast(200)
}
}
private fun sanitizeSearchInput(input: String): String = input.replace("@", "").trimStart()

View File

@@ -73,6 +73,16 @@ import kotlin.math.min
private const val TAG = "AttachmentComponents"
private fun shortDebugId(value: String): String {
if (value.isBlank()) return "empty"
val clean = value.trim()
return if (clean.length <= 8) clean else "${clean.take(8)}..."
}
private fun logPhotoDebug(message: String) {
AttachmentDownloadDebugLogger.log(message)
}
/**
* Анимированный текст с волнообразными точками.
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
@@ -910,6 +920,10 @@ fun ImageAttachment(
val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
scope.launch {
val idShort = shortDebugId(attachment.id)
val tagShort = shortDebugId(downloadTag)
val server = TransportManager.getTransportServer() ?: "unset"
logPhotoDebug("Start image download: id=$idShort, tag=$tagShort, server=$server")
try {
downloadStatus = DownloadStatus.DOWNLOADING
@@ -917,6 +931,9 @@ fun ImageAttachment(
val startTime = System.currentTimeMillis()
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
val downloadTime = System.currentTimeMillis() - startTime
logPhotoDebug(
"CDN download OK: id=$idShort, bytes=${encryptedContent.length}, time=${downloadTime}ms"
)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING
@@ -925,6 +942,9 @@ fun ImageAttachment(
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
logPhotoDebug(
"Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
)
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
// bytes в password
@@ -938,6 +958,7 @@ fun ImageAttachment(
downloadProgress = 0.8f
if (decrypted != null) {
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
withContext(Dispatchers.IO) {
imageBitmap = base64ToBitmap(decrypted)
@@ -950,18 +971,25 @@ fun ImageAttachment(
publicKey = senderPublicKey,
privateKey = privateKey
)
logPhotoDebug("Cache save result: id=$idShort, saved=$saved")
}
downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED
logPhotoDebug("Image ready: id=$idShort")
} else {
downloadStatus = DownloadStatus.ERROR
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
}
} catch (e: Exception) {
e.printStackTrace()
downloadStatus = DownloadStatus.ERROR
logPhotoDebug(
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
}
}
} else {
logPhotoDebug("Skip image download: empty tag for id=${shortDebugId(attachment.id)}")
}
}
@@ -2167,11 +2195,21 @@ internal suspend fun downloadAndDecryptImage(
if (downloadTag.isEmpty() || chachaKey.isEmpty() || privateKey.isEmpty()) return null
return withContext(Dispatchers.IO) {
val idShort = shortDebugId(attachmentId)
val tagShort = shortDebugId(downloadTag)
val server = TransportManager.getTransportServer() ?: "unset"
try {
logPhotoDebug("Start helper image download: id=$idShort, tag=$tagShort, server=$server")
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
if (encryptedContent.isEmpty()) return@withContext null
logPhotoDebug(
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
)
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
logPhotoDebug(
"Helper key decrypt OK: id=$idShort, keySize=${plainKeyAndNonce.size}"
)
// Try decryptReplyBlob first (desktop decodeWithPassword)
var decrypted = try {
@@ -2192,16 +2230,22 @@ internal suspend fun downloadAndDecryptImage(
val bitmap = base64ToBitmap(base64Data) ?: return@withContext null
ImageBitmapCache.put(cacheKey, bitmap)
AttachmentFileManager.saveAttachment(
val saved = AttachmentFileManager.saveAttachment(
context = context,
blob = base64Data,
attachmentId = attachmentId,
publicKey = senderPublicKey,
privateKey = recipientPrivateKey
)
logPhotoDebug("Helper image ready: id=$idShort, saved=$saved")
bitmap
} catch (_: Exception) { null }
} catch (e: Exception) {
logPhotoDebug(
"Helper image ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
null
}
}
}

View File

@@ -0,0 +1,27 @@
package com.rosetta.messenger.ui.chats.components
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object AttachmentDownloadDebugLogger {
private const val MAX_LOGS = 200
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
private val _logs = MutableStateFlow<List<String>>(emptyList())
val logs: StateFlow<List<String>> = _logs.asStateFlow()
fun log(message: String) {
val timestamp = dateFormat.format(Date())
val line = "[$timestamp] 🖼️ $message"
_logs.update { current -> (current + line).takeLast(MAX_LOGS) }
}
fun clear() {
_logs.value = emptyList()
}
}

View File

@@ -1148,19 +1148,48 @@ fun AnimatedMessageStatus(
}
)
} else {
Icon(
painter =
when (currentStatus) {
MessageStatus.SENDING -> TelegramIcons.Clock
MessageStatus.SENT -> TelegramIcons.Done
MessageStatus.DELIVERED -> TelegramIcons.Done
MessageStatus.READ -> TelegramIcons.Done
else -> TelegramIcons.Clock
},
contentDescription = null,
tint = animatedColor,
modifier = Modifier.size(iconSize).scale(scale)
)
if (currentStatus == MessageStatus.READ) {
Box(
modifier =
Modifier.width(iconSize + 6.dp)
.height(iconSize)
.scale(scale)
) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = animatedColor,
modifier =
Modifier.size(iconSize)
.align(Alignment.CenterStart)
)
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = animatedColor,
modifier =
Modifier.size(iconSize)
.align(Alignment.CenterStart)
.offset(x = 4.dp)
)
}
} else {
Icon(
painter =
when (currentStatus) {
MessageStatus.SENDING ->
TelegramIcons.Clock
MessageStatus.SENT ->
TelegramIcons.Done
MessageStatus.DELIVERED ->
TelegramIcons.Done
else -> TelegramIcons.Clock
},
contentDescription = null,
tint = animatedColor,
modifier = Modifier.size(iconSize).scale(scale)
)
}
}
}
@@ -1833,8 +1862,7 @@ fun KebabMenu(
isBlocked: Boolean,
onBlockClick: () -> Unit,
onUnblockClick: () -> Unit,
onDeleteClick: () -> Unit,
onLogsClick: () -> Unit = {}
onDeleteClick: () -> Unit
) {
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
val textColor = if (isDarkTheme) Color.White else Color(0xFF222222)

View File

@@ -1,46 +1,44 @@
package com.rosetta.messenger.ui.chats.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@Composable
fun DeviceVerificationBanner(
device: DeviceEntry,
isDarkTheme: Boolean,
accountPublicKey: String,
avatarRepository: AvatarRepository?,
onAccept: () -> Unit,
onDecline: () -> Unit
) {
val itemBackground = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val titleColor = if (isDarkTheme) Color.White else Color.Black
val subtitleColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val titleColor = if (isDarkTheme) Color(0xFFF2F3F5) else Color(0xFF202124)
val subtitleColor = if (isDarkTheme) Color(0xFFB7BAC1) else Color(0xFF6E7781)
val acceptColor = PrimaryBlue
val declineColor = Color(0xFFFF3B30)
val loginText =
buildString {
append("New login from ")
append("We detected a new login to your account from ")
append(device.deviceName)
if (device.deviceOs.isNotBlank()) {
append(" (")
@@ -56,68 +54,62 @@ fun DeviceVerificationBanner(
.background(itemBackground)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row {
AvatarImage(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
size = 56.dp,
isDarkTheme = isDarkTheme
)
Text(
text = "Someone just got access to your messages!",
color = titleColor,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.height(4.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = loginText,
color = subtitleColor,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
TextButton(
onClick = onAccept,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(30.dp)
) {
Text(
text = "Someone just got access to your messages!",
color = titleColor,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
text = "Yes, it's me",
color = acceptColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(3.dp))
Spacer(modifier = Modifier.width(20.dp))
TextButton(
onClick = onDecline,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(30.dp)
) {
Text(
text = loginText,
color = subtitleColor,
fontSize = 13.sp,
lineHeight = 17.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
text = "No, it's not me!",
color = declineColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(6.dp))
Row {
TextButton(
onClick = onAccept,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text(
text = "Yes, it's me",
color = acceptColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.width(12.dp))
TextButton(
onClick = onDecline,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text(
text = "No, it's not me!",
color = declineColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}

View File

@@ -921,8 +921,22 @@ private suspend fun loadBitmapForViewerImage(
val downloadTag = getDownloadTag(image.preview)
if (downloadTag.isEmpty()) return null
val idShort =
if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..."
val tagShort = if (downloadTag.length <= 8) downloadTag else "${downloadTag.take(8)}..."
val server = TransportManager.getTransportServer() ?: "unset"
AttachmentDownloadDebugLogger.log(
"Viewer download start: id=$idShort, tag=$tagShort, server=$server"
)
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
AttachmentDownloadDebugLogger.log(
"Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
AttachmentDownloadDebugLogger.log(
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
)
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
?: return null
@@ -937,9 +951,15 @@ private suspend fun loadBitmapForViewerImage(
publicKey = image.senderPublicKey,
privateKey = privateKey
)
AttachmentDownloadDebugLogger.log("Viewer image ready: id=$idShort")
decodedBitmap
} catch (_: Exception) {
} catch (e: Exception) {
val idShort =
if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..."
AttachmentDownloadDebugLogger.log(
"Viewer image ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
null
}
}

View File

@@ -89,7 +89,6 @@ import com.rosetta.messenger.ui.chats.components.ViewableImage
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.components.metaball.ProfileMetaballEffect
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
@@ -545,48 +544,7 @@ fun OtherProfileScreen(
) {
item {
// ═══════════════════════════════════════════════════════════
// 📋 INFORMATION SECTION — первый элемент
// ═══════════════════════════════════════════════════════════
TelegramSectionTitle(
title = "Information",
isDarkTheme = isDarkTheme,
color = PrimaryBlue
)
if (user.username.isNotBlank()) {
TelegramCopyField(
value = "@${user.username}",
fullValue = user.username,
label = "Username",
isDarkTheme = isDarkTheme
)
Divider(
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0),
thickness = 0.5.dp,
modifier = Modifier.padding(start = 16.dp)
)
}
TelegramCopyField(
value = user.publicKey.take(16) + "..." + user.publicKey.takeLast(6),
fullValue = user.publicKey,
label = "Public Key",
isDarkTheme = isDarkTheme
)
// ═══════════════════════════════════════════════════════════
// Разделитель секций
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.background(if (isDarkTheme) Color(0xFF0F0F0F) else Color(0xFFF0F0F0))
)
// ═══════════════════════════════════════════════════════════
// ✉️ WRITE MESSAGE + 📞 CALL BUTTONS
// ✉️ MESSAGE + 📞 CALL — первые элементы
// ═══════════════════════════════════════════════════════════
Spacer(modifier = Modifier.height(12.dp))
@@ -661,6 +619,47 @@ fun OtherProfileScreen(
Spacer(modifier = Modifier.height(12.dp))
// ═══════════════════════════════════════════════════════════
// Разделитель секций
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.background(if (isDarkTheme) Color(0xFF0F0F0F) else Color(0xFFF0F0F0))
)
// ═══════════════════════════════════════════════════════════
// 📋 INFORMATION SECTION — после кнопок
// ═══════════════════════════════════════════════════════════
TelegramSectionTitle(
title = "Information",
isDarkTheme = isDarkTheme,
color = PrimaryBlue
)
if (user.username.isNotBlank()) {
TelegramCopyField(
value = "@${user.username}",
fullValue = user.username,
label = "Username",
isDarkTheme = isDarkTheme
)
Divider(
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0),
thickness = 0.5.dp,
modifier = Modifier.padding(start = 16.dp)
)
}
TelegramCopyField(
value = user.publicKey.take(16) + "..." + user.publicKey.takeLast(6),
fullValue = user.publicKey,
label = "Public Key",
isDarkTheme = isDarkTheme
)
// ═══════════════════════════════════════════════════════════
// 🔔 NOTIFICATIONS SECTION
// ═══════════════════════════════════════════════════════════
@@ -1825,7 +1824,6 @@ private fun CollapsingOtherProfileHeader(
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR — Telegram-style expansion on pull-down
// При скролле вверх: metaball merge с Dynamic Island
// При свайпе вниз: аватарка раскрывается на весь блок (circle → rect)
// ═══════════════════════════════════════════════════════════
val avatarSize = androidx.compose.ui.unit.lerp(
@@ -1851,80 +1849,37 @@ private fun CollapsingOtherProfileHeader(
val expandedAvatarXPx = with(density) { expandedAvatarX.toPx() }
val expandedAvatarYPx = with(density) { expandedAvatarY.toPx() }
// Metaball alpha: visible only when NOT expanding (normal collapse animation)
val metaballAlpha = (1f - expandFraction * 10f).coerceIn(0f, 1f)
// Expansion avatar alpha: visible when expanding
val expansionAvatarAlpha = (expandFraction * 10f).coerceIn(0f, 1f)
// Layer 1: Metaball effect for normal collapse (fades out when expanding)
if (metaballAlpha > 0.01f) {
Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = metaballAlpha }) {
ProfileMetaballEffect(
collapseProgress = collapseProgress,
expansionProgress = 0f,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColors.backgroundColor,
modifier = Modifier.fillMaxSize()
) {
if (hasAvatar && avatarRepository != null) {
OtherProfileFullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme
)
} else {
Box(
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = getInitials(name),
fontSize = avatarFontSize,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
Box(
modifier = Modifier
.size(width = expandedAvatarWidth, height = expandedAvatarHeight)
.graphicsLayer {
translationX = expandedAvatarXPx
translationY = expandedAvatarYPx
alpha = avatarAlpha
shape = RoundedCornerShape(cornerRadius)
clip = true
}
}
}
}
}
// Layer 2: Expanding avatar (fades in when pulling down)
if (expansionAvatarAlpha > 0.01f) {
Box(
modifier = Modifier
.size(width = expandedAvatarWidth, height = expandedAvatarHeight)
.graphicsLayer {
translationX = expandedAvatarXPx
translationY = expandedAvatarYPx
alpha = avatarAlpha * expansionAvatarAlpha
shape = RoundedCornerShape(cornerRadius)
clip = true
}
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
if (hasAvatar && avatarRepository != null) {
OtherProfileFullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme
)
} else {
Text(
text = getInitials(name),
fontSize = avatarFontSize,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
if (hasAvatar && avatarRepository != null) {
OtherProfileFullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme
)
} else {
Text(
text = getInitials(name),
fontSize = avatarFontSize,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
}
// Gradient overlays when avatar is expanded
if (expansionAvatarAlpha > 0.01f) {
if (expandFraction > 0.01f) {
// Top gradient
Box(
modifier = Modifier

View File

@@ -1795,7 +1795,7 @@ fun TelegramToggleItem(
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val accentColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
val accentColor = if (isDarkTheme) PrimaryBlueDark else Color(0xFF0D8CF4)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
Column {