Фикс: reply в группах с Desktop не отображался (hex key fallback для reply blob). Оптимизация circular reveal (prewarm bitmap). Логи reply парсинга в rosettadev1. Серые миниатюры в медиа (BlurHash). Анимация онбординга на Animatable вместо while-loop.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user