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

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