Фикс: 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 ключом сообщения
|
// 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (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: Пробуем расшифровать с приватным ключом (для исходящих
|
// 🔥 Способ 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 {}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user