diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f03b4d5..dd27287 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.0.2" -val rosettaVersionCode = 2 // Increment on each release +val rosettaVersionName = "1.0.3" +val rosettaVersionCode = 3 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 8af7bec..a5e6497 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 4897d82..8069eb3 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -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)) diff --git a/app/src/main/java/com/rosetta/messenger/data/AccountDisplayName.kt b/app/src/main/java/com/rosetta/messenger/data/AccountDisplayName.kt new file mode 100644 index 0000000..0b383cf --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/data/AccountDisplayName.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 7976f46..35cdb86 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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) } /** diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketSearch.kt b/app/src/main/java/com/rosetta/messenger/network/PacketSearch.kt index bb5933e..c9ccc54 100644 --- a/app/src/main/java/com/rosetta/messenger/network/PacketSearch.kt +++ b/app/src/main/java/com/rosetta/messenger/network/PacketSearch.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 0bc24dd..6676cd6 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -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() { diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index 281fa37..64fe588 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -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") } /** diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index 35c534a..3ce5585 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -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, 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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt new file mode 100644 index 0000000..da4d999 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt @@ -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 +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt index 8fa6d35..941c810 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt @@ -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)) + ) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index 50b6cb3..f09104d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -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 = diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 875d1f7..4ed1255 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -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)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 79e16fb..f341b1f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index c16edd8..76fab04 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index f51ecb0..8fe72d1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -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 = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt index fbb2a62..3955c19 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt @@ -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 = _isSearchExpanded.asStateFlow() + + private val _searchLogs = MutableStateFlow>(emptyList()) + val searchLogs: StateFlow> = _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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 75907c8..4697b78 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt new file mode 100644 index 0000000..10def86 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt @@ -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>(emptyList()) + val logs: StateFlow> = _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() + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index a32a496..19041fa 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/DeviceVerificationBanner.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/DeviceVerificationBanner.kt index 5bd9cc1..22a90c3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/DeviceVerificationBanner.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/DeviceVerificationBanner.kt @@ -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 - ) - } - } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index b36c68b..c25932e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index bfd7c36..df2c891 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 3bb28ed..14f3c6e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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 { diff --git a/app/src/main/res/raw/letsencrypt_r12.pem b/app/src/main/res/raw/letsencrypt_r12.pem new file mode 100644 index 0000000..4fd559e --- /dev/null +++ b/app/src/main/res/raw/letsencrypt_r12.pem @@ -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----- diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index cbe4fd4..fa66d66 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,17 +1,18 @@ + + + + + + + + + + + 46.28.71.12 - - - - cdn.rosetta-im.com - rosetta-im.com - - - - -