Фикс: reply в группах с Desktop не отображался (hex key fallback для reply blob). Оптимизация circular reveal (prewarm bitmap). Логи reply парсинга в rosettadev1. Серые миниатюры в медиа (BlurHash). Анимация онбординга на Animatable вместо while-loop.

This commit is contained in:
2026-04-10 22:34:57 +05:00
parent 6124a52c84
commit 8d8b02a3ec
7 changed files with 161 additions and 103 deletions

View File

@@ -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()
}
}

View File

@@ -1872,6 +1872,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val forwardedMessages: List<ReplyData> = emptyList()
)
private fun replyLog(msg: String) {
try {
val ctx = getApplication<android.app.Application>()
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 (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}") }
}
} catch (_: Exception) {}
}
// 🔥 Способ 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<MessageAttachment>()
@@ -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 {}

View File

@@ -311,6 +311,21 @@ fun ChatsListScreen(
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
var prewarmedBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(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
}

View File

@@ -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
}

View File

@@ -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 =

View File

@@ -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<android.graphics.Bitmap?>(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

View File

@@ -99,6 +99,13 @@ fun ThemeScreen(
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
var prewarmedBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
// Prewarm bitmap on screen appear
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(300)
prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
}
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
var systemOptionCenter by remember { mutableStateOf<Offset?>(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)