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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user