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 bbb555a..8c5fbad 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -1853,7 +1853,7 @@ class MessageRepository private constructor(private val context: Context) { // 1. Расшифровываем blob с ChaCha ключом сообщения val decryptedBlob = if (groupKey != null) { - CryptoManager.decryptWithPassword(attachment.blob, groupKey) + decryptWithGroupKeyCompat(attachment.blob, groupKey) } else { plainKeyAndNonce?.let { MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) @@ -1910,7 +1910,7 @@ class MessageRepository private constructor(private val context: Context) { // 1. Расшифровываем blob с ChaCha ключом сообщения val decryptedBlob = if (groupKey != null) { - CryptoManager.decryptWithPassword(attachment.blob, groupKey) + decryptWithGroupKeyCompat(attachment.blob, groupKey) } else { plainKeyAndNonce?.let { MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) @@ -1974,7 +1974,7 @@ class MessageRepository private constructor(private val context: Context) { // 1. Расшифровываем с ChaCha ключом сообщения val decryptedBlob = if (groupKey != null) { - CryptoManager.decryptWithPassword(attachment.blob, groupKey) + decryptWithGroupKeyCompat(attachment.blob, groupKey) } else { plainKeyAndNonce?.let { MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) @@ -2039,4 +2039,26 @@ class MessageRepository private constructor(private val context: Context) { } return jsonArray.toString() } + + /** + * Desktop parity for group attachment blobs: + * old payloads may be encrypted with raw group key, new payloads with hex(groupKey bytes). + */ + private fun decryptWithGroupKeyCompat(encryptedBlob: String, groupKey: String): String? { + if (encryptedBlob.isBlank() || groupKey.isBlank()) return null + + val rawAttempt = runCatching { + CryptoManager.decryptWithPassword(encryptedBlob, groupKey) + }.getOrNull() + if (rawAttempt != null) return rawAttempt + + val hexKey = + groupKey.toByteArray(Charsets.ISO_8859_1) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + if (hexKey == groupKey) return null + + return runCatching { + CryptoManager.decryptWithPassword(encryptedBlob, hexKey) + }.getOrNull() + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 9fd0e16..d557597 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -1872,6 +1872,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val forwardedMessages: List = emptyList() ) + private fun replyLog(msg: String) { + try { + val ctx = getApplication() + val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) + val dir = java.io.File(ctx.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + java.io.File(dir, "rosettadev1.txt").appendText("$ts [Reply] $msg\n") + } catch (_: Exception) {} + } + private suspend fun parseReplyFromAttachments( attachmentsJson: String, isFromMe: Boolean, @@ -1887,26 +1897,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } return try { - val attachments = JSONArray(attachmentsJson) + val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return null for (i in 0 until attachments.length()) { val attachment = attachments.getJSONObject(i) - val type = attachment.optInt("type", 0) + val type = parseAttachmentType(attachment) // MESSAGES = 1 (цитата) - if (type == 1) { + if (type == AttachmentType.MESSAGES) { + replyLog("=== PARSE REPLY: isFromMe=$isFromMe, hasGroup=${groupPassword != null}, chachaKey=${chachaKey.take(12)}, hasPlainKey=${plainKeyAndNonce != null} ===") // Данные могут быть в blob или preview var dataJson = attachment.optString("blob", "") if (dataJson.isEmpty()) { dataJson = attachment.optString("preview", "") + replyLog(" blob empty, using preview") } if (dataJson.isEmpty()) { + replyLog(" BOTH empty → skip") continue } + replyLog(" dataJson.len=${dataJson.length}, colons=${dataJson.count { it == ':' }}, starts='${dataJson.take(20)}'") + // 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат // "iv:ciphertext" val colonCount = dataJson.count { it == ':' } @@ -1914,21 +1929,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (dataJson.contains(":") && dataJson.split(":").size == 2) { val privateKey = myPrivateKey var decryptionSuccess = false + replyLog(" encrypted format detected (iv:cipher), trying decrypt methods...") // 🔥 Способ 0: Группа — blob шифруется ключом группы - if (groupPassword != null) { + if (groupPassword != null && !decryptionSuccess) { + replyLog(" [0] group raw key (len=${groupPassword.length})") try { val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword) if (decrypted != null) { dataJson = decrypted decryptionSuccess = true + replyLog(" [0] OK raw key") + } else { + replyLog(" [0] raw key → null") } - } catch (_: Exception) {} + } catch (e: Exception) { replyLog(" [0] raw key EXCEPTION: ${e.message}") } + // Fallback: Desktop v1.2.1+ шифрует hex-версией ключа + if (!decryptionSuccess) { + try { + val hexKey = groupPassword.toByteArray(Charsets.ISO_8859_1) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + replyLog(" [0] trying hex key (len=${hexKey.length})") + val decrypted = CryptoManager.decryptWithPassword(dataJson, hexKey) + if (decrypted != null) { + dataJson = decrypted + decryptionSuccess = true + replyLog(" [0] OK hex key") + } else { + replyLog(" [0] hex key → null") + } + } catch (e: Exception) { replyLog(" [0] hex key EXCEPTION: ${e.message}") } + } } - // 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих - // сообщений) - if (privateKey != null) { + // 🔥 Способ 1: Пробуем расшифровать с приватным ключом + if (privateKey != null && !decryptionSuccess) { + replyLog(" [1] private key") try { val decrypted = CryptoManager.decryptWithPassword(dataJson, privateKey) @@ -1998,26 +2034,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } catch (e: Exception) {} } + replyLog(" FINAL: decryptionSuccess=$decryptionSuccess") if (!decryptionSuccess) { + replyLog(" ALL METHODS FAILED → skip") continue } - } else {} + } else { + replyLog(" NOT encrypted (no iv:cipher format), treating as plain JSON") + } val messagesArray = try { JSONArray(dataJson) } catch (e: Exception) { + replyLog(" JSON parse FAILED: ${e.message?.take(50)}") + replyLog(" dataJson preview: '${dataJson.take(80)}'") continue } + replyLog(" JSON OK: ${messagesArray.length()} messages") if (messagesArray.length() > 0) { val account = myPublicKey ?: return null val dialogKey = getDialogKey(account, opponentKey ?: "") - // Check if this is a forwarded set or a regular reply - // Desktop doesn't set "forwarded" flag, but sends multiple messages in the array val firstMsg = messagesArray.getJSONObject(0) val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1 + replyLog(" isForwardedSet=$isForwardedSet, firstMsg keys=${firstMsg.keys().asSequence().toList()}") if (isForwardedSet) { // 🔥 Parse ALL forwarded messages (desktop parity) @@ -2120,6 +2162,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { chachaKeyPlainHex = fwdChachaKeyPlain )) } + replyLog(" RESULT: forwarded ${forwardedList.size} messages") return ParsedReplyResult( replyData = forwardedList.firstOrNull(), forwardedMessages = forwardedList @@ -2135,13 +2178,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val senderNameFromJson = replyMessage.optString("senderName", "") val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "") - // 🔥 Detect forward: explicit flag OR publicKey belongs to a third party - // Desktop doesn't send "forwarded" flag, but if publicKey differs from - // both myPublicKey and opponentKey — it's a forwarded message from someone else - val isFromThirdParty = replyPublicKey.isNotEmpty() && + // 🔥 Detect forward: + // - explicit "forwarded" flag always wins + // - third-party heuristic applies ONLY for direct dialogs + // (in groups reply author is naturally "third-party", and that must remain a reply) + val isGroupContext = isGroupDialogKey(opponentKey ?: "") || isGroupDialogKey(dialogKey) + val isFromThirdPartyDirect = !isGroupContext && + replyPublicKey.isNotEmpty() && replyPublicKey != myPublicKey && replyPublicKey != opponentKey - val isForwarded = replyMessage.optBoolean("forwarded", false) || isFromThirdParty + val isForwarded = + replyMessage.optBoolean("forwarded", false) || isFromThirdPartyDirect // 📸 Парсим attachments из JSON reply (как в Desktop) val replyAttachmentsFromJson = mutableListOf() @@ -2308,11 +2355,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 If this is a forwarded message (from third party), return as forwardedMessages list // so it renders with "Forwarded from" header (like multi-forward) if (isForwarded) { + replyLog(" RESULT: single forward from=${result.senderName}") return ParsedReplyResult( replyData = result, forwardedMessages = listOf(result) ) } + replyLog(" RESULT: reply from=${result.senderName}, text='${result.text.take(30)}'") return ParsedReplyResult(replyData = result) } else {} } else {} 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 1911a4b..aec7712 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 @@ -311,6 +311,21 @@ fun ChatsListScreen( var themeRevealToDark by remember { mutableStateOf(false) } var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealSnapshot by remember { mutableStateOf(null) } + var prewarmedBitmap by remember { mutableStateOf(null) } + + // Prewarm: capture bitmap on first appear + when drawer opens + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(1000) + if (prewarmedBitmap == null) { + prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull() + } + } + LaunchedEffect(drawerState.isOpen) { + if (drawerState.isOpen) { + kotlinx.coroutines.delay(200) + prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull() + } + } fun startThemeReveal() { if (themeRevealActive) { @@ -324,7 +339,10 @@ fun ChatsListScreen( val center = themeToggleCenterInRoot ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.12f) - val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull() + + // Use prewarmed bitmap or capture fresh + val snapshotBitmap = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull() + prewarmedBitmap = null if (snapshotBitmap == null) { onToggleTheme() return @@ -333,6 +351,7 @@ fun ChatsListScreen( val toDark = !isDarkTheme val maxRadius = maxRevealRadius(center, rootSize) if (maxRadius <= 0f) { + snapshotBitmap.recycle() onToggleTheme() return } 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 95fcd46..2436452 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 @@ -962,6 +962,7 @@ fun MessageBubble( isOutgoing = message.isOutgoing, isDarkTheme = isDarkTheme, chachaKey = message.chachaKey, + chachaKeyPlainHex = message.chachaKeyPlainHex, privateKey = privateKey, onClick = { onReplyClick(reply.messageId) }, onImageClick = onImageClick, @@ -2097,6 +2098,7 @@ fun ReplyBubble( isOutgoing: Boolean, isDarkTheme: Boolean, chachaKey: String = "", + chachaKeyPlainHex: String = "", privateKey: String = "", onClick: () -> Unit = {}, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, @@ -2224,7 +2226,10 @@ fun ReplyBubble( cacheKey = "img_${imageAttachment.id}", context = context, senderPublicKey = replyData.senderPublicKey, - recipientPrivateKey = replyData.recipientPrivateKey + recipientPrivateKey = replyData.recipientPrivateKey, + chachaKeyPlainHex = replyData.chachaKeyPlainHex.ifEmpty { + chachaKeyPlainHex + } ) if (bitmap != null) imageBitmap = bitmap } diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt index 78f5e65..688ae2d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt @@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.onboarding import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -90,87 +92,17 @@ fun OnboardingScreen( // Theme transition animation var isTransitioning by remember { mutableStateOf(false) } - var transitionProgress by remember { mutableStateOf(0f) } + val transitionRadius = remember { androidx.compose.animation.core.Animatable(0f) } var clickPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } - var shouldUpdateStatusBar by remember { mutableStateOf(false) } var hasInitialized by remember { mutableStateOf(false) } var previousTheme by remember { mutableStateOf(isDarkTheme) } var targetTheme by remember { mutableStateOf(isDarkTheme) } + var rootSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { hasInitialized = true } - LaunchedEffect(isTransitioning) { - if (isTransitioning) { - shouldUpdateStatusBar = false - val duration = 800f - val startTime = System.currentTimeMillis() - while (transitionProgress < 1f) { - val elapsed = System.currentTimeMillis() - startTime - transitionProgress = (elapsed / duration).coerceAtMost(1f) - - delay(16) // ~60fps - } - // Update status bar icons after animation is completely finished - shouldUpdateStatusBar = true - delay(50) // Small delay to ensure UI updates - isTransitioning = false - transitionProgress = 0f - shouldUpdateStatusBar = false - previousTheme = targetTheme - } - } - - // Animate navigation bar color starting at 80% of wave animation val view = LocalView.current - val isGestureNavigation = remember(view.context) { - NavigationModeUtils.isGestureNavigation(view.context) - } - LaunchedEffect(isTransitioning, transitionProgress) { - if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) { - val window = (view.context as android.app.Activity).window - // Map 0.8-1.0 to 0-1 for smooth interpolation - val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) - - val oldColor = if (previousTheme) 0xFF1E1E1E else 0xFFFFFFFF - val newColor = if (targetTheme) 0xFF1E1E1E else 0xFFFFFFFF - - val r1 = (oldColor shr 16 and 0xFF) - val g1 = (oldColor shr 8 and 0xFF) - val b1 = (oldColor and 0xFF) - val r2 = (newColor shr 16 and 0xFF) - val g2 = (newColor shr 8 and 0xFF) - val b2 = (newColor and 0xFF) - - val r = (r1 + (r2 - r1) * navProgress).toInt() - val g = (g1 + (g2 - g1) * navProgress).toInt() - val b = (b1 + (b2 - b1) * navProgress).toInt() - - window.navigationBarColor = - (0xFF000000 or - (r.toLong() shl 16) or - (g.toLong() shl 8) or - b.toLong()) - .toInt() - } - } - - // Update status bar icons when animation finishes - LaunchedEffect(shouldUpdateStatusBar) { - if (shouldUpdateStatusBar && !view.isInEditMode) { - val window = (view.context as android.app.Activity).window - val insetsController = WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = false - window.statusBarColor = android.graphics.Color.TRANSPARENT - - // Navigation bar: показываем только если есть нативные кнопки - NavigationModeUtils.applyNavigationBarVisibility( - window = window, - insetsController = insetsController, - context = view.context, - isDarkTheme = isDarkTheme - ) - } - } // Set initial navigation bar color only on first launch LaunchedEffect(Unit) { @@ -221,7 +153,9 @@ fun OnboardingScreen( label = "indicatorColor" ) - Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { + Box(modifier = Modifier.fillMaxSize().navigationBarsPadding() + .onGloballyPositioned { rootSize = it.size } + ) { // Base background - shows the OLD theme color during transition Box( modifier = @@ -237,15 +171,11 @@ fun OnboardingScreen( // Circular reveal overlay - draws the NEW theme color expanding if (isTransitioning) { Canvas(modifier = Modifier.fillMaxSize()) { - val maxRadius = hypot(size.width, size.height) - val radius = maxRadius * transitionProgress - - // Draw the NEW theme color expanding from click point drawCircle( color = if (targetTheme) OnboardingBackground else OnboardingBackgroundLight, - radius = radius, + radius = transitionRadius.value, center = clickPosition ) } @@ -260,6 +190,22 @@ fun OnboardingScreen( clickPosition = position isTransitioning = true onThemeToggle() + scope.launch { + try { + val maxR = hypot( + rootSize.width.toFloat(), + rootSize.height.toFloat() + ).coerceAtLeast(1f) + transitionRadius.snapTo(0f) + transitionRadius.animateTo( + targetValue = maxR, + animationSpec = tween(400, easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)) + ) + } finally { + isTransitioning = false + previousTheme = targetTheme + } + } } }, modifier = diff --git a/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt index 2517118..77ecebd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt @@ -132,6 +132,13 @@ fun MyQrCodeScreen( var rootSize by remember { mutableStateOf(IntSize.Zero) } var lastRevealTime by remember { mutableLongStateOf(0L) } val revealCooldownMs = 600L + var prewarmedBitmap by remember { mutableStateOf(null) } + + // Prewarm bitmap on screen appear + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(300) + prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull() + } fun startReveal(newIndex: Int, center: Offset) { val now = System.currentTimeMillis() @@ -142,7 +149,8 @@ fun MyQrCodeScreen( return } - val snapshot = runCatching { view.drawToBitmap() }.getOrNull() + val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull() + prewarmedBitmap = null if (snapshot == null) { selectedThemeIndex = newIndex return @@ -304,7 +312,8 @@ fun MyQrCodeScreen( val now = System.currentTimeMillis() if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) { lastRevealTime = now - val snapshot = runCatching { view.drawToBitmap() }.getOrNull() + val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull() + prewarmedBitmap = null if (snapshot != null) { val maxR = maxRevealRadius(themeButtonPos, rootSize) revealActive = true diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index d352958..c0f9b31 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -99,6 +99,13 @@ fun ThemeScreen( var themeRevealToDark by remember { mutableStateOf(false) } var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealSnapshot by remember { mutableStateOf(null) } + var prewarmedBitmap by remember { mutableStateOf(null) } + + // Prewarm bitmap on screen appear + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(300) + prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull() + } var lightOptionCenter by remember { mutableStateOf(null) } var darkOptionCenter by remember { mutableStateOf(null) } var systemOptionCenter by remember { mutableStateOf(null) } @@ -130,7 +137,8 @@ fun ThemeScreen( val center = centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f) - val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull() + val snapshotBitmap = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull() + prewarmedBitmap = null if (snapshotBitmap == null) { themeMode = targetMode onThemeModeChange(targetMode)