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

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.0.2" val rosettaVersionName = "1.0.3"
val rosettaVersionCode = 2 // Increment on each release val rosettaVersionCode = 3 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -26,8 +26,10 @@ import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketPushNotification import com.rosetta.messenger.network.PacketPushNotification
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
@@ -158,31 +160,7 @@ class MainActivity : FragmentActivity() {
accountManager.logout() // Always start logged out accountManager.logout() // Always start logged out
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
hasExistingAccount = accounts.isNotEmpty() hasExistingAccount = accounts.isNotEmpty()
accountInfoList = accountInfoList = accounts.map { it.toAccountInfo() }
accounts.map { account ->
val shortKey = account.publicKey.take(7)
val displayName = account.name ?: shortKey
val initials =
displayName
.trim()
.split(Regex("\\s+"))
.filter { it.isNotEmpty() }
.let { words ->
when {
words.isEmpty() -> "??"
words.size == 1 -> words[0].take(2).uppercase()
else ->
"${words[0].first()}${words[1].first()}".uppercase()
}
}
AccountInfo(
id = account.publicKey,
name = displayName,
username = account.username ?: "",
initials = initials,
publicKey = account.publicKey
)
}
} }
// Wait for initial load // Wait for initial load
@@ -197,6 +175,15 @@ class MainActivity : FragmentActivity() {
return@setContent return@setContent
} }
// Ensure push token subscription is sent whenever protocol reaches AUTHENTICATED.
// This recovers token binding after reconnects and delayed handshakes.
LaunchedEffect(protocolState, currentAccount?.publicKey) {
currentAccount ?: return@LaunchedEffect
if (protocolState == ProtocolState.AUTHENTICATED) {
sendFcmTokenToServer()
}
}
RosettaAndroidTheme(darkTheme = isDarkTheme, animated = true) { RosettaAndroidTheme(darkTheme = isDarkTheme, animated = true) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -212,6 +199,10 @@ class MainActivity : FragmentActivity() {
"auth_new" "auth_new"
isLoggedIn != true && hasExistingAccount == true -> isLoggedIn != true && hasExistingAccount == true ->
"auth_unlock" "auth_unlock"
isLoggedIn == true &&
currentAccount == null &&
hasExistingAccount == true ->
"auth_unlock"
protocolState == protocolState ==
ProtocolState.DEVICE_VERIFICATION_REQUIRED -> ProtocolState.DEVICE_VERIFICATION_REQUIRED ->
"device_confirm" "device_confirm"
@@ -261,45 +252,12 @@ class MainActivity : FragmentActivity() {
// 📤 Отправляем FCM токен на сервер после успешной // 📤 Отправляем FCM токен на сервер после успешной
// аутентификации // аутентификации
account?.let { sendFcmTokenToServer(it) } account?.let { sendFcmTokenToServer() }
// Reload accounts list // Reload accounts list
scope.launch { scope.launch {
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
accountInfoList = accountInfoList = accounts.map { it.toAccountInfo() }
accounts.map { acc ->
val shortKey = acc.publicKey.take(7)
val displayName = acc.name ?: shortKey
val initials =
displayName
.trim()
.split(Regex("\\s+"))
.filter {
it.isNotEmpty()
}
.let { words ->
when {
words.isEmpty() ->
"??"
words.size ==
1 ->
words[0]
.take(
2
)
.uppercase()
else ->
"${words[0].first()}${words[1].first()}".uppercase()
}
}
AccountInfo(
id = acc.publicKey,
name = displayName,
username = acc.username ?: "",
initials = initials,
publicKey = acc.publicKey
)
}
} }
}, },
onLogout = { onLogout = {
@@ -359,27 +317,7 @@ class MainActivity : FragmentActivity() {
accountManager.deleteAccount(publicKey) accountManager.deleteAccount(publicKey)
// 8. Refresh accounts list // 8. Refresh accounts list
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { acc -> accountInfoList = accounts.map { it.toAccountInfo() }
val shortKey = acc.publicKey.take(7)
val displayName = acc.name ?: shortKey
val initials = displayName.trim()
.split(Regex("\\s+"))
.filter { it.isNotEmpty() }
.let { words ->
when {
words.isEmpty() -> "??"
words.size == 1 -> words[0].take(2).uppercase()
else -> "${words[0].first()}${words[1].first()}".uppercase()
}
}
AccountInfo(
id = acc.publicKey,
name = displayName,
username = acc.username ?: "",
initials = initials,
publicKey = acc.publicKey
)
}
hasExistingAccount = accounts.isNotEmpty() hasExistingAccount = accounts.isNotEmpty()
// 8. Navigate away last // 8. Navigate away last
currentAccount = null currentAccount = null
@@ -391,37 +329,7 @@ class MainActivity : FragmentActivity() {
onAccountInfoUpdated = { onAccountInfoUpdated = {
// Reload account list when profile is updated // Reload account list when profile is updated
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
accountInfoList = accountInfoList = accounts.map { it.toAccountInfo() }
accounts.map { acc ->
val shortKey = acc.publicKey.take(7)
val displayName = acc.name ?: shortKey
val initials =
displayName
.trim()
.split(Regex("\\s+"))
.filter { it.isNotEmpty() }
.let { words ->
when {
words.isEmpty() ->
"??"
words.size == 1 ->
words[0]
.take(
2
)
.uppercase()
else ->
"${words[0].first()}${words[1].first()}".uppercase()
}
}
AccountInfo(
id = acc.publicKey,
name = displayName,
username = acc.username ?: "",
initials = initials,
publicKey = acc.publicKey
)
}
}, },
onSwitchAccount = { targetPublicKey -> onSwitchAccount = { targetPublicKey ->
// Switch to another account: logout current, then auto-login target // Switch to another account: logout current, then auto-login target
@@ -491,6 +399,11 @@ class MainActivity : FragmentActivity() {
// Сохраняем токен локально // Сохраняем токен локально
saveFcmToken(token) saveFcmToken(token)
addFcmLog("💾 Токен сохранен локально") addFcmLog("💾 Токен сохранен локально")
if (ProtocolManager.state.value == ProtocolState.AUTHENTICATED) {
addFcmLog("🔁 Протокол уже AUTHENTICATED, отправляем токен сразу")
sendFcmTokenToServer()
}
} else { } else {
addFcmLog("⚠️ Токен пустой") addFcmLog("⚠️ Токен пустой")
} }
@@ -514,7 +427,7 @@ class MainActivity : FragmentActivity() {
* Отправить FCM токен на сервер Вызывается после успешной аутентификации, когда аккаунт уже * Отправить FCM токен на сервер Вызывается после успешной аутентификации, когда аккаунт уже
* расшифрован * расшифрован
*/ */
private fun sendFcmTokenToServer(account: DecryptedAccount) { private fun sendFcmTokenToServer() {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE) val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE)
@@ -558,6 +471,30 @@ class MainActivity : FragmentActivity() {
} }
} }
private fun buildInitials(displayName: String): String =
displayName
.trim()
.split(Regex("\\s+"))
.filter { it.isNotEmpty() }
.let { words ->
when {
words.isEmpty() -> "??"
words.size == 1 -> words[0].take(2).uppercase()
else -> "${words[0].first()}${words[1].first()}".uppercase()
}
}
private fun EncryptedAccount.toAccountInfo(): AccountInfo {
val displayName = resolveAccountDisplayName(publicKey, name, username)
return AccountInfo(
id = publicKey,
name = displayName,
username = username ?: "",
initials = buildInitials(displayName),
publicKey = publicKey
)
}
/** /**
* Navigation sealed class — replaces ~15 boolean flags with a type-safe navigation stack. ChatsList * Navigation sealed class — replaces ~15 boolean flags with a type-safe navigation stack. ChatsList
* is always the base layer and is not part of the stack. Each SwipeBackContainer reads a * is always the base layer and is not part of the stack. Each SwipeBackContainer reads a
@@ -592,14 +529,17 @@ fun MainScreen(
onAccountInfoUpdated: suspend () -> Unit = {}, onAccountInfoUpdated: suspend () -> Unit = {},
onSwitchAccount: (String) -> Unit = {} onSwitchAccount: (String) -> Unit = {}
) { ) {
val accountPublicKey = account?.publicKey.orEmpty()
// Reactive state for account name and username // Reactive state for account name and username
var accountName by remember { mutableStateOf(account?.name ?: "Account") } var accountName by remember(accountPublicKey) {
mutableStateOf(resolveAccountDisplayName(accountPublicKey, account?.name, null))
}
val accountPhone = val accountPhone =
account?.publicKey?.take(16)?.let { account?.publicKey?.take(16)?.let {
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}" "+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
} }
?: "+7 775 9932587" .orEmpty()
val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
val accountPrivateKey = account?.privateKey ?: "" val accountPrivateKey = account?.privateKey ?: ""
val privateKeyHash = account?.privateKeyHash ?: "" val privateKeyHash = account?.privateKeyHash ?: ""
@@ -611,11 +551,17 @@ fun MainScreen(
// Load username AND name from AccountManager (persisted in DataStore) // Load username AND name from AccountManager (persisted in DataStore)
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(accountPublicKey, reloadTrigger) { LaunchedEffect(accountPublicKey, reloadTrigger) {
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") { if (accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context) val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountPublicKey) val encryptedAccount = accountManager.getAccount(accountPublicKey)
accountUsername = encryptedAccount?.username ?: "" val username = encryptedAccount?.username
accountName = encryptedAccount?.name ?: accountName accountUsername = username.orEmpty()
accountName =
resolveAccountDisplayName(
accountPublicKey,
encryptedAccount?.name ?: accountName,
username
)
} }
} }
@@ -625,14 +571,17 @@ fun MainScreen(
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile() // Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
LaunchedEffect(ownProfileUpdated) { LaunchedEffect(ownProfileUpdated) {
if (ownProfileUpdated > 0L && if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) {
accountPublicKey.isNotBlank() &&
accountPublicKey != "04c266b98ae5"
) {
val accountManager = AccountManager(context) val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountPublicKey) val encryptedAccount = accountManager.getAccount(accountPublicKey)
accountUsername = encryptedAccount?.username ?: "" val username = encryptedAccount?.username
accountName = encryptedAccount?.name ?: accountName accountUsername = username.orEmpty()
accountName =
resolveAccountDisplayName(
accountPublicKey,
encryptedAccount?.name ?: accountName,
username
)
} }
} }
@@ -715,7 +664,7 @@ fun MainScreen(
// AvatarRepository для работы с аватарами // AvatarRepository для работы с аватарами
val avatarRepository = val avatarRepository =
remember(accountPublicKey) { remember(accountPublicKey) {
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") { if (accountPublicKey.isNotBlank()) {
val database = RosettaDatabase.getDatabase(context) val database = RosettaDatabase.getDatabase(context)
AvatarRepository( AvatarRepository(
context = context, context = context,

View File

@@ -24,6 +24,7 @@ object MessageCrypto {
private const val CHACHA_KEY_SIZE = 32 private const val CHACHA_KEY_SIZE = 32
private const val XCHACHA_NONCE_SIZE = 24 private const val XCHACHA_NONCE_SIZE = 24
private const val POLY1305_TAG_SIZE = 16 private const val POLY1305_TAG_SIZE = 16
private const val SYNC_KEY_PREFIX = "sync:"
// Кэш PBKDF2-SHA256 ключей: password → derived key bytes // Кэш PBKDF2-SHA256 ключей: password → derived key bytes
// PBKDF2 с 1000 итерациями ~50-100ms, кэш убирает повторные вычисления // PBKDF2 с 1000 итерациями ~50-100ms, кэш убирает повторные вычисления
@@ -387,6 +388,16 @@ object MessageCrypto {
* КРИТИЧНО: ephemeralPrivateKeyHex может иметь нечётную длину! * КРИТИЧНО: ephemeralPrivateKeyHex может иметь нечётную длину!
*/ */
fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray { fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray {
if (encryptedKeyBase64.startsWith(SYNC_KEY_PREFIX)) {
val aesChachaKey = encryptedKeyBase64.removePrefix(SYNC_KEY_PREFIX)
if (aesChachaKey.isBlank()) {
throw IllegalArgumentException("Invalid sync key format: empty aesChachaKey")
}
val decoded =
CryptoManager.decryptWithPassword(aesChachaKey, myPrivateKeyHex)
?: throw IllegalArgumentException("Failed to decrypt sync chacha key")
return decoded.toByteArray(Charsets.ISO_8859_1)
}
val combined = String(Base64.decode(encryptedKeyBase64, Base64.NO_WRAP)) val combined = String(Base64.decode(encryptedKeyBase64, Base64.NO_WRAP))

View File

@@ -0,0 +1,28 @@
package com.rosetta.messenger.data
private const val PLACEHOLDER_ACCOUNT_NAME = "Account"
fun resolveAccountDisplayName(publicKey: String, name: String?, username: String?): String {
val normalizedName = name?.trim().orEmpty()
if (normalizedName.isNotEmpty() && !normalizedName.equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true)) {
return normalizedName
}
val normalizedUsername = username?.trim().orEmpty()
if (normalizedUsername.isNotEmpty()) {
return normalizedUsername
}
if (publicKey.isBlank()) {
return PLACEHOLDER_ACCOUNT_NAME
}
return if (publicKey.length > 12) {
"${publicKey.take(6)}...${publicKey.takeLast(4)}"
} else {
publicKey
}
}
fun isPlaceholderAccountName(name: String?): Boolean =
name?.trim().orEmpty().equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true)

View File

@@ -612,6 +612,13 @@ class MessageRepository private constructor(private val context: Context) {
// 🔒 Шифруем plainMessage с использованием приватного ключа // 🔒 Шифруем plainMessage с использованием приватного ключа
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey) val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
val storedChachaKey =
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
"sync:${packet.aesChachaKey}"
} else {
packet.chachaKey
}
// Создаем entity для кэша и возможной вставки // Создаем entity для кэша и возможной вставки
val entity = val entity =
MessageEntity( MessageEntity(
@@ -620,7 +627,7 @@ class MessageRepository private constructor(private val context: Context) {
toPublicKey = packet.toPublicKey, toPublicKey = packet.toPublicKey,
content = packet.content, content = packet.content,
timestamp = packet.timestamp, timestamp = packet.timestamp,
chachaKey = packet.chachaKey, chachaKey = storedChachaKey,
read = 0, read = 0,
fromMe = if (isOwnMessage) 1 else 0, fromMe = if (isOwnMessage) 1 else 0,
delivered = DeliveryStatus.DELIVERED.value, delivered = DeliveryStatus.DELIVERED.value,
@@ -711,27 +718,37 @@ class MessageRepository private constructor(private val context: Context) {
*/ */
suspend fun handleRead(packet: PacketRead) { suspend fun handleRead(packet: PacketRead) {
val account = currentAccount ?: return val account = currentAccount ?: return
val fromPublicKey = packet.fromPublicKey.trim()
val toPublicKey = packet.toPublicKey.trim()
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return
MessageLogger.debug("👁 READ PACKET from: ${packet.fromPublicKey.take(16)}...") MessageLogger.debug("👁 READ PACKET from=${fromPublicKey.take(16)}..., to=${toPublicKey.take(16)}...")
// Синхронизация read может прийти как: // Desktop parity:
// 1) from=opponent, to=account (обычный read от собеседника) // 1) from=opponent, to=account -> собеседник прочитал НАШИ сообщения (double check)
// 2) from=account, to=opponent (read c другого устройства этого же аккаунта) // 2) from=account, to=opponent -> sync с другого нашего устройства (мы прочитали входящие)
val opponentKey = val isOwnReadSync = fromPublicKey == account
if (packet.fromPublicKey == account) packet.toPublicKey else packet.fromPublicKey val opponentKey = if (isOwnReadSync) toPublicKey else fromPublicKey
if (opponentKey.isBlank()) return if (opponentKey.isBlank()) return
// Проверяем последнее сообщение ДО обновления val dialogKey = getDialogKey(opponentKey)
val lastMsgBefore = messageDao.getLastMessageDebug(account, opponentKey)
// Отмечаем все наши исходящие сообщения к этому собеседнику как прочитанные if (isOwnReadSync) {
// Sync read from another own device: mark incoming messages as read.
messageDao.markDialogAsRead(account, dialogKey)
messageCache[dialogKey]?.let { flow ->
flow.value =
flow.value.map { msg ->
if (!msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
}
}
dialogDao.updateDialogFromMessages(account, opponentKey)
return
}
// Opponent read our outgoing messages.
messageDao.markAllAsRead(account, opponentKey) messageDao.markAllAsRead(account, opponentKey)
// 🔥 DEBUG: Проверяем последнее сообщение ПОСЛЕ обновления
val lastMsgAfter = messageDao.getLastMessageDebug(account, opponentKey)
// Обновляем кэш - все исходящие сообщения помечаем как прочитанные
val dialogKey = getDialogKey(opponentKey)
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0 val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
messageCache[dialogKey]?.let { flow -> messageCache[dialogKey]?.let { flow ->
flow.value = flow.value =
@@ -740,17 +757,11 @@ class MessageRepository private constructor(private val context: Context) {
} }
} }
// 🔔 Уведомляем UI о прочтении (пустой messageId = все исходящие сообщения) // Notify current dialog UI: all outgoing messages are now read.
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ)) _deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
// 📝 LOG: Статус прочтения
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount) MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
dialogDao.updateDialogFromMessages(account, opponentKey) dialogDao.updateDialogFromMessages(account, opponentKey)
// Логируем что записалось в диалог
val dialog = dialogDao.getDialog(account, opponentKey)
} }
/** /**

View File

@@ -35,6 +35,16 @@ class PacketSearch : Packet() {
stream.writeInt16(getPacketId()) stream.writeInt16(getPacketId())
stream.writeString(privateKey) stream.writeString(privateKey)
stream.writeString(search) stream.writeString(search)
// Desktop parity: packet always includes users count block.
// For client requests this is 0, but field must be present for server parser.
stream.writeInt16(users.size)
users.forEach { user ->
stream.writeString(user.username)
stream.writeString(user.title)
stream.writeString(user.publicKey)
stream.writeInt8(user.verified)
stream.writeInt8(user.online)
}
return stream return stream
} }
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Build import android.os.Build
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.isPlaceholderAccountName
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -277,7 +278,7 @@ object ProtocolManager {
// Если это наш own profile — сохраняем username/name в AccountManager // Если это наш own profile — сохраняем username/name в AccountManager
if (user.publicKey == ownPublicKey && appContext != null) { if (user.publicKey == ownPublicKey && appContext != null) {
val accountManager = AccountManager(appContext!!) val accountManager = AccountManager(appContext!!)
if (user.title.isNotBlank()) { if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) {
accountManager.updateAccountName(user.publicKey, user.title) accountManager.updateAccountName(user.publicKey, user.title)
} }
if (user.username.isNotBlank()) { if (user.username.isNotBlank()) {
@@ -333,6 +334,24 @@ object ProtocolManager {
TransportManager.requestTransportServer() TransportManager.requestTransportServer()
fetchOwnProfile() fetchOwnProfile()
requestSynchronize() requestSynchronize()
subscribePushTokenIfAvailable()
}
private fun subscribePushTokenIfAvailable() {
val context = appContext ?: return
val token =
context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
.getString("fcm_token", null)
?.trim()
.orEmpty()
if (token.isEmpty()) return
val packet = PacketPushNotification().apply {
notificationsToken = token
action = PushNotificationAction.SUBSCRIBE
}
send(packet)
addLog("🔔 Push token subscribe requested on AUTHENTICATED")
} }
private fun requestSynchronize() { private fun requestSynchronize() {

View File

@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -29,13 +28,9 @@ data class TransportState(
* Совместимо с desktop версией (TransportProvider) * Совместимо с desktop версией (TransportProvider)
*/ */
object TransportManager { object TransportManager {
private const val TAG = "TransportManager"
private const val MAX_RETRIES = 3 private const val MAX_RETRIES = 3
private const val INITIAL_BACKOFF_MS = 1000L private const val INITIAL_BACKOFF_MS = 1000L
// Fallback transport server (CDN)
private const val FALLBACK_TRANSPORT_SERVER = "https://cdn.rosetta-im.com"
private var transportServer: String? = null private var transportServer: String? = null
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList()) private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
@@ -54,20 +49,32 @@ object TransportManager {
* Установить адрес транспортного сервера * Установить адрес транспортного сервера
*/ */
fun setTransportServer(server: String) { fun setTransportServer(server: String) {
transportServer = server val normalized = server.trim().trimEnd('/')
transportServer = normalized.ifBlank { null }
} }
/** /**
* Получить адрес транспортного сервера (с fallback) * Получить текущий адрес транспортного сервера
*/ */
fun getTransportServer(): String = transportServer ?: FALLBACK_TRANSPORT_SERVER fun getTransportServer(): String? = transportServer
/** /**
* Получить активный сервер для скачивания/загрузки * Получить активный сервер для скачивания/загрузки.
* Desktop parity: ждём сервер из PacketRequestTransport (0x0F), а не используем hardcoded CDN.
*/ */
private fun getActiveServer(): String { private suspend fun getActiveServer(): String {
val server = transportServer ?: FALLBACK_TRANSPORT_SERVER transportServer?.let { return it }
return server
requestTransportServer()
repeat(40) { // 10s total
val server = transportServer
if (!server.isNullOrBlank()) {
return server
}
delay(250)
}
throw IOException("Transport server is not set")
} }
/** /**

View File

@@ -13,10 +13,15 @@ import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.network.PacketPushNotification
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.PushNotificationAction
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.util.Locale
/** /**
* Firebase Cloud Messaging Service для обработки push-уведомлений * Firebase Cloud Messaging Service для обработки push-уведомлений
@@ -47,7 +52,13 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
fun cancelNotificationForChat(context: Context, senderPublicKey: String) { fun cancelNotificationForChat(context: Context, senderPublicKey: String) {
val notificationManager = val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(getNotificationIdForChat(senderPublicKey)) val normalizedKey = senderPublicKey.trim()
if (normalizedKey.isNotEmpty()) {
notificationManager.cancel(getNotificationIdForChat(normalizedKey))
}
// Fallback: некоторые серверные payload могут прийти без sender key.
// Для них используется ID от пустой строки — тоже очищаем при входе в диалог.
notificationManager.cancel(getNotificationIdForChat(""))
} }
} }
@@ -58,36 +69,79 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
// Сохраняем токен локально // Сохраняем токен локально
saveFcmToken(token) saveFcmToken(token)
// 📤 Токен будет отправлен на сервер после успешного логина в MainActivity // Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
if (ProtocolManager.isAuthenticated()) {
runCatching {
ProtocolManager.send(
PacketPushNotification().apply {
notificationsToken = token
action = PushNotificationAction.SUBSCRIBE
}
)
}
}
} }
/** Вызывается когда получено push-уведомление */ /** Вызывается когда получено push-уведомление */
override fun onMessageReceived(remoteMessage: RemoteMessage) { override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage) super.onMessageReceived(remoteMessage)
// Обрабатываем data payload var handledMessageData = false
remoteMessage.data.isNotEmpty().let {
val type = remoteMessage.data["type"]
val senderPublicKey = remoteMessage.data["sender_public_key"]
val senderName =
remoteMessage.data["sender_name"] ?: senderPublicKey?.take(10) ?: "Unknown"
val messagePreview = remoteMessage.data["message_preview"] ?: "New message"
when (type) { // Обрабатываем data payload
"new_message" -> { if (remoteMessage.data.isNotEmpty()) {
// Показываем уведомление о новом сообщении val data = remoteMessage.data
val type =
firstNonBlank(data, "type", "event", "action")
?.lowercase(Locale.ROOT)
.orEmpty()
val senderPublicKey =
firstNonBlank(
data,
"sender_public_key",
"from_public_key",
"fromPublicKey",
"public_key",
"publicKey"
)
val senderName =
firstNonBlank(data, "sender_name", "from_title", "sender", "title", "name")
?: senderPublicKey?.take(10)
?: "Rosetta"
val messagePreview =
firstNonBlank(data, "message_preview", "message", "text", "body")
?: "New message"
val isReadEvent = type == "message_read" || type == "read"
val isMessageEvent =
type == "new_message" ||
type == "message" ||
type == "newmessage" ||
type == "msg_new"
when {
isMessageEvent -> {
showMessageNotification(senderPublicKey, senderName, messagePreview) showMessageNotification(senderPublicKey, senderName, messagePreview)
handledMessageData = true
} }
"message_read" -> { isReadEvent -> {
// Сообщение прочитано - можно обновить UI если приложение открыто handledMessageData = true
}
// Fallback for servers sending data-only payload without explicit "type".
senderPublicKey != null || data.containsKey("message_preview") || data.containsKey("message") || data.containsKey("text") -> {
showMessageNotification(senderPublicKey, senderName, messagePreview)
handledMessageData = true
} }
else -> {}
} }
} }
// Обрабатываем notification payload (если есть) // Обрабатываем notification payload (если есть).
// Для new_message используем data-ветку выше, чтобы не показывать дубликаты
// с неуправляемым notification id.
remoteMessage.notification?.let { remoteMessage.notification?.let {
showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message") if (!handledMessageData) {
showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message")
}
} }
} }
@@ -98,7 +152,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
messagePreview: String messagePreview: String
) { ) {
// 🔥 Не показываем уведомление если приложение открыто // 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground) { if (isAppInForeground || !areNotificationsEnabled()) {
return return
} }
val senderKey = senderPublicKey?.trim().orEmpty() val senderKey = senderPublicKey?.trim().orEmpty()
@@ -144,7 +198,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
/** Показать простое уведомление */ /** Показать простое уведомление */
private fun showSimpleNotification(title: String, body: String) { private fun showSimpleNotification(title: String, body: String) {
// 🔥 Не показываем уведомление если приложение открыто // 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground) { if (isAppInForeground || !areNotificationsEnabled()) {
return return
} }
@@ -204,6 +258,22 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
prefs.edit().putString("fcm_token", token).apply() prefs.edit().putString("fcm_token", token).apply()
} }
private fun areNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationsEnabled.first()
}
}.getOrDefault(true)
}
private fun firstNonBlank(data: Map<String, String>, vararg keys: String): String? {
for (key in keys) {
val value = data[key]?.trim()
if (!value.isNullOrEmpty()) return value
}
return null
}
/** Проверка: замьючен ли диалог для текущего аккаунта */ /** Проверка: замьючен ли диалог для текущего аккаунта */
private fun isDialogMuted(senderPublicKey: String): Boolean { private fun isDialogMuted(senderPublicKey: String): Boolean {
if (senderPublicKey.isBlank()) return false if (senderPublicKey.isBlank()) return false

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 package com.rosetta.messenger.ui.auth
import android.os.Build 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.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -29,6 +35,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -54,11 +62,16 @@ fun DeviceConfirmScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onExit: () -> Unit onExit: () -> Unit
) { ) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val backgroundTop = if (isDarkTheme) Color(0xFF17181D) else Color(0xFFF4F7FC)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White val backgroundBottom = if (isDarkTheme) Color(0xFF121316) else Color(0xFFE9EEF7)
val cardBorderColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE8E8ED) val cardColor = if (isDarkTheme) Color(0xFF23252B) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black val cardBorderColor = if (isDarkTheme) Color(0xFF343844) else Color(0xFFDCE4F0)
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) 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 onExitState by rememberUpdatedState(onExit)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -92,7 +105,12 @@ fun DeviceConfirmScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(backgroundColor) .background(
brush =
Brush.verticalGradient(
colors = listOf(backgroundTop, backgroundBottom)
)
)
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 22.dp), .padding(horizontal = 22.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -102,98 +120,160 @@ fun DeviceConfirmScreen(
.fillMaxWidth() .fillMaxWidth()
.widthIn(max = 400.dp), .widthIn(max = 400.dp),
color = cardColor, color = cardColor,
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(28.dp),
border = BorderStroke(1.dp, cardBorderColor) border = BorderStroke(1.dp, cardBorderColor)
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp), modifier = Modifier.padding(horizontal = 22.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
LottieAnimation( Box(
composition = composition, modifier =
progress = { progress }, Modifier
modifier = Modifier.size(128.dp) .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) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = TablerIcons.DeviceMobile, imageVector = TablerIcons.DeviceMobile,
contentDescription = null, contentDescription = null,
tint = PrimaryBlue tint = accentColor
) )
Spacer(modifier = Modifier.size(6.dp)) Spacer(modifier = Modifier.size(6.dp))
Text( Text(
text = "NEW DEVICE REQUEST", text = "NEW DEVICE REQUEST",
color = PrimaryBlue, color = accentColor,
fontSize = 12.sp, 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)) Spacer(modifier = Modifier.height(14.dp))
Text( Text(
text = "\"$localDeviceName\" is waiting for approval", text = "Waiting for approval",
color = textColor.copy(alpha = 0.9f), color = textColor,
fontSize = 13.sp, fontSize = 34.sp,
textAlign = TextAlign.Center, lineHeight = 38.sp,
lineHeight = 20.sp fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "If you didn't request this login, tap Exit.",
color = secondaryTextColor,
fontSize = 12.sp,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Button( Text(
onClick = onExitState, text = "Open Rosetta on your first device and approve this login request.",
modifier = Modifier.height(42.dp), color = secondaryTextColor,
colors = ButtonDefaults.buttonColors( fontSize = 15.sp,
containerColor = Color(0xFFFF3B30), textAlign = TextAlign.Center,
contentColor = Color.White lineHeight = 22.sp
), )
shape = RoundedCornerShape(12.dp)
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( Button(
text = "Waiting for confirmation...", onClick = onExitState,
color = secondaryTextColor, modifier =
fontSize = 11.sp 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.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -520,20 +519,26 @@ fun SetPasswordScreen(
) )
accountManager.saveAccount(account) accountManager.saveAccount(account)
accountManager.setCurrentAccount(keyPair.publicKey)
// 🔌 Connect to server and authenticate // 🔌 Connect to server and authenticate
val privateKeyHash = val privateKeyHash =
CryptoManager.generatePrivateKeyHash( CryptoManager.generatePrivateKeyHash(
keyPair.privateKey keyPair.privateKey
) )
ProtocolManager.connect()
// Give WebSocket time to connect before authenticating val handshakeState =
kotlinx.coroutines.delay(500) awaitAuthHandshakeState(
ProtocolManager.authenticate( keyPair.publicKey,
keyPair.publicKey, privateKeyHash
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 // Create DecryptedAccount to pass to callback
val decryptedAccount = 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.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase 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.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarColor
import com.rosetta.messenger.ui.chats.getAvatarText import com.rosetta.messenger.ui.chats.getAvatarText
import com.rosetta.messenger.ui.chats.utils.getInitials import com.rosetta.messenger.ui.chats.utils.getInitials
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.launch
// Account model for dropdown // Account model for dropdown
data class AccountItem( data class AccountItem(
@@ -116,33 +113,17 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
privateKey = decryptedPrivateKey, privateKey = decryptedPrivateKey,
seedPhrase = decryptedSeedPhrase, seedPhrase = decryptedSeedPhrase,
privateKeyHash = privateKeyHash, privateKeyHash = privateKeyHash,
name = account.name name = selectedAccount.name
) )
// Connect to server val handshakeState = awaitAuthHandshakeState(account.publicKey, privateKeyHash)
val connectStart = System.currentTimeMillis() if (handshakeState == null) {
ProtocolManager.connect()
// Wait for websocket connection
val connected = withTimeoutOrNull(5000) {
ProtocolManager.state.first { it != ProtocolState.DISCONNECTED }
}
val connectTime = System.currentTimeMillis() - connectStart
if (connected == null) {
onError("Failed to connect to server") onError("Failed to connect to server")
onUnlocking(false) onUnlocking(false)
return return
} }
kotlinx.coroutines.delay(300)
// Authenticate
val authStart = System.currentTimeMillis()
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
val authTime = System.currentTimeMillis() - authStart
accountManager.setCurrentAccount(account.publicKey) accountManager.setCurrentAccount(account.publicKey)
val totalTime = System.currentTimeMillis() - totalStart
onSuccess(decryptedAccount) onSuccess(decryptedAccount)
} catch (e: Exception) { } catch (e: Exception) {
onError("Failed to unlock: ${e.message}") onError("Failed to unlock: ${e.message}")
@@ -216,7 +197,11 @@ fun UnlockScreen(
val allAccounts = accountManager.getAllAccounts() val allAccounts = accountManager.getAllAccounts()
accounts = accounts =
allAccounts.map { acc -> 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 > первый // Find the target account - приоритет: selectedAccountId > lastLoggedKey > первый
@@ -359,6 +344,7 @@ fun UnlockScreen(
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = 120.dp, size = 120.dp,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
displayName = selectedAccount!!.name,
shape = RoundedCornerShape(28.dp) shape = RoundedCornerShape(28.dp)
) )
} else { } else {
@@ -479,7 +465,8 @@ fun UnlockScreen(
publicKey = account.publicKey, publicKey = account.publicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = 40.dp, size = 40.dp,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
displayName = account.name
) )
Spacer(modifier = Modifier.width(12.dp)) 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.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.* import com.rosetta.messenger.ui.chats.components.*
@@ -339,14 +338,6 @@ fun ChatDetailScreen(
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showBlockConfirm by remember { mutableStateOf(false) } var showBlockConfirm by remember { mutableStateOf(false) }
var showUnblockConfirm 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 // Наблюдаем за статусом блокировки в реальном времени через Flow
val isBlocked by val isBlocked by
database.blacklistDao() database.blacklistDao()
@@ -462,6 +453,7 @@ fun ChatDetailScreen(
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
isScreenActive = true isScreenActive = true
viewModel.setDialogActive(true) viewModel.setDialogActive(true)
viewModel.markVisibleMessagesAsRead()
// 🔥 Убираем уведомление этого чата из шторки // 🔥 Убираем уведомление этого чата из шторки
com.rosetta.messenger.push.RosettaFirebaseMessagingService com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey) .cancelNotificationForChat(context, user.publicKey)
@@ -488,6 +480,7 @@ fun ChatDetailScreen(
LaunchedEffect(user.publicKey, forwardTrigger) { LaunchedEffect(user.publicKey, forwardTrigger) {
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
viewModel.openDialog(user.publicKey, user.title, user.username) viewModel.openDialog(user.publicKey, user.title, user.username)
viewModel.markVisibleMessagesAsRead()
// 🔥 Убираем уведомление этого чата из шторки при заходе // 🔥 Убираем уведомление этого чата из шторки при заходе
com.rosetta.messenger.push.RosettaFirebaseMessagingService com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey) .cancelNotificationForChat(context, user.publicKey)
@@ -1143,12 +1136,6 @@ fun ChatDetailScreen(
false false
showDeleteConfirm = showDeleteConfirm =
true true
},
onLogsClick = {
showMenu =
false
showDebugLogs =
true
} }
) )
} }
@@ -1535,7 +1522,7 @@ fun ChatDetailScreen(
} }
} }
} }
} else { } else if (!isSystemAccount) {
// INPUT BAR // INPUT BAR
Column { Column {
MessageInputBar( MessageInputBar(

View File

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

View File

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

View File

@@ -4,8 +4,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser 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.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -13,8 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val TAG = "SearchUsersVM"
/** /**
* ViewModel для поиска пользователей через протокол * ViewModel для поиска пользователей через протокол
* Работает аналогично SearchBar в React Native приложении * Работает аналогично SearchBar в React Native приложении
@@ -34,37 +34,29 @@ class SearchUsersViewModel : ViewModel() {
private val _isSearchExpanded = MutableStateFlow(false) private val _isSearchExpanded = MutableStateFlow(false)
val isSearchExpanded: StateFlow<Boolean> = _isSearchExpanded.asStateFlow() 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 searchJob: Job? = null
private var lastSearchedText: String = ""
private var privateKeyHash: String = "" private var privateKeyHash: String = ""
private val timeFormatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
// Callback для обработки ответа поиска // Callback для обработки ответа поиска
private val searchPacketHandler: (com.rosetta.messenger.network.Packet) -> Unit = handler@{ packet -> private val searchPacketHandler: (com.rosetta.messenger.network.Packet) -> Unit = handler@{ packet ->
if (packet is PacketSearch) { if (packet is PacketSearch) {
// 🔥 ВАЖНО: Игнорируем ответы с пустым search или не соответствующие нашему запросу logSearch(
// Сервер может слать много пакетов 0x03 по разным причинам "📥 PacketSearch response: search='${packet.search}', users=${packet.users.size}"
val currentQuery = lastSearchedText )
val responseSearch = packet.search // Desktop parity: любой ответ PacketSearch обновляет результаты
// пока в поле есть активный поисковый запрос.
if (_searchQuery.value.trim().isEmpty()) {
// Принимаем ответ только если: logSearch("⏭ Ignored response: query is empty")
// 1. search в ответе совпадает с нашим запросом, ИЛИ
// 2. search пустой но мы ждём ответ (lastSearchedText не пустой)
// НО: если search пустой и мы НЕ ждём ответ - игнорируем
if (responseSearch.isEmpty() && currentQuery.isEmpty()) {
return@handler return@handler
} }
// Если search не пустой и не совпадает с нашим запросом - игнорируем
if (responseSearch.isNotEmpty() && responseSearch != currentQuery) {
return@handler
}
packet.users.forEachIndexed { index, user ->
}
_searchResults.value = packet.users _searchResults.value = packet.users
_isSearching.value = false _isSearching.value = false
logSearch("✅ Results updated")
} }
} }
@@ -84,7 +76,14 @@ class SearchUsersViewModel : ViewModel() {
* Установить приватный ключ для поиска * Установить приватный ключ для поиска
*/ */
fun setPrivateKeyHash(hash: String) { 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 * Аналогично handleSearch в React Native
*/ */
fun onSearchQueryChange(query: String) { fun onSearchQueryChange(query: String) {
_searchQuery.value = query val normalizedQuery = sanitizeSearchInput(query)
_searchQuery.value = normalizedQuery
logSearch("⌨️ Query changed: '$query' -> '$normalizedQuery'")
// Отменяем предыдущий поиск // Отменяем предыдущий поиск
searchJob?.cancel() searchJob?.cancel()
// Если пустой запрос - очищаем результаты // Если пустой запрос - очищаем результаты
if (query.trim().isEmpty()) { if (normalizedQuery.trim().isEmpty()) {
_searchResults.value = emptyList() _searchResults.value = emptyList()
_isSearching.value = false _isSearching.value = false
lastSearchedText = "" logSearch("🧹 Cleared results: empty query")
return
}
// Если текст уже был найден - не повторяем поиск
if (query == lastSearchedText) {
return return
} }
// Показываем индикатор загрузки // Показываем индикатор загрузки
_isSearching.value = true _isSearching.value = true
logSearch("⏳ Debounce started (1000ms)")
// Запускаем поиск с задержкой 1 секунда (как в React Native) // Запускаем поиск с задержкой 1 секунда (как в React Native)
searchJob = viewModelScope.launch { searchJob = viewModelScope.launch {
delay(1000) // debounce 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 return@launch
} }
lastSearchedText = query val effectivePrivateHash =
privateKeyHash.ifBlank { ProtocolManager.getProtocol().getPrivateHash().orEmpty() }
if (effectivePrivateHash.isBlank()) {
_isSearching.value = false
logSearch("❌ Skip send: private hash is empty")
return@launch
}
// Создаем и отправляем пакет поиска // Создаем и отправляем пакет поиска
val packetSearch = PacketSearch().apply { val packetSearch = PacketSearch().apply {
this.privateKey = privateKeyHash this.privateKey = effectivePrivateHash
this.search = query this.search = normalizedQuery
} }
ProtocolManager.sendPacket(packetSearch) ProtocolManager.sendPacket(packetSearch)
logSearch("📤 PacketSearch sent: '$normalizedQuery'")
} }
} }
@@ -157,8 +154,8 @@ class SearchUsersViewModel : ViewModel() {
_searchQuery.value = "" _searchQuery.value = ""
_searchResults.value = emptyList() _searchResults.value = emptyList()
_isSearching.value = false _isSearching.value = false
lastSearchedText = ""
searchJob?.cancel() searchJob?.cancel()
logSearch("↩️ Search collapsed")
} }
/** /**
@@ -168,7 +165,18 @@ class SearchUsersViewModel : ViewModel() {
_searchQuery.value = "" _searchQuery.value = ""
_searchResults.value = emptyList() _searchResults.value = emptyList()
_isSearching.value = false _isSearching.value = false
lastSearchedText = ""
searchJob?.cancel() 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 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 = { val download: () -> Unit = {
if (downloadTag.isNotEmpty()) { if (downloadTag.isNotEmpty()) {
scope.launch { 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 { try {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
@@ -917,6 +931,9 @@ fun ImageAttachment(
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
val downloadTime = System.currentTimeMillis() - startTime val downloadTime = System.currentTimeMillis() - startTime
logPhotoDebug(
"CDN download OK: id=$idShort, bytes=${encryptedContent.length}, time=${downloadTime}ms"
)
downloadProgress = 0.5f downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING downloadStatus = DownloadStatus.DECRYPTING
@@ -925,6 +942,9 @@ fun ImageAttachment(
// Сначала расшифровываем его, получаем raw bytes // Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce = val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
logPhotoDebug(
"Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
)
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует // Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
// bytes в password // bytes в password
@@ -938,6 +958,7 @@ fun ImageAttachment(
downloadProgress = 0.8f downloadProgress = 0.8f
if (decrypted != null) { if (decrypted != null) {
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
imageBitmap = base64ToBitmap(decrypted) imageBitmap = base64ToBitmap(decrypted)
@@ -950,18 +971,25 @@ fun ImageAttachment(
publicKey = senderPublicKey, publicKey = senderPublicKey,
privateKey = privateKey privateKey = privateKey
) )
logPhotoDebug("Cache save result: id=$idShort, saved=$saved")
} }
downloadProgress = 1f downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED downloadStatus = DownloadStatus.DOWNLOADED
logPhotoDebug("Image ready: id=$idShort")
} else { } else {
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
logPhotoDebug(
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
} }
} }
} else { } 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 if (downloadTag.isEmpty() || chachaKey.isEmpty() || privateKey.isEmpty()) return null
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val idShort = shortDebugId(attachmentId)
val tagShort = shortDebugId(downloadTag)
val server = TransportManager.getTransportServer() ?: "unset"
try { try {
logPhotoDebug("Start helper image download: id=$idShort, tag=$tagShort, server=$server")
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag) val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
if (encryptedContent.isEmpty()) return@withContext null if (encryptedContent.isEmpty()) return@withContext null
logPhotoDebug(
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
)
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
logPhotoDebug(
"Helper key decrypt OK: id=$idShort, keySize=${plainKeyAndNonce.size}"
)
// Try decryptReplyBlob first (desktop decodeWithPassword) // Try decryptReplyBlob first (desktop decodeWithPassword)
var decrypted = try { var decrypted = try {
@@ -2192,16 +2230,22 @@ internal suspend fun downloadAndDecryptImage(
val bitmap = base64ToBitmap(base64Data) ?: return@withContext null val bitmap = base64ToBitmap(base64Data) ?: return@withContext null
ImageBitmapCache.put(cacheKey, bitmap) ImageBitmapCache.put(cacheKey, bitmap)
AttachmentFileManager.saveAttachment( val saved = AttachmentFileManager.saveAttachment(
context = context, context = context,
blob = base64Data, blob = base64Data,
attachmentId = attachmentId, attachmentId = attachmentId,
publicKey = senderPublicKey, publicKey = senderPublicKey,
privateKey = recipientPrivateKey privateKey = recipientPrivateKey
) )
logPhotoDebug("Helper image ready: id=$idShort, saved=$saved")
bitmap 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 { } else {
Icon( if (currentStatus == MessageStatus.READ) {
painter = Box(
when (currentStatus) { modifier =
MessageStatus.SENDING -> TelegramIcons.Clock Modifier.width(iconSize + 6.dp)
MessageStatus.SENT -> TelegramIcons.Done .height(iconSize)
MessageStatus.DELIVERED -> TelegramIcons.Done .scale(scale)
MessageStatus.READ -> TelegramIcons.Done ) {
else -> TelegramIcons.Clock Icon(
}, painter = TelegramIcons.Done,
contentDescription = null, contentDescription = null,
tint = animatedColor, tint = animatedColor,
modifier = Modifier.size(iconSize).scale(scale) 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, isBlocked: Boolean,
onBlockClick: () -> Unit, onBlockClick: () -> Unit,
onUnblockClick: () -> Unit, onUnblockClick: () -> Unit,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit
onLogsClick: () -> Unit = {}
) { ) {
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
val textColor = if (isDarkTheme) Color.White else Color(0xFF222222) val textColor = if (isDarkTheme) Color.White else Color(0xFF222222)

View File

@@ -1,46 +1,44 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.DeviceEntry 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 import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@Composable @Composable
fun DeviceVerificationBanner( fun DeviceVerificationBanner(
device: DeviceEntry, device: DeviceEntry,
isDarkTheme: Boolean, isDarkTheme: Boolean,
accountPublicKey: String,
avatarRepository: AvatarRepository?,
onAccept: () -> Unit, onAccept: () -> Unit,
onDecline: () -> Unit onDecline: () -> Unit
) { ) {
val itemBackground = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val itemBackground = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val titleColor = if (isDarkTheme) Color.White else Color.Black val titleColor = if (isDarkTheme) Color(0xFFF2F3F5) else Color(0xFF202124)
val subtitleColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val subtitleColor = if (isDarkTheme) Color(0xFFB7BAC1) else Color(0xFF6E7781)
val acceptColor = PrimaryBlue val acceptColor = PrimaryBlue
val declineColor = Color(0xFFFF3B30) val declineColor = Color(0xFFFF3B30)
val loginText = val loginText =
buildString { buildString {
append("New login from ") append("We detected a new login to your account from ")
append(device.deviceName) append(device.deviceName)
if (device.deviceOs.isNotBlank()) { if (device.deviceOs.isNotBlank()) {
append(" (") append(" (")
@@ -56,68 +54,62 @@ fun DeviceVerificationBanner(
.background(itemBackground) .background(itemBackground)
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
) { ) {
Row { Text(
AvatarImage( text = "Someone just got access to your messages!",
publicKey = accountPublicKey, color = titleColor,
avatarRepository = avatarRepository, fontSize = 15.sp,
size = 56.dp, fontWeight = FontWeight.SemiBold,
isDarkTheme = isDarkTheme 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(
text = "Someone just got access to your messages!", text = "Yes, it's me",
color = titleColor, color = acceptColor,
fontSize = 15.sp, fontSize = 14.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
}
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(
text = loginText, text = "No, it's not me!",
color = subtitleColor, color = declineColor,
fontSize = 13.sp, fontSize = 14.sp,
lineHeight = 17.sp, fontWeight = FontWeight.SemiBold
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
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) val downloadTag = getDownloadTag(image.preview)
if (downloadTag.isEmpty()) return null 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) 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) val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
AttachmentDownloadDebugLogger.log(
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
)
val decrypted = val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce) MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
?: return null ?: return null
@@ -937,9 +951,15 @@ private suspend fun loadBitmapForViewerImage(
publicKey = image.senderPublicKey, publicKey = image.senderPublicKey,
privateKey = privateKey privateKey = privateKey
) )
AttachmentDownloadDebugLogger.log("Viewer image ready: id=$idShort")
decodedBitmap 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 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.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge 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.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
@@ -545,48 +544,7 @@ fun OtherProfileScreen(
) { ) {
item { item {
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 📋 INFORMATION SECTION — первый элемент // ✉️ MESSAGE + 📞 CALL — первые элементы
// ═══════════════════════════════════════════════════════════
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
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -661,6 +619,47 @@ fun OtherProfileScreen(
Spacer(modifier = Modifier.height(12.dp)) 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 // 🔔 NOTIFICATIONS SECTION
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -1825,7 +1824,6 @@ private fun CollapsingOtherProfileHeader(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 👤 AVATAR — Telegram-style expansion on pull-down // 👤 AVATAR — Telegram-style expansion on pull-down
// При скролле вверх: metaball merge с Dynamic Island
// При свайпе вниз: аватарка раскрывается на весь блок (circle → rect) // При свайпе вниз: аватарка раскрывается на весь блок (circle → rect)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val avatarSize = androidx.compose.ui.unit.lerp( val avatarSize = androidx.compose.ui.unit.lerp(
@@ -1851,80 +1849,37 @@ private fun CollapsingOtherProfileHeader(
val expandedAvatarXPx = with(density) { expandedAvatarX.toPx() } val expandedAvatarXPx = with(density) { expandedAvatarX.toPx() }
val expandedAvatarYPx = with(density) { expandedAvatarY.toPx() } val expandedAvatarYPx = with(density) { expandedAvatarY.toPx() }
// Metaball alpha: visible only when NOT expanding (normal collapse animation) Box(
val metaballAlpha = (1f - expandFraction * 10f).coerceIn(0f, 1f) modifier = Modifier
// Expansion avatar alpha: visible when expanding .size(width = expandedAvatarWidth, height = expandedAvatarHeight)
val expansionAvatarAlpha = (expandFraction * 10f).coerceIn(0f, 1f) .graphicsLayer {
translationX = expandedAvatarXPx
// Layer 1: Metaball effect for normal collapse (fades out when expanding) translationY = expandedAvatarYPx
if (metaballAlpha > 0.01f) { alpha = avatarAlpha
Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = metaballAlpha }) { shape = RoundedCornerShape(cornerRadius)
ProfileMetaballEffect( clip = true
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
)
} }
} .background(avatarColors.backgroundColor),
} contentAlignment = Alignment.Center
} ) {
} if (hasAvatar && avatarRepository != null) {
OtherProfileFullSizeAvatar(
// Layer 2: Expanding avatar (fades in when pulling down) publicKey = publicKey,
if (expansionAvatarAlpha > 0.01f) { avatarRepository = avatarRepository,
Box( isDarkTheme = isDarkTheme
modifier = Modifier )
.size(width = expandedAvatarWidth, height = expandedAvatarHeight) } else {
.graphicsLayer { Text(
translationX = expandedAvatarXPx text = getInitials(name),
translationY = expandedAvatarYPx fontSize = avatarFontSize,
alpha = avatarAlpha * expansionAvatarAlpha fontWeight = FontWeight.Bold,
shape = RoundedCornerShape(cornerRadius) color = avatarColors.textColor
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
)
}
} }
} }
// Gradient overlays when avatar is expanded // Gradient overlays when avatar is expanded
if (expansionAvatarAlpha > 0.01f) { if (expandFraction > 0.01f) {
// Top gradient // Top gradient
Box( Box(
modifier = Modifier modifier = Modifier

View File

@@ -1795,7 +1795,7 @@ fun TelegramToggleItem(
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val iconColor = 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) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
Column { Column {

View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFBjCCAu6gAwIBAgIRAMISMktwqbSRcdxA9+KFJjwwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAzMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDEMMAoGA1UEAxMDUjEyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEA2pgodK2+lP474B7i5Ut1qywSf+2nAzJ+Npfs6DGPpRONC5kuHs0BUT1M
5ShuCVUxqqUiXXL0LQfCTUA83wEjuXg39RplMjTmhnGdBO+ECFu9AhqZ66YBAJpz
kG2Pogeg0JfT2kVhgTU9FPnEwF9q3AuWGrCf4yrqvSrWmMebcas7dA8827JgvlpL
Thjp2ypzXIlhZZ7+7Tymy05v5J75AEaz/xlNKmOzjmbGGIVwx1Blbzt05UiDDwhY
XS0jnV6j/ujbAKHS9OMZTfLuevYnnuXNnC2i8n+cF63vEzc50bTILEHWhsDp7CH4
WRt/uTp8n1wBnWIEwii9Cq08yhDsGwIDAQABo4H4MIH1MA4GA1UdDwEB/wQEAwIB
hjAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwEgYDVR0TAQH/BAgwBgEB
/wIBADAdBgNVHQ4EFgQUALUp8i2ObzHom0yteD763OkM0dIwHwYDVR0jBBgwFoAU
ebRZ5nu25eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAC
hhZodHRwOi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcG
A1UdHwQgMB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcN
AQELBQADggIBAI910AnPanZIZTKS3rVEyIV29BWEjAK/duuz8eL5boSoVpHhkkv3
4eoAeEiPdZLj5EZ7G2ArIK+gzhTlRQ1q4FKGpPPaFBSpqV/xbUb5UlAXQOnkHn3m
FVj+qYv87/WeY+Bm4sN3Ox8BhyaU7UAQ3LeZ7N1X01xxQe4wIAAE3JVLUCiHmZL+
qoCUtgYIFPgcg350QMUIWgxPXNGEncT921ne7nluI02V8pLUmClqXOsCwULw+PVO
ZCB7qOMxxMBoCUeL2Ll4oMpOSr5pJCpLN3tRA2s6P1KLs9TSrVhOk+7LX28NMUlI
usQ/nxLJID0RhAeFtPjyOCOscQBA53+NRjSCak7P4A5jX7ppmkcJECL+S0i3kXVU
y5Me5BbrU8973jZNv/ax6+ZK6TM8jWmimL6of6OrX7ZU6E2WqazzsFrLG3o2kySb
zlhSgJ81Cl4tv3SbYiYXnJExKQvzf83DYotox3f0fwv7xln1A2ZLplCb0O+l/AK0
YE0DS2FPxSAHi0iwMfW2nNHJrXcY3LLHD77gRgje4Eveubi2xxa+Nmk/hmhLdIET
iVDFanoCrMVIpQ59XWHkzdFmoHXHBV7oibVjGSO7ULSQ7MJ1Nz51phuDJSgAIU7A
0zrLnOrAj/dfrlEWRhCvAgbuwLZX1A2sjNjXoPOHbsPiy+lO1KF8/XY7
-----END CERTIFICATE-----

View File

@@ -1,17 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config> <network-security-config>
<!-- Базовые trust anchors для всех HTTPS доменов (включая новый CDN) -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<!-- GlobalSign GCC R6 AlphaSSL CA 2025 (цепочка *.rosetta.im) -->
<certificates src="@raw/globalsign_ca"/>
<!-- Let's Encrypt R12 (цепочка rosetta-im.com/defaultcontent) -->
<certificates src="@raw/letsencrypt_r12"/>
<certificates src="system"/>
</trust-anchors>
</base-config>
<!-- HTTP для локального сервера --> <!-- HTTP для локального сервера -->
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">46.28.71.12</domain> <domain includeSubdomains="true">46.28.71.12</domain>
</domain-config> </domain-config>
<!-- HTTPS с кастомным CA для CDN -->
<domain-config>
<domain includeSubdomains="true">cdn.rosetta-im.com</domain>
<domain includeSubdomains="true">rosetta-im.com</domain>
<trust-anchors>
<certificates src="@raw/globalsign_ca"/>
<certificates src="system"/>
</trust-anchors>
</domain-config>
</network-security-config> </network-security-config>