Фикс: 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 ключом сообщения // 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
if (groupKey != null) { if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey) decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else { } else {
plainKeyAndNonce?.let { plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1910,7 +1910,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения // 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
if (groupKey != null) { if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey) decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else { } else {
plainKeyAndNonce?.let { plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1974,7 +1974,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем с ChaCha ключом сообщения // 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
if (groupKey != null) { if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey) decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else { } else {
plainKeyAndNonce?.let { plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -2039,4 +2039,26 @@ class MessageRepository private constructor(private val context: Context) {
} }
return jsonArray.toString() 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() 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( private suspend fun parseReplyFromAttachments(
attachmentsJson: String, attachmentsJson: String,
isFromMe: Boolean, isFromMe: Boolean,
@@ -1887,26 +1897,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
return try { return try {
val attachments = JSONArray(attachmentsJson) val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return null
for (i in 0 until attachments.length()) { for (i in 0 until attachments.length()) {
val attachment = attachments.getJSONObject(i) val attachment = attachments.getJSONObject(i)
val type = attachment.optInt("type", 0) val type = parseAttachmentType(attachment)
// MESSAGES = 1 (цитата) // 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 // Данные могут быть в blob или preview
var dataJson = attachment.optString("blob", "") var dataJson = attachment.optString("blob", "")
if (dataJson.isEmpty()) { if (dataJson.isEmpty()) {
dataJson = attachment.optString("preview", "") dataJson = attachment.optString("preview", "")
replyLog(" blob empty, using preview")
} }
if (dataJson.isEmpty()) { if (dataJson.isEmpty()) {
replyLog(" BOTH empty → skip")
continue continue
} }
replyLog(" dataJson.len=${dataJson.length}, colons=${dataJson.count { it == ':' }}, starts='${dataJson.take(20)}'")
// 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат // 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат
// "iv:ciphertext" // "iv:ciphertext"
val colonCount = dataJson.count { it == ':' } val colonCount = dataJson.count { it == ':' }
@@ -1914,21 +1929,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (dataJson.contains(":") && dataJson.split(":").size == 2) { if (dataJson.contains(":") && dataJson.split(":").size == 2) {
val privateKey = myPrivateKey val privateKey = myPrivateKey
var decryptionSuccess = false var decryptionSuccess = false
replyLog(" encrypted format detected (iv:cipher), trying decrypt methods...")
// 🔥 Способ 0: Группа — blob шифруется ключом группы // 🔥 Способ 0: Группа — blob шифруется ключом группы
if (groupPassword != null) { if (groupPassword != null && !decryptionSuccess) {
replyLog(" [0] group raw key (len=${groupPassword.length})")
try { try {
val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword) val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
if (decrypted != null) { if (decrypted != null) {
dataJson = decrypted dataJson = decrypted
decryptionSuccess = true 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: Пробуем расшифровать с приватным ключом (для исходящих // 🔥 Способ 1: Пробуем расшифровать с приватным ключом
// сообщений) if (privateKey != null && !decryptionSuccess) {
if (privateKey != null) { replyLog(" [1] private key")
try { try {
val decrypted = val decrypted =
CryptoManager.decryptWithPassword(dataJson, privateKey) CryptoManager.decryptWithPassword(dataJson, privateKey)
@@ -1998,26 +2034,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} catch (e: Exception) {} } catch (e: Exception) {}
} }
replyLog(" FINAL: decryptionSuccess=$decryptionSuccess")
if (!decryptionSuccess) { if (!decryptionSuccess) {
replyLog(" ALL METHODS FAILED → skip")
continue continue
} }
} else {} } else {
replyLog(" NOT encrypted (no iv:cipher format), treating as plain JSON")
}
val messagesArray = val messagesArray =
try { try {
JSONArray(dataJson) JSONArray(dataJson)
} catch (e: Exception) { } catch (e: Exception) {
replyLog(" JSON parse FAILED: ${e.message?.take(50)}")
replyLog(" dataJson preview: '${dataJson.take(80)}'")
continue continue
} }
replyLog(" JSON OK: ${messagesArray.length()} messages")
if (messagesArray.length() > 0) { if (messagesArray.length() > 0) {
val account = myPublicKey ?: return null val account = myPublicKey ?: return null
val dialogKey = getDialogKey(account, opponentKey ?: "") 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 firstMsg = messagesArray.getJSONObject(0)
val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1 val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1
replyLog(" isForwardedSet=$isForwardedSet, firstMsg keys=${firstMsg.keys().asSequence().toList()}")
if (isForwardedSet) { if (isForwardedSet) {
// 🔥 Parse ALL forwarded messages (desktop parity) // 🔥 Parse ALL forwarded messages (desktop parity)
@@ -2120,6 +2162,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
chachaKeyPlainHex = fwdChachaKeyPlain chachaKeyPlainHex = fwdChachaKeyPlain
)) ))
} }
replyLog(" RESULT: forwarded ${forwardedList.size} messages")
return ParsedReplyResult( return ParsedReplyResult(
replyData = forwardedList.firstOrNull(), replyData = forwardedList.firstOrNull(),
forwardedMessages = forwardedList forwardedMessages = forwardedList
@@ -2135,13 +2178,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val senderNameFromJson = replyMessage.optString("senderName", "") val senderNameFromJson = replyMessage.optString("senderName", "")
val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "") val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "")
// 🔥 Detect forward: explicit flag OR publicKey belongs to a third party // 🔥 Detect forward:
// Desktop doesn't send "forwarded" flag, but if publicKey differs from // - explicit "forwarded" flag always wins
// both myPublicKey and opponentKey — it's a forwarded message from someone else // - third-party heuristic applies ONLY for direct dialogs
val isFromThirdParty = replyPublicKey.isNotEmpty() && // (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 != myPublicKey &&
replyPublicKey != opponentKey replyPublicKey != opponentKey
val isForwarded = replyMessage.optBoolean("forwarded", false) || isFromThirdParty val isForwarded =
replyMessage.optBoolean("forwarded", false) || isFromThirdPartyDirect
// 📸 Парсим attachments из JSON reply (как в Desktop) // 📸 Парсим attachments из JSON reply (как в Desktop)
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>() 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 // 🔥 If this is a forwarded message (from third party), return as forwardedMessages list
// so it renders with "Forwarded from" header (like multi-forward) // so it renders with "Forwarded from" header (like multi-forward)
if (isForwarded) { if (isForwarded) {
replyLog(" RESULT: single forward from=${result.senderName}")
return ParsedReplyResult( return ParsedReplyResult(
replyData = result, replyData = result,
forwardedMessages = listOf(result) forwardedMessages = listOf(result)
) )
} }
replyLog(" RESULT: reply from=${result.senderName}, text='${result.text.take(30)}'")
return ParsedReplyResult(replyData = result) return ParsedReplyResult(replyData = result)
} else {} } else {}
} else {} } else {}

View File

@@ -311,6 +311,21 @@ fun ChatsListScreen(
var themeRevealToDark by remember { mutableStateOf(false) } var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) } 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() { fun startThemeReveal() {
if (themeRevealActive) { if (themeRevealActive) {
@@ -324,7 +339,10 @@ fun ChatsListScreen(
val center = val center =
themeToggleCenterInRoot themeToggleCenterInRoot
?: Offset(rootSize.width * 0.85f, rootSize.height * 0.12f) ?: 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) { if (snapshotBitmap == null) {
onToggleTheme() onToggleTheme()
return return
@@ -333,6 +351,7 @@ fun ChatsListScreen(
val toDark = !isDarkTheme val toDark = !isDarkTheme
val maxRadius = maxRevealRadius(center, rootSize) val maxRadius = maxRevealRadius(center, rootSize)
if (maxRadius <= 0f) { if (maxRadius <= 0f) {
snapshotBitmap.recycle()
onToggleTheme() onToggleTheme()
return return
} }

View File

@@ -962,6 +962,7 @@ fun MessageBubble(
isOutgoing = message.isOutgoing, isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
chachaKey = message.chachaKey, chachaKey = message.chachaKey,
chachaKeyPlainHex = message.chachaKeyPlainHex,
privateKey = privateKey, privateKey = privateKey,
onClick = { onReplyClick(reply.messageId) }, onClick = { onReplyClick(reply.messageId) },
onImageClick = onImageClick, onImageClick = onImageClick,
@@ -2097,6 +2098,7 @@ fun ReplyBubble(
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
chachaKey: String = "", chachaKey: String = "",
chachaKeyPlainHex: String = "",
privateKey: String = "", privateKey: String = "",
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
@@ -2224,7 +2226,10 @@ fun ReplyBubble(
cacheKey = "img_${imageAttachment.id}", cacheKey = "img_${imageAttachment.id}",
context = context, context = context,
senderPublicKey = replyData.senderPublicKey, senderPublicKey = replyData.senderPublicKey,
recipientPrivateKey = replyData.recipientPrivateKey recipientPrivateKey = replyData.recipientPrivateKey,
chachaKeyPlainHex = replyData.chachaKeyPlainHex.ifEmpty {
chachaKeyPlainHex
}
) )
if (bitmap != null) imageBitmap = bitmap 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.animateColorAsState
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -90,87 +92,17 @@ fun OnboardingScreen(
// Theme transition animation // Theme transition animation
var isTransitioning by remember { mutableStateOf(false) } 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 clickPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var shouldUpdateStatusBar by remember { mutableStateOf(false) }
var hasInitialized by remember { mutableStateOf(false) } var hasInitialized by remember { mutableStateOf(false) }
var previousTheme by remember { mutableStateOf(isDarkTheme) } var previousTheme by remember { mutableStateOf(isDarkTheme) }
var targetTheme 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(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 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 // Set initial navigation bar color only on first launch
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -221,7 +153,9 @@ fun OnboardingScreen(
label = "indicatorColor" 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 // Base background - shows the OLD theme color during transition
Box( Box(
modifier = modifier =
@@ -237,15 +171,11 @@ fun OnboardingScreen(
// Circular reveal overlay - draws the NEW theme color expanding // Circular reveal overlay - draws the NEW theme color expanding
if (isTransitioning) { if (isTransitioning) {
Canvas(modifier = Modifier.fillMaxSize()) { 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( drawCircle(
color = color =
if (targetTheme) OnboardingBackground if (targetTheme) OnboardingBackground
else OnboardingBackgroundLight, else OnboardingBackgroundLight,
radius = radius, radius = transitionRadius.value,
center = clickPosition center = clickPosition
) )
} }
@@ -260,6 +190,22 @@ fun OnboardingScreen(
clickPosition = position clickPosition = position
isTransitioning = true isTransitioning = true
onThemeToggle() 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 = modifier =

View File

@@ -132,6 +132,13 @@ fun MyQrCodeScreen(
var rootSize by remember { mutableStateOf(IntSize.Zero) } var rootSize by remember { mutableStateOf(IntSize.Zero) }
var lastRevealTime by remember { mutableLongStateOf(0L) } var lastRevealTime by remember { mutableLongStateOf(0L) }
val revealCooldownMs = 600L 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) { fun startReveal(newIndex: Int, center: Offset) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -142,7 +149,8 @@ fun MyQrCodeScreen(
return return
} }
val snapshot = runCatching { view.drawToBitmap() }.getOrNull() val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
prewarmedBitmap = null
if (snapshot == null) { if (snapshot == null) {
selectedThemeIndex = newIndex selectedThemeIndex = newIndex
return return
@@ -304,7 +312,8 @@ fun MyQrCodeScreen(
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) { if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
lastRevealTime = now lastRevealTime = now
val snapshot = runCatching { view.drawToBitmap() }.getOrNull() val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
prewarmedBitmap = null
if (snapshot != null) { if (snapshot != null) {
val maxR = maxRevealRadius(themeButtonPos, rootSize) val maxR = maxRevealRadius(themeButtonPos, rootSize)
revealActive = true revealActive = true

View File

@@ -99,6 +99,13 @@ fun ThemeScreen(
var themeRevealToDark by remember { mutableStateOf(false) } var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) } 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 lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) } var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) } var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
@@ -130,7 +137,8 @@ fun ThemeScreen(
val center = val center =
centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f) 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) { if (snapshotBitmap == null) {
themeMode = targetMode themeMode = targetMode
onThemeModeChange(targetMode) onThemeModeChange(targetMode)