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

@@ -26,8 +26,10 @@ import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketPushNotification
import com.rosetta.messenger.network.ProtocolManager
@@ -158,31 +160,7 @@ class MainActivity : FragmentActivity() {
accountManager.logout() // Always start logged out
val accounts = accountManager.getAllAccounts()
hasExistingAccount = accounts.isNotEmpty()
accountInfoList =
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
)
}
accountInfoList = accounts.map { it.toAccountInfo() }
}
// Wait for initial load
@@ -197,6 +175,15 @@ class MainActivity : FragmentActivity() {
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) {
Surface(
modifier = Modifier.fillMaxSize(),
@@ -212,6 +199,10 @@ class MainActivity : FragmentActivity() {
"auth_new"
isLoggedIn != true && hasExistingAccount == true ->
"auth_unlock"
isLoggedIn == true &&
currentAccount == null &&
hasExistingAccount == true ->
"auth_unlock"
protocolState ==
ProtocolState.DEVICE_VERIFICATION_REQUIRED ->
"device_confirm"
@@ -261,45 +252,12 @@ class MainActivity : FragmentActivity() {
// 📤 Отправляем FCM токен на сервер после успешной
// аутентификации
account?.let { sendFcmTokenToServer(it) }
account?.let { sendFcmTokenToServer() }
// Reload accounts list
scope.launch {
val accounts = accountManager.getAllAccounts()
accountInfoList =
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
)
}
accountInfoList = accounts.map { it.toAccountInfo() }
}
},
onLogout = {
@@ -359,27 +317,7 @@ class MainActivity : FragmentActivity() {
accountManager.deleteAccount(publicKey)
// 8. Refresh accounts list
val accounts = accountManager.getAllAccounts()
accountInfoList = 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
)
}
accountInfoList = accounts.map { it.toAccountInfo() }
hasExistingAccount = accounts.isNotEmpty()
// 8. Navigate away last
currentAccount = null
@@ -391,37 +329,7 @@ class MainActivity : FragmentActivity() {
onAccountInfoUpdated = {
// Reload account list when profile is updated
val accounts = accountManager.getAllAccounts()
accountInfoList =
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
)
}
accountInfoList = accounts.map { it.toAccountInfo() }
},
onSwitchAccount = { targetPublicKey ->
// Switch to another account: logout current, then auto-login target
@@ -491,6 +399,11 @@ class MainActivity : FragmentActivity() {
// Сохраняем токен локально
saveFcmToken(token)
addFcmLog("💾 Токен сохранен локально")
if (ProtocolManager.state.value == ProtocolState.AUTHENTICATED) {
addFcmLog("🔁 Протокол уже AUTHENTICATED, отправляем токен сразу")
sendFcmTokenToServer()
}
} else {
addFcmLog("⚠️ Токен пустой")
}
@@ -514,7 +427,7 @@ class MainActivity : FragmentActivity() {
* Отправить FCM токен на сервер Вызывается после успешной аутентификации, когда аккаунт уже
* расшифрован
*/
private fun sendFcmTokenToServer(account: DecryptedAccount) {
private fun sendFcmTokenToServer() {
lifecycleScope.launch {
try {
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
* 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 = {},
onSwitchAccount: (String) -> Unit = {}
) {
val accountPublicKey = account?.publicKey.orEmpty()
// 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 =
account?.publicKey?.take(16)?.let {
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
}
?: "+7 775 9932587"
val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
.orEmpty()
val accountPrivateKey = account?.privateKey ?: ""
val privateKeyHash = account?.privateKeyHash ?: ""
@@ -611,11 +551,17 @@ fun MainScreen(
// Load username AND name from AccountManager (persisted in DataStore)
val context = LocalContext.current
LaunchedEffect(accountPublicKey, reloadTrigger) {
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
if (accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountPublicKey)
accountUsername = encryptedAccount?.username ?: ""
accountName = encryptedAccount?.name ?: accountName
val username = encryptedAccount?.username
accountUsername = username.orEmpty()
accountName =
resolveAccountDisplayName(
accountPublicKey,
encryptedAccount?.name ?: accountName,
username
)
}
}
@@ -625,14 +571,17 @@ fun MainScreen(
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
LaunchedEffect(ownProfileUpdated) {
if (ownProfileUpdated > 0L &&
accountPublicKey.isNotBlank() &&
accountPublicKey != "04c266b98ae5"
) {
if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountPublicKey)
accountUsername = encryptedAccount?.username ?: ""
accountName = encryptedAccount?.name ?: accountName
val username = encryptedAccount?.username
accountUsername = username.orEmpty()
accountName =
resolveAccountDisplayName(
accountPublicKey,
encryptedAccount?.name ?: accountName,
username
)
}
}
@@ -715,7 +664,7 @@ fun MainScreen(
// AvatarRepository для работы с аватарами
val avatarRepository =
remember(accountPublicKey) {
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
if (accountPublicKey.isNotBlank()) {
val database = RosettaDatabase.getDatabase(context)
AvatarRepository(
context = context,

View File

@@ -24,6 +24,7 @@ object MessageCrypto {
private const val CHACHA_KEY_SIZE = 32
private const val XCHACHA_NONCE_SIZE = 24
private const val POLY1305_TAG_SIZE = 16
private const val SYNC_KEY_PREFIX = "sync:"
// Кэш PBKDF2-SHA256 ключей: password → derived key bytes
// PBKDF2 с 1000 итерациями ~50-100ms, кэш убирает повторные вычисления
@@ -387,6 +388,16 @@ object MessageCrypto {
* КРИТИЧНО: ephemeralPrivateKeyHex может иметь нечётную длину!
*/
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))

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 с использованием приватного ключа
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
val storedChachaKey =
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
"sync:${packet.aesChachaKey}"
} else {
packet.chachaKey
}
// Создаем entity для кэша и возможной вставки
val entity =
MessageEntity(
@@ -620,7 +627,7 @@ class MessageRepository private constructor(private val context: Context) {
toPublicKey = packet.toPublicKey,
content = packet.content,
timestamp = packet.timestamp,
chachaKey = packet.chachaKey,
chachaKey = storedChachaKey,
read = 0,
fromMe = if (isOwnMessage) 1 else 0,
delivered = DeliveryStatus.DELIVERED.value,
@@ -711,27 +718,37 @@ class MessageRepository private constructor(private val context: Context) {
*/
suspend fun handleRead(packet: PacketRead) {
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 может прийти как:
// 1) from=opponent, to=account (обычный read от собеседника)
// 2) from=account, to=opponent (read c другого устройства этого же аккаунта)
val opponentKey =
if (packet.fromPublicKey == account) packet.toPublicKey else packet.fromPublicKey
// Desktop parity:
// 1) from=opponent, to=account -> собеседник прочитал НАШИ сообщения (double check)
// 2) from=account, to=opponent -> sync с другого нашего устройства (мы прочитали входящие)
val isOwnReadSync = fromPublicKey == account
val opponentKey = if (isOwnReadSync) toPublicKey else fromPublicKey
if (opponentKey.isBlank()) return
// Проверяем последнее сообщение ДО обновления
val lastMsgBefore = messageDao.getLastMessageDebug(account, opponentKey)
val dialogKey = getDialogKey(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)
// 🔥 DEBUG: Проверяем последнее сообщение ПОСЛЕ обновления
val lastMsgAfter = messageDao.getLastMessageDebug(account, opponentKey)
// Обновляем кэш - все исходящие сообщения помечаем как прочитанные
val dialogKey = getDialogKey(opponentKey)
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
messageCache[dialogKey]?.let { flow ->
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))
// 📝 LOG: Статус прочтения
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
dialogDao.updateDialogFromMessages(account, opponentKey)
// Логируем что записалось в диалог
val dialog = dialogDao.getDialog(account, opponentKey)
}
/**

View File

@@ -35,6 +35,16 @@ class PacketSearch : Packet() {
stream.writeInt16(getPacketId())
stream.writeString(privateKey)
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
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Build
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.isPlaceholderAccountName
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -277,7 +278,7 @@ object ProtocolManager {
// Если это наш own profile — сохраняем username/name в AccountManager
if (user.publicKey == ownPublicKey && appContext != null) {
val accountManager = AccountManager(appContext!!)
if (user.title.isNotBlank()) {
if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) {
accountManager.updateAccountName(user.publicKey, user.title)
}
if (user.username.isNotBlank()) {
@@ -333,6 +334,24 @@ object ProtocolManager {
TransportManager.requestTransportServer()
fetchOwnProfile()
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() {

View File

@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
@@ -29,12 +28,8 @@ data class TransportState(
* Совместимо с desktop версией (TransportProvider)
*/
object TransportManager {
private const val TAG = "TransportManager"
private const val MAX_RETRIES = 3
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
@@ -54,20 +49,32 @@ object TransportManager {
* Установить адрес транспортного сервера
*/
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 {
val server = transportServer ?: FALLBACK_TRANSPORT_SERVER
return server
private suspend fun getActiveServer(): String {
transportServer?.let { return it }
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.data.AccountManager
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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.util.Locale
/**
* Firebase Cloud Messaging Service для обработки push-уведомлений
@@ -47,7 +52,13 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
fun cancelNotificationForChat(context: Context, senderPublicKey: String) {
val 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)
// 📤 Токен будет отправлен на сервер после успешного логина в MainActivity
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
if (ProtocolManager.isAuthenticated()) {
runCatching {
ProtocolManager.send(
PacketPushNotification().apply {
notificationsToken = token
action = PushNotificationAction.SUBSCRIBE
}
)
}
}
}
/** Вызывается когда получено push-уведомление */
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
// Обрабатываем data payload
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"
var handledMessageData = false
when (type) {
"new_message" -> {
// Показываем уведомление о новом сообщении
// Обрабатываем data payload
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)
handledMessageData = true
}
"message_read" -> {
// Сообщение прочитано - можно обновить UI если приложение открыто
isReadEvent -> {
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 {
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
) {
// 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground) {
if (isAppInForeground || !areNotificationsEnabled()) {
return
}
val senderKey = senderPublicKey?.trim().orEmpty()
@@ -144,7 +198,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
/** Показать простое уведомление */
private fun showSimpleNotification(title: String, body: String) {
// 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground) {
if (isAppInForeground || !areNotificationsEnabled()) {
return
}
@@ -204,6 +258,22 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
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 {
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
import android.os.Build
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -15,6 +20,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -29,6 +35,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -54,11 +62,16 @@ fun DeviceConfirmScreen(
isDarkTheme: Boolean,
onExit: () -> Unit
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val cardBorderColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE8E8ED)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val backgroundTop = if (isDarkTheme) Color(0xFF17181D) else Color(0xFFF4F7FC)
val backgroundBottom = if (isDarkTheme) Color(0xFF121316) else Color(0xFFE9EEF7)
val cardColor = if (isDarkTheme) Color(0xFF23252B) else Color.White
val cardBorderColor = if (isDarkTheme) Color(0xFF343844) else Color(0xFFDCE4F0)
val textColor = if (isDarkTheme) Color(0xFFF2F3F5) else Color(0xFF1B1C1F)
val secondaryTextColor = if (isDarkTheme) Color(0xFFB2B5BD) else Color(0xFF6F7480)
val accentColor = if (isDarkTheme) Color(0xFF4A9FFF) else PrimaryBlue
val deviceCardColor = if (isDarkTheme) Color(0xFF1A1C22) else Color(0xFFF5F8FD)
val exitButtonColor = if (isDarkTheme) Color(0xFF3D2227) else Color(0xFFFFEAED)
val exitButtonTextColor = Color(0xFFFF5E61)
val onExitState by rememberUpdatedState(onExit)
val scope = rememberCoroutineScope()
@@ -92,7 +105,12 @@ fun DeviceConfirmScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.background(
brush =
Brush.verticalGradient(
colors = listOf(backgroundTop, backgroundBottom)
)
)
.navigationBarsPadding()
.padding(horizontal = 22.dp),
contentAlignment = Alignment.Center
@@ -102,98 +120,160 @@ fun DeviceConfirmScreen(
.fillMaxWidth()
.widthIn(max = 400.dp),
color = cardColor,
shape = RoundedCornerShape(24.dp),
shape = RoundedCornerShape(28.dp),
border = BorderStroke(1.dp, cardBorderColor)
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
modifier = Modifier.padding(horizontal = 22.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(128.dp)
)
Box(
modifier =
Modifier
.size(118.dp)
.clip(CircleShape)
.background(accentColor.copy(alpha = if (isDarkTheme) 0.16f else 0.1f)),
contentAlignment = Alignment.Center
) {
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(96.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = TablerIcons.DeviceMobile,
contentDescription = null,
tint = PrimaryBlue
tint = accentColor
)
Spacer(modifier = Modifier.size(6.dp))
Text(
text = "NEW DEVICE REQUEST",
color = PrimaryBlue,
color = accentColor,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Waiting for approval",
color = textColor,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Open Rosetta on your first device and approve this login request.",
color = secondaryTextColor,
fontSize = 14.sp,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(14.dp))
Text(
text = "\"$localDeviceName\" is waiting for approval",
color = textColor.copy(alpha = 0.9f),
fontSize = 13.sp,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "If you didn't request this login, tap Exit.",
color = secondaryTextColor,
fontSize = 12.sp,
text = "Waiting for approval",
color = textColor,
fontSize = 34.sp,
lineHeight = 38.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onExitState,
modifier = Modifier.height(42.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF3B30),
contentColor = Color.White
),
shape = RoundedCornerShape(12.dp)
Text(
text = "Open Rosetta on your first device and approve this login request.",
color = secondaryTextColor,
fontSize = 15.sp,
textAlign = TextAlign.Center,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(18.dp))
Surface(
modifier = Modifier.fillMaxWidth(),
color = deviceCardColor,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, cardBorderColor.copy(alpha = if (isDarkTheme) 0.7f else 1f))
) {
Text("Exit")
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = "Device waiting for approval",
color = secondaryTextColor,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = localDeviceName,
color = textColor,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Waiting for confirmation...",
color = secondaryTextColor,
fontSize = 11.sp
)
Button(
onClick = onExitState,
modifier =
Modifier
.fillMaxWidth()
.height(46.dp),
colors = ButtonDefaults.buttonColors(
containerColor = exitButtonColor,
contentColor = exitButtonTextColor
),
border = BorderStroke(1.dp, exitButtonTextColor.copy(alpha = 0.35f)),
shape = RoundedCornerShape(14.dp)
) {
Text(
text = "Exit",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(14.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Waiting for confirmation",
color = secondaryTextColor,
fontSize = 12.sp
)
Spacer(modifier = Modifier.size(8.dp))
WaitingDots(color = secondaryTextColor)
}
}
}
}
}
@Composable
private fun WaitingDots(color: Color) {
val transition = rememberInfiniteTransition(label = "waiting-dots")
val progress by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 1000, easing = FastOutSlowInEasing)
),
label = "waiting-dots-progress"
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(3) { index ->
val shifted = (progress + index * 0.22f) % 1f
val alpha = 0.3f + (1f - shifted) * 0.7f
Box(
modifier =
Modifier
.size(5.dp)
.clip(CircleShape)
.background(color.copy(alpha = alpha))
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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"?>
<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 для локального сервера -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">46.28.71.12</domain>
</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>