diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5b68197..81e9a6c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
-val rosettaVersionName = "1.5.0"
-val rosettaVersionCode = 52 // Increment on each release
+val rosettaVersionName = "1.5.1"
+val rosettaVersionCode = 53 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cffe881..2ad9cf4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -47,10 +47,7 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait">
-
-
-
-
+
@@ -65,6 +62,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
startCreateAccountFlow = false
- currentAccount = account
- cacheSessionAccount(account)
+ val normalizedAccount =
+ account?.let {
+ val normalizedName =
+ resolveAccountDisplayName(
+ it.publicKey,
+ it.name,
+ null
+ )
+ if (it.name == normalizedName) it
+ else it.copy(name = normalizedName)
+ }
+ currentAccount = normalizedAccount
+ cacheSessionAccount(normalizedAccount)
hasExistingAccount = true
// Save as last logged account
- account?.let {
+ normalizedAccount?.let {
accountManager.setLastLoggedPublicKey(it.publicKey)
}
+ // Первый запуск после регистрации:
+ // дополнительно перезапускаем auth/connect, чтобы не оставаться
+ // в "залипшем CONNECTING" до ручного рестарта приложения.
+ normalizedAccount?.let { authAccount ->
+ startAuthHandshakeFast(
+ authAccount.publicKey,
+ authAccount.privateKeyHash
+ )
+ scope.launch {
+ repeat(3) { attempt ->
+ if (ProtocolManager.isAuthenticated()) return@launch
+ delay(2000L * (attempt + 1))
+ if (ProtocolManager.isAuthenticated()) return@launch
+ ProtocolManager.reconnectNowIfNeeded(
+ "post_auth_complete_retry_${attempt + 1}"
+ )
+ startAuthHandshakeFast(
+ authAccount.publicKey,
+ authAccount.privateKeyHash
+ )
+ }
+ }
+ }
+
// Reload accounts list
scope.launch {
+ normalizedAccount?.let {
+ // Синхронно помечаем текущий аккаунт активным в DataStore.
+ runCatching {
+ accountManager.setCurrentAccount(it.publicKey)
+ }
+ }
val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { it.toAccountInfo() }
}
@@ -672,6 +721,7 @@ sealed class Screen {
data object CrashLogs : Screen()
data object Biometric : Screen()
data object Appearance : Screen()
+ data object AppIcon : Screen()
data object QrScanner : Screen()
data object MyQr : Screen()
}
@@ -1031,6 +1081,9 @@ fun MainScreen(
val isAppearanceVisible by remember {
derivedStateOf { navStack.any { it is Screen.Appearance } }
}
+ val isAppIconVisible by remember {
+ derivedStateOf { navStack.any { it is Screen.AppIcon } }
+ }
val isQrScannerVisible by remember { derivedStateOf { navStack.any { it is Screen.QrScanner } } }
val isMyQrVisible by remember { derivedStateOf { navStack.any { it is Screen.MyQr } } }
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
@@ -1437,12 +1490,25 @@ fun MainScreen(
}
},
onToggleTheme = onToggleTheme,
+ onAppIconClick = { navStack = navStack + Screen.AppIcon },
accountPublicKey = accountPublicKey,
accountName = accountName,
avatarRepository = avatarRepository
)
}
+ SwipeBackContainer(
+ isVisible = isAppIconVisible,
+ onBack = { navStack = navStack.filterNot { it is Screen.AppIcon } },
+ isDarkTheme = isDarkTheme,
+ layer = 3
+ ) {
+ com.rosetta.messenger.ui.settings.AppIconScreen(
+ isDarkTheme = isDarkTheme,
+ onBack = { navStack = navStack.filterNot { it is Screen.AppIcon } }
+ )
+ }
+
SwipeBackContainer(
isVisible = isUpdatesVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Updates } },
@@ -1469,9 +1535,18 @@ fun MainScreen(
}
}.collectAsState(initial = 0)
+ var chatSelectionActive by remember { mutableStateOf(false) }
+ val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) }
+
SwipeBackContainer(
isVisible = selectedUser != null,
onBack = { popChatAndChildren() },
+ onInterceptSwipeBack = {
+ if (chatSelectionActive) {
+ chatClearSelectionRef.value()
+ true
+ } else false
+ },
isDarkTheme = isDarkTheme,
layer = 1,
swipeEnabled = !isChatSwipeLocked,
@@ -1516,7 +1591,9 @@ fun MainScreen(
avatarRepository = avatarRepository,
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
isCallActive = callUiState.isVisible,
- onOpenCallOverlay = { isCallOverlayExpanded = true }
+ onOpenCallOverlay = { isCallOverlayExpanded = true },
+ onSelectionModeChange = { chatSelectionActive = it },
+ registerClearSelection = { fn -> chatClearSelectionRef.value = fn }
)
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt
index b7def35..b754626 100644
--- a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt
+++ b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt
@@ -45,6 +45,10 @@ object CryptoManager {
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
// расшифровке
private const val DECRYPTION_CACHE_SIZE = 2000
+ // Не кэшируем большие payload (вложения), чтобы избежать OOM на конкатенации cache key
+ // и хранения гигантских plaintext в памяти.
+ private const val MAX_CACHEABLE_ENCRYPTED_CHARS = 64 * 1024
+ private const val MAX_CACHEABLE_DECRYPTED_CHARS = 64 * 1024
private val decryptionCache = ConcurrentHashMap(DECRYPTION_CACHE_SIZE, 0.75f, 4)
init {
@@ -298,17 +302,21 @@ object CryptoManager {
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
*/
fun decryptWithPassword(encryptedData: String, password: String): String? {
+ val useCache = encryptedData.length <= MAX_CACHEABLE_ENCRYPTED_CHARS
+ val cacheKey = if (useCache) "$password:$encryptedData" else null
+
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
- val cacheKey = "$password:$encryptedData"
- decryptionCache[cacheKey]?.let {
- return it
+ if (cacheKey != null) {
+ decryptionCache[cacheKey]?.let {
+ return it
+ }
}
return try {
val result = decryptWithPasswordInternal(encryptedData, password)
// 🚀 Сохраняем в кэш (lock-free)
- if (result != null) {
+ if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
// Ограничиваем размер кэша
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
// Удаляем ~10% самых старых записей
diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt
index bbb555a..b06b8ba 100644
--- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt
+++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt
@@ -30,7 +30,6 @@ data class Message(
val replyToMessageId: String? = null
)
-/** UI модель диалога */
data class Dialog(
val opponentKey: String,
val opponentTitle: String,
@@ -599,6 +598,12 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
dialogDao.updateDialogFromMessages(account, toPublicKey)
+ // Notify listeners (ChatViewModel) that a new message was persisted
+ // so the chat UI reloads from DB. Without this, messages produced by
+ // non-input flows (e.g. CallManager's missed-call attachment) only
+ // appear after the user re-enters the chat.
+ _newMessageEvents.tryEmit(dialogKey)
+
// 📁 Для saved messages - гарантируем создание/обновление dialog
if (isSavedMessages) {
val existing = dialogDao.getDialog(account, account)
@@ -1853,7 +1858,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 +1915,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 +1979,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 +2044,26 @@ class MessageRepository private constructor(private val context: Context) {
}
return jsonArray.toString()
}
+
+ /**
+ * Desktop parity for group attachment blobs:
+ * old payloads may be encrypted with raw group key, new payloads with hex(groupKey bytes).
+ */
+ private fun decryptWithGroupKeyCompat(encryptedBlob: String, groupKey: String): String? {
+ if (encryptedBlob.isBlank() || groupKey.isBlank()) return null
+
+ val rawAttempt = runCatching {
+ CryptoManager.decryptWithPassword(encryptedBlob, groupKey)
+ }.getOrNull()
+ if (rawAttempt != null) return rawAttempt
+
+ val hexKey =
+ groupKey.toByteArray(Charsets.ISO_8859_1)
+ .joinToString("") { "%02x".format(it.toInt() and 0xff) }
+ if (hexKey == groupKey) return null
+
+ return runCatching {
+ CryptoManager.decryptWithPassword(encryptedBlob, hexKey)
+ }.getOrNull()
+ }
}
diff --git a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt
index 8879c92..ea968f7 100644
--- a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt
+++ b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt
@@ -58,6 +58,9 @@ class PreferencesManager(private val context: Context) {
val BACKGROUND_BLUR_COLOR_ID =
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
+ // App Icon disguise: "default", "calculator", "weather", "notes"
+ val APP_ICON = stringPreferencesKey("app_icon")
+
// Pinned Chats (max 3)
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
@@ -333,6 +336,19 @@ class PreferencesManager(private val context: Context) {
return wasPinned
}
+ // ═════════════════════════════════════════════════════════════
+ // 🎨 APP ICON
+ // ═════════════════════════════════════════════════════════════
+
+ val appIcon: Flow =
+ context.dataStore.data.map { preferences ->
+ preferences[APP_ICON] ?: "default"
+ }
+
+ suspend fun setAppIcon(value: String) {
+ context.dataStore.edit { preferences -> preferences[APP_ICON] = value }
+ }
+
// ═════════════════════════════════════════════════════════════
// 🔕 MUTED CHATS
// ═════════════════════════════════════════════════════════════
diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
index d9d663e..392ffda 100644
--- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
+++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
@@ -17,12 +17,22 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
- - Исправлена расшифровка фото в группах (совместимость с Desktop v1.2.1)
- - Исправлен статус доставки: галочки больше не откатываются на часики
- - Исправлен просмотр фото из медиа-галереи профиля
- - Зашифрованные ключи больше не отображаются как подпись к фото
- - Анимация удаления сообщений (плавное сжатие + fade)
- - Фильтрация пустых push-уведомлений
+ - Полностью переработан UX записи голосовых: удержание для записи, отправка по отпусканию, Slide to cancel
+ - Пересобрана панель записи ГС в Telegram-style с новым layout, волнами и анимациями
+ - Добавлена и доработана анимация удаления ГС (корзина), устранены рывки и визуальные артефакты
+ - Исправлены зависания/ANR при записи и отмене голосовых (race-condition, stuck-состояния, watchdog-сценарии)
+ - Исправлены скачки и наложения input-панели во время записи (включая Type message/overlay конфликты)
+ - Добавлены улучшения плеера голосовых: мини-плеер, интеграция в чат, корректная работа скоростей
+ - В чат-листе улучшено отображение и поведение активного воспроизведения голосовых
+ - Добавлена и отшлифована система выделения текста: handles, magnifier, toolbar (Copy/Select All), haptic
+ - Исправлены координаты и стабильность выделения текста в сложных сценариях
+ - Исправлена обработка reply в группах с Desktop (fallback на hex-ключ для reply blob)
+ - Оптимизированы тяжелые UI-сценарии: prewarm для circular reveal, ускорена анимация онбординга
+ - Улучшены миниатюры медиа через BlurHash и стабильность загрузки вложений
+ - Доработан экран звонков и related UI (включая пустой экран с Lottie-анимацией)
+ - Доработаны элементы профиля и сайдбара (включая обновления аккаунт-блока и действий)
+ - Добавлена смена иконки приложения (калькулятор, погода, заметки) через настройки
+ - Выполнен большой пакет фиксов по чатам/звонкам/коннекту и визуальному паритету с Telegram
""".trimIndent()
fun getNotice(version: String): String =
diff --git a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt
index a6e610d..e194ceb 100644
--- a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt
+++ b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt
@@ -9,6 +9,8 @@ enum class AttachmentType(val value: Int) {
FILE(2), // Файл
AVATAR(3), // Аватар пользователя
CALL(4), // Событие звонка (пропущен/принят/завершен)
+ VOICE(5), // Голосовое сообщение
+ VIDEO_CIRCLE(6), // Видео-кружок (video note)
UNKNOWN(-1); // Неизвестный тип
companion object {
diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
index 5bf11cc..ef90b1e 100644
--- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
+++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
@@ -95,7 +95,11 @@ object CallManager {
private const val TAIL_LINES = 300
private const val PROTOCOL_LOG_TAIL_LINES = 180
private const val MAX_LOG_PREFIX = 180
- private const val INCOMING_RING_TIMEOUT_MS = 45_000L
+ // Backend's CallManager.java uses RINGING_TIMEOUT = 30s. Local timeouts are
+ // slightly larger so the server's RINGING_TIMEOUT signal takes precedence when
+ // the network is healthy; local jobs are a fallback when the signal is lost.
+ private const val INCOMING_RING_TIMEOUT_MS = 35_000L
+ private const val OUTGOING_RING_TIMEOUT_MS = 35_000L
private const val CONNECTING_TIMEOUT_MS = 30_000L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -127,6 +131,7 @@ object CallManager {
private var protocolStateJob: Job? = null
private var disconnectResetJob: Job? = null
private var incomingRingTimeoutJob: Job? = null
+ private var outgoingRingTimeoutJob: Job? = null
private var connectingTimeoutJob: Job? = null
private var signalWaiter: ((Packet) -> Unit)? = null
@@ -290,6 +295,18 @@ object CallManager {
)
breadcrumbState("startOutgoingCall")
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
+
+ // Local fallback for caller: if RINGING_TIMEOUT signal from the server is lost,
+ // stop ringing after the same window the server uses (~30s + small buffer).
+ outgoingRingTimeoutJob?.cancel()
+ outgoingRingTimeoutJob = scope.launch {
+ delay(OUTGOING_RING_TIMEOUT_MS)
+ val snap = _state.value
+ if (snap.phase == CallPhase.OUTGOING && snap.peerPublicKey == targetKey) {
+ breadcrumb("startOutgoingCall: local ring timeout (${OUTGOING_RING_TIMEOUT_MS}ms) → reset")
+ resetSession(reason = "No answer", notifyPeer = true)
+ }
+ }
return CallActionResult.STARTED
}
@@ -551,6 +568,9 @@ object CallManager {
breadcrumb("SIG: ACCEPT ignored — role=$role")
return
}
+ // Callee answered before timeout — cancel outgoing ring timer
+ outgoingRingTimeoutJob?.cancel()
+ outgoingRingTimeoutJob = null
if (localPrivateKey == null || localPublicKey == null) {
breadcrumb("SIG: ACCEPT — generating local session keys")
generateSessionKeys()
@@ -1033,9 +1053,14 @@ object CallManager {
preview = durationSec.toString()
)
+ // Capture role synchronously before the coroutine launches, because
+ // resetSession() sets role = null right after calling this function —
+ // otherwise the async check below would fall through to the callee branch.
+ val capturedRole = role
+
scope.launch {
runCatching {
- if (role == CallRole.CALLER) {
+ if (capturedRole == CallRole.CALLER) {
// CALLER: send call attachment as a message (peer will receive it)
MessageRepository.getInstance(context).sendMessage(
toPublicKey = peerPublicKey,
@@ -1082,6 +1107,8 @@ object CallManager {
disconnectResetJob = null
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
+ outgoingRingTimeoutJob?.cancel()
+ outgoingRingTimeoutJob = null
// Play end call sound, then stop all
if (wasActive) {
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt
index f0a7813..65943f4 100644
--- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt
+++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt
@@ -35,6 +35,7 @@ class Protocol(
private const val TAG = "RosettaProtocol"
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
+ private const val CONNECTING_STUCK_TIMEOUT_MS = 15_000L
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
@@ -182,6 +183,7 @@ class Protocol(
private var lastSuccessfulConnection = 0L
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
private var isConnecting = false // Флаг для защиты от одновременных подключений
+ private var connectingSinceMs = 0L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -385,6 +387,7 @@ class Protocol(
*/
fun connect() {
val currentState = _state.value
+ val now = System.currentTimeMillis()
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
@@ -403,10 +406,20 @@ class Protocol(
return
}
- // КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
+ // КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
+ // Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
if (isConnecting || currentState == ProtocolState.CONNECTING) {
- log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
- return
+ val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
+ if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
+ log("⚠️ Already connecting, skipping... (elapsed=${elapsed}ms)")
+ return
+ }
+ log("🧯 CONNECTING STUCK detected (elapsed=${elapsed}ms) -> forcing reconnect reset")
+ isConnecting = false
+ connectingSinceMs = 0L
+ runCatching { webSocket?.cancel() }
+ webSocket = null
+ setState(ProtocolState.DISCONNECTED, "Reset stuck CONNECTING (${elapsed}ms)")
}
val networkReady = isNetworkAvailable?.invoke() ?: true
@@ -424,6 +437,7 @@ class Protocol(
// Устанавливаем флаг ПЕРЕД любыми операциями
isConnecting = true
+ connectingSinceMs = now
reconnectAttempts++
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
@@ -455,6 +469,7 @@ class Protocol(
// Сбрасываем флаг подключения
isConnecting = false
+ connectingSinceMs = 0L
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
// Flush queue as soon as socket is open.
@@ -500,6 +515,7 @@ class Protocol(
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
isConnecting = false // Сбрасываем флаг
+ connectingSinceMs = 0L
handleDisconnect()
}
@@ -511,6 +527,7 @@ class Protocol(
log(" Reconnect attempts: $reconnectAttempts")
t.printStackTrace()
isConnecting = false // Сбрасываем флаг
+ connectingSinceMs = 0L
_lastError.value = t.message
handleDisconnect()
}
@@ -801,6 +818,7 @@ class Protocol(
log("🔌 Manual disconnect requested")
isManuallyClosed = true
isConnecting = false // Сбрасываем флаг
+ connectingSinceMs = 0L
reconnectJob?.cancel() // Отменяем запланированные переподключения
reconnectJob = null
handshakeJob?.cancel()
@@ -823,6 +841,7 @@ class Protocol(
fun reconnectNowIfNeeded(reason: String = "foreground") {
val currentState = _state.value
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
+ val now = System.currentTimeMillis()
log(
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
@@ -830,12 +849,22 @@ class Protocol(
if (!hasCredentials) return
- if (
+ if (currentState == ProtocolState.CONNECTING && isConnecting) {
+ val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
+ if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
+ return
+ }
+ log("🧯 FAST RECONNECT: stuck CONNECTING (${elapsed}ms) -> reset and reconnect")
+ isConnecting = false
+ connectingSinceMs = 0L
+ runCatching { webSocket?.cancel() }
+ webSocket = null
+ setState(ProtocolState.DISCONNECTED, "Fast reconnect reset stuck CONNECTING")
+ } else if (
currentState == ProtocolState.AUTHENTICATED ||
currentState == ProtocolState.HANDSHAKING ||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
- currentState == ProtocolState.CONNECTED ||
- (currentState == ProtocolState.CONNECTING && isConnecting)
+ currentState == ProtocolState.CONNECTED
) {
return
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt
index 5c084a2..f3861bf 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt
@@ -102,6 +102,7 @@ import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
import com.rosetta.messenger.ui.chats.components.*
+import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
import com.rosetta.messenger.ui.chats.input.*
@@ -324,7 +325,9 @@ fun ChatDetailScreen(
avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {},
isCallActive: Boolean = false,
- onOpenCallOverlay: () -> Unit = {}
+ onOpenCallOverlay: () -> Unit = {},
+ onSelectionModeChange: (Boolean) -> Unit = {},
+ registerClearSelection: (() -> Unit) -> Unit = {}
) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current
@@ -389,11 +392,23 @@ fun ChatDetailScreen(
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
var selectedMessages by remember { mutableStateOf>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty()
+
+ // Notify parent about selection mode changes so it can intercept swipe-back
+ LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) }
+ // Register selection-clear callback so parent can cancel selection on swipe-back
+ DisposableEffect(Unit) {
+ registerClearSelection { selectedMessages = emptySet() }
+ onDispose { registerClearSelection {} }
+ }
// После long press AndroidView текста может прислать tap на отпускание.
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
var longPressSuppressedMessageId by remember { mutableStateOf(null) }
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
+ // 🔤 TEXT SELECTION - Telegram-style character-level selection
+ val textSelectionHelper = remember { com.rosetta.messenger.ui.chats.components.TextSelectionHelper() }
+ LaunchedEffect(Unit) { textSelectionHelper.setMagnifierView(view) }
+
// 💬 MESSAGE CONTEXT MENU STATE
var contextMenuMessage by remember { mutableStateOf(null) }
var showContextMenu by remember { mutableStateOf(false) }
@@ -437,11 +452,29 @@ fun ChatDetailScreen(
showEmojiPicker = false
}
- // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
+ // 🔥 Принудительное закрытие экрана (используется в explicit actions вроде Delete chat)
val hideKeyboardAndBack: () -> Unit = {
hideInputOverlays()
onBack()
}
+ // 🔥 Поведение как у нативного Android back:
+ // сначала закрываем IME/emoji, и только следующим back выходим из чата.
+ val handleBackWithInputPriority: () -> Unit = {
+ val imeVisible =
+ androidx.core.view.ViewCompat.getRootWindowInsets(view)
+ ?.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()) == true
+ val hasInputOverlay =
+ showEmojiPicker ||
+ coordinator.isEmojiBoxVisible ||
+ coordinator.isKeyboardVisible ||
+ imeVisible
+
+ if (hasInputOverlay) {
+ hideInputOverlays()
+ } else {
+ onBack()
+ }
+ }
// Определяем это Saved Messages или обычный чат
val isSavedMessages = user.publicKey == currentUserPublicKey
@@ -611,6 +644,8 @@ fun ChatDetailScreen(
showImageViewer,
showMediaPicker,
showEmojiPicker,
+ textSelectionHelper.isActive,
+ textSelectionHelper.movingHandle,
pendingCameraPhotoUri,
pendingGalleryImages,
showInAppCamera,
@@ -620,6 +655,8 @@ fun ChatDetailScreen(
showImageViewer ||
showMediaPicker ||
showEmojiPicker ||
+ textSelectionHelper.isActive ||
+ textSelectionHelper.movingHandle ||
pendingCameraPhotoUri != null ||
pendingGalleryImages.isNotEmpty() ||
showInAppCamera ||
@@ -838,6 +875,7 @@ fun ChatDetailScreen(
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
{ messageId, canSelect ->
+ textSelectionHelper.clear()
if (canSelect && !selectedMessages.contains(messageId)) {
selectedMessages = selectedMessages + messageId
}
@@ -886,6 +924,13 @@ fun ChatDetailScreen(
}
}
+ // 🔤 Сброс текстового выделения при скролле
+ LaunchedEffect(listState.isScrollInProgress) {
+ if (listState.isScrollInProgress && textSelectionHelper.isActive) {
+ textSelectionHelper.clear()
+ }
+ }
+
// 🔥 Display reply messages - получаем полную информацию о сообщениях для reply
val displayReplyMessages =
remember(replyMessages, messages) {
@@ -1325,10 +1370,10 @@ fun ChatDetailScreen(
// 🔥 Обработка системной кнопки назад
BackHandler {
- if (isInChatSearchMode) {
- closeInChatSearch()
- } else {
- hideKeyboardAndBack()
+ when {
+ isSelectionMode -> selectedMessages = emptySet()
+ isInChatSearchMode -> closeInChatSearch()
+ else -> handleBackWithInputPriority()
}
}
@@ -1843,7 +1888,7 @@ fun ChatDetailScreen(
Box {
IconButton(
onClick =
- hideKeyboardAndBack,
+ handleBackWithInputPriority,
modifier =
Modifier.size(
40.dp
@@ -2289,6 +2334,36 @@ fun ChatDetailScreen(
avatarRepository = avatarRepository
)
}
+ // Voice mini player — shown right under the chat header when audio is playing
+ val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
+ val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
+ val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
+ val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
+ val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
+ AnimatedVisibility(
+ visible = !playingVoiceAttachmentId.isNullOrBlank(),
+ enter = expandVertically(
+ animationSpec = tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing),
+ expandFrom = Alignment.Top
+ ) + fadeIn(animationSpec = tween(220)),
+ exit = shrinkVertically(
+ animationSpec = tween(260, easing = androidx.compose.animation.core.FastOutSlowInEasing),
+ shrinkTowards = Alignment.Top
+ ) + fadeOut(animationSpec = tween(180))
+ ) {
+ val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" }
+ val time = playingVoiceTimeLabel.trim()
+ val voiceTitle = if (time.isBlank()) sender else "$sender at $time"
+ VoiceTopMiniPlayer(
+ title = voiceTitle,
+ isDarkTheme = isDarkTheme,
+ isPlaying = isVoicePlaybackRunning,
+ speed = voicePlaybackSpeed,
+ onTogglePlay = { VoicePlaybackCoordinator.toggleCurrentPlayback() },
+ onCycleSpeed = { VoicePlaybackCoordinator.cycleSpeed() },
+ onClose = { VoicePlaybackCoordinator.stop() }
+ )
+ }
} // Закрытие Column topBar
},
containerColor = backgroundColor, // Фон всего чата
@@ -2679,6 +2754,20 @@ fun ChatDetailScreen(
isSendingMessage = false
}
},
+ onSendVoiceMessage = { voiceHex, durationSec, waves ->
+ isSendingMessage = true
+ viewModel.sendVoiceMessage(
+ voiceHex = voiceHex,
+ durationSec = durationSec,
+ waves = waves
+ )
+ scope.launch {
+ delay(120)
+ listState.animateScrollToItem(0)
+ delay(220)
+ isSendingMessage = false
+ }
+ },
isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor,
textColor = textColor,
@@ -3011,6 +3100,7 @@ fun ChatDetailScreen(
else -> {
LazyColumn(
state = listState,
+ userScrollEnabled = !textSelectionHelper.movingHandle,
modifier =
Modifier.fillMaxSize()
.nestedScroll(
@@ -3150,6 +3240,8 @@ fun ChatDetailScreen(
MessageBubble(
message =
message,
+ textSelectionHelper =
+ textSelectionHelper,
isDarkTheme =
isDarkTheme,
hasWallpaper =
@@ -3630,6 +3722,11 @@ fun ChatDetailScreen(
}
}
}
+ // 🔤 Text selection overlay
+ com.rosetta.messenger.ui.chats.components.TextSelectionOverlay(
+ helper = textSelectionHelper,
+ modifier = Modifier.fillMaxSize()
+ )
}
}
}
@@ -3691,16 +3788,32 @@ fun ChatDetailScreen(
onMediaSelected = { selectedMedia, caption ->
val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
- if (imageUris.isNotEmpty()) {
+ val videoUris =
+ selectedMedia.filter { it.isVideo }.map { it.uri }
+ if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) {
showMediaPicker = false
inputFocusTrigger++
- viewModel.sendImageGroupFromUris(imageUris, caption)
+ if (imageUris.isNotEmpty()) {
+ viewModel.sendImageGroupFromUris(
+ imageUris,
+ caption
+ )
+ }
+ if (videoUris.isNotEmpty()) {
+ videoUris.forEach { uri ->
+ viewModel.sendVideoCircleFromUri(uri)
+ }
+ }
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false
inputFocusTrigger++
- viewModel.sendImageFromUri(mediaItem.uri, caption)
+ if (mediaItem.isVideo) {
+ viewModel.sendVideoCircleFromUri(mediaItem.uri)
+ } else {
+ viewModel.sendImageFromUri(mediaItem.uri, caption)
+ }
},
onOpenCamera = {
val imm =
@@ -3792,16 +3905,32 @@ fun ChatDetailScreen(
onMediaSelected = { selectedMedia, caption ->
val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
- if (imageUris.isNotEmpty()) {
+ val videoUris =
+ selectedMedia.filter { it.isVideo }.map { it.uri }
+ if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) {
showMediaPicker = false
inputFocusTrigger++
- viewModel.sendImageGroupFromUris(imageUris, caption)
+ if (imageUris.isNotEmpty()) {
+ viewModel.sendImageGroupFromUris(
+ imageUris,
+ caption
+ )
+ }
+ if (videoUris.isNotEmpty()) {
+ videoUris.forEach { uri ->
+ viewModel.sendVideoCircleFromUri(uri)
+ }
+ }
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false
inputFocusTrigger++
- viewModel.sendImageFromUri(mediaItem.uri, caption)
+ if (mediaItem.isVideo) {
+ viewModel.sendVideoCircleFromUri(mediaItem.uri)
+ } else {
+ viewModel.sendImageFromUri(mediaItem.uri, caption)
+ }
},
onOpenCamera = {
val imm =
@@ -4258,6 +4387,7 @@ private fun ChatInputBarSection(
viewModel: ChatViewModel,
isSavedMessages: Boolean,
onSend: () -> Unit,
+ onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List) -> Unit,
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
@@ -4295,6 +4425,7 @@ private fun ChatInputBarSection(
}
},
onSend = onSend,
+ onSendVoiceMessage = onSendVoiceMessage,
isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor,
textColor = textColor,
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
index 9fd0e16..94e1a7a 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
@@ -3,7 +3,9 @@ package com.rosetta.messenger.ui.chats
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
+import android.media.MediaMetadataRetriever
import android.util.Base64
+import android.webkit.MimeTypeMap
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
@@ -656,7 +658,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
when (parseAttachmentType(attachment)) {
AttachmentType.IMAGE,
AttachmentType.FILE,
- AttachmentType.AVATAR -> {
+ AttachmentType.AVATAR,
+ AttachmentType.VIDEO_CIRCLE -> {
hasMediaAttachment = true
if (attachment.optString("localUri", "").isNotBlank()) {
// Локальный URI ещё есть => загрузка/подготовка не завершена.
@@ -853,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isOutgoing = fm.isOutgoing,
publicKey = fm.senderPublicKey,
senderName = fm.senderName,
- attachments = fm.attachments
+ attachments = fm.attachments,
+ chachaKeyPlainHex = fm.chachaKeyPlain
)
}
_isForwardMode.value = true
@@ -1625,6 +1629,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
"file" -> AttachmentType.FILE.value
"avatar" -> AttachmentType.AVATAR.value
"call" -> AttachmentType.CALL.value
+ "voice" -> AttachmentType.VOICE.value
+ "video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" ->
+ AttachmentType.VIDEO_CIRCLE.value
else -> -1
}
}
@@ -1792,9 +1799,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
)
)
- // 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
+ // 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой
if ((effectiveType == AttachmentType.IMAGE ||
- effectiveType == AttachmentType.AVATAR) &&
+ effectiveType == AttachmentType.AVATAR ||
+ effectiveType == AttachmentType.VOICE ||
+ effectiveType == AttachmentType.VIDEO_CIRCLE) &&
blob.isEmpty() &&
attachmentId.isNotEmpty()
) {
@@ -1872,6 +1881,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val forwardedMessages: List = emptyList()
)
+ private fun replyLog(msg: String) {
+ try {
+ val ctx = getApplication()
+ val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
+ val dir = java.io.File(ctx.filesDir, "crash_reports")
+ if (!dir.exists()) dir.mkdirs()
+ java.io.File(dir, "rosettadev1.txt").appendText("$ts [Reply] $msg\n")
+ } catch (_: Exception) {}
+ }
+
private suspend fun parseReplyFromAttachments(
attachmentsJson: String,
isFromMe: Boolean,
@@ -1887,26 +1906,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 +1938,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (dataJson.contains(":") && dataJson.split(":").size == 2) {
val privateKey = myPrivateKey
var decryptionSuccess = false
+ replyLog(" encrypted format detected (iv:cipher), trying decrypt methods...")
// 🔥 Способ 0: Группа — blob шифруется ключом группы
- if (groupPassword != null) {
+ if (groupPassword != null && !decryptionSuccess) {
+ replyLog(" [0] group raw key (len=${groupPassword.length})")
try {
val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
if (decrypted != null) {
dataJson = decrypted
decryptionSuccess = true
+ replyLog(" [0] OK raw key")
+ } else {
+ replyLog(" [0] raw key → null")
}
- } catch (_: Exception) {}
+ } catch (e: Exception) { replyLog(" [0] raw key EXCEPTION: ${e.message}") }
+ // Fallback: Desktop v1.2.1+ шифрует hex-версией ключа
+ if (!decryptionSuccess) {
+ try {
+ val hexKey = groupPassword.toByteArray(Charsets.ISO_8859_1)
+ .joinToString("") { "%02x".format(it.toInt() and 0xff) }
+ replyLog(" [0] trying hex key (len=${hexKey.length})")
+ val decrypted = CryptoManager.decryptWithPassword(dataJson, hexKey)
+ if (decrypted != null) {
+ dataJson = decrypted
+ decryptionSuccess = true
+ replyLog(" [0] OK hex key")
+ } else {
+ replyLog(" [0] hex key → null")
+ }
+ } catch (e: Exception) { replyLog(" [0] hex key EXCEPTION: ${e.message}") }
+ }
}
- // 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих
- // сообщений)
- if (privateKey != null) {
+ // 🔥 Способ 1: Пробуем расшифровать с приватным ключом
+ if (privateKey != null && !decryptionSuccess) {
+ replyLog(" [1] private key")
try {
val decrypted =
CryptoManager.decryptWithPassword(dataJson, privateKey)
@@ -1998,26 +2043,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)
@@ -2110,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
forwardedList.add(ReplyData(
messageId = fwdMessageId,
senderName = senderDisplayName,
- text = fwdText,
+ text = resolveReplyPreviewText(fwdText, fwdAttachments),
isFromMe = fwdIsFromMe,
isForwarded = true,
forwardedFromName = senderDisplayName,
@@ -2120,6 +2171,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
chachaKeyPlainHex = fwdChachaKeyPlain
))
}
+ replyLog(" RESULT: forwarded ${forwardedList.size} messages")
return ParsedReplyResult(
replyData = forwardedList.firstOrNull(),
forwardedMessages = forwardedList
@@ -2135,13 +2187,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()
@@ -2291,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData(
messageId = realMessageId,
senderName = resolvedSenderName,
- text = replyText,
+ text = resolveReplyPreviewText(replyText, originalAttachments),
isFromMe = isReplyFromMe,
isForwarded = isForwarded,
forwardedFromName = forwardFromDisplay,
@@ -2308,11 +2364,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 {}
@@ -2444,6 +2502,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
+ private fun resolveReplyPreviewText(
+ text: String,
+ attachments: List
+ ): String {
+ if (text.isNotBlank()) return text
+ return when {
+ attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
+ attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
+ else -> text
+ }
+ }
+
/**
* 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для
* правильного отображения цитаты
@@ -2458,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
msg.senderPublicKey.trim().ifEmpty {
if (msg.isOutgoing) sender else opponent
}
+ val resolvedAttachments =
+ msg.attachments
+ .filter { it.type != AttachmentType.MESSAGES }
ReplyMessage(
messageId = msg.id,
- text = msg.text,
+ text = resolveReplyPreviewText(msg.text, resolvedAttachments),
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
publicKey = resolvedPublicKey,
senderName = msg.senderName,
- attachments =
- msg.attachments
- .filter { it.type != AttachmentType.MESSAGES },
+ attachments = resolvedAttachments,
chachaKeyPlainHex = msg.chachaKeyPlainHex
)
}
@@ -2485,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
msg.senderPublicKey.trim().ifEmpty {
if (msg.isOutgoing) sender else opponent
}
+ val resolvedAttachments =
+ msg.attachments
+ .filter { it.type != AttachmentType.MESSAGES }
ReplyMessage(
messageId = msg.id,
- text = msg.text,
+ text = resolveReplyPreviewText(msg.text, resolvedAttachments),
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
publicKey = resolvedPublicKey,
senderName = msg.senderName,
- attachments =
- msg.attachments
- .filter { it.type != AttachmentType.MESSAGES }
+ attachments = resolvedAttachments,
+ chachaKeyPlainHex = msg.chachaKeyPlainHex
)
}
_isForwardMode.value = true
@@ -2517,6 +2590,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
+ message.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message"
+ message.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video message"
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
message.replyData != null -> "Reply"
else -> "Pinned message"
@@ -2883,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData(
messageId = firstReply.messageId,
senderName = firstReplySenderName,
- text = firstReply.text,
+ text = resolveReplyPreviewText(firstReply.text, replyAttachments),
isFromMe = firstReply.isOutgoing,
isForwarded = isForward,
forwardedFromName =
@@ -2913,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData(
messageId = msg.messageId,
senderName = senderDisplayName,
- text = msg.text,
+ text = resolveReplyPreviewText(msg.text, resolvedAttachments),
isFromMe = msg.isOutgoing,
isForwarded = true,
forwardedFromName = senderDisplayName,
@@ -3084,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (isForwardToSend) {
put("forwarded", true)
put("senderName", msg.senderName)
+ if (msg.chachaKeyPlainHex.isNotEmpty()) {
+ put("chacha_key_plain", msg.chachaKeyPlainHex)
+ }
}
}
replyJsonArray.put(replyJson)
@@ -4757,6 +4835,530 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
+ private data class VideoCircleMeta(
+ val durationSec: Int,
+ val width: Int,
+ val height: Int,
+ val mimeType: String
+ )
+
+ private fun bytesToHex(bytes: ByteArray): String {
+ val hexChars = "0123456789abcdef".toCharArray()
+ val output = CharArray(bytes.size * 2)
+ var index = 0
+ bytes.forEach { byte ->
+ val value = byte.toInt() and 0xFF
+ output[index++] = hexChars[value ushr 4]
+ output[index++] = hexChars[value and 0x0F]
+ }
+ return String(output)
+ }
+
+ private fun resolveVideoCircleMeta(
+ application: Application,
+ videoUri: android.net.Uri
+ ): VideoCircleMeta {
+ var durationSec = 1
+ var width = 0
+ var height = 0
+
+ val mimeType =
+ application.contentResolver.getType(videoUri)?.trim().orEmpty().ifBlank {
+ val ext =
+ MimeTypeMap.getFileExtensionFromUrl(videoUri.toString())
+ ?.lowercase(Locale.ROOT)
+ ?: ""
+ MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "video/mp4"
+ }
+
+ runCatching {
+ val retriever = MediaMetadataRetriever()
+ retriever.setDataSource(application, videoUri)
+ val durationMs =
+ retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_DURATION
+ )
+ ?.toLongOrNull()
+ ?: 0L
+ val rawWidth =
+ retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH
+ )
+ ?.toIntOrNull()
+ ?: 0
+ val rawHeight =
+ retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT
+ )
+ ?.toIntOrNull()
+ ?: 0
+ val rotation =
+ retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION
+ )
+ ?.toIntOrNull()
+ ?: 0
+ retriever.release()
+
+ durationSec = ((durationMs + 999L) / 1000L).toInt().coerceAtLeast(1)
+ val rotated = rotation == 90 || rotation == 270
+ width = if (rotated) rawHeight else rawWidth
+ height = if (rotated) rawWidth else rawHeight
+ }
+
+ return VideoCircleMeta(
+ durationSec = durationSec,
+ width = width.coerceAtLeast(0),
+ height = height.coerceAtLeast(0),
+ mimeType = mimeType
+ )
+ }
+
+ private suspend fun encodeVideoUriToHex(
+ application: Application,
+ videoUri: android.net.Uri
+ ): String? {
+ return withContext(Dispatchers.IO) {
+ runCatching {
+ application.contentResolver.openInputStream(videoUri)?.use { stream ->
+ val bytes = stream.readBytes()
+ if (bytes.isEmpty()) null else bytesToHex(bytes)
+ }
+ }.getOrNull()
+ }
+ }
+
+ /**
+ * 🎥 Отправка видео-кружка (video note) из URI.
+ * Использует такой же transport + шифрование пайплайн, как voice attachment.
+ */
+ fun sendVideoCircleFromUri(videoUri: android.net.Uri) {
+ val recipient = opponentKey
+ val sender = myPublicKey
+ val privateKey = myPrivateKey
+ val context = getApplication()
+
+ if (recipient == null || sender == null || privateKey == null) {
+ return
+ }
+ if (isSending) {
+ return
+ }
+
+ val fileSize = runCatching { com.rosetta.messenger.utils.MediaUtils.getFileSize(context, videoUri) }
+ .getOrDefault(0L)
+ val maxBytes = com.rosetta.messenger.utils.MediaUtils.MAX_FILE_SIZE_MB * 1024L * 1024L
+ if (fileSize > 0L && fileSize > maxBytes) {
+ return
+ }
+
+ isSending = true
+
+ val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
+ val timestamp = System.currentTimeMillis()
+ val attachmentId = "video_circle_$timestamp"
+ val meta = resolveVideoCircleMeta(context, videoUri)
+ val preview = "${meta.durationSec}::${meta.mimeType}"
+
+ val optimisticMessage =
+ ChatMessage(
+ id = messageId,
+ text = "",
+ isOutgoing = true,
+ timestamp = Date(timestamp),
+ status = MessageStatus.SENDING,
+ attachments =
+ listOf(
+ MessageAttachment(
+ id = attachmentId,
+ blob = "",
+ type = AttachmentType.VIDEO_CIRCLE,
+ preview = preview,
+ width = meta.width,
+ height = meta.height,
+ localUri = videoUri.toString()
+ )
+ )
+ )
+ addMessageSafely(optimisticMessage)
+ _inputText.value = ""
+
+ backgroundUploadScope.launch {
+ try {
+ val optimisticAttachmentsJson =
+ JSONArray()
+ .apply {
+ put(
+ JSONObject().apply {
+ put("id", attachmentId)
+ put("type", AttachmentType.VIDEO_CIRCLE.value)
+ put("preview", preview)
+ put("blob", "")
+ put("width", meta.width)
+ put("height", meta.height)
+ put("localUri", videoUri.toString())
+ }
+ )
+ }
+ .toString()
+
+ saveMessageToDatabase(
+ messageId = messageId,
+ text = "",
+ encryptedContent = "",
+ encryptedKey = "",
+ timestamp = timestamp,
+ isFromMe = true,
+ delivered = 0,
+ attachmentsJson = optimisticAttachmentsJson,
+ opponentPublicKey = recipient
+ )
+ saveDialog("Video message", timestamp, opponentPublicKey = recipient)
+ } catch (_: Exception) {
+ }
+
+ try {
+ val videoHex = encodeVideoUriToHex(context, videoUri)
+ if (videoHex.isNullOrBlank()) {
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.ERROR)
+ }
+ return@launch
+ }
+ sendVideoCircleMessageInternal(
+ messageId = messageId,
+ attachmentId = attachmentId,
+ timestamp = timestamp,
+ videoHex = videoHex,
+ preview = preview,
+ width = meta.width,
+ height = meta.height,
+ recipient = recipient,
+ sender = sender,
+ privateKey = privateKey
+ )
+ } catch (_: Exception) {
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.ERROR)
+ }
+ updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
+ } finally {
+ isSending = false
+ }
+ }
+ }
+
+ private suspend fun sendVideoCircleMessageInternal(
+ messageId: String,
+ attachmentId: String,
+ timestamp: Long,
+ videoHex: String,
+ preview: String,
+ width: Int,
+ height: Int,
+ recipient: String,
+ sender: String,
+ privateKey: String
+ ) {
+ var packetSentToProtocol = false
+ try {
+ val application = getApplication()
+
+ val encryptionContext =
+ buildEncryptionContext(
+ plaintext = "",
+ recipient = recipient,
+ privateKey = privateKey
+ ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
+ val encryptedContent = encryptionContext.encryptedContent
+ val encryptedKey = encryptionContext.encryptedKey
+ val aesChachaKey = encryptionContext.aesChachaKey
+ val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
+
+ val encryptedVideoBlob = encryptAttachmentPayload(videoHex, encryptionContext)
+
+ val isSavedMessages = (sender == recipient)
+ val uploadTag =
+ if (!isSavedMessages) {
+ TransportManager.uploadFile(attachmentId, encryptedVideoBlob)
+ } else {
+ ""
+ }
+ val attachmentTransportServer =
+ if (uploadTag.isNotEmpty()) {
+ TransportManager.getTransportServer().orEmpty()
+ } else {
+ ""
+ }
+
+ val videoAttachment =
+ MessageAttachment(
+ id = attachmentId,
+ blob = "",
+ type = AttachmentType.VIDEO_CIRCLE,
+ preview = preview,
+ width = width,
+ height = height,
+ transportTag = uploadTag,
+ transportServer = attachmentTransportServer
+ )
+
+ val packet =
+ PacketMessage().apply {
+ fromPublicKey = sender
+ toPublicKey = recipient
+ content = encryptedContent
+ chachaKey = encryptedKey
+ this.aesChachaKey = aesChachaKey
+ this.timestamp = timestamp
+ this.privateKey = privateKeyHash
+ this.messageId = messageId
+ attachments = listOf(videoAttachment)
+ }
+
+ if (!isSavedMessages) {
+ ProtocolManager.send(packet)
+ packetSentToProtocol = true
+ }
+
+ runCatching {
+ AttachmentFileManager.saveAttachment(
+ context = application,
+ blob = videoHex,
+ attachmentId = attachmentId,
+ publicKey = sender,
+ privateKey = privateKey
+ )
+ }
+
+ val attachmentsJson =
+ JSONArray()
+ .apply {
+ put(
+ JSONObject().apply {
+ put("id", attachmentId)
+ put("type", AttachmentType.VIDEO_CIRCLE.value)
+ put("preview", preview)
+ put("blob", "")
+ put("width", width)
+ put("height", height)
+ put("transportTag", uploadTag)
+ put("transportServer", attachmentTransportServer)
+ }
+ )
+ }
+ .toString()
+
+ updateMessageStatusAndAttachmentsInDb(
+ messageId = messageId,
+ delivered = if (isSavedMessages) 1 else 0,
+ attachmentsJson = attachmentsJson
+ )
+
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.SENT)
+ updateMessageAttachments(messageId, null)
+ }
+ saveDialog("Video message", timestamp, opponentPublicKey = recipient)
+ } catch (_: Exception) {
+ if (packetSentToProtocol) {
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.SENT)
+ }
+ } else {
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.ERROR)
+ }
+ }
+ }
+ }
+
+ /**
+ * 🎙️ Отправка голосового сообщения.
+ * blob хранится как HEX строка opus/webm байт (desktop parity).
+ * preview формат: "::"
+ */
+ fun sendVoiceMessage(
+ voiceHex: String,
+ durationSec: Int,
+ waves: List
+ ) {
+ val recipient = opponentKey
+ val sender = myPublicKey
+ val privateKey = myPrivateKey
+
+ if (recipient == null || sender == null || privateKey == null) {
+ return
+ }
+ if (isSending) {
+ return
+ }
+
+ val normalizedVoiceHex = voiceHex.trim()
+ if (normalizedVoiceHex.isEmpty()) {
+ return
+ }
+
+ val normalizedDuration = durationSec.coerceAtLeast(1)
+ val normalizedWaves =
+ waves.asSequence()
+ .map { it.coerceIn(0f, 1f) }
+ .take(120)
+ .toList()
+ val wavesPreview =
+ normalizedWaves.joinToString(",") {
+ String.format(Locale.US, "%.3f", it)
+ }
+ val preview = "$normalizedDuration::$wavesPreview"
+
+ isSending = true
+
+ val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
+ val timestamp = System.currentTimeMillis()
+ val attachmentId = "voice_$timestamp"
+
+ // 1. 🚀 Optimistic UI
+ val optimisticMessage =
+ ChatMessage(
+ id = messageId,
+ text = "",
+ isOutgoing = true,
+ timestamp = Date(timestamp),
+ status = MessageStatus.SENDING,
+ attachments =
+ listOf(
+ MessageAttachment(
+ id = attachmentId,
+ type = AttachmentType.VOICE,
+ preview = preview,
+ blob = normalizedVoiceHex
+ )
+ )
+ )
+ addMessageSafely(optimisticMessage)
+ _inputText.value = ""
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val encryptionContext =
+ buildEncryptionContext(
+ plaintext = "",
+ recipient = recipient,
+ privateKey = privateKey
+ ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
+ val encryptedContent = encryptionContext.encryptedContent
+ val encryptedKey = encryptionContext.encryptedKey
+ val aesChachaKey = encryptionContext.aesChachaKey
+
+ val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
+
+ val encryptedVoiceBlob =
+ encryptAttachmentPayload(normalizedVoiceHex, encryptionContext)
+
+ val isSavedMessages = (sender == recipient)
+ var uploadTag = ""
+ if (!isSavedMessages) {
+ uploadTag = TransportManager.uploadFile(attachmentId, encryptedVoiceBlob)
+ }
+ val attachmentTransportServer =
+ if (uploadTag.isNotEmpty()) {
+ TransportManager.getTransportServer().orEmpty()
+ } else {
+ ""
+ }
+
+ val voiceAttachment =
+ MessageAttachment(
+ id = attachmentId,
+ blob = "",
+ type = AttachmentType.VOICE,
+ preview = preview,
+ transportTag = uploadTag,
+ transportServer = attachmentTransportServer
+ )
+
+ val packet =
+ PacketMessage().apply {
+ fromPublicKey = sender
+ toPublicKey = recipient
+ content = encryptedContent
+ chachaKey = encryptedKey
+ this.aesChachaKey = aesChachaKey
+ this.timestamp = timestamp
+ this.privateKey = privateKeyHash
+ this.messageId = messageId
+ attachments = listOf(voiceAttachment)
+ }
+
+ if (!isSavedMessages) {
+ ProtocolManager.send(packet)
+ }
+
+ // Для отправителя сохраняем voice blob локально в encrypted cache.
+ runCatching {
+ AttachmentFileManager.saveAttachment(
+ context = getApplication(),
+ blob = normalizedVoiceHex,
+ attachmentId = attachmentId,
+ publicKey = sender,
+ privateKey = privateKey
+ )
+ }
+
+ val attachmentsJson =
+ JSONArray()
+ .apply {
+ put(
+ JSONObject().apply {
+ put("id", attachmentId)
+ put("type", AttachmentType.VOICE.value)
+ put("preview", preview)
+ put("blob", "")
+ put("transportTag", uploadTag)
+ put("transportServer", attachmentTransportServer)
+ }
+ )
+ }
+ .toString()
+
+ saveMessageToDatabase(
+ messageId = messageId,
+ text = "",
+ encryptedContent = encryptedContent,
+ encryptedKey =
+ if (encryptionContext.isGroup) {
+ buildStoredGroupKey(
+ encryptionContext.attachmentPassword,
+ privateKey
+ )
+ } else {
+ encryptedKey
+ },
+ timestamp = timestamp,
+ isFromMe = true,
+ delivered = if (isSavedMessages) 1 else 0,
+ attachmentsJson = attachmentsJson
+ )
+
+ withContext(Dispatchers.Main) {
+ if (isSavedMessages) {
+ updateMessageStatus(messageId, MessageStatus.SENT)
+ }
+ }
+
+ saveDialog("Voice message", timestamp)
+ } catch (_: Exception) {
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.ERROR)
+ }
+ updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
+ saveDialog("Voice message", timestamp)
+ } finally {
+ isSending = false
+ }
+ }
+ }
+
/**
* Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение
*/
@@ -5280,6 +5882,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return
}
+ // ⚡ Оптимизация: не отправляем typing indicator если собеседник офлайн
+ // (для групп продолжаем отправлять — кто-то из участников может быть в сети)
+ if (!isGroupDialogKey(opponent) && !_opponentOnline.value) {
+ return
+ }
+
val privateKey =
myPrivateKey
?: run {
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt
index 1911a4b..de91f4c 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt
@@ -14,6 +14,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.Immutable
@@ -62,6 +66,7 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager
+import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallPhase
@@ -74,6 +79,7 @@ import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
+import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
@@ -222,6 +228,18 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set): Bo
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
}
+private fun isVoicePlayingForDialog(dialogKey: String, playingDialogKey: String?): Boolean {
+ val active = playingDialogKey?.trim().orEmpty()
+ if (active.isEmpty()) return false
+ if (isGroupDialogKey(dialogKey) || isGroupDialogKey(active)) {
+ return normalizeGroupDialogKey(dialogKey).equals(
+ normalizeGroupDialogKey(active),
+ ignoreCase = true
+ )
+ }
+ return dialogKey.trim().equals(active, ignoreCase = true)
+}
+
private fun shortPublicKey(value: String): String {
val trimmed = value.trim()
if (trimmed.length <= 12) return trimmed
@@ -237,6 +255,15 @@ private fun resolveTypingDisplayName(publicKey: String): String {
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
}
+private fun rosettaDev1Log(context: Context, tag: String, message: String) {
+ runCatching {
+ val dir = java.io.File(context.filesDir, "crash_reports")
+ if (!dir.exists()) dir.mkdirs()
+ val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
+ java.io.File(dir, "rosettadev1.txt").appendText("$ts [$tag] $message\n")
+ }
+}
+
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -297,9 +324,6 @@ fun ChatsListScreen(
val view = androidx.compose.ui.platform.LocalView.current
val context = androidx.compose.ui.platform.LocalContext.current
- val hasNativeNavigationBar = remember(context) {
- com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
- }
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -311,6 +335,21 @@ fun ChatsListScreen(
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf(null) }
+ var prewarmedBitmap by remember { mutableStateOf(null) }
+
+ // Prewarm: capture bitmap on first appear + when drawer opens
+ LaunchedEffect(Unit) {
+ kotlinx.coroutines.delay(1000)
+ if (prewarmedBitmap == null) {
+ prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
+ }
+ }
+ LaunchedEffect(drawerState.isOpen) {
+ if (drawerState.isOpen) {
+ kotlinx.coroutines.delay(200)
+ prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
+ }
+ }
fun startThemeReveal() {
if (themeRevealActive) {
@@ -324,7 +363,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 +375,7 @@ fun ChatsListScreen(
val toDark = !isDarkTheme
val maxRadius = maxRevealRadius(center, rootSize)
if (maxRadius <= 0f) {
+ snapshotBitmap.recycle()
onToggleTheme()
return
}
@@ -448,23 +491,44 @@ fun ChatsListScreen(
// �🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState()
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
+ val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
+ val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState()
+ val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
+ val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
+ val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
+ val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
- // Load dialogs when account is available
+ // Load dialogs as soon as public key is available.
+ // Private key may appear a bit later right after fresh registration on some devices.
LaunchedEffect(accountPublicKey, accountPrivateKey) {
- if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
- val launchStart = System.currentTimeMillis()
- chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
- // Устанавливаем аккаунт для RecentSearchesManager
- RecentSearchesManager.setAccount(accountPublicKey)
+ val normalizedPublicKey = accountPublicKey.trim()
+ if (normalizedPublicKey.isEmpty()) return@LaunchedEffect
- // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
- // сообщений
- ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
- android.util.Log.d(
- "ChatsListScreen",
- "✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms"
- )
+ val normalizedPrivateKey = accountPrivateKey.trim()
+ val launchStart = System.currentTimeMillis()
+
+ chatsViewModel.setAccount(normalizedPublicKey, normalizedPrivateKey)
+ // Устанавливаем аккаунт для RecentSearchesManager
+ RecentSearchesManager.setAccount(normalizedPublicKey)
+
+ // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
+ // сообщений только когда приватный ключ уже доступен.
+ if (normalizedPrivateKey.isNotEmpty()) {
+ ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
}
+
+ android.util.Log.d(
+ "ChatsListScreen",
+ "✅ Account init effect: pubReady=true privReady=${normalizedPrivateKey.isNotEmpty()} " +
+ "in ${System.currentTimeMillis() - launchStart}ms"
+ )
+ rosettaDev1Log(
+ context = context,
+ tag = "ChatsListScreen",
+ message =
+ "Account init effect pub=${shortPublicKey(normalizedPublicKey)} " +
+ "privReady=${normalizedPrivateKey.isNotEmpty()}"
+ )
}
// Status dialog state
@@ -562,9 +626,44 @@ fun ChatsListScreen(
LaunchedEffect(accountPublicKey) {
val accountManager = AccountManager(context)
val accounts = accountManager.getAllAccounts()
- allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
+ val preferredPublicKey =
+ accountPublicKey.trim().ifBlank {
+ accountManager.getLastLoggedPublicKey().orEmpty()
+ }
+ allAccounts = accounts.sortedByDescending { it.publicKey == preferredPublicKey }
}
+ val effectiveCurrentPublicKey =
+ remember(accountPublicKey, allAccounts) {
+ accountPublicKey.trim().ifBlank { allAccounts.firstOrNull()?.publicKey.orEmpty() }
+ }
+ val currentSidebarAccount =
+ remember(allAccounts, effectiveCurrentPublicKey) {
+ allAccounts.firstOrNull {
+ it.publicKey.equals(effectiveCurrentPublicKey, ignoreCase = true)
+ } ?: allAccounts.firstOrNull()
+ }
+ val sidebarAccountUsername =
+ remember(accountUsername, currentSidebarAccount) {
+ accountUsername.ifBlank { currentSidebarAccount?.username.orEmpty() }
+ }
+ val sidebarAccountName =
+ remember(accountName, sidebarAccountUsername, currentSidebarAccount, effectiveCurrentPublicKey) {
+ val preferredName =
+ when {
+ accountName.isNotBlank() &&
+ !isPlaceholderAccountName(accountName) -> accountName
+ !currentSidebarAccount?.name.isNullOrBlank() ->
+ currentSidebarAccount?.name.orEmpty()
+ else -> accountName
+ }
+ resolveAccountDisplayName(
+ effectiveCurrentPublicKey,
+ preferredName,
+ sidebarAccountUsername
+ )
+ }
+
// Confirmation dialogs state
var dialogsToDelete by remember { mutableStateOf>(emptyList()) }
var dialogToLeave by remember { mutableStateOf(null) }
@@ -583,7 +682,7 @@ fun ChatsListScreen(
val hapticFeedback = LocalHapticFeedback.current
var showSelectionMenu by remember { mutableStateOf(false) }
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
- val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey)
+ val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey)
.collectAsState(initial = emptySet())
// Back: drawer → закрыть, selection → сбросить
@@ -614,6 +713,31 @@ fun ChatsListScreen(
val topLevelRequestsCount = topLevelChatsState.requestsCount
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
+ // Anti-stuck guard:
+ // если соединение уже AUTHENTICATED и синхронизация завершена,
+ // loading не должен висеть бесконечно.
+ LaunchedEffect(accountPublicKey, protocolState, syncInProgress, topLevelIsLoading) {
+ val normalizedPublicKey = accountPublicKey.trim()
+ if (normalizedPublicKey.isBlank()) return@LaunchedEffect
+ if (!topLevelIsLoading) return@LaunchedEffect
+ if (protocolState != ProtocolState.AUTHENTICATED || syncInProgress) return@LaunchedEffect
+
+ delay(1200)
+ if (
+ topLevelIsLoading &&
+ protocolState == ProtocolState.AUTHENTICATED &&
+ !syncInProgress
+ ) {
+ rosettaDev1Log(
+ context = context,
+ tag = "ChatsListScreen",
+ message =
+ "loading guard fired pub=${shortPublicKey(normalizedPublicKey)}"
+ )
+ chatsViewModel.forceStopLoading("ui_guard_authenticated_no_sync")
+ }
+ }
+
// Dev console dialog - commented out for now
/*
if (showDevConsole) {
@@ -764,10 +888,6 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.onSizeChanged { rootSize = it }
.background(backgroundColor)
- .then(
- if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
- else Modifier
- )
) {
ModalNavigationDrawer(
drawerState = drawerState,
@@ -850,16 +970,16 @@ fun ChatsListScreen(
) {
AvatarImage(
publicKey =
- accountPublicKey,
+ effectiveCurrentPublicKey,
avatarRepository =
avatarRepository,
size = 72.dp,
isDarkTheme =
isDarkTheme,
displayName =
- accountName
+ sidebarAccountName
.ifEmpty {
- accountUsername
+ sidebarAccountUsername
}
)
}
@@ -911,13 +1031,13 @@ fun ChatsListScreen(
) {
Column(modifier = Modifier.weight(1f)) {
// Display name
- if (accountName.isNotEmpty()) {
+ if (sidebarAccountName.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
- text = accountName,
+ text = sidebarAccountName,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
@@ -936,10 +1056,10 @@ fun ChatsListScreen(
}
// Username
- if (accountUsername.isNotEmpty()) {
+ if (sidebarAccountUsername.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
- text = "@$accountUsername",
+ text = "@$sidebarAccountUsername",
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.7f)
)
@@ -980,7 +1100,9 @@ fun ChatsListScreen(
Column(modifier = Modifier.fillMaxWidth()) {
// All accounts list (max 5 like Telegram sidebar behavior)
allAccounts.take(5).forEach { account ->
- val isCurrentAccount = account.publicKey == accountPublicKey
+ val isCurrentAccount =
+ account.publicKey ==
+ effectiveCurrentPublicKey
val displayName =
resolveAccountDisplayName(
account.publicKey,
@@ -1260,6 +1382,9 @@ fun ChatsListScreen(
}
)
+ // Keep distance from footer divider so it never overlays Settings.
+ Spacer(modifier = Modifier.height(8.dp))
+
}
// ═══════════════════════════════════════════════════════════
@@ -1268,9 +1393,12 @@ fun ChatsListScreen(
Column(
modifier =
Modifier.fillMaxWidth()
- .then(
- if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
- else Modifier
+ .windowInsetsPadding(
+ WindowInsets
+ .navigationBars
+ .only(
+ WindowInsetsSides.Bottom
+ )
)
) {
// Telegram-style update banner
@@ -2111,6 +2239,50 @@ fun ChatsListScreen(
callUiState.phase != CallPhase.INCOMING
}
val callBannerHeight = 40.dp
+ val showVoiceMiniPlayer =
+ remember(
+ showRequestsScreen,
+ showDownloadsScreen,
+ showCallsScreen,
+ playingVoiceAttachmentId
+ ) {
+ !showRequestsScreen &&
+ !showDownloadsScreen &&
+ !showCallsScreen &&
+ !playingVoiceAttachmentId.isNullOrBlank()
+ }
+ val voiceBannerHeight = 36.dp
+ val stickyTopInset =
+ remember(
+ showStickyCallBanner,
+ showVoiceMiniPlayer
+ ) {
+ var topInset = 0.dp
+ if (showStickyCallBanner) {
+ topInset += callBannerHeight
+ }
+ if (showVoiceMiniPlayer) {
+ topInset += voiceBannerHeight
+ }
+ topInset
+ }
+ val voiceMiniPlayerTitle =
+ remember(
+ playingVoiceSenderLabel,
+ playingVoiceTimeLabel
+ ) {
+ val sender =
+ playingVoiceSenderLabel
+ .trim()
+ .ifBlank {
+ "Voice"
+ }
+ val time =
+ playingVoiceTimeLabel
+ .trim()
+ if (time.isBlank()) sender
+ else "$sender at $time"
+ }
// 🔥 Берем dialogs из chatsState для
// консистентности
// 📌 Порядок по времени готовится в ViewModel.
@@ -2313,9 +2485,7 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.padding(
top =
- if (showStickyCallBanner)
- callBannerHeight
- else 0.dp
+ stickyTopInset
)
.then(
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
@@ -2553,6 +2723,18 @@ fun ChatsListScreen(
}
}
}
+ val isVoicePlaybackActive by
+ remember(
+ dialog.opponentKey,
+ playingVoiceDialogKey
+ ) {
+ derivedStateOf {
+ isVoicePlayingForDialog(
+ dialog.opponentKey,
+ playingVoiceDialogKey
+ )
+ }
+ }
val isSelectedDialog =
selectedChatKeys
.contains(
@@ -2594,6 +2776,8 @@ fun ChatsListScreen(
typingDisplayName,
typingSenderPublicKey =
typingSenderPublicKey,
+ isVoicePlaybackActive =
+ isVoicePlaybackActive,
isBlocked =
isBlocked,
isSavedMessages =
@@ -2727,14 +2911,51 @@ fun ChatsListScreen(
}
}
}
- if (showStickyCallBanner) {
- CallTopBanner(
- state = callUiState,
- isSticky = true,
- isDarkTheme = isDarkTheme,
- avatarRepository = avatarRepository,
- onOpenCall = onOpenCallOverlay
- )
+ if (showStickyCallBanner || showVoiceMiniPlayer) {
+ Column(
+ modifier =
+ Modifier.fillMaxWidth()
+ .align(
+ Alignment.TopCenter
+ )
+ ) {
+ if (showStickyCallBanner) {
+ CallTopBanner(
+ state = callUiState,
+ isSticky = true,
+ isDarkTheme = isDarkTheme,
+ avatarRepository = avatarRepository,
+ onOpenCall = onOpenCallOverlay
+ )
+ }
+ AnimatedVisibility(
+ visible = showVoiceMiniPlayer,
+ enter = expandVertically(
+ animationSpec = tween(220, easing = FastOutSlowInEasing),
+ expandFrom = Alignment.Top
+ ) + fadeIn(animationSpec = tween(220)),
+ exit = shrinkVertically(
+ animationSpec = tween(260, easing = FastOutSlowInEasing),
+ shrinkTowards = Alignment.Top
+ ) + fadeOut(animationSpec = tween(180))
+ ) {
+ VoiceTopMiniPlayer(
+ title = voiceMiniPlayerTitle,
+ isDarkTheme = isDarkTheme,
+ isPlaying = isVoicePlaybackRunning,
+ speed = voicePlaybackSpeed,
+ onTogglePlay = {
+ VoicePlaybackCoordinator.toggleCurrentPlayback()
+ },
+ onCycleSpeed = {
+ VoicePlaybackCoordinator.cycleSpeed()
+ },
+ onClose = {
+ VoicePlaybackCoordinator.stop()
+ }
+ )
+ }
+ }
}
}
}
@@ -3703,6 +3924,7 @@ fun SwipeableDialogItem(
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
+ isVoicePlaybackActive: Boolean = false,
isBlocked: Boolean = false,
isGroupChat: Boolean = false,
isSavedMessages: Boolean = false,
@@ -4106,6 +4328,7 @@ fun SwipeableDialogItem(
isTyping = isTyping,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey,
+ isVoicePlaybackActive = isVoicePlaybackActive,
isPinned = isPinned,
isBlocked = isBlocked,
isMuted = isMuted,
@@ -4125,6 +4348,7 @@ fun DialogItemContent(
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
+ isVoicePlaybackActive: Boolean = false,
isPinned: Boolean = false,
isBlocked: Boolean = false,
isMuted: Boolean = false,
@@ -4259,12 +4483,12 @@ fun DialogItemContent(
// Name and last message
Column(modifier = Modifier.weight(1f)) {
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
- modifier = Modifier.weight(1f),
+ modifier = Modifier.weight(1f).heightIn(min = 22.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppleEmojiText(
@@ -4274,7 +4498,8 @@ fun DialogItemContent(
color = textColor,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
- enableLinks = false
+ enableLinks = false,
+ minHeightMultiplier = 1f
)
if (isGroupDialog) {
Spacer(modifier = Modifier.width(5.dp))
@@ -4282,7 +4507,7 @@ fun DialogItemContent(
imageVector = TablerIcons.Users,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.9f),
- modifier = Modifier.size(15.dp)
+ modifier = Modifier.size(14.dp)
)
}
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
@@ -4291,7 +4516,7 @@ fun DialogItemContent(
VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1,
size = 16,
- modifier = Modifier.offset(y = (-2).dp),
+ modifier = Modifier,
isDarkTheme = isDarkTheme,
badgeTint = PrimaryBlue
)
@@ -4318,6 +4543,7 @@ fun DialogItemContent(
}
Row(
+ modifier = Modifier.heightIn(min = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
@@ -4448,7 +4674,7 @@ fun DialogItemContent(
0.6f
),
modifier =
- Modifier.size(14.dp)
+ Modifier.size(16.dp)
)
Spacer(
modifier =
@@ -4468,9 +4694,11 @@ fun DialogItemContent(
Text(
text = formattedTime,
fontSize = 13.sp,
+ lineHeight = 13.sp,
color =
if (visibleUnreadCount > 0) PrimaryBlue
- else secondaryTextColor
+ else secondaryTextColor,
+ modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
@@ -4487,18 +4715,35 @@ fun DialogItemContent(
modifier = Modifier.weight(1f).heightIn(min = 20.dp),
contentAlignment = Alignment.CenterStart
) {
+ val subtitleMode =
+ remember(
+ isVoicePlaybackActive,
+ isTyping,
+ dialog.draftText
+ ) {
+ when {
+ isVoicePlaybackActive -> "voice"
+ isTyping -> "typing"
+ !dialog.draftText.isNullOrEmpty() -> "draft"
+ else -> "message"
+ }
+ }
Crossfade(
- targetState = isTyping,
+ targetState = subtitleMode,
animationSpec = tween(150),
label = "chatSubtitle"
- ) { showTyping ->
- if (showTyping) {
+ ) { mode ->
+ if (mode == "voice") {
+ VoicePlaybackIndicatorSmall(
+ isDarkTheme = isDarkTheme
+ )
+ } else if (mode == "typing") {
TypingIndicatorSmall(
isDarkTheme = isDarkTheme,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey
)
- } else if (!dialog.draftText.isNullOrEmpty()) {
+ } else if (mode == "draft") {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Draft: ",
@@ -4508,7 +4753,7 @@ fun DialogItemContent(
maxLines = 1
)
AppleEmojiText(
- text = dialog.draftText,
+ text = dialog.draftText.orEmpty(),
modifier = Modifier.weight(1f),
fontSize = 14.sp,
color = secondaryTextColor,
@@ -4530,6 +4775,8 @@ fun DialogItemContent(
"Avatar" -> "Avatar"
dialog.lastMessageAttachmentType ==
"Call" -> "Call"
+ dialog.lastMessageAttachmentType ==
+ "Voice message" -> "Voice message"
dialog.lastMessageAttachmentType ==
"Forwarded" ->
"Forwarded message"
@@ -4847,6 +5094,167 @@ fun TypingIndicatorSmall(
}
}
+@Composable
+private fun VoicePlaybackIndicatorSmall(
+ isDarkTheme: Boolean
+) {
+ val accentColor = if (isDarkTheme) PrimaryBlue else Color(0xFF2481CC)
+ val transition = rememberInfiniteTransition(label = "voicePlaybackIndicator")
+ val levels = List(3) { index ->
+ transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = keyframes {
+ durationMillis = 900
+ 0f at 0
+ 1f at 280
+ 0.2f at 580
+ 0f at 900
+ },
+ repeatMode = RepeatMode.Restart,
+ initialStartOffset = StartOffset(index * 130)
+ ),
+ label = "voiceBar$index"
+ ).value
+ }
+
+ Row(
+ modifier = Modifier.heightIn(min = 18.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Canvas(modifier = Modifier.size(width = 14.dp, height = 12.dp)) {
+ val barWidth = 2.dp.toPx()
+ val gap = 2.dp.toPx()
+ val baseY = size.height
+ repeat(3) { index ->
+ val x = index * (barWidth + gap)
+ val progress = levels[index].coerceIn(0f, 1f)
+ val minH = 3.dp.toPx()
+ val maxH = 10.dp.toPx()
+ val height = minH + (maxH - minH) * progress
+ drawRoundRect(
+ color = accentColor.copy(alpha = 0.6f + progress * 0.4f),
+ topLeft = Offset(x, baseY - height),
+ size = androidx.compose.ui.geometry.Size(barWidth, height),
+ cornerRadius =
+ androidx.compose.ui.geometry.CornerRadius(
+ x = barWidth,
+ y = barWidth
+ )
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(5.dp))
+ AppleEmojiText(
+ text = "Listening",
+ fontSize = 14.sp,
+ color = accentColor,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = android.text.TextUtils.TruncateAt.END,
+ enableLinks = false,
+ minHeightMultiplier = 1f
+ )
+ }
+}
+
+fun formatVoiceSpeedLabel(speed: Float): String {
+ val normalized = (speed * 10f).roundToInt() / 10f
+ return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
+ "${normalized.toInt()}x"
+ } else {
+ "${normalized}x"
+ }
+}
+
+@Composable
+fun VoiceTopMiniPlayer(
+ title: String,
+ isDarkTheme: Boolean,
+ isPlaying: Boolean,
+ speed: Float,
+ onTogglePlay: () -> Unit,
+ onCycleSpeed: () -> Unit,
+ onClose: () -> Unit
+) {
+ // Match overall screen surface aesthetic — neutral elevated surface, no blue accent
+ val containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
+ val dividerColor = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFE5E5EA)
+ val primaryIconColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
+ val textColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
+ val secondaryColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
+
+ Column(modifier = Modifier.fillMaxWidth().background(containerColor)) {
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ .height(40.dp)
+ .padding(horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ onClick = onTogglePlay,
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector =
+ if (isPlaying) Icons.Default.Pause
+ else Icons.Default.PlayArrow,
+ contentDescription = if (isPlaying) "Pause voice" else "Play voice",
+ tint = primaryIconColor,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ AppleEmojiText(
+ text = title,
+ fontSize = 14.sp,
+ color = textColor,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = android.text.TextUtils.TruncateAt.END,
+ modifier = Modifier.weight(1f),
+ enableLinks = false,
+ minHeightMultiplier = 1f
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Box(
+ modifier = Modifier.clip(RoundedCornerShape(8.dp))
+ .border(1.dp, secondaryColor.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
+ .clickable { onCycleSpeed() }
+ .padding(horizontal = 8.dp, vertical = 3.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = formatVoiceSpeedLabel(speed),
+ color = secondaryColor,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ IconButton(
+ onClick = onClose,
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close voice",
+ tint = secondaryColor,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ Box(modifier = Modifier.fillMaxWidth().height(0.5.dp).background(dividerColor))
+ }
+}
+
@Composable
private fun SwipeBackContainer(
onBack: () -> Unit,
@@ -5446,7 +5854,7 @@ fun DrawerMenuItemEnhanced(
Text(
text = text,
fontSize = 15.sp,
- fontWeight = FontWeight.Bold,
+ fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.weight(1f)
)
@@ -5506,7 +5914,7 @@ fun DrawerMenuItemEnhanced(
Text(
text = text,
fontSize = 15.sp,
- fontWeight = FontWeight.Bold,
+ fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.weight(1f)
)
@@ -5540,7 +5948,7 @@ fun DrawerMenuItemEnhanced(
fun DrawerDivider(isDarkTheme: Boolean) {
Spacer(modifier = Modifier.height(8.dp))
Divider(
- modifier = Modifier.padding(horizontal = 20.dp),
+ modifier = Modifier.fillMaxWidth(),
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
thickness = 0.5.dp
)
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt
index 7f9ee46..0f038e7 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt
@@ -14,11 +14,14 @@ import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
+import java.text.SimpleDateFormat
+import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -92,6 +95,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null
+ private var loadingFailSafeJob: Job? = null
// Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow>(emptyList())
@@ -132,9 +136,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
ChatsUiState()
)
- // Загрузка (🔥 true по умолчанию — skeleton на первом кадре, чтобы не мигало empty→skeleton→empty)
- private val _isLoading = MutableStateFlow(true)
+ // Загрузка
+ // Важно: false по умолчанию, чтобы исключить "вечный skeleton", если setAccount не был вызван.
+ private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow = _isLoading.asStateFlow()
+ private val loadingFailSafeTimeoutMs = 4500L
private val TAG = "ChatsListVM"
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
@@ -146,6 +152,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
+ private fun rosettaDev1Log(msg: String) {
+ runCatching {
+ val app = getApplication()
+ val dir = java.io.File(app.filesDir, "crash_reports")
+ if (!dir.exists()) dir.mkdirs()
+ val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
+ java.io.File(dir, "rosettadev1.txt").appendText("$ts [ChatsListVM] $msg\n")
+ }
+ }
+
private data class GroupLastSenderInfo(
val senderPrefix: String,
val senderKey: String
@@ -345,15 +361,39 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) {
- val setAccountStart = System.currentTimeMillis()
- if (currentAccount == publicKey) {
+ val resolvedPrivateKey =
+ when {
+ privateKey.isNotBlank() -> privateKey
+ currentAccount == publicKey -> currentPrivateKey.orEmpty()
+ else -> ""
+ }
+
+ if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) {
// 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
- if (_isLoading.value) _isLoading.value = false
+ if (_isLoading.value) {
+ _isLoading.value = false
+ }
+ loadingFailSafeJob?.cancel()
return
}
// 🔥 Показываем skeleton пока данные грузятся
_isLoading.value = true
+ loadingFailSafeJob?.cancel()
+ loadingFailSafeJob =
+ viewModelScope.launch {
+ delay(loadingFailSafeTimeoutMs)
+ if (_isLoading.value) {
+ _isLoading.value = false
+ android.util.Log.w(
+ TAG,
+ "Fail-safe: forced isLoading=false after ${loadingFailSafeTimeoutMs}ms for account=${publicKey.take(8)}..."
+ )
+ rosettaDev1Log(
+ "Fail-safe isLoading=false account=${publicKey.take(8)} timeoutMs=$loadingFailSafeTimeoutMs"
+ )
+ }
+ }
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear()
@@ -369,7 +409,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
accountSubscriptionsJob?.cancel()
currentAccount = publicKey
- currentPrivateKey = privateKey
+ currentPrivateKey = resolvedPrivateKey
// � Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
DraftManager.setAccount(publicKey)
@@ -380,7 +420,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requestsCount.value = 0
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
- viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
+ if (resolvedPrivateKey.isNotEmpty()) {
+ viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) }
+ }
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
accountSubscriptionsJob = viewModelScope.launch {
@@ -410,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else {
mapDialogListIncremental(
dialogsList = dialogsList,
- privateKey = privateKey,
+ privateKey = resolvedPrivateKey,
cache = dialogsUiCache,
isRequestsFlow = false
)
@@ -418,10 +460,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
.filterNotNull()
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
+ .catch { e ->
+ android.util.Log.e(TAG, "Dialogs flow failed in setAccount()", e)
+ rosettaDev1Log("Dialogs flow failed: ${e.message}")
+ if (_isLoading.value) _isLoading.value = false
+ emit(emptyList())
+ }
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
// 🚀 Убираем skeleton после первой загрузки
- if (_isLoading.value) _isLoading.value = false
+ if (_isLoading.value) {
+ _isLoading.value = false
+ loadingFailSafeJob?.cancel()
+ }
// 🟢 Подписываемся на онлайн-статусы всех собеседников
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
@@ -430,7 +481,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedDialogs.filter { !it.isSavedMessages }.map {
it.opponentKey
}
- subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
+ subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey)
}
}
@@ -450,7 +501,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else {
mapDialogListIncremental(
dialogsList = requestsList,
- privateKey = privateKey,
+ privateKey = resolvedPrivateKey,
cache = requestsUiCache,
isRequestsFlow = true
)
@@ -498,6 +549,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
} // end accountSubscriptionsJob
+
+ accountSubscriptionsJob?.invokeOnCompletion { cause ->
+ if (cause != null && _isLoading.value) {
+ _isLoading.value = false
+ loadingFailSafeJob?.cancel()
+ android.util.Log.e(TAG, "accountSubscriptionsJob completed with error", cause)
+ rosettaDev1Log("accountSubscriptionsJob error: ${cause.message}")
+ }
+ }
+ }
+
+ fun forceStopLoading(reason: String) {
+ if (_isLoading.value) {
+ _isLoading.value = false
+ loadingFailSafeJob?.cancel()
+ android.util.Log.w(TAG, "forceStopLoading: $reason")
+ rosettaDev1Log("forceStopLoading: $reason")
+ }
}
/**
@@ -506,6 +575,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
*/
private fun subscribeToOnlineStatuses(opponentKeys: List, privateKey: String) {
if (opponentKeys.isEmpty()) return
+ if (privateKey.isBlank()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys =
@@ -573,16 +643,52 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
lastMessageAttachments: String
): String? {
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
- return when (attachmentType) {
+ val effectiveType =
+ if (attachmentType >= 0) attachmentType
+ else inferAttachmentTypeFromJson(lastMessageAttachments)
+
+ return when (effectiveType) {
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
4 -> "Call" // AttachmentType.CALL = 4
+ 5 -> "Voice message" // AttachmentType.VOICE = 5
+ 6 -> "Video message" // AttachmentType.VIDEO_CIRCLE = 6
else -> if (inferredCall) "Call" else null
}
}
+ private fun inferAttachmentTypeFromJson(rawAttachments: String): Int {
+ if (rawAttachments.isBlank() || rawAttachments == "[]") return -1
+ return try {
+ val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
+ if (attachments.length() <= 0) return -1
+ val first = attachments.optJSONObject(0) ?: return -1
+ val rawType = first.opt("type")
+ when (rawType) {
+ is Number -> rawType.toInt()
+ is String -> {
+ val normalized = rawType.trim()
+ normalized.toIntOrNull()
+ ?: when (normalized.lowercase(Locale.ROOT)) {
+ "image" -> 0
+ "messages", "reply", "forward" -> 1
+ "file" -> 2
+ "avatar" -> 3
+ "call" -> 4
+ "voice" -> 5
+ "video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> 6
+ else -> -1
+ }
+ }
+ else -> -1
+ }
+ } catch (_: Throwable) {
+ -1
+ }
+ }
+
private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
if (rawAttachments.isBlank() || rawAttachments == "[]") return false
return try {
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt
index 2d4fc14..78fba8c 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt
@@ -1169,30 +1169,46 @@ fun GroupInfoScreen(
)
}
- if (groupDescription.isNotBlank()) {
- Spacer(modifier = Modifier.height(10.dp))
- AppleEmojiText(
- text = groupDescription,
- color = Color.White.copy(alpha = 0.7f),
- fontSize = 12.sp,
- maxLines = 2,
- overflow = android.text.TextUtils.TruncateAt.END,
- enableLinks = false
- )
- }
}
}
- Spacer(modifier = Modifier.height(8.dp))
+ if (groupDescription.isNotBlank()) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = sectionColor
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
+ ) {
+ AppleEmojiText(
+ text = groupDescription,
+ color = primaryText,
+ fontSize = 16.sp,
+ maxLines = 8,
+ overflow = android.text.TextUtils.TruncateAt.END,
+ enableLinks = true
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "Description",
+ color = Color(0xFF8E8E93),
+ fontSize = 13.sp
+ )
+ }
+ }
+ Divider(
+ color = borderColor,
+ thickness = 0.5.dp,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
Surface(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- color = sectionColor,
- shape = RoundedCornerShape(12.dp)
+ modifier = Modifier.fillMaxWidth(),
+ color = sectionColor
) {
Column {
- // Add Members
+ // Add Members — flat Telegram style, edge-to-edge, white text
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1200,27 +1216,28 @@ fun GroupInfoScreen(
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
+ Icon(
+ imageVector = Icons.Default.PersonAdd,
+ contentDescription = null,
+ tint = accentColor,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(28.dp))
Text(
text = "Add Members",
color = primaryText,
fontSize = 16.sp,
modifier = Modifier.weight(1f)
)
- Icon(
- imageVector = Icons.Default.PersonAdd,
- contentDescription = null,
- tint = accentColor,
- modifier = Modifier.size(groupMenuTrailingIconSize)
- )
}
Divider(
color = borderColor,
thickness = 0.5.dp,
- modifier = Modifier.padding(start = 16.dp)
+ modifier = Modifier.fillMaxWidth()
)
- // Encryption Key
+ // Encryption Key — flat Telegram style, edge-to-edge, white text
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1228,6 +1245,13 @@ fun GroupInfoScreen(
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = null,
+ tint = accentColor,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(28.dp))
Text(
text = "Encryption Key",
color = primaryText,
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt
index 5f1ae95..134e9c5 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt
@@ -573,23 +573,23 @@ private fun ChatsTabContent(
}
}
- // ─── Recent header (always show with Clear All) ───
- item {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp)
- .padding(top = 14.dp, bottom = 6.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- "Recent",
- fontSize = 15.sp,
- fontWeight = FontWeight.SemiBold,
- color = PrimaryBlue
- )
- if (recentUsers.isNotEmpty()) {
+ // ─── Recent header (only when there are recents) ───
+ if (recentUsers.isNotEmpty()) {
+ item {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(top = 14.dp, bottom = 6.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "Recent",
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = PrimaryBlue
+ )
Text(
"Clear All",
fontSize = 13.sp,
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt
index 4550e95..86f6d63 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt
@@ -3,7 +3,6 @@ package com.rosetta.messenger.ui.chats.calls
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -16,7 +15,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallMade
@@ -34,10 +33,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.animateLottieCompositionAsState
+import com.airbnb.lottie.compose.rememberLottieComposition
+import com.rosetta.messenger.R
import com.rosetta.messenger.database.CallHistoryRow
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.SearchUser
@@ -106,16 +111,21 @@ fun CallsHistoryScreen(
LazyColumn(
modifier = modifier.fillMaxSize().background(backgroundColor),
- contentPadding = PaddingValues(bottom = 16.dp)
+ contentPadding = if (items.isEmpty()) PaddingValues(0.dp) else PaddingValues(bottom = 16.dp)
) {
if (items.isEmpty()) {
item(key = "empty_calls") {
- EmptyCallsState(
- isDarkTheme = isDarkTheme,
- title = "No calls yet",
- subtitle = "Your call history will appear here",
- modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
- )
+ Column(
+ modifier = Modifier.fillParentMaxSize(),
+ verticalArrangement = Arrangement.Center
+ ) {
+ EmptyCallsState(
+ isDarkTheme = isDarkTheme,
+ title = "No Calls Yet",
+ subtitle = "Your recent voice and video calls will\nappear here.",
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)
+ )
+ }
}
} else {
items(items, key = { it.messageId }) { item ->
@@ -273,39 +283,63 @@ private fun EmptyCallsState(
subtitle: String,
modifier: Modifier = Modifier
) {
- val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8)
- val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23)
- val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
+ val titleColor = if (isDarkTheme) Color(0xFFEDEDF2) else Color(0xFF1C1C1E)
+ val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF8E8E93)
+ val cardColor = if (isDarkTheme) Color(0xFF242529) else Color(0xFFF6F6FA)
+ val lottieComposition by rememberLottieComposition(
+ LottieCompositionSpec.RawRes(R.raw.phone_duck)
+ )
+ val lottieProgress by animateLottieCompositionAsState(
+ composition = lottieComposition,
+ iterations = 1
+ )
Column(
- modifier = modifier.padding(horizontal = 32.dp),
+ modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
- Box(
- modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape),
- contentAlignment = Alignment.Center
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(cardColor, RoundedCornerShape(28.dp))
+ .padding(horizontal = 20.dp, vertical = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
) {
- Icon(
- imageVector = Icons.Default.Call,
- contentDescription = null,
- tint = iconTint,
- modifier = Modifier.size(34.dp)
+ if (lottieComposition != null) {
+ LottieAnimation(
+ composition = lottieComposition,
+ progress = { lottieProgress },
+ modifier = Modifier.size(184.dp)
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.Call,
+ contentDescription = null,
+ tint = subtitleColor,
+ modifier = Modifier.size(52.dp)
+ )
+ }
+ Spacer(modifier = Modifier.height(18.dp))
+ if (title.isNotBlank()) {
+ Text(
+ text = title,
+ color = titleColor,
+ fontSize = 22.sp,
+ lineHeight = 24.sp,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ Text(
+ text = subtitle,
+ color = subtitleColor,
+ fontSize = 15.sp,
+ lineHeight = 20.sp,
+ textAlign = TextAlign.Center
)
}
- Spacer(modifier = Modifier.height(14.dp))
- Text(
- text = title,
- color = titleColor,
- fontSize = 18.sp,
- fontWeight = FontWeight.SemiBold
- )
- Spacer(modifier = Modifier.height(6.dp))
- Text(
- text = subtitle,
- color = subtitleColor,
- fontSize = 14.sp
- )
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt
index b355d91..e1b25bb 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt
@@ -1,11 +1,20 @@
package com.rosetta.messenger.ui.chats.components
+import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
+import android.media.AudioAttributes
+import android.media.MediaPlayer
+import android.net.Uri
+import android.os.Build
+import android.os.SystemClock
import android.util.Base64
import android.util.LruCache
+import android.webkit.MimeTypeMap
+import android.widget.VideoView
import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
@@ -34,6 +43,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@@ -53,6 +64,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager
@@ -61,27 +73,35 @@ import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.repository.AvatarRepository
+import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.chats.models.MessageStatus
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.vanniktech.blurhash.BlurHash
-import com.rosetta.messenger.ui.icons.TelegramIcons
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.core.content.FileProvider
-import android.content.Intent
-import android.os.SystemClock
-import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream
import java.io.File
import java.security.MessageDigest
+import java.text.SimpleDateFormat
+import java.util.Locale
import kotlin.math.min
+import kotlin.math.PI
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.core.content.FileProvider
private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
@@ -126,6 +146,210 @@ private fun decodeBase64Payload(data: String): ByteArray? {
}
}
+private fun decodeHexPayload(data: String): ByteArray? {
+ val raw = data.trim().removePrefix("0x")
+ if (raw.isBlank() || raw.length % 2 != 0) return null
+ fun nibble(ch: Char): Int =
+ when (ch) {
+ in '0'..'9' -> ch.code - '0'.code
+ in 'a'..'f' -> ch.code - 'a'.code + 10
+ in 'A'..'F' -> ch.code - 'A'.code + 10
+ else -> -1
+ }
+ val out = ByteArray(raw.length / 2)
+ var outIndex = 0
+ var index = 0
+ while (index < raw.length) {
+ val hi = nibble(raw[index])
+ val lo = nibble(raw[index + 1])
+ if (hi < 0 || lo < 0) return null
+ out[outIndex++] = ((hi shl 4) or lo).toByte()
+ index += 2
+ }
+ return out
+}
+
+private fun decodeVoicePayload(data: String): ByteArray? {
+ return decodeHexPayload(data) ?: decodeBase64Payload(data)
+}
+
+object VoicePlaybackCoordinator {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private val speedSteps = listOf(1f, 1.5f, 2f)
+ private var player: MediaPlayer? = null
+ private var currentAttachmentId: String? = null
+ private var progressJob: Job? = null
+ private val _playingAttachmentId = MutableStateFlow(null)
+ val playingAttachmentId: StateFlow = _playingAttachmentId.asStateFlow()
+ private val _playingDialogKey = MutableStateFlow(null)
+ val playingDialogKey: StateFlow = _playingDialogKey.asStateFlow()
+ private val _positionMs = MutableStateFlow(0)
+ val positionMs: StateFlow = _positionMs.asStateFlow()
+ private val _durationMs = MutableStateFlow(0)
+ val durationMs: StateFlow = _durationMs.asStateFlow()
+ private val _isPlaying = MutableStateFlow(false)
+ val isPlaying: StateFlow = _isPlaying.asStateFlow()
+ private val _playbackSpeed = MutableStateFlow(1f)
+ val playbackSpeed: StateFlow = _playbackSpeed.asStateFlow()
+ private val _playingSenderLabel = MutableStateFlow("")
+ val playingSenderLabel: StateFlow = _playingSenderLabel.asStateFlow()
+ private val _playingTimeLabel = MutableStateFlow("")
+ val playingTimeLabel: StateFlow = _playingTimeLabel.asStateFlow()
+
+ fun toggle(
+ attachmentId: String,
+ sourceFile: File,
+ dialogKey: String = "",
+ senderLabel: String = "",
+ playedAtLabel: String = "",
+ onError: (String) -> Unit = {}
+ ) {
+ if (!sourceFile.exists()) {
+ onError("Voice file is missing")
+ return
+ }
+
+ if (currentAttachmentId == attachmentId && player != null) {
+ if (_isPlaying.value) {
+ pause()
+ } else {
+ resume(onError = onError)
+ }
+ return
+ }
+
+ stop()
+ val mediaPlayer = MediaPlayer()
+ try {
+ mediaPlayer.setAudioAttributes(
+ AudioAttributes.Builder()
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .build()
+ )
+ mediaPlayer.setDataSource(sourceFile.absolutePath)
+ mediaPlayer.setOnCompletionListener { stop() }
+ mediaPlayer.prepare()
+ applyPlaybackSpeed(mediaPlayer)
+ mediaPlayer.start()
+ player = mediaPlayer
+ currentAttachmentId = attachmentId
+ _playingAttachmentId.value = attachmentId
+ _playingDialogKey.value = dialogKey.trim().ifBlank { null }
+ _playingSenderLabel.value = senderLabel.trim()
+ _playingTimeLabel.value = playedAtLabel.trim()
+ _durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
+ _positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
+ _isPlaying.value = true
+ startProgressUpdates(attachmentId)
+ } catch (e: Exception) {
+ runCatching { mediaPlayer.release() }
+ stop()
+ onError(e.message ?: "Playback failed")
+ }
+ }
+
+ fun toggleCurrentPlayback(onError: (String) -> Unit = {}) {
+ if (player == null || currentAttachmentId.isNullOrBlank()) return
+ if (_isPlaying.value) {
+ pause()
+ } else {
+ resume(onError = onError)
+ }
+ }
+
+ fun pause() {
+ val active = player ?: return
+ runCatching {
+ if (active.isPlaying) active.pause()
+ }
+ _isPlaying.value = false
+ progressJob?.cancel()
+ progressJob = null
+ _positionMs.value = active.currentPosition.coerceAtLeast(0)
+ }
+
+ fun resume(onError: (String) -> Unit = {}) {
+ val active = player ?: return
+ val attachmentId = currentAttachmentId
+ if (attachmentId.isNullOrBlank()) return
+ try {
+ applyPlaybackSpeed(active)
+ active.start()
+ _durationMs.value = active.duration.coerceAtLeast(0)
+ _positionMs.value = active.currentPosition.coerceAtLeast(0)
+ _isPlaying.value = true
+ startProgressUpdates(attachmentId)
+ } catch (e: Exception) {
+ stop()
+ onError(e.message ?: "Playback failed")
+ }
+ }
+
+ fun cycleSpeed() {
+ val current = _playbackSpeed.value
+ val currentIndex = speedSteps.indexOfFirst { kotlin.math.abs(it - current) < 0.01f }
+ val next = if (currentIndex < 0) speedSteps.first() else speedSteps[(currentIndex + 1) % speedSteps.size]
+ setPlaybackSpeed(next)
+ }
+
+ private fun setPlaybackSpeed(speed: Float) {
+ val normalized =
+ speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
+ _playbackSpeed.value = normalized
+ // Only apply to the player if it's currently playing — otherwise setting
+ // playbackParams auto-resumes a paused MediaPlayer (Android quirk).
+ // The new speed will be applied on the next resume() call.
+ if (_isPlaying.value) {
+ player?.let { applyPlaybackSpeed(it) }
+ }
+ }
+
+ private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
+ runCatching {
+ val current = mediaPlayer.playbackParams
+ mediaPlayer.playbackParams = current.setSpeed(_playbackSpeed.value)
+ }
+ }
+
+ private fun startProgressUpdates(attachmentId: String) {
+ progressJob?.cancel()
+ progressJob =
+ scope.launch {
+ while (isActive && currentAttachmentId == attachmentId) {
+ val active = player ?: break
+ _positionMs.value = active.currentPosition.coerceAtLeast(0)
+ _durationMs.value = active.duration.coerceAtLeast(0)
+ if (!active.isPlaying) break
+ delay(120)
+ }
+ _isPlaying.value = player?.isPlaying == true && currentAttachmentId == attachmentId
+ }
+ }
+
+ fun stop() {
+ val active = player
+ player = null
+ currentAttachmentId = null
+ progressJob?.cancel()
+ progressJob = null
+ _playingAttachmentId.value = null
+ _playingDialogKey.value = null
+ _playingSenderLabel.value = ""
+ _playingTimeLabel.value = ""
+ _positionMs.value = 0
+ _durationMs.value = 0
+ _isPlaying.value = false
+ if (active != null) {
+ runCatching {
+ if (active.isPlaying) active.stop()
+ }
+ runCatching { active.release() }
+ }
+ }
+}
+
private fun shortDebugId(value: String): String {
if (value.isBlank()) return "empty"
val clean = value.trim()
@@ -486,10 +710,12 @@ private fun TelegramFileActionButton(
fun MessageAttachments(
attachments: List,
chachaKey: String,
+ chachaKeyPlainHex: String = "",
privateKey: String,
isOutgoing: Boolean,
isDarkTheme: Boolean,
senderPublicKey: String,
+ senderDisplayName: String = "",
dialogPublicKey: String = "",
isGroupChat: Boolean = false,
timestamp: java.util.Date,
@@ -573,6 +799,34 @@ fun MessageAttachments(
isDarkTheme = isDarkTheme
)
}
+ AttachmentType.VOICE -> {
+ VoiceAttachment(
+ attachment = attachment,
+ chachaKey = chachaKey,
+ chachaKeyPlainHex = chachaKeyPlainHex,
+ privateKey = privateKey,
+ senderPublicKey = senderPublicKey,
+ senderDisplayName = senderDisplayName,
+ dialogPublicKey = dialogPublicKey,
+ isOutgoing = isOutgoing,
+ isDarkTheme = isDarkTheme,
+ timestamp = timestamp,
+ messageStatus = messageStatus
+ )
+ }
+ AttachmentType.VIDEO_CIRCLE -> {
+ VideoCircleAttachment(
+ attachment = attachment,
+ chachaKey = chachaKey,
+ chachaKeyPlainHex = chachaKeyPlainHex,
+ privateKey = privateKey,
+ senderPublicKey = senderPublicKey,
+ isOutgoing = isOutgoing,
+ isDarkTheme = isDarkTheme,
+ timestamp = timestamp,
+ messageStatus = messageStatus
+ )
+ }
else -> {
// Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
@@ -1679,6 +1933,174 @@ private fun parseCallDurationSeconds(preview: String): Int {
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
}
+private fun parseVoicePreview(preview: String): Pair> {
+ if (preview.isBlank()) return 0 to emptyList()
+ val durationPart = preview.substringBefore("::", preview).trim()
+ val wavesPart = preview.substringAfter("::", "").trim()
+ val duration = durationPart.toIntOrNull()?.coerceAtLeast(0) ?: 0
+ val waves =
+ if (wavesPart.isBlank()) {
+ emptyList()
+ } else {
+ wavesPart.split(",")
+ .mapNotNull { it.trim().toFloatOrNull() }
+ .map { it.coerceIn(0f, 1f) }
+ }
+ return duration to waves
+}
+
+private data class VideoCirclePreviewMeta(
+ val durationSec: Int,
+ val mimeType: String
+)
+
+private fun parseVideoCirclePreview(preview: String): VideoCirclePreviewMeta {
+ if (preview.isBlank()) {
+ return VideoCirclePreviewMeta(durationSec = 1, mimeType = "video/mp4")
+ }
+ val durationPart = preview.substringBefore("::", preview).trim()
+ val mimePart = preview.substringAfter("::", "").trim()
+ val duration = durationPart.toIntOrNull()?.coerceAtLeast(1) ?: 1
+ val mime =
+ if (mimePart.contains("/")) {
+ mimePart
+ } else {
+ "video/mp4"
+ }
+ return VideoCirclePreviewMeta(durationSec = duration, mimeType = mime)
+}
+
+private fun decodeVideoCirclePayload(data: String): ByteArray? {
+ return decodeHexPayload(data) ?: decodeBase64Payload(data)
+}
+
+private fun ensureVideoCirclePlaybackUri(
+ context: android.content.Context,
+ attachmentId: String,
+ payload: String,
+ mimeType: String,
+ localUri: String = ""
+): Uri? {
+ if (localUri.isNotBlank()) {
+ return runCatching { Uri.parse(localUri) }.getOrNull()
+ }
+ val bytes = decodeVideoCirclePayload(payload) ?: return null
+ val extension =
+ MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.ifBlank { null } ?: "mp4"
+ val directory = File(context.cacheDir, "video_circles").apply { mkdirs() }
+ val file = File(directory, "$attachmentId.$extension")
+ runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
+ return Uri.fromFile(file)
+}
+
+private fun normalizeVoiceWaves(source: List, targetLength: Int): List {
+ if (targetLength <= 0) return emptyList()
+ if (source.isEmpty()) return List(targetLength) { 0f }
+ if (source.size == targetLength) return source
+ if (source.size > targetLength) {
+ val bucket = source.size.toFloat() / targetLength.toFloat()
+ return List(targetLength) { idx ->
+ val start = kotlin.math.floor(idx * bucket).toInt()
+ val end = kotlin.math.max(start + 1, kotlin.math.floor((idx + 1) * bucket).toInt())
+ var maxValue = 0f
+ var i = start
+ while (i < end && i < source.size) {
+ if (source[i] > maxValue) maxValue = source[i]
+ i++
+ }
+ maxValue
+ }
+ }
+ if (targetLength == 1) return listOf(source.first())
+ val lastIndex = source.lastIndex.toFloat()
+ return List(targetLength) { idx ->
+ val pos = idx * lastIndex / (targetLength - 1).toFloat()
+ val left = kotlin.math.floor(pos).toInt()
+ val right = kotlin.math.min(kotlin.math.ceil(pos).toInt(), source.lastIndex)
+ if (left == right) source[left] else {
+ val t = pos - left.toFloat()
+ source[left] * (1f - t) + source[right] * t
+ }
+ }
+}
+
+private fun formatVoiceDuration(seconds: Int): String {
+ val safe = seconds.coerceAtLeast(0)
+ val minutes = (safe / 60).toString().padStart(2, '0')
+ val rem = (safe % 60).toString().padStart(2, '0')
+ return "$minutes:$rem"
+}
+
+private fun formatVoicePlaybackSpeedLabel(speed: Float): String {
+ val normalized = kotlin.math.round(speed * 10f) / 10f
+ return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
+ "${normalized.toInt()}x"
+ } else {
+ "${normalized}x"
+ }
+}
+
+@Composable
+private fun VoicePlaybackButtonBlob(
+ level: Float,
+ isOutgoing: Boolean,
+ isDarkTheme: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val clampedLevel = level.coerceIn(0f, 1f)
+ val animatedLevel by animateFloatAsState(
+ targetValue = clampedLevel,
+ animationSpec = tween(durationMillis = 140),
+ label = "voice_blob_level"
+ )
+ val transition = rememberInfiniteTransition(label = "voice_blob_motion")
+ val pulse by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1420, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "voice_blob_pulse"
+ )
+
+ val blobColor =
+ if (isOutgoing) {
+ Color.White
+ } else if (isDarkTheme) {
+ Color(0xFF5DB8FF)
+ } else {
+ PrimaryBlue
+ }
+
+ Canvas(modifier = modifier) {
+ val center = Offset(x = size.width * 0.5f, y = size.height * 0.5f)
+ val buttonRadius = 20.dp.toPx() // Play button is 40dp.
+ val amp = animatedLevel.coerceIn(0f, 1f)
+
+ // Telegram-like: soft concentric glow, centered, no geometry distortion.
+ val r1 = buttonRadius + 4.2.dp.toPx() + amp * 4.0.dp.toPx() + pulse * 1.6.dp.toPx()
+ val r2 = buttonRadius + 2.6.dp.toPx() + amp * 2.9.dp.toPx() + pulse * 0.9.dp.toPx()
+ val r3 = buttonRadius + 1.3.dp.toPx() + amp * 1.8.dp.toPx() + pulse * 0.5.dp.toPx()
+
+ drawCircle(
+ color = blobColor.copy(alpha = (0.14f + amp * 0.08f).coerceAtMost(0.24f)),
+ radius = r1,
+ center = center
+ )
+ drawCircle(
+ color = blobColor.copy(alpha = (0.11f + amp * 0.06f).coerceAtMost(0.18f)),
+ radius = r2,
+ center = center
+ )
+ drawCircle(
+ color = blobColor.copy(alpha = (0.08f + amp * 0.05f).coerceAtMost(0.14f)),
+ radius = r3,
+ center = center
+ )
+ }
+}
+
private fun formatDesktopCallDuration(durationSec: Int): String {
val minutes = durationSec / 60
val seconds = durationSec % 60
@@ -1790,6 +2212,839 @@ fun CallAttachment(
}
}
+private fun ensureVoiceAudioFile(
+ context: android.content.Context,
+ attachmentId: String,
+ payload: String
+): File? {
+ val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() }
+ val file = File(directory, "$attachmentId.webm")
+ if (file.exists() && file.length() > 0L) return file
+ val bytes = decodeVoicePayload(payload) ?: return null
+ runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
+ return file
+}
+
+@Composable
+private fun VoiceAttachment(
+ attachment: MessageAttachment,
+ chachaKey: String,
+ chachaKeyPlainHex: String,
+ privateKey: String,
+ senderPublicKey: String,
+ senderDisplayName: String,
+ dialogPublicKey: String,
+ isOutgoing: Boolean,
+ isDarkTheme: Boolean,
+ timestamp: java.util.Date,
+ messageStatus: MessageStatus = MessageStatus.READ
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
+ val isActiveTrack = activeAttachmentId == attachment.id
+ val playbackPositionMs by
+ (if (isActiveTrack) VoicePlaybackCoordinator.positionMs else flowOf(0))
+ .collectAsState(initial = 0)
+ val playbackDurationMs by
+ (if (isActiveTrack) VoicePlaybackCoordinator.durationMs else flowOf(0))
+ .collectAsState(initial = 0)
+ val playbackIsPlaying by
+ (if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false))
+ .collectAsState(initial = false)
+ val playbackSpeed by
+ (if (isActiveTrack) VoicePlaybackCoordinator.playbackSpeed else flowOf(1f))
+ .collectAsState(initial = 1f)
+ val isPlaying = isActiveTrack && playbackIsPlaying
+
+ val (previewDurationSecRaw, previewWavesRaw) =
+ remember(attachment.preview) { parseVoicePreview(attachment.preview) }
+ val previewDurationSec = previewDurationSecRaw.coerceAtLeast(1)
+ val previewWaves =
+ remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, 40) }
+ val waves =
+ remember(previewWaves) {
+ if (previewWaves.isEmpty()) List(40) { 0f } else previewWaves
+ }
+
+ var payload by
+ remember(attachment.id, attachment.blob) {
+ mutableStateOf(attachment.blob)
+ }
+ val cachedAudioPath =
+ remember(attachment.id) {
+ val file = File(context.cacheDir, "voice_messages/${attachment.id}.webm")
+ file.takeIf { it.exists() && it.length() > 0L }?.absolutePath
+ }
+ var audioFilePath by remember(attachment.id) { mutableStateOf(cachedAudioPath) }
+ var downloadStatus by
+ remember(attachment.id, attachment.blob, attachment.transportTag) {
+ mutableStateOf(
+ when {
+ cachedAudioPath != null -> DownloadStatus.DOWNLOADED
+ attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
+ attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
+ else -> DownloadStatus.ERROR
+ }
+ )
+ }
+ var errorText by remember { mutableStateOf("") }
+
+ val effectiveDurationSec =
+ remember(isPlaying, playbackDurationMs, previewDurationSec) {
+ val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
+ if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec
+ }
+ val progress =
+ if (isActiveTrack && playbackDurationMs > 0) {
+ (playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
+ } else {
+ 0f
+ }
+ val liveWaveLevel =
+ remember(isPlaying, progress, waves) {
+ if (!isPlaying || waves.isEmpty()) {
+ 0f
+ } else {
+ val maxIndex = waves.lastIndex.coerceAtLeast(0)
+ val sampleIndex = (progress * maxIndex.toFloat()).toInt().coerceIn(0, maxIndex)
+ waves[sampleIndex].coerceIn(0f, 1f)
+ }
+ }
+ val timeText =
+ if (isActiveTrack && playbackDurationMs > 0) {
+ val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
+ formatVoiceDuration(leftSec)
+ } else {
+ formatVoiceDuration(effectiveDurationSec)
+ }
+ val playbackSenderLabel =
+ remember(isOutgoing, senderDisplayName) {
+ val senderName = senderDisplayName.trim()
+ when {
+ isOutgoing -> "You"
+ senderName.isNotBlank() -> senderName
+ else -> "Voice"
+ }
+ }
+ val playbackTimeLabel =
+ remember(timestamp.time) {
+ runCatching {
+ SimpleDateFormat("h:mm a", Locale.getDefault()).format(timestamp)
+ }
+ .getOrDefault("")
+ }
+
+ val triggerDownload: () -> Unit = download@{
+ if (attachment.transportTag.isBlank()) {
+ downloadStatus = DownloadStatus.ERROR
+ errorText = "Voice not available"
+ return@download
+ }
+ scope.launch {
+ downloadStatus = DownloadStatus.DOWNLOADING
+ errorText = ""
+ val decrypted =
+ downloadAndDecryptVoicePayload(
+ attachmentId = attachment.id,
+ downloadTag = attachment.transportTag,
+ chachaKey = chachaKey,
+ privateKey = privateKey,
+ transportServer = attachment.transportServer,
+ chachaKeyPlainHex = chachaKeyPlainHex
+ )
+ if (decrypted.isNullOrBlank()) {
+ downloadStatus = DownloadStatus.ERROR
+ errorText = "Failed to decrypt"
+ return@launch
+ }
+ downloadStatus = DownloadStatus.DECRYPTING
+ val prepared =
+ withContext(Dispatchers.IO) {
+ ensureVoiceAudioFile(context, attachment.id, decrypted)
+ }
+ if (prepared == null) {
+ downloadStatus = DownloadStatus.ERROR
+ errorText = "Cannot decode voice"
+ return@launch
+ }
+ audioFilePath = prepared.absolutePath
+ val saved =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ AttachmentFileManager.saveAttachment(
+ context = context,
+ blob = decrypted,
+ attachmentId = attachment.id,
+ publicKey = senderPublicKey,
+ privateKey = privateKey
+ )
+ }
+ .getOrDefault(false)
+ }
+ payload = decrypted
+ if (!saved) {
+ // Не блокируем UI, но оставляем маркер в логе.
+ runCatching { android.util.Log.w(TAG, "Voice cache save failed: ${attachment.id}") }
+ }
+ downloadStatus = DownloadStatus.DOWNLOADED
+ }
+ }
+
+ val onMainAction: () -> Unit = {
+ when (downloadStatus) {
+ DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload()
+ DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit
+ DownloadStatus.DOWNLOADED, DownloadStatus.PENDING -> {
+ val file = audioFilePath?.let { File(it) }
+ if (file == null || !file.exists()) {
+ if (payload.isNotBlank()) {
+ scope.launch {
+ downloadStatus = DownloadStatus.DECRYPTING
+ errorText = ""
+ val prepared =
+ withContext(Dispatchers.IO) {
+ ensureVoiceAudioFile(context, attachment.id, payload)
+ }
+ if (prepared != null) {
+ audioFilePath = prepared.absolutePath
+ downloadStatus = DownloadStatus.DOWNLOADED
+ VoicePlaybackCoordinator.toggle(
+ attachmentId = attachment.id,
+ sourceFile = prepared,
+ dialogKey = dialogPublicKey,
+ senderLabel = playbackSenderLabel,
+ playedAtLabel = playbackTimeLabel
+ ) { message ->
+ downloadStatus = DownloadStatus.ERROR
+ errorText = message
+ }
+ } else {
+ downloadStatus = DownloadStatus.ERROR
+ errorText = "Cannot decode voice"
+ }
+ }
+ } else {
+ triggerDownload()
+ }
+ } else {
+ VoicePlaybackCoordinator.toggle(
+ attachmentId = attachment.id,
+ sourceFile = file,
+ dialogKey = dialogPublicKey,
+ senderLabel = playbackSenderLabel,
+ playedAtLabel = playbackTimeLabel
+ ) { message ->
+ downloadStatus = DownloadStatus.ERROR
+ errorText = message
+ }
+ }
+ }
+ }
+ }
+
+ val barInactiveColor =
+ if (isOutgoing) Color.White.copy(alpha = 0.38f)
+ else if (isDarkTheme) Color(0xFF5D6774)
+ else Color(0xFFB6C0CC)
+ val barActiveColor = if (isOutgoing) Color.White else PrimaryBlue
+ val secondaryTextColor =
+ if (isOutgoing) Color.White.copy(alpha = 0.72f)
+ else if (isDarkTheme) Color(0xFF9EAABD)
+ else Color(0xFF667283)
+
+ val actionBackground =
+ when (downloadStatus) {
+ DownloadStatus.ERROR -> Color(0xFFE55757)
+ else -> if (isOutgoing) Color.White.copy(alpha = 0.2f) else PrimaryBlue
+ }
+ val actionTint =
+ when {
+ downloadStatus == DownloadStatus.ERROR -> Color.White
+ isOutgoing -> Color.White
+ else -> Color.White
+ }
+
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) { onMainAction() },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier.size(40.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ if (downloadStatus == DownloadStatus.DOWNLOADED && isPlaying) {
+ VoicePlaybackButtonBlob(
+ level = liveWaveLevel,
+ isOutgoing = isOutgoing,
+ isDarkTheme = isDarkTheme,
+ modifier =
+ Modifier.requiredSize(64.dp).graphicsLayer {
+ // Blob lives strictly behind the button; keep button geometry untouched.
+ alpha = 0.96f
+ compositingStrategy = CompositingStrategy.Offscreen
+ }
+ )
+ }
+ Box(
+ modifier =
+ Modifier.size(40.dp)
+ .clip(CircleShape)
+ .background(actionBackground),
+ contentAlignment = Alignment.Center
+ ) {
+ if (downloadStatus == DownloadStatus.DOWNLOADING ||
+ downloadStatus == DownloadStatus.DECRYPTING
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(22.dp),
+ color = Color.White,
+ strokeWidth = 2.2.dp
+ )
+ } else {
+ when (downloadStatus) {
+ DownloadStatus.NOT_DOWNLOADED -> {
+ Icon(
+ painter = painterResource(R.drawable.msg_download),
+ contentDescription = null,
+ tint = actionTint,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ DownloadStatus.ERROR -> {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = null,
+ tint = actionTint,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ else -> {
+ Icon(
+ imageVector =
+ if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = null,
+ tint = actionTint,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.width(10.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().height(28.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ waves.forEachIndexed { index, value ->
+ val normalized = value.coerceIn(0f, 1f)
+ val passed = (progress * waves.size) - index
+ val fill = passed.coerceIn(0f, 1f)
+ val color =
+ if (fill > 0f) {
+ barActiveColor
+ } else {
+ barInactiveColor
+ }
+ Box(
+ modifier =
+ Modifier.width(2.dp)
+ .height((4f + normalized * 18f).dp)
+ .clip(RoundedCornerShape(100))
+ .background(color)
+ )
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text =
+ if (downloadStatus == DownloadStatus.ERROR && errorText.isNotBlank())
+ errorText
+ else timeText,
+ fontSize = 12.sp,
+ color =
+ if (downloadStatus == DownloadStatus.ERROR) {
+ Color(0xFFE55757)
+ } else {
+ secondaryTextColor
+ }
+ )
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ if (isActiveTrack) {
+ val speedChipBackground =
+ if (isOutgoing) {
+ Color.White.copy(alpha = 0.2f)
+ } else if (isDarkTheme) {
+ Color(0xFF31435A)
+ } else {
+ Color(0xFFDCEBFD)
+ }
+ val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
+ Box(
+ modifier =
+ Modifier.clip(RoundedCornerShape(10.dp))
+ .background(speedChipBackground)
+ .clickable(
+ interactionSource =
+ remember { MutableInteractionSource() },
+ indication = null
+ ) {
+ VoicePlaybackCoordinator.cycleSpeed()
+ }
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = formatVoicePlaybackSpeedLabel(playbackSpeed),
+ fontSize = 10.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = speedChipTextColor
+ )
+ }
+ }
+ Text(
+ text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
+ fontSize = 11.sp,
+ color = secondaryTextColor
+ )
+
+ if (isOutgoing) {
+ when (messageStatus) {
+ MessageStatus.SENDING -> {
+ Icon(
+ painter = TelegramIcons.Clock,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ MessageStatus.SENT,
+ MessageStatus.DELIVERED -> {
+ Icon(
+ painter = TelegramIcons.Done,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ MessageStatus.READ -> {
+ Box(modifier = Modifier.height(14.dp)) {
+ Icon(
+ painter = TelegramIcons.Done,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(14.dp)
+ )
+ Icon(
+ painter = TelegramIcons.Done,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(14.dp).offset(x = 4.dp)
+ )
+ }
+ }
+ MessageStatus.ERROR -> {
+ Icon(
+ imageVector = Icons.Default.Error,
+ contentDescription = null,
+ tint = Color(0xFFE55757),
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun VideoCircleAttachment(
+ attachment: MessageAttachment,
+ chachaKey: String,
+ chachaKeyPlainHex: String,
+ privateKey: String,
+ senderPublicKey: String,
+ isOutgoing: Boolean,
+ isDarkTheme: Boolean,
+ timestamp: java.util.Date,
+ messageStatus: MessageStatus = MessageStatus.READ
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val previewMeta = remember(attachment.preview) { parseVideoCirclePreview(getPreview(attachment)) }
+ val fallbackDurationMs = previewMeta.durationSec.coerceAtLeast(1) * 1000
+
+ var payload by
+ remember(attachment.id, attachment.blob) {
+ mutableStateOf(attachment.blob.trim())
+ }
+ var downloadStatus by
+ remember(attachment.id, attachment.blob, attachment.transportTag, attachment.localUri) {
+ mutableStateOf(
+ when {
+ attachment.localUri.isNotBlank() -> DownloadStatus.DOWNLOADED
+ attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
+ attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
+ else -> DownloadStatus.ERROR
+ }
+ )
+ }
+ var errorText by remember { mutableStateOf("") }
+ var playbackUri by remember(attachment.id, attachment.localUri) {
+ mutableStateOf(
+ runCatching {
+ if (attachment.localUri.isNotBlank()) Uri.parse(attachment.localUri) else null
+ }.getOrNull()
+ )
+ }
+ var boundUri by remember(attachment.id) { mutableStateOf(null) }
+ var isPrepared by remember(attachment.id) { mutableStateOf(false) }
+ var isPlaying by remember(attachment.id) { mutableStateOf(false) }
+ var playbackPositionMs by remember(attachment.id) { mutableIntStateOf(0) }
+ var playbackDurationMs by remember(attachment.id) { mutableIntStateOf(fallbackDurationMs) }
+ var videoViewRef by remember(attachment.id) { mutableStateOf(null) }
+
+ LaunchedEffect(payload, attachment.localUri, attachment.id, previewMeta.mimeType) {
+ if (playbackUri != null) return@LaunchedEffect
+ if (attachment.localUri.isNotBlank()) {
+ playbackUri = runCatching { Uri.parse(attachment.localUri) }.getOrNull()
+ return@LaunchedEffect
+ }
+ if (payload.isBlank()) return@LaunchedEffect
+ val prepared =
+ ensureVideoCirclePlaybackUri(
+ context = context,
+ attachmentId = attachment.id,
+ payload = payload,
+ mimeType = previewMeta.mimeType
+ )
+ if (prepared != null) {
+ playbackUri = prepared
+ if (downloadStatus != DownloadStatus.DOWNLOADING &&
+ downloadStatus != DownloadStatus.DECRYPTING
+ ) {
+ downloadStatus = DownloadStatus.DOWNLOADED
+ }
+ if (errorText.isNotBlank()) errorText = ""
+ } else {
+ downloadStatus = DownloadStatus.ERROR
+ if (errorText.isBlank()) errorText = "Cannot decode video"
+ }
+ }
+
+ LaunchedEffect(isPlaying, videoViewRef) {
+ val player = videoViewRef ?: return@LaunchedEffect
+ while (isPlaying) {
+ playbackPositionMs = runCatching { player.currentPosition }.getOrDefault(0).coerceAtLeast(0)
+ delay(120)
+ }
+ }
+
+ DisposableEffect(attachment.id) {
+ onDispose {
+ runCatching {
+ videoViewRef?.stopPlayback()
+ }
+ videoViewRef = null
+ isPlaying = false
+ isPrepared = false
+ boundUri = null
+ }
+ }
+
+ val triggerDownload: () -> Unit = download@{
+ if (attachment.transportTag.isBlank()) {
+ downloadStatus = DownloadStatus.ERROR
+ errorText = "Video is not available"
+ return@download
+ }
+ scope.launch {
+ downloadStatus = DownloadStatus.DOWNLOADING
+ errorText = ""
+ val decrypted =
+ downloadAndDecryptVoicePayload(
+ attachmentId = attachment.id,
+ downloadTag = attachment.transportTag,
+ chachaKey = chachaKey,
+ privateKey = privateKey,
+ transportServer = attachment.transportServer,
+ chachaKeyPlainHex = chachaKeyPlainHex
+ )
+ if (decrypted.isNullOrBlank()) {
+ downloadStatus = DownloadStatus.ERROR
+ errorText = "Failed to decrypt"
+ return@launch
+ }
+ val saved =
+ runCatching {
+ AttachmentFileManager.saveAttachment(
+ context = context,
+ blob = decrypted,
+ attachmentId = attachment.id,
+ publicKey = senderPublicKey,
+ privateKey = privateKey
+ )
+ }
+ .getOrDefault(false)
+ payload = decrypted
+ playbackUri =
+ ensureVideoCirclePlaybackUri(
+ context = context,
+ attachmentId = attachment.id,
+ payload = decrypted,
+ mimeType = previewMeta.mimeType
+ )
+ if (!saved) {
+ runCatching { android.util.Log.w(TAG, "Video circle cache save failed: ${attachment.id}") }
+ }
+ if (playbackUri == null) {
+ downloadStatus = DownloadStatus.ERROR
+ errorText = "Cannot decode video"
+ } else {
+ downloadStatus = DownloadStatus.DOWNLOADED
+ }
+ }
+ }
+
+ val onMainAction: () -> Unit = {
+ when (downloadStatus) {
+ DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload()
+ DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit
+ DownloadStatus.DOWNLOADED, DownloadStatus.PENDING -> {
+ if (playbackUri == null) {
+ triggerDownload()
+ } else {
+ isPlaying = !isPlaying
+ }
+ }
+ }
+ }
+
+ val durationToShowSec =
+ if (isPrepared && playbackDurationMs > 0) {
+ (playbackDurationMs / 1000).coerceAtLeast(1)
+ } else {
+ previewMeta.durationSec.coerceAtLeast(1)
+ }
+ val secondaryTextColor =
+ if (isOutgoing) Color.White.copy(alpha = 0.82f)
+ else if (isDarkTheme) Color(0xFFCCD3E0)
+ else Color(0xFF5F6D82)
+
+ Box(
+ modifier =
+ Modifier.padding(vertical = 4.dp)
+ .size(220.dp)
+ .clip(CircleShape)
+ .background(
+ if (isOutgoing) {
+ Color(0xFF3A9DFB)
+ } else if (isDarkTheme) {
+ Color(0xFF22252B)
+ } else {
+ Color(0xFFE8EEF7)
+ }
+ )
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) { onMainAction() }
+ ) {
+ val uri = playbackUri
+ if (uri != null && (downloadStatus == DownloadStatus.DOWNLOADED || downloadStatus == DownloadStatus.PENDING)) {
+ AndroidView(
+ factory = { ctx -> VideoView(ctx) },
+ modifier = Modifier.fillMaxSize(),
+ update = { videoView ->
+ videoViewRef = videoView
+ val targetUri = uri.toString()
+ if (boundUri != targetUri) {
+ boundUri = targetUri
+ isPrepared = false
+ playbackPositionMs = 0
+ runCatching {
+ videoView.setVideoURI(uri)
+ videoView.setOnPreparedListener { mediaPlayer ->
+ mediaPlayer.isLooping = false
+ playbackDurationMs =
+ mediaPlayer.duration
+ .coerceAtLeast(fallbackDurationMs)
+ isPrepared = true
+ if (isPlaying) {
+ runCatching { videoView.start() }
+ }
+ }
+ videoView.setOnCompletionListener {
+ isPlaying = false
+ playbackPositionMs = playbackDurationMs
+ }
+ }
+ }
+ if (isPrepared) {
+ if (isPlaying && !videoView.isPlaying) {
+ runCatching { videoView.start() }
+ } else if (!isPlaying && videoView.isPlaying) {
+ runCatching { videoView.pause() }
+ }
+ }
+ }
+ )
+ } else {
+ Box(
+ modifier =
+ Modifier.fillMaxSize()
+ .background(
+ Brush.radialGradient(
+ colors =
+ if (isOutgoing) {
+ listOf(
+ Color(0x6637A7FF),
+ Color(0x3337A7FF),
+ Color(0x0037A7FF)
+ )
+ } else if (isDarkTheme) {
+ listOf(
+ Color(0x553A4150),
+ Color(0x33262C39),
+ Color(0x00262C39)
+ )
+ } else {
+ listOf(
+ Color(0x5593B4E8),
+ Color(0x338AB0E5),
+ Color(0x008AB0E5)
+ )
+ }
+ )
+ )
+ )
+ }
+
+ Box(
+ modifier =
+ Modifier.align(Alignment.Center)
+ .size(52.dp)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.38f)),
+ contentAlignment = Alignment.Center
+ ) {
+ when (downloadStatus) {
+ DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(26.dp),
+ color = Color.White,
+ strokeWidth = 2.2.dp
+ )
+ }
+ DownloadStatus.NOT_DOWNLOADED -> {
+ Icon(
+ painter = painterResource(R.drawable.msg_download),
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ DownloadStatus.ERROR -> {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = null,
+ tint = Color(0xFFFF8A8A),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ else -> {
+ Icon(
+ imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+ }
+
+ Row(
+ modifier =
+ Modifier.align(Alignment.BottomEnd)
+ .padding(end = 10.dp, bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = formatVoiceDuration(durationToShowSec),
+ fontSize = 11.sp,
+ color = secondaryTextColor
+ )
+ if (isOutgoing) {
+ when (messageStatus) {
+ MessageStatus.SENDING -> {
+ Icon(
+ painter = TelegramIcons.Clock,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ MessageStatus.SENT,
+ MessageStatus.DELIVERED -> {
+ Icon(
+ painter = TelegramIcons.Done,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ MessageStatus.READ -> {
+ Box(modifier = Modifier.height(14.dp)) {
+ Icon(
+ painter = TelegramIcons.Done,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(14.dp)
+ )
+ Icon(
+ painter = TelegramIcons.Done,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(14.dp).offset(x = 4.dp)
+ )
+ }
+ }
+ MessageStatus.ERROR -> {
+ Icon(
+ imageVector = Icons.Default.Error,
+ contentDescription = null,
+ tint = Color(0xFFE55757),
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
/** File attachment - Telegram style */
@Composable
fun FileAttachment(
@@ -2933,6 +4188,60 @@ private suspend fun processDownloadedImage(
}
}
+/**
+ * CDN download + decrypt helper for voice payload (hex string).
+ */
+internal suspend fun downloadAndDecryptVoicePayload(
+ attachmentId: String,
+ downloadTag: String,
+ chachaKey: String,
+ privateKey: String,
+ transportServer: String = "",
+ chachaKeyPlainHex: String = ""
+): String? {
+ if (downloadTag.isBlank() || privateKey.isBlank()) return null
+ if (chachaKeyPlainHex.isBlank() && chachaKey.isBlank()) return null
+
+ return withContext(Dispatchers.IO) {
+ runCatching {
+ val encryptedContent =
+ TransportManager.downloadFile(
+ attachmentId,
+ downloadTag,
+ transportServer.ifBlank { null }
+ )
+ if (encryptedContent.isBlank()) return@withContext null
+
+ when {
+ chachaKeyPlainHex.isNotBlank() -> {
+ val plainKey =
+ chachaKeyPlainHex.chunked(2)
+ .mapNotNull { part -> part.toIntOrNull(16)?.toByte() }
+ .toByteArray()
+ if (plainKey.isEmpty()) {
+ null
+ } else {
+ MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
+ ?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey)
+ .takeIf { it.isNotEmpty() }
+ }
+ }
+ isGroupStoredKey(chachaKey) -> {
+ val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
+ CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
+ ?: run {
+ val hexKey =
+ groupPassword.toByteArray(Charsets.ISO_8859_1)
+ .joinToString("") { "%02x".format(it.toInt() and 0xff) }
+ CryptoManager.decryptWithPassword(encryptedContent, hexKey)
+ }
+ }
+ else -> MessageCrypto.decryptAttachmentBlob(encryptedContent, chachaKey, privateKey)
+ }
+ }.getOrNull()
+ }
+}
+
/**
* CDN download + decrypt + cache + save.
* Shared between ReplyBubble and ForwardedImagePreview.
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt
index 95fcd46..1598a93 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt
@@ -320,6 +320,7 @@ fun TypingIndicator(
@Composable
fun MessageBubble(
message: ChatMessage,
+ textSelectionHelper: com.rosetta.messenger.ui.chats.components.TextSelectionHelper? = null,
isDarkTheme: Boolean,
hasWallpaper: Boolean = false,
isSystemSafeChat: Boolean = false,
@@ -354,6 +355,16 @@ fun MessageBubble(
onGroupInviteOpen: (SearchUser) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {}
) {
+ val isTextSelectionOnThisMessage =
+ remember(
+ textSelectionHelper?.isInSelectionMode,
+ textSelectionHelper?.selectedMessageId,
+ message.id
+ ) {
+ textSelectionHelper?.isInSelectionMode == true &&
+ textSelectionHelper.selectedMessageId == message.id
+ }
+
// Swipe-to-reply state
val hapticFeedback = LocalHapticFeedback.current
var swipeOffset by remember { mutableStateOf(0f) }
@@ -373,7 +384,7 @@ fun MessageBubble(
// Selection animations
val selectionAlpha by
animateFloatAsState(
- targetValue = if (isSelected) 0.85f else 1f,
+ targetValue = if (isSelected && !isTextSelectionOnThisMessage) 0.85f else 1f,
animationSpec = tween(150),
label = "selectionAlpha"
)
@@ -400,6 +411,10 @@ fun MessageBubble(
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
}
+ var textViewRef by remember { mutableStateOf(null) }
+ val selectionDragEndHandler: (() -> Unit)? = if (textSelectionHelper != null) {
+ { textSelectionHelper.hideMagnifier(); textSelectionHelper.endHandleDrag() }
+ } else null
val linksEnabled = !isSelectionMode
val textClickHandler: (() -> Unit)? = onClick
val mentionClickHandler: ((String) -> Unit)? =
@@ -475,8 +490,9 @@ fun MessageBubble(
Box(
modifier =
- Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) {
+ Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) {
if (isSystemSafeChat) return@pointerInput
+ if (textSelectionHelper?.isActive == true) return@pointerInput
// 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со
// скроллом
@@ -552,7 +568,8 @@ fun MessageBubble(
val selectionBackgroundColor by
animateColorAsState(
targetValue =
- if (isSelected) PrimaryBlue.copy(alpha = 0.15f)
+ if (isSelected && !isTextSelectionOnThisMessage)
+ PrimaryBlue.copy(alpha = 0.15f)
else Color.Transparent,
animationSpec = tween(200),
label = "selectionBg"
@@ -684,7 +701,18 @@ fun MessageBubble(
message.attachments.all {
it.type ==
com.rosetta.messenger.network.AttachmentType
- .IMAGE
+ .IMAGE ||
+ it.type ==
+ com.rosetta.messenger.network
+ .AttachmentType
+ .VIDEO_CIRCLE
+ }
+ val hasOnlyVideoCircle =
+ hasOnlyMedia &&
+ message.attachments.all {
+ it.type ==
+ com.rosetta.messenger.network.AttachmentType
+ .VIDEO_CIRCLE
}
// Фото + caption (как в Telegram)
@@ -707,6 +735,8 @@ fun MessageBubble(
message.attachments.all {
it.type == AttachmentType.CALL
}
+ val hasVoiceAttachment =
+ message.attachments.any { it.type == AttachmentType.VOICE }
val isStandaloneGroupInvite =
message.attachments.isEmpty() &&
@@ -725,7 +755,8 @@ fun MessageBubble(
hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
}
- val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp
+ val bubbleBorderWidth =
+ if (hasOnlyMedia && !hasOnlyVideoCircle) 1.dp else 0.dp
// Telegram-style: ширина пузырька = ширина фото
// Caption переносится на новые строки, не расширяя пузырёк
@@ -743,7 +774,9 @@ fun MessageBubble(
// Вычисляем ширину фото для ограничения пузырька
val photoWidth =
if (hasImageWithCaption || hasOnlyMedia) {
- if (isImageCollage) {
+ if (hasOnlyVideoCircle) {
+ 220.dp
+ } else if (isImageCollage) {
maxCollageWidth
} else {
val firstImage =
@@ -843,6 +876,21 @@ fun MessageBubble(
if (isCallMessage) {
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
Modifier
+ } else if (hasVoiceAttachment) {
+ // Для voice не клипуем содержимое пузыря:
+ // playback-blob может выходить за границы, как в Telegram.
+ Modifier.background(
+ color =
+ if (isSafeSystemMessage) {
+ if (isDarkTheme)
+ Color(0xFF2A2A2D)
+ else Color(0xFFF0F0F4)
+ } else {
+ bubbleColor
+ },
+ shape = bubbleShape
+ )
+ .padding(bubblePadding)
} else {
Modifier.clip(bubbleShape)
.then(
@@ -962,6 +1010,7 @@ fun MessageBubble(
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
chachaKey = message.chachaKey,
+ chachaKeyPlainHex = message.chachaKeyPlainHex,
privateKey = privateKey,
onClick = { onReplyClick(reply.messageId) },
onImageClick = onImageClick,
@@ -978,10 +1027,12 @@ fun MessageBubble(
MessageAttachments(
attachments = message.attachments,
chachaKey = message.chachaKey,
+ chachaKeyPlainHex = message.chachaKeyPlainHex,
privateKey = privateKey,
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
senderPublicKey = senderPublicKey,
+ senderDisplayName = senderName,
dialogPublicKey = dialogPublicKey,
isGroupChat = isGroupChat,
timestamp = message.timestamp,
@@ -1050,7 +1101,32 @@ fun MessageBubble(
onClick =
textClickHandler,
onLongClick =
- onLongClick // 🔥 Long press для selection
+ onLongClick, // 🔥 Long press для selection
+ onViewCreated = { textViewRef = it },
+ onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
+ val info = textViewRef?.getLayoutInfo()
+ if (info != null) {
+ textSelectionHelper.startSelection(
+ messageId = message.id,
+ info = info,
+ touchX = touchX,
+ touchY = touchY,
+ view = textViewRef,
+ isOwnMessage = message.isOutgoing
+ )
+ }
+ } else null,
+ onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
+ textSelectionHelper.moveHandle(
+ (tx - textSelectionHelper.overlayWindowX),
+ (ty - textSelectionHelper.overlayWindowY)
+ )
+ textSelectionHelper.showMagnifier(
+ (tx - textSelectionHelper.overlayWindowX),
+ (ty - textSelectionHelper.overlayWindowY)
+ )
+ } else null,
+ onSelectionDragEnd = selectionDragEndHandler
)
},
timeContent = {
@@ -1141,12 +1217,22 @@ fun MessageBubble(
suppressBubbleTapFromSpan,
onClick = textClickHandler,
onLongClick =
- onLongClick // 🔥
- // Long
- // press
- // для
- // selection
- )
+ onLongClick, // 🔥 Long press для selection
+ onViewCreated = { textViewRef = it },
+ onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY ->
+ val info = textViewRef?.getLayoutInfo()
+ if (info != null) {
+ textSelectionHelper.startSelection(
+ messageId = message.id,
+ info = info,
+ touchX = touchX,
+ touchY = touchY,
+ view = textViewRef,
+ isOwnMessage = message.isOutgoing
+ )
+ }
+ } else null
+ )
},
timeContent = {
Row(
@@ -1245,11 +1331,32 @@ fun MessageBubble(
suppressBubbleTapFromSpan,
onClick = textClickHandler,
onLongClick =
- onLongClick // 🔥
- // Long
- // press
- // для
- // selection
+ onLongClick, // 🔥 Long press для selection
+ onViewCreated = { textViewRef = it },
+ onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
+ val info = textViewRef?.getLayoutInfo()
+ if (info != null) {
+ textSelectionHelper.startSelection(
+ messageId = message.id,
+ info = info,
+ touchX = touchX,
+ touchY = touchY,
+ view = textViewRef,
+ isOwnMessage = message.isOutgoing
+ )
+ }
+ } else null,
+ onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
+ textSelectionHelper.moveHandle(
+ (tx - textSelectionHelper.overlayWindowX),
+ (ty - textSelectionHelper.overlayWindowY)
+ )
+ textSelectionHelper.showMagnifier(
+ (tx - textSelectionHelper.overlayWindowX),
+ (ty - textSelectionHelper.overlayWindowY)
+ )
+ } else null,
+ onSelectionDragEnd = selectionDragEndHandler
)
},
timeContent = {
@@ -2097,6 +2204,7 @@ fun ReplyBubble(
isOutgoing: Boolean,
isDarkTheme: Boolean,
chachaKey: String = "",
+ chachaKeyPlainHex: String = "",
privateKey: String = "",
onClick: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
@@ -2224,7 +2332,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
}
@@ -2302,6 +2413,8 @@ fun ReplyBubble(
)
} else if (!hasImage) {
val displayText = when {
+ replyData.attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
+ replyData.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
else -> "..."
@@ -3540,6 +3653,7 @@ fun ProfilePhotoMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDarkTheme: Boolean,
+ onQrCodeClick: (() -> Unit)? = null,
onSetPhotoClick: () -> Unit,
onDeletePhotoClick: (() -> Unit)? = null,
hasAvatar: Boolean = false
@@ -3569,6 +3683,16 @@ fun ProfilePhotoMenu(
dismissOnClickOutside = true
)
) {
+ onQrCodeClick?.let { onQrClick ->
+ ProfilePhotoMenuItem(
+ icon = androidx.compose.ui.graphics.vector.rememberVectorPainter(TablerIcons.Scan),
+ text = "QR Code",
+ onClick = onQrClick,
+ tintColor = iconColor,
+ textColor = textColor
+ )
+ }
+
ProfilePhotoMenuItem(
icon = TelegramIcons.AddPhoto,
text = if (hasAvatar) "Set Profile Photo" else "Add Photo",
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt
new file mode 100644
index 0000000..65cdf2f
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt
@@ -0,0 +1,601 @@
+package com.rosetta.messenger.ui.chats.components
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.text.Layout
+import android.view.HapticFeedbackConstants
+import android.view.View
+import android.widget.Toast
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.RoundRect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInWindow
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Popup
+import com.rosetta.messenger.ui.onboarding.PrimaryBlue
+import kotlinx.coroutines.delay
+
+data class LayoutInfo(
+ val layout: Layout,
+ val windowX: Int,
+ val windowY: Int,
+ val text: CharSequence
+)
+
+class TextSelectionHelper {
+
+ var selectionStart by mutableIntStateOf(-1)
+ private set
+ var selectionEnd by mutableIntStateOf(-1)
+ private set
+ var selectedMessageId by mutableStateOf(null)
+ private set
+ // True when the selected message is the user's own (blue bubble) — used to pick
+ // white handles against the blue background instead of the default blue handles.
+ var isOwnMessage by mutableStateOf(false)
+ private set
+ var layoutInfo by mutableStateOf(null)
+ private set
+ var isActive by mutableStateOf(false)
+ private set
+ var handleViewProgress by mutableFloatStateOf(0f)
+ private set
+ var movingHandle by mutableStateOf(false)
+ private set
+ var movingHandleStart by mutableStateOf(false)
+ private set
+ // Telegram: isOneTouch = true during initial long-press drag (before finger lifts)
+ var isOneTouch by mutableStateOf(false)
+ private set
+ // Telegram: direction not determined yet — first drag decides start or end handle
+ private var movingDirectionSettling = false
+ private var movingOffsetX = 0f
+ private var movingOffsetY = 0f
+
+ var startHandleX by mutableFloatStateOf(0f)
+ var startHandleY by mutableFloatStateOf(0f)
+ var endHandleX by mutableFloatStateOf(0f)
+ var endHandleY by mutableFloatStateOf(0f)
+
+ // Back gesture callback — registered/unregistered by overlay
+ var backCallback: Any? = null
+
+ // Overlay position in window — set by TextSelectionOverlay
+ var overlayWindowX = 0f
+ var overlayWindowY = 0f
+
+ val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart
+
+ fun startSelection(
+ messageId: String,
+ info: LayoutInfo,
+ touchX: Int,
+ touchY: Int,
+ view: View?,
+ isOwnMessage: Boolean = false
+ ) {
+ this.isOwnMessage = isOwnMessage
+ val layout = info.layout
+ val localX = touchX - info.windowX
+ val localY = touchY - info.windowY
+
+ val line = layout.getLineForVertical(localY)
+ val hx = localX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
+ val offset = layout.getOffsetForHorizontal(line, hx)
+
+ val text = info.text
+ var start = offset
+ var end = offset
+
+ while (start > 0 && Character.isLetterOrDigit(text[start - 1])) start--
+ while (end < text.length && Character.isLetterOrDigit(text[end])) end++
+
+ if (start == end && end < text.length) end++
+
+ selectedMessageId = messageId
+ layoutInfo = info
+ selectionStart = start
+ selectionEnd = end
+ isActive = true
+ handleViewProgress = 1f
+
+ // Telegram: immediately enter drag mode — user can drag without lifting finger
+ movingHandle = true
+ movingDirectionSettling = true
+ isOneTouch = true
+ movingOffsetX = 0f
+ movingOffsetY = 0f
+
+ view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ showToolbar = false
+ }
+
+ fun updateSelectionStart(charOffset: Int) {
+ if (!isActive) return
+ val text = layoutInfo?.text ?: return
+ val newStart = charOffset.coerceIn(0, text.length)
+ if (newStart >= selectionEnd) return
+ val changed = newStart != selectionStart
+ selectionStart = newStart
+ if (changed) hapticOnSelectionChange()
+ }
+
+ fun updateSelectionEnd(charOffset: Int) {
+ if (!isActive) return
+ val text = layoutInfo?.text ?: return
+ val newEnd = charOffset.coerceIn(0, text.length)
+ if (newEnd <= selectionStart) return
+ val changed = newEnd != selectionEnd
+ selectionEnd = newEnd
+ if (changed) hapticOnSelectionChange()
+ }
+
+ private fun hapticOnSelectionChange() {
+ magnifierView?.performHapticFeedback(
+ HapticFeedbackConstants.TEXT_HANDLE_MOVE,
+ HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
+ )
+ }
+
+ fun beginHandleDrag(isStart: Boolean, touchX: Float, touchY: Float) {
+ movingHandle = true
+ movingHandleStart = isStart
+ movingOffsetX = if (isStart) startHandleX - touchX else endHandleX - touchX
+ movingOffsetY = if (isStart) startHandleY - touchY else endHandleY - touchY
+ }
+
+ fun moveHandle(touchX: Float, touchY: Float) {
+ if (!movingHandle) return
+ val x = (touchX + movingOffsetX).toInt()
+ val y = (touchY + movingOffsetY).toInt()
+ val offset = getCharOffsetFromCoords(x, y)
+ if (offset < 0) return
+
+ // Telegram: first drag determines which handle to move
+ if (movingDirectionSettling) {
+ if (offset < selectionStart) {
+ movingDirectionSettling = false
+ movingHandleStart = true
+ } else if (offset > selectionEnd) {
+ movingDirectionSettling = false
+ movingHandleStart = false
+ } else {
+ return // still within selected word, wait for more movement
+ }
+ }
+
+ if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset)
+ }
+
+ fun endHandleDrag() {
+ movingHandle = false
+ movingDirectionSettling = false
+ isOneTouch = false
+ showFloatingToolbar()
+ }
+
+ var showToolbar by mutableStateOf(false)
+ private set
+
+ fun showFloatingToolbar() {
+ if (isInSelectionMode && !movingHandle) {
+ showToolbar = true
+ }
+ }
+
+ fun hideFloatingToolbar() {
+ showToolbar = false
+ }
+
+ private var magnifier: android.widget.Magnifier? = null
+ private var magnifierView: View? = null
+
+ fun setMagnifierView(view: View?) {
+ magnifierView = view
+ }
+
+ fun showMagnifier(overlayLocalX: Float, overlayLocalY: Float) {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
+ val view = magnifierView ?: return
+ if (!movingHandle) return
+ if (magnifier == null) {
+ magnifier = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ android.widget.Magnifier.Builder(view)
+ .setSize(240, 64)
+ .setCornerRadius(12f)
+ .setElevation(4f)
+ .setDefaultSourceToMagnifierOffset(0, -96)
+ .build()
+ } else {
+ @Suppress("DEPRECATION")
+ android.widget.Magnifier(view)
+ }
+ }
+ val info = layoutInfo ?: return
+
+ // Magnifier should show at the HANDLE position (current char), not finger
+ // Use handle X for horizontal, and line center for vertical
+ val handleX = if (movingHandleStart) startHandleX else endHandleX
+ val handleY = if (movingHandleStart) startHandleY else endHandleY
+ val activeOffset = if (movingHandleStart) selectionStart else selectionEnd
+ val layout = info.layout
+ val line = layout.getLineForOffset(activeOffset.coerceIn(0, info.text.length))
+ val lineCenter = (layout.getLineTop(line) + layout.getLineBottom(line)) / 2f
+
+ // Convert to view-local coordinates
+ val viewLoc = IntArray(2)
+ view.getLocationInWindow(viewLoc)
+ val sourceX = (handleX + overlayWindowX - viewLoc[0]).coerceIn(0f, view.width.toFloat())
+ val sourceY = (lineCenter + info.windowY - viewLoc[1]).coerceIn(0f, view.height.toFloat())
+ magnifier?.show(sourceX, sourceY)
+ }
+
+ fun hideMagnifier() {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
+ magnifier?.dismiss()
+ magnifier = null
+ }
+
+ fun getCharOffsetFromCoords(overlayLocalX: Int, overlayLocalY: Int): Int {
+ val info = layoutInfo ?: return -1
+ // overlay-local → text-local: subtract text position relative to overlay
+ val textLocalX = overlayLocalX - (info.windowX - overlayWindowX)
+ val textLocalY = overlayLocalY - (info.windowY - overlayWindowY)
+ val layout = info.layout
+ val line = layout.getLineForVertical(textLocalY.toInt().coerceIn(0, layout.height))
+ val hx = textLocalX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
+ return layout.getOffsetForHorizontal(line, hx)
+ }
+
+ fun getSelectedText(): CharSequence? {
+ if (!isInSelectionMode) return null
+ val text = layoutInfo?.text ?: return null
+ val start = selectionStart.coerceIn(0, text.length)
+ val end = selectionEnd.coerceIn(start, text.length)
+ return text.subSequence(start, end)
+ }
+
+ fun copySelectedText(context: Context) {
+ val selectedText = getSelectedText() ?: return
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText("selected_text", selectedText))
+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
+ clear()
+ }
+
+ fun selectAll() {
+ val text = layoutInfo?.text ?: return
+ selectionStart = 0
+ selectionEnd = text.length
+ }
+
+ fun clear() {
+ selectionStart = -1
+ selectionEnd = -1
+ selectedMessageId = null
+ layoutInfo = null
+ isActive = false
+ handleViewProgress = 0f
+ movingHandle = false
+ movingDirectionSettling = false
+ isOneTouch = false
+ showToolbar = false
+ hideMagnifier()
+ }
+}
+
+private val HandleSize = 22.dp
+private val HandleInset = 8.dp
+private val HighlightCorner = 6.dp
+private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f)
+private val HandleColor = PrimaryBlue
+
+@Composable
+private fun FloatingToolbarPopup(
+ helper: TextSelectionHelper
+) {
+ val context = LocalContext.current
+
+ LaunchedEffect(helper.isActive, helper.movingHandle) {
+ if (helper.isActive && !helper.movingHandle && !helper.showToolbar) {
+ delay(200)
+ helper.showFloatingToolbar()
+ }
+ }
+
+ if (!helper.showToolbar || !helper.isInSelectionMode) return
+
+ val info = helper.layoutInfo ?: return
+ val layout = info.layout
+ val density = LocalDensity.current
+ val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
+
+ // Toolbar positioned ABOVE selection top, in overlay-local coordinates
+ val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f
+ val selectionTopY = layout.getLineTop(startLine).toFloat() +
+ (info.windowY - helper.overlayWindowY)
+ // Toolbar is ~48dp tall + 8dp gap above selection
+ val toolbarOffsetPx = with(density) { 56.dp.toPx() }
+ val toolbarWidthPx = with(density) { 200.dp.toPx() }
+ val toolbarX = (selectionCenterX - toolbarWidthPx / 2f).coerceAtLeast(with(density) { 8.dp.toPx() })
+ val toolbarY = (selectionTopY - toolbarOffsetPx).coerceAtLeast(0f)
+
+ Popup(
+ alignment = Alignment.TopStart,
+ offset = IntOffset(toolbarX.toInt(), toolbarY.toInt())
+ ) {
+ Row(
+ modifier = Modifier
+ .shadow(4.dp, RoundedCornerShape(8.dp))
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color(0xFF333333))
+ .padding(horizontal = 4.dp, vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Copy",
+ color = Color.White,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier
+ .clickable { helper.copySelectedText(context) }
+ .padding(horizontal = 12.dp, vertical = 8.dp)
+ )
+ val allSelected = helper.selectionStart <= 0 &&
+ helper.selectionEnd >= info.text.length
+ if (!allSelected) {
+ Text(
+ text = "Select All",
+ color = Color.White,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier
+ .clickable {
+ helper.selectAll()
+ helper.hideFloatingToolbar()
+ }
+ .padding(horizontal = 12.dp, vertical = 8.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun TextSelectionOverlay(
+ helper: TextSelectionHelper,
+ modifier: Modifier = Modifier
+) {
+ if (!helper.isInSelectionMode) return
+
+ val density = LocalDensity.current
+ val handleSizePx = with(density) { HandleSize.toPx() }
+ val handleInsetPx = with(density) { HandleInset.toPx() }
+ val highlightCornerPx = with(density) { HighlightCorner.toPx() }
+ // Read isOwnMessage at composition level so Canvas properly invalidates on change.
+ // On own (blue) bubbles use the light-blue typing color — reads better than pure white.
+ val handleColor = if (helper.isOwnMessage) Color(0xFF54A9EB) else HandleColor
+ val highlightColor = if (helper.isOwnMessage) Color(0xFF54A9EB).copy(alpha = 0.45f) else HighlightColor
+
+ // Block predictive back gesture completely during text selection.
+ // BackHandler alone doesn't prevent the swipe animation on Android 13+
+ // with enableOnBackInvokedCallback=true. We must register an
+ // OnBackInvokedCallback at PRIORITY_OVERLAY to fully suppress it.
+ val activity = LocalContext.current as? android.app.Activity
+ LaunchedEffect(helper.isActive) {
+ if (android.os.Build.VERSION.SDK_INT >= 33 && activity != null) {
+ if (helper.isActive) {
+ val cb = android.window.OnBackInvokedCallback { /* consumed, do nothing */ }
+ activity.onBackInvokedDispatcher.registerOnBackInvokedCallback(
+ android.window.OnBackInvokedDispatcher.PRIORITY_OVERLAY, cb
+ )
+ helper.backCallback = cb
+ } else {
+ helper.backCallback?.let { cb ->
+ runCatching {
+ activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(
+ cb as android.window.OnBackInvokedCallback
+ )
+ }
+ }
+ helper.backCallback = null
+ }
+ }
+ }
+ // Fallback for Android < 13
+ androidx.activity.compose.BackHandler(enabled = helper.isActive) {
+ // consumed — no navigation back while selecting
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .onGloballyPositioned { coords ->
+ val pos = coords.positionInWindow()
+ helper.overlayWindowX = pos.x
+ helper.overlayWindowY = pos.y
+ }
+ ) {
+ FloatingToolbarPopup(helper = helper)
+ Canvas(
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(helper.isActive) {
+ if (!helper.isActive) return@pointerInput
+ awaitPointerEventScope {
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.firstOrNull() ?: continue
+
+ when {
+ change.pressed && !helper.movingHandle -> {
+ val x = change.position.x
+ val y = change.position.y
+ val startRect = Rect(
+ helper.startHandleX - handleSizePx / 2 - handleInsetPx,
+ helper.startHandleY - handleInsetPx,
+ helper.startHandleX + handleSizePx / 2 + handleInsetPx,
+ helper.startHandleY + handleSizePx + handleInsetPx
+ )
+ val endRect = Rect(
+ helper.endHandleX - handleSizePx / 2 - handleInsetPx,
+ helper.endHandleY - handleInsetPx,
+ helper.endHandleX + handleSizePx / 2 + handleInsetPx,
+ helper.endHandleY + handleSizePx + handleInsetPx
+ )
+ when {
+ startRect.contains(Offset(x, y)) -> {
+ helper.beginHandleDrag(isStart = true, x, y)
+ helper.hideFloatingToolbar()
+ change.consume()
+ }
+ endRect.contains(Offset(x, y)) -> {
+ helper.beginHandleDrag(isStart = false, x, y)
+ helper.hideFloatingToolbar()
+ change.consume()
+ }
+ else -> {
+ helper.clear()
+ }
+ }
+ }
+ change.pressed && helper.movingHandle -> {
+ helper.moveHandle(change.position.x, change.position.y)
+ helper.showMagnifier(change.position.x, change.position.y)
+ change.consume()
+ }
+ !change.pressed && helper.movingHandle -> {
+ helper.hideMagnifier()
+ helper.endHandleDrag()
+ change.consume()
+ }
+ }
+ }
+ }
+ }
+ ) {
+ val info = helper.layoutInfo ?: return@Canvas
+ val layout = info.layout
+ val text = info.text
+
+ // Convert window coords to overlay-local coords
+ val offsetX = info.windowX - helper.overlayWindowX
+ val offsetY = info.windowY - helper.overlayWindowY
+
+ val startOffset = helper.selectionStart.coerceIn(0, text.length)
+ val endOffset = helper.selectionEnd.coerceIn(0, text.length)
+ if (startOffset >= endOffset) return@Canvas
+
+ val startLine = layout.getLineForOffset(startOffset)
+ val endLine = layout.getLineForOffset(endOffset)
+
+ // Padding around highlight for breathing room
+ val padH = 3.dp.toPx()
+ val padV = 2.dp.toPx()
+
+ // Build a single unified Path from all per-line rects, then fill once.
+ // This avoids double-alpha artifacts where adjacent lines' padding overlaps.
+ val highlightPath = Path()
+ for (line in startLine..endLine) {
+ // Only pad the outer edges (top of first line, bottom of last line).
+ // Inner edges meet at lineBottom == nextLineTop so the union fills fully.
+ val topPad = if (line == startLine) padV else 0f
+ val bottomPad = if (line == endLine) padV else 0f
+ val lineTop = layout.getLineTop(line).toFloat() + offsetY - topPad
+ val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + bottomPad
+ val left = if (line == startLine) {
+ layout.getPrimaryHorizontal(startOffset) + offsetX - padH
+ } else {
+ layout.getLineLeft(line) + offsetX - padH
+ }
+ val right = if (line == endLine) {
+ layout.getPrimaryHorizontal(endOffset) + offsetX + padH
+ } else {
+ layout.getLineRight(line) + offsetX + padH
+ }
+ highlightPath.addRoundRect(
+ RoundRect(
+ rect = Rect(left, lineTop, right, lineBottom),
+ cornerRadius = CornerRadius(highlightCornerPx)
+ )
+ )
+ }
+ drawPath(path = highlightPath, color = highlightColor)
+
+ val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX
+ val startHy = layout.getLineBottom(startLine).toFloat() + offsetY
+ val endHx = layout.getPrimaryHorizontal(endOffset) + offsetX
+ val endHy = layout.getLineBottom(endLine).toFloat() + offsetY
+
+ helper.startHandleX = startHx
+ helper.startHandleY = startHy
+ helper.endHandleX = endHx
+ helper.endHandleY = endHy
+
+ drawStartHandle(startHx, startHy, handleSizePx, handleColor)
+ drawEndHandle(endHx, endHy, handleSizePx, handleColor)
+ }
+ }
+}
+
+private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float, color: Color) {
+ val half = size / 2f
+ drawCircle(
+ color = color,
+ radius = half,
+ center = Offset(x, y + half)
+ )
+ drawRect(
+ color = color,
+ topLeft = Offset(x, y),
+ size = Size(half, half)
+ )
+}
+
+private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float, color: Color) {
+ val half = size / 2f
+ drawCircle(
+ color = color,
+ radius = half,
+ center = Offset(x, y + half)
+ )
+ drawRect(
+ color = color,
+ topLeft = Offset(x - half, y),
+ size = Size(half, half)
+ )
+}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt
index 29da07e..3afdfb5 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt
@@ -1,12 +1,32 @@
package com.rosetta.messenger.ui.chats.input
+import android.Manifest
import android.content.Context
+import android.content.pm.PackageManager
+import android.media.MediaRecorder
+import android.os.Build
import android.view.inputmethod.InputMethodManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import com.airbnb.lottie.LottieProperty
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.rememberLottieDynamicProperties
+import com.airbnb.lottie.compose.rememberLottieDynamicProperty
+import com.airbnb.lottie.compose.rememberLottieComposition
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material.icons.filled.Videocam
import androidx.compose.animation.*
import androidx.compose.animation.core.*
+import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -18,25 +38,36 @@ import androidx.compose.material3.*
import androidx.compose.ui.draw.alpha
import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
@@ -44,10 +75,12 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
+import androidx.core.content.ContextCompat
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.repository.AvatarRepository
+import com.rosetta.messenger.R
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.AvatarImage
@@ -57,10 +90,24 @@ import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.chats.ChatViewModel
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import java.io.File
import java.util.Locale
+import java.util.UUID
+import kotlin.math.PI
+import kotlin.math.sin
+
+private val EaseOutQuint = CubicBezierEasing(0.23f, 1f, 0.32f, 1f)
+private const val INPUT_JUMP_LOG_ENABLED = false
+
+private fun lerpFloat(start: Float, end: Float, fraction: Float): Float {
+ return start + (end - start) * fraction
+}
private fun truncateEmojiSafe(text: String, maxLen: Int): String {
if (text.length <= maxLen) return text
@@ -75,6 +122,821 @@ private fun truncateEmojiSafe(text: String, maxLen: Int): String {
return text.substring(0, cutAt).trimEnd() + "..."
}
+private fun bytesToHexLower(bytes: ByteArray): String {
+ if (bytes.isEmpty()) return ""
+ val out = StringBuilder(bytes.size * 2)
+ bytes.forEach { out.append(String.format("%02x", it.toInt() and 0xff)) }
+ return out.toString()
+}
+
+internal fun compressVoiceWaves(source: List, targetLength: Int): List {
+ if (targetLength <= 0) return emptyList()
+ if (source.isEmpty()) return List(targetLength) { 0f }
+ if (source.size == targetLength) return source
+
+ if (source.size > targetLength) {
+ val bucketSize = source.size / targetLength.toFloat()
+ return List(targetLength) { index ->
+ val start = kotlin.math.floor(index * bucketSize).toInt()
+ val end = kotlin.math.max(start + 1, kotlin.math.floor((index + 1) * bucketSize).toInt())
+ var maxValue = 0f
+ for (i in start until end.coerceAtMost(source.size)) {
+ if (source[i] > maxValue) maxValue = source[i]
+ }
+ maxValue
+ }
+ }
+
+ if (targetLength == 1) return listOf(source.first())
+
+ val lastIndex = source.lastIndex.toFloat()
+ return List(targetLength) { index ->
+ val pos = index * lastIndex / (targetLength - 1).toFloat()
+ val left = kotlin.math.floor(pos).toInt()
+ val right = kotlin.math.min(kotlin.math.ceil(pos).toInt(), source.lastIndex)
+ if (left == right) {
+ source[left]
+ } else {
+ val t = pos - left.toFloat()
+ source[left] * (1f - t) + source[right] * t
+ }
+ }
+}
+
+internal fun formatVoiceRecordTimer(elapsedMs: Long): String {
+ val safeTenths = (elapsedMs.coerceAtLeast(0L) / 100L).toInt()
+ val totalSeconds = safeTenths / 10
+ val tenths = safeTenths % 10
+ val minutes = totalSeconds / 60
+ val seconds = (totalSeconds % 60).toString().padStart(2, '0')
+ return "$minutes:$seconds,$tenths"
+}
+
+private enum class RecordMode {
+ VOICE,
+ VIDEO
+}
+
+private enum class RecordUiState {
+ IDLE,
+ PRESSING,
+ RECORDING,
+ LOCKED,
+ PAUSED
+}
+
+@Composable
+private fun RecordBlinkDot(
+ isDarkTheme: Boolean,
+ indicatorSize: Dp = 24.dp,
+ modifier: Modifier = Modifier
+) {
+ var entered by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) { entered = true }
+
+ val enterScale by animateFloatAsState(
+ targetValue = if (entered) 1f else 0f,
+ animationSpec = tween(durationMillis = 180, easing = LinearOutSlowInEasing),
+ label = "record_dot_enter_scale"
+ )
+ val blinkAlpha by rememberInfiniteTransition(label = "record_dot_blink").animateFloat(
+ initialValue = 1f,
+ targetValue = 0f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 600, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "record_dot_alpha"
+ )
+
+ val dotColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
+ Box(
+ modifier = modifier
+ .size(indicatorSize)
+ .graphicsLayer {
+ scaleX = enterScale
+ scaleY = enterScale
+ alpha = blinkAlpha
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .size(10.dp) // Telegram fallback dot radius = 5dp
+ .clip(CircleShape)
+ .background(dotColor)
+ )
+ }
+}
+
+@Composable
+private fun TelegramVoiceDeleteIndicator(
+ cancelProgress: Float,
+ isDarkTheme: Boolean,
+ indicatorSize: Dp = 24.dp,
+ modifier: Modifier = Modifier
+) {
+ val progress = cancelProgress.coerceIn(0f, 1f)
+ // Ensure red dot is clearly visible first; trash appears with a delayed phase.
+ val trashStart = 0.28f
+ val reveal = ((progress - trashStart) / (1f - trashStart)).coerceIn(0f, 1f)
+ val lottieProgress = FastOutSlowInEasing.transform(reveal)
+ val lottieAlpha = FastOutSlowInEasing.transform(reveal)
+ val composition by rememberLottieComposition(
+ LottieCompositionSpec.RawRes(R.raw.chat_audio_record_delete_2)
+ )
+ val dangerColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
+ val neutralColor = if (isDarkTheme) Color(0xFF8EA2B4) else Color(0xFF8FA2B3)
+ val panelBlendColor = if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD)
+ val dynamicProperties = rememberLottieDynamicProperties(
+ rememberLottieDynamicProperty(
+ property = LottieProperty.COLOR,
+ value = dangerColor.toArgb(),
+ keyPath = arrayOf("Cup Red", "**")
+ ),
+ rememberLottieDynamicProperty(
+ property = LottieProperty.COLOR,
+ value = dangerColor.toArgb(),
+ keyPath = arrayOf("Box Red", "**")
+ ),
+ rememberLottieDynamicProperty(
+ property = LottieProperty.COLOR,
+ value = neutralColor.toArgb(),
+ keyPath = arrayOf("Cup Grey", "**")
+ ),
+ rememberLottieDynamicProperty(
+ property = LottieProperty.COLOR,
+ value = neutralColor.toArgb(),
+ keyPath = arrayOf("Box Grey", "**")
+ ),
+ rememberLottieDynamicProperty(
+ property = LottieProperty.COLOR,
+ value = panelBlendColor.toArgb(),
+ keyPath = arrayOf("Line 1", "**")
+ ),
+ rememberLottieDynamicProperty(
+ property = LottieProperty.COLOR,
+ value = panelBlendColor.toArgb(),
+ keyPath = arrayOf("Line 2", "**")
+ ),
+ rememberLottieDynamicProperty(
+ property = LottieProperty.COLOR,
+ value = panelBlendColor.toArgb(),
+ keyPath = arrayOf("Line 3", "**")
+ )
+ )
+
+ Box(
+ modifier = modifier.size(indicatorSize),
+ contentAlignment = Alignment.Center
+ ) {
+ // Single recording dot (no duplicate red indicators).
+ RecordBlinkDot(
+ isDarkTheme = isDarkTheme,
+ indicatorSize = indicatorSize,
+ modifier = Modifier.graphicsLayer {
+ alpha = 1f - lottieAlpha
+ scaleX = 1f - 0.12f * lottieAlpha
+ scaleY = 1f - 0.12f * lottieAlpha
+ }
+ )
+ if (composition != null) {
+ LottieAnimation(
+ composition = composition,
+ progress = { lottieProgress },
+ dynamicProperties = dynamicProperties,
+ modifier = Modifier
+ .matchParentSize()
+ .graphicsLayer {
+ alpha = lottieAlpha
+ scaleX = 0.92f + 0.08f * lottieAlpha
+ scaleY = 0.92f + 0.08f * lottieAlpha
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun VoiceMovingBlob(
+ voiceLevel: Float,
+ isDarkTheme: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val rawLevel = voiceLevel.coerceIn(0f, 1f)
+ val level by animateFloatAsState(
+ targetValue = rawLevel,
+ animationSpec = tween(durationMillis = 100, easing = LinearOutSlowInEasing),
+ label = "voice_blob_level"
+ )
+ val transition = rememberInfiniteTransition(label = "voice_blob_motion")
+ val phase by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1400, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "voice_blob_phase"
+ )
+
+ val waveColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
+ Canvas(
+ modifier = modifier
+ .width(36.dp)
+ .height(18.dp)
+ ) {
+ val cxBase = size.width * 0.5f
+ val cy = size.height * 0.5f
+ val waveShift = (size.width * 0.16f * sin(phase * 2f * PI.toFloat()))
+ val cx = cxBase + waveShift
+ val base = size.minDimension * 0.22f
+
+ drawCircle(
+ color = waveColor.copy(alpha = 0.2f + level * 0.15f),
+ radius = base * (2.1f + level * 1.0f),
+ center = Offset(cx - waveShift * 0.55f, cy)
+ )
+ drawCircle(
+ color = waveColor.copy(alpha = 0.38f + level * 0.20f),
+ radius = base * (1.55f + level * 0.75f),
+ center = Offset(cx + waveShift * 0.35f, cy)
+ )
+ drawCircle(
+ color = waveColor.copy(alpha = 0.95f),
+ radius = base * (0.88f + level * 0.15f),
+ center = Offset(cx, cy)
+ )
+ }
+}
+
+@Composable
+private fun VoiceButtonBlob(
+ voiceLevel: Float,
+ isDarkTheme: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val rawLevel = voiceLevel.coerceIn(0f, 1f)
+ val bigLevel by animateFloatAsState(
+ targetValue = rawLevel,
+ animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing),
+ label = "voice_btn_blob_big_level"
+ )
+ val smallLevel by animateFloatAsState(
+ targetValue = rawLevel * 0.88f,
+ animationSpec = tween(durationMillis = 220, easing = LinearOutSlowInEasing),
+ label = "voice_btn_blob_small_level"
+ )
+ val transition = rememberInfiniteTransition(label = "voice_btn_blob_motion")
+ val morphPhase by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = (2f * PI.toFloat()),
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 2200, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "voice_btn_blob_morph"
+ )
+ val driftX by transition.animateFloat(
+ initialValue = -1f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1650, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "voice_btn_blob_drift_x"
+ )
+ val blobColor = if (isDarkTheme) Color(0xFF52C3FF) else Color(0xFF2D9CFF)
+
+ fun createBlobPath(
+ center: Offset,
+ baseRadius: Float,
+ points: Int,
+ phase: Float,
+ seed: Float,
+ level: Float,
+ formMax: Float
+ ): Path {
+ val coords = ArrayList(points)
+ val step = (2f * PI.toFloat()) / points.toFloat()
+ val deform = (0.08f + formMax * 0.34f) * level
+ for (i in 0 until points) {
+ val angle = i * step
+ val p = angle + phase * (0.6f + seed * 0.22f)
+ val n1 = sin(p * (2.2f + seed * 0.45f))
+ val n2 = sin(p * (3.4f + seed * 0.28f) - phase * (0.7f + seed * 0.18f))
+ val mix = n1 * 0.65f + n2 * 0.35f
+ val radius = baseRadius * (1f + mix * deform)
+ coords += Offset(
+ x = center.x + radius * kotlin.math.cos(angle),
+ y = center.y + radius * kotlin.math.sin(angle)
+ )
+ }
+
+ val path = Path()
+ if (coords.isEmpty()) return path
+ val firstMid = Offset(
+ (coords.last().x + coords.first().x) * 0.5f,
+ (coords.last().y + coords.first().y) * 0.5f
+ )
+ path.moveTo(firstMid.x, firstMid.y)
+ for (i in coords.indices) {
+ val current = coords[i]
+ val next = coords[(i + 1) % coords.size]
+ val mid = Offset((current.x + next.x) * 0.5f, (current.y + next.y) * 0.5f)
+ path.quadraticBezierTo(current.x, current.y, mid.x, mid.y)
+ }
+ path.close()
+ return path
+ }
+
+ Canvas(modifier = modifier) {
+ val center = Offset(
+ x = size.width * 0.5f + size.width * 0.05f * driftX,
+ y = size.height * 0.5f
+ )
+ val baseRadius = size.minDimension * 0.25f
+
+ // Telegram-like constants:
+ // SCALE_BIG_MIN=0.878, SCALE_SMALL_MIN=0.926, +1.4*amplitude
+ val bigScale = 0.878f + 1.4f * bigLevel
+ val smallScale = 0.926f + 1.4f * smallLevel
+
+ val bigPath = createBlobPath(
+ center = center,
+ baseRadius = baseRadius * bigScale,
+ points = 12,
+ phase = morphPhase,
+ seed = 0.23f,
+ level = bigLevel,
+ formMax = 0.6f
+ )
+ val smallPath = createBlobPath(
+ center = Offset(center.x - size.width * 0.01f, center.y + size.height * 0.01f),
+ baseRadius = baseRadius * smallScale,
+ points = 11,
+ phase = morphPhase + 0.7f,
+ seed = 0.61f,
+ level = smallLevel,
+ formMax = 0.6f
+ )
+
+ drawPath(
+ path = bigPath,
+ color = blobColor.copy(alpha = 0.30f)
+ )
+ drawPath(
+ path = smallPath,
+ color = blobColor.copy(alpha = 0.15f)
+ )
+ }
+}
+
+private const val LOCK_HINT_PREF_KEY = "lock_record_hint_shown_count"
+private const val LOCK_HINT_MAX_SHOWS = 3
+
+@Composable
+private fun LockIcon(
+ lockProgress: Float,
+ isLocked: Boolean,
+ isPaused: Boolean,
+ isDarkTheme: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val progress = lockProgress.coerceIn(0f, 1f)
+ val lockedOrPaused = isLocked || isPaused
+
+ // Staggered snap animations — Telegram timing
+ val snapAnim by animateFloatAsState(
+ targetValue = if (lockedOrPaused) 1f else 0f,
+ animationSpec = tween(durationMillis = 250, easing = EaseOutQuint),
+ label = "lock_snap"
+ )
+ val pauseTransform by animateFloatAsState(
+ targetValue = if (lockedOrPaused) 1f else 0f,
+ animationSpec = tween(durationMillis = 300, delayMillis = 150, easing = FastOutSlowInEasing),
+ label = "lock_to_pause"
+ )
+ // Idle "breathing" animation for shackle
+ val idlePhase by rememberInfiniteTransition(label = "lock_idle").animateFloat(
+ initialValue = 0f, targetValue = 1f,
+ animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing), RepeatMode.Reverse),
+ label = "lock_idle_phase"
+ )
+
+ val bgColor = if (isDarkTheme) Color(0xFF2A2A3E) else Color(0xFFF0F0F0)
+ val iconColor = if (isDarkTheme) Color.White else Color(0xFF333333)
+ val shadowColor = if (isDarkTheme) Color.Black.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.15f)
+ val bgPaintColor = if (isDarkTheme) Color(0xFF3A3A4E) else Color(0xFFE8E8E8)
+
+ // Lock is always visible during recording (Telegram shows it immediately)
+ val enterAlpha by animateFloatAsState(
+ targetValue = 1f,
+ animationSpec = tween(durationMillis = 200),
+ label = "lock_enter_alpha"
+ )
+
+ Canvas(
+ modifier = modifier.graphicsLayer { alpha = enterAlpha }
+ ) {
+ val cx = size.width / 2f
+ val dp1 = size.width / 36f // normalize to 36dp base
+ val moveProgress = if (lockedOrPaused) 1f else progress
+
+ // Telegram rotation: dual-phase snap
+ val snapRotateBack = if (moveProgress > 0.4f) 1f else moveProgress / 0.4f
+ val rotation = if (lockedOrPaused) {
+ 9f * (1f - moveProgress) * (1f - snapAnim) -
+ 15f * snapAnim * (1f - snapRotateBack)
+ } else {
+ 9f * (1f - moveProgress)
+ }
+
+ // ── Background pill with shadow ──
+ val pillW = 36f * dp1
+ val pillH = 50f * dp1
+ val pillLeft = cx - pillW / 2f
+ val pillTop = 0f
+ val pillRadius = pillW / 2f
+
+ // Shadow
+ drawRoundRect(
+ color = shadowColor,
+ topLeft = Offset(pillLeft - 3f * dp1, pillTop - 2f * dp1),
+ size = androidx.compose.ui.geometry.Size(pillW + 6f * dp1, pillH + 4f * dp1),
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(pillRadius + 3f * dp1)
+ )
+ // Pill background
+ drawRoundRect(
+ color = bgColor,
+ topLeft = Offset(pillLeft, pillTop),
+ size = androidx.compose.ui.geometry.Size(pillW, pillH),
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(pillRadius)
+ )
+
+ // ── Lock icon drawing (Telegram-exact) ──
+ // Body: 16dp × 16dp centered, with corner radius 3dp
+ val bodyW = 16f * dp1
+ val bodyH = 16f * dp1
+ val bodyRadius = 3f * dp1
+ val bodyCx = cx
+ val bodyCy = pillTop + pillH * 0.62f // body center in lower part of pill
+ val bodyLeft = bodyCx - bodyW / 2f
+ val bodyTop = bodyCy - bodyH / 2f
+
+ // Shackle: 8dp × 8dp arc above body, stroke 1.7dp
+ val shackleW = 8f * dp1
+ val shackleH = 8f * dp1
+ val shackleStroke = 1.7f * dp1
+ val shackleLeft = bodyCx - shackleW / 2f
+ val shackleTop = bodyTop - shackleH * 0.7f - 2f * dp1
+
+ val lockIconAlpha = 1f - pauseTransform
+ val idleOffset = idlePhase * 2f * dp1 * (1f - moveProgress) // breathing on left leg
+
+ if (lockIconAlpha > 0.01f) {
+ rotate(degrees = rotation, pivot = Offset(bodyCx, bodyCy)) {
+ // Shackle arc (half circle)
+ drawArc(
+ color = iconColor.copy(alpha = lockIconAlpha),
+ startAngle = 180f,
+ sweepAngle = 180f,
+ useCenter = false,
+ topLeft = Offset(shackleLeft, shackleTop),
+ size = androidx.compose.ui.geometry.Size(shackleW, shackleH),
+ style = androidx.compose.ui.graphics.drawscope.Stroke(
+ width = shackleStroke,
+ cap = androidx.compose.ui.graphics.StrokeCap.Round
+ )
+ )
+ // Right leg (fixed)
+ drawLine(
+ color = iconColor.copy(alpha = lockIconAlpha),
+ start = Offset(shackleLeft + shackleW, shackleTop + shackleH / 2f),
+ end = Offset(shackleLeft + shackleW, bodyTop + 2f * dp1),
+ strokeWidth = shackleStroke,
+ cap = androidx.compose.ui.graphics.StrokeCap.Round
+ )
+ // Left leg (animated — idle breathing + lock closing)
+ val leftLegEnd = bodyTop + 2f * dp1 + idleOffset +
+ 4f * dp1 * snapAnim * (1f - moveProgress)
+ drawLine(
+ color = iconColor.copy(alpha = lockIconAlpha),
+ start = Offset(shackleLeft, shackleTop + shackleH / 2f),
+ end = Offset(shackleLeft, leftLegEnd),
+ strokeWidth = shackleStroke,
+ cap = androidx.compose.ui.graphics.StrokeCap.Round
+ )
+
+ // Body (filled rounded rect)
+ drawRoundRect(
+ color = iconColor.copy(alpha = lockIconAlpha),
+ topLeft = Offset(bodyLeft, bodyTop),
+ size = androidx.compose.ui.geometry.Size(bodyW, bodyH),
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(bodyRadius)
+ )
+
+ // Keyhole dot (Telegram: dpf2(2) radius at center)
+ drawCircle(
+ color = bgPaintColor.copy(alpha = lockIconAlpha),
+ radius = 2f * dp1,
+ center = Offset(bodyCx, bodyCy)
+ )
+ }
+ }
+
+ // ── Pause transform: body splits into two bars ──
+ if (pauseTransform > 0.01f) {
+ val gap = 1.66f * dp1 * pauseTransform
+ val barW = 4f * dp1
+ val barH = 14f * dp1
+ val barRadius = 1.5f * dp1
+ drawRoundRect(
+ color = iconColor.copy(alpha = pauseTransform),
+ topLeft = Offset(bodyCx - gap - barW, bodyCy - barH / 2f),
+ size = androidx.compose.ui.geometry.Size(barW, barH),
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
+ )
+ drawRoundRect(
+ color = iconColor.copy(alpha = pauseTransform),
+ topLeft = Offset(bodyCx + gap, bodyCy - barH / 2f),
+ size = androidx.compose.ui.geometry.Size(barW, barH),
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
+ )
+ }
+ }
+}
+
+/**
+ * iOS parity slide-to-cancel transform.
+ *
+ * Port from VoiceRecordingPanel.updateCancelTranslation():
+ * - don't move until |dx| > 8dp
+ * - then translationX = -(abs(dx) - 8) * 0.5
+ * - fade out while dragging left
+ * - idle arrow jiggle only when close to rest
+ */
+@Composable
+private fun SlideToCancel(
+ slideDx: Float,
+ isDarkTheme: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val density = LocalDensity.current
+ val dragPx = (-slideDx).coerceAtLeast(0f)
+ val dragTransformThresholdPx = with(density) { 8.dp.toPx() }
+ val effectiveDragPx = (dragPx - dragTransformThresholdPx).coerceAtLeast(0f)
+ val slideTranslationX = -effectiveDragPx * 0.5f
+ val fadeDistancePx = with(density) { 90.dp.toPx() }
+ val contentAlpha = (1f - (effectiveDragPx / fadeDistancePx)).coerceIn(0f, 1f)
+
+ val maxOffsetPx = with(density) { 6.dp.toPx() }
+ val speedPxPerMs = with(density) { (3f / 250f).dp.toPx() }
+
+ // Arrow oscillation: only when near the resting position.
+ var xOffset by remember { mutableFloatStateOf(0f) }
+ var moveForward by remember { mutableStateOf(true) }
+
+ LaunchedEffect(contentAlpha > 0.85f) {
+ if (contentAlpha <= 0.85f) {
+ xOffset = 0f
+ moveForward = true
+ return@LaunchedEffect
+ }
+ var lastTime = System.nanoTime()
+ while (true) {
+ delay(16)
+ val now = System.nanoTime()
+ val dtMs = (now - lastTime) / 1_000_000f
+ lastTime = now
+ val step = speedPxPerMs * dtMs
+ if (moveForward) {
+ xOffset += step
+ if (xOffset > maxOffsetPx) { xOffset = maxOffsetPx; moveForward = false }
+ } else {
+ xOffset -= step
+ if (xOffset < -maxOffsetPx) { xOffset = -maxOffsetPx; moveForward = true }
+ }
+ }
+ }
+
+ val textColor = if (isDarkTheme) Color.White else Color(0xFF1F1F1F)
+ val arrowColor = if (isDarkTheme) Color.White else Color(0xFF1F1F1F)
+
+ Box(
+ modifier = modifier.clipToBounds()
+ ) {
+ Row(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .graphicsLayer {
+ translationX = slideTranslationX + xOffset * contentAlpha
+ alpha = contentAlpha
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ // Telegram: arrow path 4×5dp, stroke 1.6dp, round caps+joins, total 10dp offset to text
+ Canvas(
+ modifier = Modifier.size(width = 4.dp, height = 10.dp)
+ ) {
+ val midY = size.height / 2f
+ val arrowW = size.width
+ val arrowH = 5.dp.toPx()
+ val strokeW = 1.6f.dp.toPx()
+
+ drawLine(
+ color = arrowColor,
+ start = Offset(arrowW, midY - arrowH),
+ end = Offset(0f, midY),
+ strokeWidth = strokeW,
+ cap = androidx.compose.ui.graphics.StrokeCap.Round
+ )
+ drawLine(
+ color = arrowColor,
+ start = Offset(0f, midY),
+ end = Offset(arrowW, midY + arrowH),
+ strokeWidth = strokeW,
+ cap = androidx.compose.ui.graphics.StrokeCap.Round
+ )
+ }
+
+ Spacer(modifier = Modifier.width(6.dp))
+
+ Text(
+ text = "Slide to cancel",
+ color = textColor,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Normal,
+ maxLines = 1
+ )
+ }
+ }
+}
+
+@Composable
+private fun LockTooltip(
+ visible: Boolean,
+ isDarkTheme: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val alpha by animateFloatAsState(
+ targetValue = if (visible) 1f else 0f,
+ animationSpec = tween(durationMillis = 150),
+ label = "tooltip_alpha"
+ )
+
+ if (alpha > 0.01f) {
+ Row(
+ modifier = modifier
+ .graphicsLayer { this.alpha = alpha }
+ .background(
+ color = Color(0xFF333333),
+ shape = RoundedCornerShape(5.dp)
+ )
+ .padding(horizontal = 10.dp, vertical = 5.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Slide Up to Lock",
+ color = Color.White,
+ fontSize = 14.sp,
+ maxLines = 1
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "↑",
+ color = Color.White,
+ fontSize = 14.sp
+ )
+ }
+ }
+}
+
+@Composable
+private fun VoiceWaveformBar(
+ waves: List,
+ isDarkTheme: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val barColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
+ val barWidthDp = 2.dp
+ val barGapDp = 1.dp
+ val minBarHeightDp = 2.dp
+ val maxBarHeightDp = 20.dp
+
+ Canvas(modifier = modifier.height(maxBarHeightDp)) {
+ val barWidthPx = barWidthDp.toPx()
+ val barGapPx = barGapDp.toPx()
+ val minH = minBarHeightDp.toPx()
+ val maxH = maxBarHeightDp.toPx()
+ val totalBarWidth = barWidthPx + barGapPx
+ val maxBars = (size.width / totalBarWidth).toInt().coerceAtLeast(1)
+ val displayWaves = if (waves.size > maxBars) waves.takeLast(maxBars) else waves
+ val cy = size.height / 2f
+
+ displayWaves.forEachIndexed { index, level ->
+ val barH = minH + (maxH - minH) * level.coerceIn(0f, 1f)
+ val x = (maxBars - displayWaves.size + index) * totalBarWidth
+ drawRoundRect(
+ color = barColor,
+ topLeft = Offset(x, cy - barH / 2f),
+ size = androidx.compose.ui.geometry.Size(barWidthPx, barH),
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidthPx / 2f)
+ )
+ }
+ }
+}
+
+/**
+ * Telegram-exact locked recording controls.
+ *
+ * Layout: [CANCEL text-button] [⏸/▶ circle button]
+ *
+ * - CANCEL = blue text (15sp bold, uppercase), clickable — cancels recording
+ * - ⏸ = small circle button (36dp), toggles pause/resume
+ * - No separate delete icon — CANCEL IS delete
+ *
+ * Reference: ChatActivityEnterView recordedAudioPanel + SlideTextView cancelToProgress
+ */
+@Composable
+private fun RecordLockedControls(
+ isPaused: Boolean,
+ isDarkTheme: Boolean,
+ onDelete: () -> Unit,
+ onTogglePause: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val cancelColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
+ val pauseBgColor = if (isDarkTheme) Color(0xFF69CCFF).copy(alpha = 0.15f) else Color(0xFF2D9CFF).copy(alpha = 0.1f)
+ val pauseIconColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
+
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // CANCEL text button — Telegram: blue bold uppercase
+ Text(
+ text = "CANCEL",
+ color = cancelColor,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ modifier = Modifier
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) { onDelete() }
+ .padding(horizontal = 4.dp, vertical = 8.dp)
+ )
+
+ // Pause/Resume button — circle with icon
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(pauseBgColor)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) { onTogglePause() },
+ contentAlignment = Alignment.Center
+ ) {
+ if (isPaused) {
+ // Play triangle
+ Canvas(modifier = Modifier.size(14.dp)) {
+ val path = Path().apply {
+ moveTo(size.width * 0.2f, 0f)
+ lineTo(size.width, size.height / 2f)
+ lineTo(size.width * 0.2f, size.height)
+ close()
+ }
+ drawPath(path, color = pauseIconColor)
+ }
+ } else {
+ // Pause bars
+ Canvas(modifier = Modifier.size(14.dp)) {
+ val barW = size.width * 0.22f
+ val gap = size.width * 0.14f
+ drawRoundRect(
+ color = pauseIconColor,
+ topLeft = Offset(size.width / 2f - gap - barW, size.height * 0.1f),
+ size = androidx.compose.ui.geometry.Size(barW, size.height * 0.8f),
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f)
+ )
+ drawRoundRect(
+ color = pauseIconColor,
+ topLeft = Offset(size.width / 2f + gap, size.height * 0.1f),
+ size = androidx.compose.ui.geometry.Size(barW, size.height * 0.8f),
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f)
+ )
+ }
+ }
+ }
+ }
+}
+
/**
* Message input bar and related components
* Extracted from ChatDetailScreen.kt for better organization
@@ -102,6 +964,7 @@ fun MessageInputBar(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
+ onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List) -> Unit = { _, _, _ -> },
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
@@ -245,6 +1108,522 @@ fun MessageInputBar(
}
}
+ var voiceRecorder by remember { mutableStateOf(null) }
+ var voiceOutputFile by remember { mutableStateOf(null) }
+ var isVoiceRecording by remember { mutableStateOf(false) }
+ var isVoiceRecordTransitioning by remember { mutableStateOf(false) }
+ var isVoiceCancelAnimating by remember { mutableStateOf(false) }
+ var holdCancelVisualUntilHidden by remember { mutableStateOf(false) }
+ var cancelProgressSeed by remember { mutableFloatStateOf(0f) }
+ var cancelFrozenElapsedMs by remember { mutableLongStateOf(0L) }
+ var keepMicGestureCapture by remember { mutableStateOf(false) }
+ var recordMode by rememberSaveable { mutableStateOf(RecordMode.VOICE) }
+ var recordUiState by remember { mutableStateOf(RecordUiState.IDLE) }
+ var pressStartX by remember { mutableFloatStateOf(0f) }
+ var pressStartY by remember { mutableFloatStateOf(0f) }
+ var rawSlideDx by remember { mutableFloatStateOf(0f) }
+ var rawSlideDy by remember { mutableFloatStateOf(0f) }
+ var slideDx by remember { mutableFloatStateOf(0f) }
+ var slideDy by remember { mutableFloatStateOf(0f) }
+ var lockProgress by remember { mutableFloatStateOf(0f) }
+ var dragVelocityX by remember { mutableFloatStateOf(0f) }
+ var dragVelocityY by remember { mutableFloatStateOf(0f) }
+ var lastDragDx by remember { mutableFloatStateOf(0f) }
+ var lastDragDy by remember { mutableFloatStateOf(0f) }
+ var lastDragEventTimeMs by remember { mutableLongStateOf(0L) }
+ var didCancelHaptic by remember { mutableStateOf(false) }
+ var didLockHaptic by remember { mutableStateOf(false) }
+ var pendingLongPressJob by remember { mutableStateOf(null) }
+ var pendingRecordAfterPermission by remember { mutableStateOf(false) }
+ var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) }
+ var voiceElapsedMs by remember { mutableLongStateOf(0L) }
+ var voiceWaves by remember { mutableStateOf>(emptyList()) }
+ var isVoicePaused by remember { mutableStateOf(false) }
+ var voicePausedElapsedMs by remember { mutableLongStateOf(0L) }
+ var inputPanelHeightPx by remember { mutableIntStateOf(0) }
+ var inputPanelY by remember { mutableFloatStateOf(0f) }
+ var normalInputRowHeightPx by remember { mutableIntStateOf(0) }
+ var normalInputRowY by remember { mutableFloatStateOf(0f) }
+ var recordingInputRowHeightPx by remember { mutableIntStateOf(0) }
+ var recordingInputRowY by remember { mutableFloatStateOf(0f) }
+ fun inputJumpLog(msg: String) {
+ if (!INPUT_JUMP_LOG_ENABLED) return
+ }
+
+ fun inputHeightsSnapshot(): String {
+ val panelDp = with(density) { inputPanelHeightPx.toDp().value.toInt() }
+ val normalDp = with(density) { normalInputRowHeightPx.toDp().value.toInt() }
+ val recDp = with(density) { recordingInputRowHeightPx.toDp().value.toInt() }
+ return "panel=${inputPanelHeightPx}px(${panelDp}dp) normal=${normalInputRowHeightPx}px(${normalDp}dp) rec=${recordingInputRowHeightPx}px(${recDp}dp)"
+ }
+
+ fun setRecordUiState(newState: RecordUiState, reason: String) {
+ // Temporary rollout: lock/pause flow disabled, keep a single recording state.
+ val normalizedState = when (newState) {
+ RecordUiState.LOCKED, RecordUiState.PAUSED -> RecordUiState.RECORDING
+ else -> newState
+ }
+ if (recordUiState == normalizedState) return
+ val oldState = recordUiState
+ recordUiState = normalizedState
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("recordState $oldState -> $normalizedState reason=$reason mode=$recordMode")
+ }
+
+ fun resetGestureState() {
+ rawSlideDx = 0f
+ rawSlideDy = 0f
+ slideDx = 0f
+ slideDy = 0f
+ dragVelocityX = 0f
+ dragVelocityY = 0f
+ lastDragDx = 0f
+ lastDragDy = 0f
+ lastDragEventTimeMs = 0L
+ didCancelHaptic = false
+ didLockHaptic = false
+ pressStartX = 0f
+ pressStartY = 0f
+ lockProgress = 0f
+ pendingLongPressJob?.cancel()
+ pendingLongPressJob = null
+ }
+
+ fun toggleRecordModeByTap() {
+ recordMode = if (recordMode == RecordMode.VOICE) RecordMode.VIDEO else RecordMode.VOICE
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("recordMode toggled -> $recordMode (short tap)")
+ }
+
+ val shouldPinBottomForInput =
+ isKeyboardVisible ||
+ coordinator.isEmojiBoxVisible ||
+ isVoiceRecordTransitioning
+ val shouldAddNavBarPadding = hasNativeNavigationBar && !shouldPinBottomForInput
+
+ fun stopVoiceRecording(
+ send: Boolean,
+ preserveCancelAnimation: Boolean = false
+ ) {
+ isVoiceRecordTransitioning = false
+ if (!preserveCancelAnimation) {
+ isVoiceCancelAnimating = false
+ cancelFrozenElapsedMs = 0L
+ cancelProgressSeed = 0f
+ }
+ keepMicGestureCapture = false
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "stopVoiceRecording begin send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
+ "emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " +
+ "normalH=$normalInputRowHeightPx recH=$recordingInputRowHeightPx"
+ )
+ val recorder = voiceRecorder
+ val outputFile = voiceOutputFile
+ val elapsedSnapshot =
+ if (isVoicePaused && voicePausedElapsedMs > 0L) {
+ voicePausedElapsedMs
+ } else if (voiceRecordStartedAtMs > 0L) {
+ maxOf(voiceElapsedMs, System.currentTimeMillis() - voiceRecordStartedAtMs)
+ } else {
+ voiceElapsedMs
+ }
+ val durationSnapshot = ((elapsedSnapshot + 999L) / 1000L).toInt().coerceAtLeast(1)
+ val wavesSnapshot = voiceWaves
+
+ voiceRecorder = null
+ voiceOutputFile = null
+ isVoiceRecording = false
+ isVoicePaused = false
+ voicePausedElapsedMs = 0L
+ voiceRecordStartedAtMs = 0L
+ voiceElapsedMs = 0L
+ voiceWaves = emptyList()
+ resetGestureState()
+ setRecordUiState(RecordUiState.IDLE, "stop(send=$send)")
+
+ // Heavy I/O off main thread to prevent ANR
+ scope.launch(kotlinx.coroutines.Dispatchers.IO) {
+ var recordedOk = false
+ if (recorder != null) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: calling recorder.stop() send=$send")
+ val stopResult = runCatching {
+ recorder.stop()
+ true
+ }
+ recordedOk = stopResult.getOrDefault(false)
+ if (stopResult.isFailure) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: recorder.stop() FAILED: ${stopResult.exceptionOrNull()?.message}")
+ }
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: recorder.stop() ok=$recordedOk, calling reset+release")
+ runCatching { recorder.reset() }
+ runCatching { recorder.release() }
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: recorder released")
+ }
+
+ if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) {
+ val fileSize = outputFile.length()
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: reading file ${outputFile.name} size=${fileSize}bytes duration=${durationSnapshot}s")
+ val voiceHex =
+ runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("")
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: hex length=${voiceHex.length} sending=${voiceHex.isNotBlank()}")
+ if (voiceHex.isNotBlank()) {
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording Main: calling onSendVoiceMessage duration=$durationSnapshot waves=${wavesSnapshot.size}")
+ onSendVoiceMessage(
+ voiceHex,
+ durationSnapshot,
+ compressVoiceWaves(wavesSnapshot, 35)
+ )
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording Main: onSendVoiceMessage done")
+ }
+ }
+ } else if (send) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: NOT sending — recordedOk=$recordedOk file=${outputFile?.name} exists=${outputFile?.exists()} size=${outputFile?.length()}")
+ }
+ runCatching { outputFile?.delete() }
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: cleanup done")
+ }
+
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "stopVoiceRecording end send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
+ "emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " +
+ "normalH=$normalInputRowHeightPx recH=$recordingInputRowHeightPx"
+ )
+ }
+
+ fun startVoiceRecording() {
+ if (isVoiceRecording || isVoiceRecordTransitioning || voiceRecorder != null) return
+ // New recording session must never inherit stale cancel visuals.
+ isVoiceCancelAnimating = false
+ holdCancelVisualUntilHidden = false
+ cancelProgressSeed = 0f
+ cancelFrozenElapsedMs = 0L
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " +
+ "emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx"
+ )
+
+ try {
+ val voiceDir = File(context.cacheDir, "voice_recordings").apply { mkdirs() }
+ val output = File(voiceDir, "voice_${UUID.randomUUID()}.webm")
+
+ val recorder =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(context)
+ } else {
+ @Suppress("DEPRECATION")
+ MediaRecorder()
+ }
+ recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
+ recorder.setOutputFormat(MediaRecorder.OutputFormat.WEBM)
+ recorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
+ recorder.setAudioEncodingBitRate(32_000)
+ recorder.setAudioSamplingRate(48_000)
+ recorder.setOutputFile(output.absolutePath)
+ recorder.setMaxDuration(15 * 60 * 1000) // 15 min safety limit
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording: calling prepare() file=${output.name}")
+ recorder.prepare()
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording: calling start()")
+ recorder.start()
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording: recorder started OK")
+ recorder.setOnErrorListener { _, what, extra ->
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("MediaRecorder error what=$what extra=$extra")
+ stopVoiceRecording(send = false)
+ }
+
+ voiceRecorder = recorder
+ voiceOutputFile = output
+ voiceRecordStartedAtMs = System.currentTimeMillis()
+ voiceElapsedMs = 0L
+ voiceWaves = emptyList()
+
+ isVoiceRecordTransitioning = true
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.hideSoftInputFromWindow(view.windowToken, 0)
+ focusManager.clearFocus(force = true)
+ if (showEmojiPicker || coordinator.isEmojiBoxVisible) {
+ onToggleEmojiPicker(false)
+ }
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "startVoiceRecording armed mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
+ "emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " +
+ "pinBottom=$shouldPinBottomForInput " +
+ "panelH=$inputPanelHeightPx recH=$recordingInputRowHeightPx"
+ )
+
+ scope.launch {
+ try {
+ repeat(12) {
+ if (!isKeyboardVisible && !coordinator.isEmojiBoxVisible) return@repeat
+ delay(16)
+ }
+ isVoiceRecording = true
+ isVoiceRecordTransitioning = false
+ if (recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.IDLE) {
+ setRecordUiState(RecordUiState.RECORDING, "voice-recorder-started")
+ }
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "startVoiceRecording ui-enter mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
+ "emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " +
+ "panelH=$inputPanelHeightPx recH=$recordingInputRowHeightPx"
+ )
+ } catch (e: Exception) {
+ isVoiceRecordTransitioning = false
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording launch failed: ${e.message}")
+ }
+ }
+ } catch (_: Exception) {
+ isVoiceRecordTransitioning = false
+ stopVoiceRecording(send = false)
+ android.widget.Toast.makeText(
+ context,
+ "Voice recording is not supported on this device",
+ android.widget.Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ fun cancelVoiceRecordingWithAnimation(origin: String) {
+ if (isVoiceCancelAnimating) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "cancelVoiceRecordingWithAnimation already animating origin=$origin " +
+ "voice=$isVoiceRecording recorder=${voiceRecorder != null}"
+ )
+ if (isVoiceRecording || voiceRecorder != null) {
+ stopVoiceRecording(send = false)
+ }
+ return
+ }
+ if (!isVoiceRecording && voiceRecorder == null) {
+ setRecordUiState(RecordUiState.IDLE, "cancel-no-recorder origin=$origin")
+ return
+ }
+ keepMicGestureCapture = false
+ // Freeze current swipe progress so cancel animation never "jumps back"
+ // after slideDx is reset by stopVoiceRecording().
+ val swipeSnapshot =
+ ((-slideDx).coerceAtLeast(0f) / with(density) { 150.dp.toPx() })
+ .coerceIn(0f, 1f)
+ cancelProgressSeed = maxOf(cancelProgressSeed, swipeSnapshot)
+ cancelFrozenElapsedMs =
+ if (isVoicePaused && voicePausedElapsedMs > 0L) {
+ voicePausedElapsedMs
+ } else if (voiceRecordStartedAtMs > 0L) {
+ maxOf(voiceElapsedMs, System.currentTimeMillis() - voiceRecordStartedAtMs)
+ } else {
+ voiceElapsedMs
+ }
+ holdCancelVisualUntilHidden = true
+ isVoiceCancelAnimating = true
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancelVoiceRecordingWithAnimation start origin=$origin")
+ // Stop recorder immediately (off-main) to avoid stuck recording state / ANR on cancel.
+ stopVoiceRecording(send = false, preserveCancelAnimation = true)
+ }
+
+ fun pauseVoiceRecording() {
+ val recorder = voiceRecorder ?: return
+ if (!isVoiceRecording || isVoicePaused) return
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("pauseVoiceRecording mode=$recordMode state=$recordUiState")
+ try {
+ recorder.pause()
+ isVoicePaused = true
+ voicePausedElapsedMs = voiceElapsedMs
+ setRecordUiState(RecordUiState.PAUSED, "pause-pressed")
+ } catch (e: Exception) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("pauseVoiceRecording failed: ${e.message}")
+ }
+ }
+
+ fun resumeVoiceRecording() {
+ val recorder = voiceRecorder ?: return
+ if (!isVoiceRecording || !isVoicePaused) return
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("resumeVoiceRecording mode=$recordMode state=$recordUiState")
+ try {
+ recorder.resume()
+ voiceRecordStartedAtMs = System.currentTimeMillis() - voicePausedElapsedMs
+ isVoicePaused = false
+ voicePausedElapsedMs = 0L
+ setRecordUiState(RecordUiState.LOCKED, "resume-pressed")
+ } catch (e: Exception) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("resumeVoiceRecording failed: ${e.message}")
+ }
+ }
+
+ if (INPUT_JUMP_LOG_ENABLED) {
+ LaunchedEffect(Unit) {
+ snapshotFlow {
+ val kb = coordinator.keyboardHeight.value.toInt()
+ val em = coordinator.emojiHeight.value.toInt()
+ val panelY = (inputPanelY * 10f).toInt() / 10f
+ val normalY = (normalInputRowY * 10f).toInt() / 10f
+ val recY = (recordingInputRowY * 10f).toInt() / 10f
+ val pinBottom =
+ isKeyboardVisible ||
+ coordinator.isEmojiBoxVisible ||
+ isVoiceRecordTransitioning ||
+ recordUiState == RecordUiState.PAUSED
+ val navPad = hasNativeNavigationBar && !pinBottom
+ "mode=$recordMode state=$recordUiState slideDx=${slideDx.toInt()} slideDy=${slideDy.toInt()} " +
+ "voice=$isVoiceRecording kbVis=$isKeyboardVisible kbDp=$kb emojiBox=${coordinator.isEmojiBoxVisible} " +
+ "emojiVisible=$showEmojiPicker emojiDp=$em suppress=$suppressKeyboard " +
+ "voiceTransitioning=$isVoiceRecordTransitioning " +
+ "pinBottom=$pinBottom navPad=$navPad " +
+ "panelH=$inputPanelHeightPx panelY=$panelY normalH=$normalInputRowHeightPx " +
+ "normalY=$normalY recH=$recordingInputRowHeightPx recY=$recY"
+ }.distinctUntilChanged().collect { stateLine ->
+ inputJumpLog(stateLine)
+ }
+ }
+ }
+
+ val recordAudioPermissionLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ if (granted) {
+ if (pendingRecordAfterPermission) {
+ pendingRecordAfterPermission = false
+ setRecordUiState(RecordUiState.RECORDING, "audio-permission-granted")
+ startVoiceRecording()
+ }
+ } else {
+ pendingRecordAfterPermission = false
+ setRecordUiState(RecordUiState.IDLE, "audio-permission-denied")
+ android.widget.Toast.makeText(
+ context,
+ "Microphone permission is required for voice messages",
+ android.widget.Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ fun requestVoiceRecordingFromHold(): Boolean {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "requestVoiceRecordingFromHold mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
+ "emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}"
+ )
+ val granted =
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+ if (granted) {
+ startVoiceRecording()
+ return true
+ } else {
+ pendingRecordAfterPermission = true
+ recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ return true
+ }
+ }
+
+ // iOS parity (RecordingMicButton.swift / VoiceRecordingParityMath.swift):
+ // hold=0.19s, cancel=-150, cancel-on-release=-100, velocityGate=-400.
+ val holdToRecordDelayMs = 190L
+ val preHoldCancelDistancePx = with(density) { 10.dp.toPx() }
+ val cancelDragThresholdPx = with(density) { 150.dp.toPx() }
+ val releaseCancelThresholdPx = with(density) { 100.dp.toPx() }
+ val velocityGatePxPerSec = -400f
+ val dragSmoothingPrev = 0.7f
+ val dragSmoothingNew = 0.3f
+
+ var showLockTooltip by remember { mutableStateOf(false) }
+
+ fun tryStartRecordingForCurrentMode(): Boolean {
+ return if (recordMode == RecordMode.VOICE) {
+ setRecordUiState(RecordUiState.RECORDING, "hold-threshold-passed")
+ requestVoiceRecordingFromHold()
+ } else {
+ setRecordUiState(RecordUiState.IDLE, "video-mode-record-not-ready")
+ android.widget.Toast.makeText(
+ context,
+ "Video circles recording will be enabled in next step",
+ android.widget.Toast.LENGTH_SHORT
+ ).show()
+ false
+ }
+ }
+
+ LaunchedEffect(isVoiceRecording, voiceRecorder, isVoicePaused) {
+ if (!isVoiceRecording || isVoicePaused) return@LaunchedEffect
+ while (isVoiceRecording && voiceRecorder != null && !isVoicePaused) {
+ if (voiceRecordStartedAtMs > 0L) {
+ voiceElapsedMs =
+ (System.currentTimeMillis() - voiceRecordStartedAtMs).coerceAtLeast(0L)
+ }
+ delay(100)
+ }
+ }
+
+ LaunchedEffect(isVoiceRecording, voiceRecorder, isVoicePaused) {
+ if (!isVoiceRecording || isVoicePaused) return@LaunchedEffect
+ while (isVoiceRecording && voiceRecorder != null && !isVoicePaused) {
+ val amplitude = runCatching { voiceRecorder?.maxAmplitude ?: 0 }.getOrDefault(0)
+ val normalized = (amplitude.toFloat() / 32_767f).coerceIn(0f, 1f)
+ voiceWaves = (voiceWaves + normalized).takeLast(120)
+ delay(90)
+ }
+ }
+
+ LaunchedEffect(recordUiState, lockProgress) {
+ showLockTooltip = false
+ }
+
+ // Deterministic cancel commit: after animation, always finalize recording stop.
+ LaunchedEffect(isVoiceCancelAnimating) {
+ if (!isVoiceCancelAnimating) return@LaunchedEffect
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "cancel animation commit scheduled voice=$isVoiceRecording recorder=${voiceRecorder != null}"
+ )
+ delay(220)
+ if (!isVoiceCancelAnimating) return@LaunchedEffect
+ if (isVoiceRecording || voiceRecorder != null) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancel animation commit -> stopVoiceRecording(send=false)")
+ stopVoiceRecording(send = false)
+ } else {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancel animation commit -> no recorder, reset UI")
+ isVoiceCancelAnimating = false
+ keepMicGestureCapture = false
+ setRecordUiState(RecordUiState.IDLE, "cancel-commit-no-recorder")
+ resetGestureState()
+ }
+ }
+
+ // Safety guard: never allow cancel animation flag to stick and block UI.
+ LaunchedEffect(isVoiceCancelAnimating) {
+ if (!isVoiceCancelAnimating) return@LaunchedEffect
+ delay(1300)
+ if (isVoiceCancelAnimating) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "cancel animation watchdog: force-finish voice=$isVoiceRecording recorder=${voiceRecorder != null}"
+ )
+ if (isVoiceRecording || voiceRecorder != null) {
+ stopVoiceRecording(send = false)
+ } else {
+ isVoiceCancelAnimating = false
+ keepMicGestureCapture = false
+ setRecordUiState(RecordUiState.IDLE, "cancel-watchdog-reset")
+ resetGestureState()
+ }
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ pendingRecordAfterPermission = false
+ isVoiceRecordTransitioning = false
+ isVoiceCancelAnimating = false
+ holdCancelVisualUntilHidden = false
+ cancelProgressSeed = 0f
+ cancelFrozenElapsedMs = 0L
+ keepMicGestureCapture = false
+ resetGestureState()
+ if (isVoiceRecording || voiceRecorder != null) {
+ stopVoiceRecording(send = false)
+ } else {
+ setRecordUiState(RecordUiState.IDLE, "dispose")
+ }
+ }
+ }
+
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
var isSending by remember { mutableStateOf(false) }
var showForwardCancelDialog by remember { mutableStateOf(false) }
@@ -329,6 +1708,9 @@ fun MessageInputBar(
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus(force = true)
+ if (isVoiceRecording) {
+ stopVoiceRecording(send = false)
+ }
}
}
@@ -339,7 +1721,7 @@ fun MessageInputBar(
}
fun toggleEmojiPicker() {
- if (suppressKeyboard) return
+ if (suppressKeyboard || isVoiceRecording) return
val currentTime = System.currentTimeMillis()
val timeSinceLastToggle = currentTime - lastToggleTime
@@ -389,6 +1771,10 @@ fun MessageInputBar(
}
fun handleSend() {
+ if (isVoiceRecording) {
+ stopVoiceRecording(send = true)
+ return
+ }
if (value.isNotBlank() || hasReply) {
isSending = true
onSend()
@@ -472,19 +1858,18 @@ fun MessageInputBar(
)
)
- val shouldAddNavBarPadding =
- hasNativeNavigationBar &&
- !isKeyboardVisible &&
- !coordinator.isEmojiBoxVisible
-
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = backgroundColor)
.padding(
- bottom = if (isKeyboardVisible || coordinator.isEmojiBoxVisible) 0.dp else 16.dp
+ bottom = if (shouldPinBottomForInput) 0.dp else 16.dp
)
.then(if (shouldAddNavBarPadding) Modifier.navigationBarsPadding() else Modifier)
+ .onGloballyPositioned { coordinates ->
+ inputPanelHeightPx = coordinates.size.height
+ inputPanelY = coordinates.positionInWindow().y
+ }
) {
AnimatedVisibility(
visible = mentionSuggestions.isNotEmpty(),
@@ -689,9 +2074,19 @@ fun MessageInputBar(
val hasCallAttachment = msg.attachments.any {
it.type == AttachmentType.CALL
}
+ val hasVoiceAttachment = msg.attachments.any {
+ it.type == AttachmentType.VOICE
+ }
+ val hasVideoCircleAttachment = msg.attachments.any {
+ it.type == AttachmentType.VIDEO_CIRCLE
+ }
AppleEmojiText(
text = if (panelReplyMessages.size == 1) {
- if (msg.text.isEmpty() && hasCallAttachment) {
+ if (msg.text.isEmpty() && hasVoiceAttachment) {
+ "Voice Message"
+ } else if (msg.text.isEmpty() && hasVideoCircleAttachment) {
+ "Video Message"
+ } else if (msg.text.isEmpty() && hasCallAttachment) {
"Call"
} else if (msg.text.isEmpty() && hasImageAttachment) {
"Photo"
@@ -870,93 +2265,736 @@ fun MessageInputBar(
}
}
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(min = 48.dp)
- .padding(horizontal = 12.dp, vertical = 8.dp),
- verticalAlignment = Alignment.Bottom
- ) {
- IconButton(
- onClick = onAttachClick,
- modifier = Modifier.size(40.dp)
- ) {
- Icon(
- painter = TelegramIcons.Attach,
- contentDescription = "Attach",
- tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
- else Color(0xFF8E8E93).copy(alpha = 0.6f),
- modifier = Modifier.size(24.dp)
- )
- }
-
- Spacer(modifier = Modifier.width(4.dp))
-
- Box(
- modifier = Modifier
- .weight(1f)
- .heightIn(min = 40.dp, max = 150.dp)
- .background(color = backgroundColor)
- .padding(horizontal = 12.dp, vertical = 8.dp),
- contentAlignment = Alignment.TopStart
- ) {
- AppleEmojiTextField(
- value = value,
- onValueChange = { newValue -> onValueChange(newValue) },
- textColor = textColor,
- textSize = 16f,
- hint = "Type message...",
- hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93),
- modifier = Modifier.fillMaxWidth(),
- requestFocus = hasReply,
- onViewCreated = { view -> editTextView = view },
- onFocusChanged = { hasFocus ->
- if (hasFocus && showEmojiPicker) {
- onToggleEmojiPicker(false)
- }
- },
- onSelectionChanged = { start, end ->
- selectionStart = start
- selectionEnd = end
- }
- )
- }
-
- Spacer(modifier = Modifier.width(6.dp))
-
- IconButton(
- onClick = { toggleEmojiPicker() },
- modifier = Modifier.size(40.dp)
- ) {
- Icon(
- painter = if (showEmojiPicker) TelegramIcons.Keyboard
- else TelegramIcons.Smile,
- contentDescription = "Emoji",
- tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
- else Color(0xFF8E8E93).copy(alpha = 0.6f),
- modifier = Modifier.size(24.dp)
- )
- }
-
- Spacer(modifier = Modifier.width(2.dp))
-
- AnimatedVisibility(
- visible = canSend || isSending,
- enter = scaleIn(tween(150)) + fadeIn(tween(150)),
- exit = scaleOut(tween(100)) + fadeOut(tween(100))
- ) {
- IconButton(
- onClick = { handleSend() },
- modifier = Modifier.size(40.dp)
- ) {
- Icon(
- imageVector = TelegramSendIcon,
- contentDescription = "Send",
- tint = PrimaryBlue,
- modifier = Modifier.size(24.dp)
- )
+ // ── Recording panel (layered architecture) ──
+ // Layer 1: panel bar (timer + center content)
+ // Layer 2: mic/send circle OVERLAY at right edge (extends beyond panel)
+ // Layer 3: lock icon ABOVE circle (extends above panel)
+ val isRecordingPanelVisible = isVoiceRecording || isVoiceCancelAnimating
+ val recordingPanelTransitionState =
+ remember { MutableTransitionState(false) }.apply {
+ targetState = isRecordingPanelVisible
+ }
+ // True while visible OR while enter/exit animation is still running.
+ val isRecordingPanelComposed =
+ recordingPanelTransitionState.currentState || recordingPanelTransitionState.targetState
+ LaunchedEffect(isRecordingPanelComposed) {
+ if (!isRecordingPanelComposed) {
+ holdCancelVisualUntilHidden = false
+ cancelProgressSeed = 0f
+ cancelFrozenElapsedMs = 0L
}
}
+ androidx.compose.animation.AnimatedVisibility(
+ visibleState = recordingPanelTransitionState,
+ // Telegram-like smooth dissolve without any vertical resize.
+ enter = fadeIn(tween(durationMillis = 170, easing = LinearOutSlowInEasing)),
+ exit = fadeOut(tween(durationMillis = 210, easing = FastOutLinearInEasing))
+ ) {
+ val recordingPanelColor =
+ if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD)
+ val recordingTextColor =
+ if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF1E2A37)
+ // Keep layout height equal to normal input row.
+ // The button "grows" visually via scale, not via measured size.
+ val recordingActionButtonBaseSize = 40.dp
+ // Telegram-like proportions: large button that does not dominate the panel.
+ val recordingActionVisualScale = 1.42f // 40dp -> ~57dp visual size
+ val recordingActionInset = 34.dp
+ // Keep the scaled circle fully on-screen (no right-edge clipping).
+ val recordingActionOverflowX = 0.dp
+ val recordingActionOverflowY = 0.dp
+ val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f }
+ val cancelAnimProgress by animateFloatAsState(
+ targetValue = if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) 1f else 0f,
+ animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing),
+ label = "voice_cancel_anim"
+ )
+ var recordUiEntered by remember { mutableStateOf(false) }
+ val keepRecordUiVisible =
+ isVoiceRecording || isVoiceCancelAnimating || holdCancelVisualUntilHidden
+ LaunchedEffect(keepRecordUiVisible) {
+ if (keepRecordUiVisible) {
+ recordUiEntered = false
+ delay(16)
+ recordUiEntered = true
+ } else {
+ recordUiEntered = false
+ }
+ }
+ val recordUiAlpha by animateFloatAsState(
+ targetValue = if (recordUiEntered) 1f else 0f,
+ animationSpec = tween(durationMillis = 180, easing = LinearOutSlowInEasing),
+ label = "record_ui_alpha"
+ )
+ val recordUiShift by animateDpAsState(
+ targetValue = if (recordUiEntered) 0.dp else 20.dp,
+ animationSpec = tween(durationMillis = 180, easing = FastOutLinearInEasing),
+ label = "record_ui_shift"
+ )
+
+ // ── Telegram-exact recording layout ──
+ // RECORDING: [dot][timer] [◀ Slide to cancel] ... [Circle+Blob]
+ // LOCKED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Pause above
+ // PAUSED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Play above
+ // Timer and dot are HIDDEN in LOCKED/PAUSED (Telegram exact)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .padding(horizontal = 12.dp, vertical = 8.dp)
+ .zIndex(2f)
+ .onGloballyPositioned { coordinates ->
+ recordingInputRowHeightPx = coordinates.size.height
+ recordingInputRowY = coordinates.positionInWindow().y
+ },
+ contentAlignment = Alignment.BottomStart
+ ) {
+ val isLockedOrPaused = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED
+ // iOS parity (VoiceRecordingOverlay.applyCurrentTransforms):
+ // valueX = abs(distanceX) / 300
+ // innerScale = clamp(1 - valueX, 0.4..1)
+ // translatedX = distanceX * innerScale
+ val slideDistanceX = slideDx.coerceAtMost(0f)
+ val slideValueX = (kotlin.math.abs(slideDistanceX) / with(density) { 300.dp.toPx() })
+ .coerceIn(0f, 1f)
+ val circleSlideCancelScale = (1f - slideValueX).coerceIn(0.4f, 1f)
+ val circleSlideDelta = slideDistanceX * circleSlideCancelScale
+
+ // Crossfade between RECORDING panel and LOCKED panel
+ AnimatedContent(
+ targetState = isLockedOrPaused,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(40.dp)
+ .padding(end = recordingActionInset), // keep panel under large circle (Telegram-like overlap)
+ transitionSpec = {
+ fadeIn(tween(200)) togetherWith fadeOut(tween(200))
+ },
+ label = "record_panel_mode"
+ ) { locked ->
+ if (locked) {
+ // ── LOCKED/PAUSED panel (Telegram: recordedAudioPanel) ──
+ // [Delete 44dp] [Waveform fills rest]
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(24.dp))
+ .background(recordingPanelColor),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Delete button — Telegram-style trash action
+ Box(
+ modifier = Modifier
+ .size(recordingActionButtonBaseSize)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState")
+ stopVoiceRecording(send = false)
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Delete recording",
+ tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ // Waveform — Telegram: 32dp height, fills remaining width
+ VoiceWaveformBar(
+ waves = voiceWaves,
+ isDarkTheme = isDarkTheme,
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 4.dp)
+ )
+ }
+ } else {
+ // ── RECORDING panel ──
+ // [attach-slot => dot/trash morph][timer] [◀ Slide to cancel]
+ val dragCancelProgress =
+ ((-slideDx).coerceAtLeast(0f) / cancelDragThresholdPx)
+ .coerceIn(0f, 1f)
+ val seededCancelProgress =
+ if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) {
+ cancelProgressSeed
+ } else {
+ 0f
+ }
+ val leftDeleteProgress =
+ maxOf(
+ cancelAnimProgress,
+ seededCancelProgress,
+ FastOutSlowInEasing.transform(
+ (dragCancelProgress * 0.85f).coerceIn(0f, 1f)
+ )
+ )
+ val collapseToTrash =
+ FastOutSlowInEasing.transform(
+ ((leftDeleteProgress - 0.14f) / 0.86f).coerceIn(0f, 1f)
+ )
+ val collapseShiftPx =
+ with(density) { (-58).dp.toPx() * collapseToTrash }
+ val collapseScale = 1f - 0.14f * collapseToTrash
+ val collapseAlpha = 1f - 0.55f * collapseToTrash
+ // Telegram-like timer -> trash flight.
+ // Start a little later than trash reveal, then accelerate into bin.
+ val timerFlyProgress =
+ FastOutLinearInEasing.transform(
+ ((leftDeleteProgress - 0.1f) / 0.9f).coerceIn(0f, 1f)
+ )
+ // Stop motion at bin and fade out before any overshoot.
+ val timerReachBinProgress =
+ FastOutLinearInEasing.transform(
+ (timerFlyProgress / 0.78f).coerceIn(0f, 1f)
+ )
+ val timerToTrashShiftPx =
+ with(density) { (-46).dp.toPx() } * timerReachBinProgress
+ val timerToTrashScale = lerpFloat(1f, 0.52f, timerReachBinProgress)
+ val timerToTrashAlpha = 1f - timerReachBinProgress
+ val timerSpacerDp = lerpFloat(10f, 2f, collapseToTrash).dp
+ // Hard-hide trash right after cancel commit to prevent any one-frame reappear.
+ val hideIndicatorAfterCancelProgress =
+ if (holdCancelVisualUntilHidden && !isVoiceCancelAnimating) 1f else 0f
+ val timerDisplayMs =
+ if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) {
+ if (cancelFrozenElapsedMs > 0L) cancelFrozenElapsedMs else voiceElapsedMs
+ } else {
+ voiceElapsedMs
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(24.dp))
+ .background(recordingPanelColor)
+ .padding(start = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Left slot (same anchor as attach icon in normal input):
+ // morphs from recording dot to trash while user cancels.
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .graphicsLayer {
+ alpha = recordUiAlpha * (1f - hideIndicatorAfterCancelProgress)
+ translationX = with(density) { recordUiShift.toPx() }
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ TelegramVoiceDeleteIndicator(
+ cancelProgress = leftDeleteProgress,
+ isDarkTheme = isDarkTheme,
+ indicatorSize = 24.dp
+ )
+ }
+
+ Spacer(modifier = Modifier.width(2.dp))
+
+ Text(
+ text = formatVoiceRecordTimer(timerDisplayMs),
+ color = recordingTextColor,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.graphicsLayer {
+ alpha =
+ recordUiAlpha *
+ timerToTrashAlpha *
+ collapseAlpha
+ translationX =
+ with(density) { recordUiShift.toPx() } + timerToTrashShiftPx
+ scaleX = collapseScale * timerToTrashScale
+ scaleY = collapseScale * timerToTrashScale
+ }
+ )
+
+ Spacer(modifier = Modifier.width(timerSpacerDp))
+
+ // Slide to cancel
+ SlideToCancel(
+ slideDx = slideDx,
+ isDarkTheme = isDarkTheme,
+ modifier = Modifier
+ .weight(1f)
+ .graphicsLayer {
+ alpha =
+ recordUiAlpha *
+ (1f - leftDeleteProgress) *
+ collapseAlpha
+ translationX =
+ with(density) { recordUiShift.toPx() } + collapseShiftPx
+ scaleX = collapseScale
+ scaleY = collapseScale
+ }
+ )
+ }
+ }
+ }
+
+ // ── Layer 2: Circle + Lock overlay ──
+ Box(
+ modifier = Modifier
+ .size(recordingActionButtonBaseSize)
+ .align(Alignment.BottomEnd)
+ .offset(x = recordingActionOverflowX, y = recordingActionOverflowY)
+ .graphicsLayer {
+ translationX = circleSlideDelta
+ scaleX = recordingActionVisualScale * circleSlideCancelScale
+ scaleY = recordingActionVisualScale * circleSlideCancelScale
+ transformOrigin = TransformOrigin(0.5f, 0.5f)
+ }
+ .zIndex(5f),
+ contentAlignment = Alignment.Center
+ ) {
+ // Lock icon above circle
+ if (recordUiState == RecordUiState.LOCKED ||
+ recordUiState == RecordUiState.PAUSED
+ ) {
+ val lockSizeDp = 36.dp + 10.dp * (1f - lockProgress)
+ val lockYDp = -80.dp + 14.dp * lockProgress
+ LockIcon(
+ lockProgress = lockProgress,
+ isLocked = recordUiState == RecordUiState.LOCKED,
+ isPaused = recordUiState == RecordUiState.PAUSED,
+ isDarkTheme = isDarkTheme,
+ modifier = Modifier
+ .size(lockSizeDp)
+ .graphicsLayer {
+ translationY = with(density) { lockYDp.toPx() }
+ clip = false
+ }
+ .zIndex(10f)
+ )
+
+ if (showLockTooltip && recordUiState == RecordUiState.RECORDING) {
+ LockTooltip(
+ visible = showLockTooltip,
+ isDarkTheme = isDarkTheme,
+ modifier = Modifier
+ .graphicsLayer {
+ translationX = with(density) { (-90).dp.toPx() }
+ translationY = with(density) { (-80).dp.toPx() }
+ clip = false
+ }
+ .zIndex(11f)
+ )
+ }
+ }
+
+ // Blob: only during RECORDING
+ if (recordUiState == RecordUiState.RECORDING && !isVoiceCancelAnimating) {
+ VoiceButtonBlob(
+ voiceLevel = voiceLevel,
+ isDarkTheme = isDarkTheme,
+ modifier = Modifier
+ .size(recordingActionButtonBaseSize)
+ .graphicsLayer {
+ scaleX = recordingActionVisualScale * 1.1f
+ scaleY = recordingActionVisualScale * 1.1f
+ clip = false
+ }
+ )
+ }
+
+ // Mic/Send circle — same size as panel height
+ val sendScale by animateFloatAsState(
+ targetValue = if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) 1f else 0f,
+ animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing),
+ label = "send_btn_scale"
+ )
+ if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) {
+ Box(
+ modifier = Modifier
+ .size(recordingActionButtonBaseSize)
+ .graphicsLayer {
+ scaleX = sendScale * recordingActionVisualScale
+ scaleY = sendScale * recordingActionVisualScale
+ shadowElevation = 8f
+ shape = CircleShape
+ }
+ .clip(CircleShape)
+ .background(PrimaryBlue)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "tap SEND (locked/paused) mode=$recordMode state=$recordUiState voice=$isVoiceRecording " +
+ "kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}"
+ )
+ stopVoiceRecording(send = true)
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = TelegramSendIcon,
+ contentDescription = "Send voice message",
+ tint = Color.White,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ } else {
+ Box(
+ modifier = Modifier
+ .size(recordingActionButtonBaseSize)
+ .graphicsLayer {
+ scaleX = recordingActionVisualScale
+ scaleY = recordingActionVisualScale
+ shadowElevation = 8f
+ shape = CircleShape
+ }
+ .clip(CircleShape)
+ .background(PrimaryBlue),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(19.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+ val gestureCaptureOnly = isRecordingPanelComposed && keepMicGestureCapture
+ if (!isRecordingPanelComposed || keepMicGestureCapture) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(
+ if (gestureCaptureOnly) {
+ Modifier.requiredHeight(0.dp)
+ } else {
+ Modifier
+ .heightIn(min = 48.dp)
+ .padding(horizontal = 12.dp, vertical = 8.dp)
+ }
+ )
+ .zIndex(1f)
+ .graphicsLayer {
+ // Keep gesture layer alive during hold, but never show base input under recording panel.
+ alpha = if (isRecordingPanelComposed) 0f else 1f
+ }
+ .onGloballyPositioned { coordinates ->
+ normalInputRowHeightPx = coordinates.size.height
+ normalInputRowY = coordinates.positionInWindow().y
+ },
+ verticalAlignment = Alignment.Bottom
+ ) {
+ IconButton(
+ onClick = onAttachClick,
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ painter = TelegramIcons.Attach,
+ contentDescription = "Attach",
+ tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
+ else Color(0xFF8E8E93).copy(alpha = 0.6f),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .heightIn(min = 40.dp, max = 150.dp)
+ .background(color = backgroundColor)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ contentAlignment = Alignment.TopStart
+ ) {
+ AppleEmojiTextField(
+ value = value,
+ onValueChange = { newValue -> onValueChange(newValue) },
+ textColor = textColor,
+ textSize = 16f,
+ hint = "Type message...",
+ hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93),
+ modifier = Modifier.fillMaxWidth(),
+ requestFocus = hasReply,
+ onViewCreated = { view -> editTextView = view },
+ onFocusChanged = { hasFocus ->
+ if (hasFocus) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "tap INPUT focus=true voice=$isVoiceRecording kb=$isKeyboardVisible " +
+ "emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}"
+ )
+ }
+ if (hasFocus && showEmojiPicker) {
+ onToggleEmojiPicker(false)
+ }
+ },
+ onSelectionChanged = { start, end ->
+ selectionStart = start
+ selectionEnd = end
+ }
+ )
+ }
+
+ Spacer(modifier = Modifier.width(6.dp))
+
+ IconButton(
+ onClick = { toggleEmojiPicker() },
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ painter = if (showEmojiPicker) TelegramIcons.Keyboard
+ else TelegramIcons.Smile,
+ contentDescription = "Emoji",
+ tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
+ else Color(0xFF8E8E93).copy(alpha = 0.6f),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(2.dp))
+
+ AnimatedVisibility(
+ visible = canSend || isSending,
+ enter = scaleIn(tween(150)) + fadeIn(tween(150)),
+ exit = scaleOut(tween(100)) + fadeOut(tween(100))
+ ) {
+ IconButton(
+ onClick = { handleSend() },
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ imageVector = TelegramSendIcon,
+ contentDescription = "Send",
+ tint = PrimaryBlue,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+
+ AnimatedVisibility(
+ visible = !canSend && !isSending,
+ enter = scaleIn(tween(140)) + fadeIn(tween(140)),
+ exit = scaleOut(tween(100)) + fadeOut(tween(100))
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size(40.dp)
+ .pointerInput(Unit) {
+ awaitEachGesture {
+ if (canSend || isSending || isVoiceRecording || isVoiceRecordTransitioning) {
+ return@awaitEachGesture
+ }
+
+ val down = awaitFirstDown(requireUnconsumed = false)
+ val tapSlopPx = viewConfiguration.touchSlop
+ var pointerIsDown = true
+ var armingCancelledByMove = false
+ var maxAbsDx = 0f
+ var maxAbsDy = 0f
+ pressStartX = down.position.x
+ pressStartY = down.position.y
+ keepMicGestureCapture = true
+ rawSlideDx = 0f
+ rawSlideDy = 0f
+ slideDx = 0f
+ slideDy = 0f
+ dragVelocityX = 0f
+ dragVelocityY = 0f
+ lastDragDx = 0f
+ lastDragDy = 0f
+ lastDragEventTimeMs = System.currentTimeMillis()
+ didCancelHaptic = false
+ didLockHaptic = false
+ pendingRecordAfterPermission = false
+ setRecordUiState(RecordUiState.PRESSING, "mic-down")
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "mic DOWN mode=$recordMode state=$recordUiState " +
+ "voice=$isVoiceRecording kb=$isKeyboardVisible ${inputHeightsSnapshot()}"
+ )
+
+ pendingLongPressJob?.cancel()
+ pendingLongPressJob =
+ scope.launch {
+ delay(holdToRecordDelayMs)
+ if (pointerIsDown && recordUiState == RecordUiState.PRESSING) {
+ val started = tryStartRecordingForCurrentMode()
+ if (!started) {
+ resetGestureState()
+ setRecordUiState(RecordUiState.IDLE, "hold-start-failed")
+ }
+ }
+ }
+
+ fun finalizePointerRelease(
+ rawReleaseDx: Float,
+ rawReleaseDy: Float,
+ source: String
+ ) {
+ keepMicGestureCapture = false
+ pointerIsDown = false
+ pendingLongPressJob?.cancel()
+ pendingLongPressJob = null
+ pendingRecordAfterPermission = false
+ when (recordUiState) {
+ RecordUiState.PRESSING -> {
+ val movedBeyondTap =
+ maxAbsDx > tapSlopPx || maxAbsDy > tapSlopPx
+ if (!movedBeyondTap) {
+ toggleRecordModeByTap()
+ setRecordUiState(RecordUiState.IDLE, "$source-short-tap-toggle")
+ } else {
+ setRecordUiState(RecordUiState.IDLE, "$source-press-release-after-move")
+ }
+ }
+ RecordUiState.RECORDING -> {
+ // iOS parity:
+ // - dominant-axis release evaluation
+ // - velocity gate (-400 px/s)
+ // - fallback to distance thresholds.
+ var releaseDx = rawReleaseDx.coerceAtMost(0f)
+ var releaseDy = rawReleaseDy.coerceAtMost(0f)
+ if (kotlin.math.abs(releaseDx) > kotlin.math.abs(releaseDy)) {
+ releaseDy = 0f
+ } else {
+ releaseDx = 0f
+ }
+ val cancelOnRelease =
+ dragVelocityX <= velocityGatePxPerSec ||
+ releaseDx <= -releaseCancelThresholdPx
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "$source mode=$recordMode state=$recordUiState releaseDx=${releaseDx.toInt()} " +
+ "releaseDy=${releaseDy.toInt()} vX=${dragVelocityX.toInt()} vY=${dragVelocityY.toInt()} " +
+ "cancel=$cancelOnRelease"
+ )
+ if (cancelOnRelease) {
+ if (isVoiceRecording || voiceRecorder != null) {
+ cancelVoiceRecordingWithAnimation("$source-cancel")
+ } else {
+ setRecordUiState(RecordUiState.IDLE, "$source-cancel-without-recorder")
+ }
+ } else {
+ if (isVoiceRecording || voiceRecorder != null) {
+ stopVoiceRecording(send = true)
+ } else {
+ setRecordUiState(RecordUiState.IDLE, "$source-without-recorder")
+ }
+ }
+ }
+ RecordUiState.LOCKED -> {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "$source while LOCKED -> keep recording mode=$recordMode state=$recordUiState"
+ )
+ }
+ RecordUiState.PAUSED -> {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "$source while PAUSED -> stay paused mode=$recordMode state=$recordUiState"
+ )
+ }
+ RecordUiState.IDLE -> Unit
+ }
+ resetGestureState()
+ }
+
+ var finished = false
+ while (!finished) {
+ val event = awaitPointerEvent()
+ val trackedChange = event.changes.firstOrNull { it.id == down.id }
+ val change = trackedChange
+ ?: event.changes.firstOrNull()
+ ?: continue
+ val allPointersReleased = event.changes.none { it.pressed }
+ val releaseDetected =
+ change.changedToUpIgnoreConsumed() || !change.pressed ||
+ (trackedChange == null && allPointersReleased)
+
+ if (releaseDetected) {
+ val releaseDx =
+ if (trackedChange != null) change.position.x - pressStartX else rawSlideDx
+ val releaseDy =
+ if (trackedChange != null) change.position.y - pressStartY else rawSlideDy
+ val source =
+ if (trackedChange == null) "mic UP fallback-lost-pointer"
+ else "mic UP"
+ finalizePointerRelease(releaseDx, releaseDy, source)
+ finished = true
+ } else if (recordUiState == RecordUiState.PRESSING) {
+ val dx = change.position.x - pressStartX
+ val dy = change.position.y - pressStartY
+ val absDx = kotlin.math.abs(dx)
+ val absDy = kotlin.math.abs(dy)
+ if (absDx > maxAbsDx) maxAbsDx = absDx
+ if (absDy > maxAbsDy) maxAbsDy = absDy
+ val totalDistance = kotlin.math.sqrt(dx * dx + dy * dy)
+ if (!armingCancelledByMove && totalDistance > preHoldCancelDistancePx) {
+ armingCancelledByMove = true
+ pendingLongPressJob?.cancel()
+ pendingLongPressJob = null
+ pendingRecordAfterPermission = false
+ keepMicGestureCapture = false
+ setRecordUiState(
+ RecordUiState.IDLE,
+ "pre-hold-move-cancel dist=${totalDistance.toInt()}"
+ )
+ }
+ } else if (recordUiState == RecordUiState.RECORDING) {
+ // iOS parity:
+ // raw drag from touch + smoothed drag for UI (0.7 / 0.3).
+ val rawDx = (change.position.x - pressStartX).coerceAtMost(0f)
+ val rawDy = (change.position.y - pressStartY).coerceAtMost(0f)
+ rawSlideDx = rawDx
+ rawSlideDy = rawDy
+
+ val nowMs = System.currentTimeMillis()
+ val dtMs = (nowMs - lastDragEventTimeMs).coerceAtLeast(1L).toFloat()
+ dragVelocityX = ((rawDx - lastDragDx) / dtMs) * 1000f
+ dragVelocityY = ((rawDy - lastDragDy) / dtMs) * 1000f
+ lastDragDx = rawDx
+ lastDragDy = rawDy
+ lastDragEventTimeMs = nowMs
+
+ slideDx = (slideDx * dragSmoothingPrev) + (rawDx * dragSmoothingNew)
+ slideDy = (slideDy * dragSmoothingPrev) + (rawDy * dragSmoothingNew)
+ lockProgress = 0f
+
+ if (!didCancelHaptic && rawDx <= -releaseCancelThresholdPx) {
+ didCancelHaptic = true
+ view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP)
+ }
+ // Trigger cancel immediately while finger is still down,
+ // using the same threshold as release-cancel behavior.
+ if (rawDx <= -releaseCancelThresholdPx) {
+ if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
+ "gesture CANCEL dx=${rawDx.toInt()} threshold=${releaseCancelThresholdPx.toInt()} mode=$recordMode"
+ )
+ cancelVoiceRecordingWithAnimation("slide-cancel")
+ finished = true
+ }
+ }
+ change.consume()
+ }
+
+ pendingLongPressJob?.cancel()
+ pendingLongPressJob = null
+ if (recordUiState == RecordUiState.PRESSING) {
+ keepMicGestureCapture = false
+ setRecordUiState(RecordUiState.IDLE, "gesture-end")
+ resetGestureState()
+ }
+ }
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam,
+ contentDescription = "Record message",
+ tint = PrimaryBlue,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt
index 988ee82..1306a59 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt
@@ -558,6 +558,10 @@ fun AppleEmojiText(
onClickableSpanPressStart: (() -> Unit)? = null,
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
+ onTextLongPress: ((touchX: Int, touchY: Int) -> Unit)? = null,
+ onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null,
+ onSelectionDragEnd: (() -> Unit)? = null,
+ onViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiTextView) -> Unit)? = null,
minHeightMultiplier: Float = 1.5f
) {
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
@@ -601,6 +605,10 @@ fun AppleEmojiText(
enableMentionHighlight(enableMentions)
setOnMentionClickListener(onMentionClick)
setOnClickableSpanPressStartListener(onClickableSpanPressStart)
+ onTextLongPressCallback = onTextLongPress
+ this.onSelectionDrag = onSelectionDrag
+ this.onSelectionDragEnd = onSelectionDragEnd
+ onViewCreated?.invoke(this)
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
val canUseTextViewClick = !enableLinks
setOnClickListener(
@@ -634,6 +642,9 @@ fun AppleEmojiText(
view.enableMentionHighlight(enableMentions)
view.setOnMentionClickListener(onMentionClick)
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
+ view.onTextLongPressCallback = onTextLongPress
+ view.onSelectionDrag = onSelectionDrag
+ view.onSelectionDragEnd = onSelectionDragEnd
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
val canUseTextViewClick = !enableLinks
view.setOnClickListener(
@@ -695,13 +706,23 @@ class AppleEmojiTextView @JvmOverloads constructor(
// 🔥 Long press callback для selection в MessageBubble
var onLongClickCallback: (() -> Unit)? = null
+ var onTextLongPressCallback: ((touchX: Int, touchY: Int) -> Unit)? = null
+ // Telegram flow: forward drag/up events after long press fires
+ var onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null
+ var onSelectionDragEnd: (() -> Unit)? = null
private var downOnClickableSpan: Boolean = false
private var suppressPerformClickOnce: Boolean = false
+ private var selectionDragActive: Boolean = false
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onLongPress(e: MotionEvent) {
- if (!downOnClickableSpan) {
+ if (downOnClickableSpan) return
+ if (onTextLongPressCallback != null) {
+ onTextLongPressCallback?.invoke(e.rawX.toInt(), e.rawY.toInt())
+ selectionDragActive = true
+ parent?.requestDisallowInterceptTouchEvent(true) // block scroll during drag
+ } else {
onLongClickCallback?.invoke()
}
}
@@ -721,21 +742,33 @@ class AppleEmojiTextView @JvmOverloads constructor(
MotionEvent.ACTION_DOWN -> {
downOnClickableSpan = isTouchOnClickableSpan(event)
suppressPerformClickOnce = downOnClickableSpan
+ selectionDragActive = false
if (downOnClickableSpan) {
clickableSpanPressStartCallback?.invoke()
parent?.requestDisallowInterceptTouchEvent(true)
}
}
+ MotionEvent.ACTION_MOVE -> {
+ if (selectionDragActive) {
+ onSelectionDrag?.invoke(event.rawX.toInt(), event.rawY.toInt())
+ return true
+ }
+ }
MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> {
+ if (selectionDragActive) {
+ selectionDragActive = false
+ onSelectionDragEnd?.invoke()
+ downOnClickableSpan = false
+ parent?.requestDisallowInterceptTouchEvent(false)
+ return true
+ }
downOnClickableSpan = false
parent?.requestDisallowInterceptTouchEvent(false)
}
}
- // Позволяем GestureDetector обработать событие (для long press)
gestureDetector.onTouchEvent(event)
- // Передаем событие дальше для обработки ссылок
return super.dispatchTouchEvent(event)
}
@@ -822,6 +855,18 @@ class AppleEmojiTextView @JvmOverloads constructor(
}
}
+ fun getLayoutInfo(): com.rosetta.messenger.ui.chats.components.LayoutInfo? {
+ val l = layout ?: return null
+ val loc = IntArray(2)
+ getLocationInWindow(loc)
+ return com.rosetta.messenger.ui.chats.components.LayoutInfo(
+ layout = l,
+ windowX = loc[0] + totalPaddingLeft,
+ windowY = loc[1] + totalPaddingTop,
+ text = text ?: return null
+ )
+ }
+
fun setTextWithEmojis(text: String) {
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
val processMentions = mentionsEnabled && !isLargeText
diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt
index 23a13d7..f391d6d 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt
@@ -23,6 +23,9 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.findViewTreeLifecycleOwner
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -136,6 +139,8 @@ fun SwipeBackContainer(
propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false,
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
+ // Return true to cancel the swipe — screen bounces back and onBack is NOT called.
+ onInterceptSwipeBack: () -> Boolean = { false },
content: @Composable () -> Unit
) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
@@ -160,7 +165,7 @@ fun SwipeBackContainer(
// Alpha animation for fade-in entry
val alphaAnimatable = remember { Animatable(0f) }
- // Drag state - direct update without animation
+ // Drag state
var dragOffset by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) }
@@ -177,6 +182,7 @@ fun SwipeBackContainer(
val context = LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
+ val lifecycleOwner = view.findViewTreeLifecycleOwner()
val dismissKeyboard: () -> Unit = {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
@@ -187,21 +193,16 @@ fun SwipeBackContainer(
focusManager.clearFocus(force = true)
}
- // Current offset: use drag offset during drag, animatable otherwise + optional enter slide
- val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
- val enterOffset =
- if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
- enterOffsetAnimatable.value
- } else {
- 0f
- }
- val currentOffset = baseOffset + enterOffset
+ fun computeCurrentOffset(): Float {
+ val base = if (isDragging) dragOffset else offsetAnimatable.value
+ val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
+ enterOffsetAnimatable.value
+ } else 0f
+ return base + enter
+ }
// Current alpha: use animatable during fade animations, otherwise 1
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
-
- // Scrim alpha based on swipe progress
- val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
val sharedOwnerId = SwipeBackSharedProgress.ownerId
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
val sharedProgress = SwipeBackSharedProgress.progress
@@ -239,6 +240,21 @@ fun SwipeBackContainer(
}
}
+ fun forceResetSwipeState() {
+ isDragging = false
+ dragOffset = 0f
+ clearSharedSwipeProgressIfOwner()
+ scope.launch {
+ offsetAnimatable.snapTo(0f)
+ if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
+ enterOffsetAnimatable.snapTo(0f)
+ }
+ if (shouldShow && !isAnimatingOut) {
+ alphaAnimatable.snapTo(1f)
+ }
+ }
+ }
+
// Handle visibility changes
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
// LaunchedEffect is cancelled by rapid isVisible changes (fast swipes).
@@ -292,10 +308,34 @@ fun SwipeBackContainer(
}
}
- DisposableEffect(Unit) { onDispose { clearSharedSwipeProgressIfOwner() } }
+ DisposableEffect(lifecycleOwner) {
+ if (lifecycleOwner == null) {
+ onDispose { }
+ } else {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
+ forceResetSwipeState()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ forceResetSwipeState()
+ }
+ }
if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return
+ val currentOffset = computeCurrentOffset()
+ val swipeProgress = (currentOffset / screenWidthPx).coerceIn(0f, 1f)
+ val scrimAlpha = if (isDragging || currentOffset > 0f) 0.14f * (1f - swipeProgress) else 0f
+
Box(
modifier =
Modifier.fillMaxSize().graphicsLayer {
@@ -346,13 +386,15 @@ fun SwipeBackContainer(
var totalDragY = 0f
var passedSlop = false
var keyboardHiddenForGesture = false
+ var resetOnFinally = true
// deferToChildren=true: pre-slop uses Main pass so children
// (e.g. LazyRow) process first — if they consume, we back off.
// deferToChildren=false (default): always use Initial pass
// to intercept before children (original behavior).
// Post-claim: always Initial to block children.
- while (true) {
+ try {
+ while (true) {
val pass =
if (startedSwipe || !deferToChildren)
PointerEventPass.Initial
@@ -365,6 +407,7 @@ fun SwipeBackContainer(
?: break
if (change.changedToUpIgnoreConsumed()) {
+ resetOnFinally = false
break
}
@@ -443,6 +486,13 @@ fun SwipeBackContainer(
)
change.consume()
}
+ }
+ } finally {
+ // Сбрасываем только при отмене/прерывании жеста.
+ // При обычном UP сброс делаем позже, чтобы не было рывка.
+ if (resetOnFinally && isDragging) {
+ forceResetSwipeState()
+ }
}
// Handle drag end
@@ -475,6 +525,32 @@ fun SwipeBackContainer(
)
if (shouldComplete) {
+ // Intercept: if owner handled back locally (e.g. clear
+ // message selection), bounce back without exiting.
+ if (onInterceptSwipeBack()) {
+ dismissKeyboard()
+ offsetAnimatable.animateTo(
+ targetValue = 0f,
+ animationSpec =
+ tween(
+ durationMillis =
+ ANIMATION_DURATION_EXIT,
+ easing =
+ TelegramEasing
+ ),
+ block = {
+ updateSharedSwipeProgress(
+ progress =
+ value /
+ screenWidthPx,
+ active = true
+ )
+ }
+ )
+ dragOffset = 0f
+ clearSharedSwipeProgressIfOwner()
+ return@launch
+ }
offsetAnimatable.animateTo(
targetValue = screenWidthPx,
animationSpec =
diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt
index 78f5e65..688ae2d 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt
@@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.onboarding
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.rememberCoroutineScope
+import kotlinx.coroutines.launch
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -90,87 +92,17 @@ fun OnboardingScreen(
// Theme transition animation
var isTransitioning by remember { mutableStateOf(false) }
- var transitionProgress by remember { mutableStateOf(0f) }
+ val transitionRadius = remember { androidx.compose.animation.core.Animatable(0f) }
var clickPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
- var shouldUpdateStatusBar by remember { mutableStateOf(false) }
var hasInitialized by remember { mutableStateOf(false) }
var previousTheme by remember { mutableStateOf(isDarkTheme) }
var targetTheme by remember { mutableStateOf(isDarkTheme) }
+ var rootSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) }
+ val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { hasInitialized = true }
- LaunchedEffect(isTransitioning) {
- if (isTransitioning) {
- shouldUpdateStatusBar = false
- val duration = 800f
- val startTime = System.currentTimeMillis()
- while (transitionProgress < 1f) {
- val elapsed = System.currentTimeMillis() - startTime
- transitionProgress = (elapsed / duration).coerceAtMost(1f)
-
- delay(16) // ~60fps
- }
- // Update status bar icons after animation is completely finished
- shouldUpdateStatusBar = true
- delay(50) // Small delay to ensure UI updates
- isTransitioning = false
- transitionProgress = 0f
- shouldUpdateStatusBar = false
- previousTheme = targetTheme
- }
- }
-
- // Animate navigation bar color starting at 80% of wave animation
val view = LocalView.current
- val isGestureNavigation = remember(view.context) {
- NavigationModeUtils.isGestureNavigation(view.context)
- }
- LaunchedEffect(isTransitioning, transitionProgress) {
- if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
- val window = (view.context as android.app.Activity).window
- // Map 0.8-1.0 to 0-1 for smooth interpolation
- val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
-
- val oldColor = if (previousTheme) 0xFF1E1E1E else 0xFFFFFFFF
- val newColor = if (targetTheme) 0xFF1E1E1E else 0xFFFFFFFF
-
- val r1 = (oldColor shr 16 and 0xFF)
- val g1 = (oldColor shr 8 and 0xFF)
- val b1 = (oldColor and 0xFF)
- val r2 = (newColor shr 16 and 0xFF)
- val g2 = (newColor shr 8 and 0xFF)
- val b2 = (newColor and 0xFF)
-
- val r = (r1 + (r2 - r1) * navProgress).toInt()
- val g = (g1 + (g2 - g1) * navProgress).toInt()
- val b = (b1 + (b2 - b1) * navProgress).toInt()
-
- window.navigationBarColor =
- (0xFF000000 or
- (r.toLong() shl 16) or
- (g.toLong() shl 8) or
- b.toLong())
- .toInt()
- }
- }
-
- // Update status bar icons when animation finishes
- LaunchedEffect(shouldUpdateStatusBar) {
- if (shouldUpdateStatusBar && !view.isInEditMode) {
- val window = (view.context as android.app.Activity).window
- val insetsController = WindowCompat.getInsetsController(window, view)
- insetsController.isAppearanceLightStatusBars = false
- window.statusBarColor = android.graphics.Color.TRANSPARENT
-
- // Navigation bar: показываем только если есть нативные кнопки
- NavigationModeUtils.applyNavigationBarVisibility(
- window = window,
- insetsController = insetsController,
- context = view.context,
- isDarkTheme = isDarkTheme
- )
- }
- }
// Set initial navigation bar color only on first launch
LaunchedEffect(Unit) {
@@ -221,7 +153,9 @@ fun OnboardingScreen(
label = "indicatorColor"
)
- Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) {
+ Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()
+ .onGloballyPositioned { rootSize = it.size }
+ ) {
// Base background - shows the OLD theme color during transition
Box(
modifier =
@@ -237,15 +171,11 @@ fun OnboardingScreen(
// Circular reveal overlay - draws the NEW theme color expanding
if (isTransitioning) {
Canvas(modifier = Modifier.fillMaxSize()) {
- val maxRadius = hypot(size.width, size.height)
- val radius = maxRadius * transitionProgress
-
- // Draw the NEW theme color expanding from click point
drawCircle(
color =
if (targetTheme) OnboardingBackground
else OnboardingBackgroundLight,
- radius = radius,
+ radius = transitionRadius.value,
center = clickPosition
)
}
@@ -260,6 +190,22 @@ fun OnboardingScreen(
clickPosition = position
isTransitioning = true
onThemeToggle()
+ scope.launch {
+ try {
+ val maxR = hypot(
+ rootSize.width.toFloat(),
+ rootSize.height.toFloat()
+ ).coerceAtLeast(1f)
+ transitionRadius.snapTo(0f)
+ transitionRadius.animateTo(
+ targetValue = maxR,
+ animationSpec = tween(400, easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f))
+ )
+ } finally {
+ isTransitioning = false
+ previousTheme = targetTheme
+ }
+ }
}
},
modifier =
diff --git a/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt
index 2517118..c6b468c 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt
@@ -92,16 +92,8 @@ fun MyQrCodeScreen(
val scope = rememberCoroutineScope()
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
-
- // Auto-switch to matching theme group when app theme changes
- LaunchedEffect(isDarkTheme) {
- val currentTheme = qrThemes.getOrNull(selectedThemeIndex)
- if (currentTheme != null && currentTheme.isDark != isDarkTheme) {
- // Map to same position in the other group
- val posInGroup = if (currentTheme.isDark) selectedThemeIndex else selectedThemeIndex - 3
- selectedThemeIndex = if (isDarkTheme) posInGroup.coerceIn(0, 2) else (posInGroup + 3).coerceIn(3, 5)
- }
- }
+ // Local dark/light state — independent from the global app theme
+ var localIsDark by remember { mutableStateOf(isDarkTheme) }
val theme = qrThemes[selectedThemeIndex]
@@ -132,6 +124,13 @@ fun MyQrCodeScreen(
var rootSize by remember { mutableStateOf(IntSize.Zero) }
var lastRevealTime by remember { mutableLongStateOf(0L) }
val revealCooldownMs = 600L
+ var prewarmedBitmap by remember { mutableStateOf(null) }
+
+ // Prewarm bitmap on screen appear
+ LaunchedEffect(Unit) {
+ kotlinx.coroutines.delay(300)
+ prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
+ }
fun startReveal(newIndex: Int, center: Offset) {
val now = System.currentTimeMillis()
@@ -142,7 +141,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
@@ -264,7 +264,7 @@ fun MyQrCodeScreen(
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
- color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
+ color = if (localIsDark) Color(0xFF1C1C1E) else Color.White,
shadowElevation = 16.dp
) {
Column(
@@ -291,30 +291,31 @@ fun MyQrCodeScreen(
) {
IconButton(onClick = onBack) {
Icon(TablerIcons.X, contentDescription = "Close",
- tint = if (isDarkTheme) Color.White else Color.Black)
+ tint = if (localIsDark) Color.White else Color.Black)
}
Spacer(modifier = Modifier.weight(1f))
Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold,
- color = if (isDarkTheme) Color.White else Color.Black)
+ color = if (localIsDark) Color.White else Color.Black)
Spacer(modifier = Modifier.weight(1f))
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
IconButton(
onClick = {
- // Snapshot → toggle theme → circular reveal
+ // Snapshot → toggle LOCAL theme → circular reveal
+ // Does NOT toggle the global app theme
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
revealCenter = themeButtonPos
revealSnapshot = snapshot.asImageBitmap()
- // Switch to matching wallpaper in new theme
- val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3
- val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
+ val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3
+ val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
selectedThemeIndex = newIndex
- onToggleTheme()
+ localIsDark = !localIsDark
scope.launch {
try {
revealRadius.snapTo(0f)
@@ -328,11 +329,8 @@ fun MyQrCodeScreen(
revealActive = false
}
}
- } else {
- // drawToBitmap failed — skip
}
}
- // else: cooldown active — ignore tap
},
modifier = Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
@@ -341,9 +339,9 @@ fun MyQrCodeScreen(
}
) {
Icon(
- imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars,
+ imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars,
contentDescription = "Toggle theme",
- tint = if (isDarkTheme) Color.White else Color.Black
+ tint = if (localIsDark) Color.White else Color.Black
)
}
}
@@ -351,7 +349,7 @@ fun MyQrCodeScreen(
Spacer(modifier = Modifier.height(12.dp))
// Wallpaper selector — show current theme's wallpapers
- val currentThemes = qrThemes.filter { it.isDark == isDarkTheme }
+ val currentThemes = qrThemes.filter { it.isDark == localIsDark }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
@@ -385,7 +383,7 @@ fun MyQrCodeScreen(
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
}
Icon(TablerIcons.Scan, contentDescription = null,
- tint = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
+ tint = if (localIsDark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
modifier = Modifier.size(22.dp))
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt
new file mode 100644
index 0000000..0555844
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt
@@ -0,0 +1,269 @@
+package com.rosetta.messenger.ui.settings
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import compose.icons.TablerIcons
+import compose.icons.tablericons.ChevronLeft
+import com.rosetta.messenger.R
+import com.rosetta.messenger.data.PreferencesManager
+import com.rosetta.messenger.ui.onboarding.PrimaryBlue
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+data class AppIconOption(
+ val id: String,
+ val label: String,
+ val subtitle: String,
+ val aliasName: String,
+ val iconRes: Int,
+ val previewBg: Color
+)
+
+private val iconOptions = listOf(
+ AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.rosetta_icon, Color(0xFF1B1B1B)),
+ AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color.White),
+ AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color.White),
+ AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color.White)
+)
+
+@Composable
+fun AppIconScreen(
+ isDarkTheme: Boolean,
+ onBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val prefs = remember { PreferencesManager(context) }
+ var currentIcon by remember { mutableStateOf("default") }
+
+ LaunchedEffect(Unit) {
+ currentIcon = prefs.appIcon.first()
+ }
+
+ val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
+ val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+ val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
+ val dividerColor = if (isDarkTheme) Color(0xFF38383A) else Color(0xFFE5E5EA)
+
+ // Status bar
+ val view = androidx.compose.ui.platform.LocalView.current
+ if (!view.isInEditMode) {
+ DisposableEffect(isDarkTheme) {
+ val window = (view.context as android.app.Activity).window
+ val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
+ val prev = insetsController.isAppearanceLightStatusBars
+ insetsController.isAppearanceLightStatusBars = !isDarkTheme
+ onDispose { insetsController.isAppearanceLightStatusBars = prev }
+ }
+ }
+
+ BackHandler { onBack() }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(backgroundColor)
+ ) {
+ // ═══════════════════════════════════════════════════════
+ // TOP BAR — same style as SafetyScreen
+ // ═══════════════════════════════════════════════════════
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = backgroundColor
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
+ .padding(horizontal = 4.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = TablerIcons.ChevronLeft,
+ contentDescription = "Back",
+ tint = textColor
+ )
+ }
+ Text(
+ text = "App Icon",
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = textColor,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════
+ // CONTENT
+ // ═══════════════════════════════════════════════════════
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Section header
+ Text(
+ text = "CHOOSE ICON",
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ color = secondaryTextColor,
+ letterSpacing = 0.5.sp,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+
+ // Icon cards in grouped surface (Telegram style)
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ shape = RoundedCornerShape(12.dp),
+ color = surfaceColor
+ ) {
+ Column {
+ iconOptions.forEachIndexed { index, option ->
+ val isSelected = currentIcon == option.id
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ if (!isSelected) {
+ scope.launch {
+ changeAppIcon(context, prefs, option.id)
+ currentIcon = option.id
+ }
+ }
+ }
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Icon preview
+ Box(
+ modifier = Modifier
+ .size(52.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(option.previewBg),
+ contentAlignment = Alignment.Center
+ ) {
+ val imgSize = if (option.id == "default") 52.dp else 44.dp
+ val imgScale = if (option.id == "default")
+ android.widget.ImageView.ScaleType.CENTER_CROP
+ else
+ android.widget.ImageView.ScaleType.FIT_CENTER
+ androidx.compose.ui.viewinterop.AndroidView(
+ factory = { ctx ->
+ android.widget.ImageView(ctx).apply {
+ setImageResource(option.iconRes)
+ scaleType = imgScale
+ }
+ },
+ modifier = Modifier.size(imgSize)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(14.dp))
+
+ // Label + subtitle
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = option.label,
+ color = textColor,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Normal
+ )
+ Text(
+ text = option.subtitle,
+ color = secondaryTextColor,
+ fontSize = 13.sp
+ )
+ }
+
+ // Checkmark
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Selected",
+ tint = PrimaryBlue,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ }
+
+ // Divider between items (not after last)
+ if (index < iconOptions.lastIndex) {
+ Divider(
+ modifier = Modifier.padding(start = 82.dp),
+ thickness = 0.5.dp,
+ color = dividerColor
+ )
+ }
+ }
+ }
+ }
+
+ // Info text below
+ Text(
+ text = "The app icon and name on your home screen will change. Rosetta will continue to work normally. The launcher may take a moment to update.",
+ fontSize = 13.sp,
+ color = secondaryTextColor,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ lineHeight = 18.sp
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+ }
+}
+
+private suspend fun changeAppIcon(context: Context, prefs: PreferencesManager, newIconId: String) {
+ val pm = context.packageManager
+ val packageName = context.packageName
+
+ iconOptions.forEach { option ->
+ val component = ComponentName(packageName, "$packageName${option.aliasName}")
+ pm.setComponentEnabledSetting(
+ component,
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP
+ )
+ }
+
+ val selected = iconOptions.first { it.id == newIconId }
+ val component = ComponentName(packageName, "$packageName${selected.aliasName}")
+ pm.setComponentEnabledSetting(
+ component,
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+ PackageManager.DONT_KILL_APP
+ )
+
+ prefs.setAppIcon(newIconId)
+ Toast.makeText(context, "Icon changed to ${selected.label}", Toast.LENGTH_SHORT).show()
+}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt
index 0956fef..a216553 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt
@@ -78,6 +78,7 @@ fun AppearanceScreen(
onBack: () -> Unit,
onBlurColorChange: (String) -> Unit,
onToggleTheme: () -> Unit = {},
+ onAppIconClick: () -> Unit = {},
accountPublicKey: String = "",
accountName: String = "",
avatarRepository: AvatarRepository? = null
@@ -282,6 +283,49 @@ fun AppearanceScreen(
lineHeight = 18.sp
)
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // ═══════════════════════════════════════════════════════
+ // APP ICON SECTION
+ // ═══════════════════════════════════════════════════════
+ Text(
+ text = "APP ICON",
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ color = secondaryTextColor,
+ letterSpacing = 0.5.sp,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onAppIconClick() }
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "Change App Icon",
+ fontSize = 16.sp,
+ color = textColor
+ )
+ Icon(
+ imageVector = TablerIcons.ChevronRight,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ Text(
+ text = "Disguise Rosetta as a calculator, weather app, or notes.",
+ fontSize = 13.sp,
+ color = secondaryTextColor,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ lineHeight = 18.sp
+ )
+
Spacer(modifier = Modifier.height(32.dp))
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
index 7943570..5e3608a 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
@@ -995,6 +995,7 @@ fun ProfileScreen(
hasAvatar = hasAvatar,
avatarRepository = avatarRepository,
backgroundBlurColorId = backgroundBlurColorId,
+ onQrCodeClick = onNavigateToMyQr,
onAvatarLongPress = {
if (hasAvatar) {
scope.launch {
@@ -1014,13 +1015,13 @@ fun ProfileScreen(
)
// ═══════════════════════════════════════════════════════════
- // 📷 CAMERA BUTTON — at boundary between header and content
+ // 📷 + QR FLOATING BUTTONS — at boundary between header and content
// Positioned at bottom-right of header, half overlapping content area
// Fades out when collapsed or when avatar is expanded
// ═══════════════════════════════════════════════════════════
val cameraButtonSize = 60.dp
- val cameraButtonAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f)
- if (cameraButtonAlpha > 0.01f) {
+ val floatingButtonsAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f)
+ if (floatingButtonsAlpha > 0.01f) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
@@ -1028,24 +1029,31 @@ fun ProfileScreen(
x = (-16).dp,
y = headerHeight - cameraButtonSize / 2
)
- .size(cameraButtonSize)
- .graphicsLayer { alpha = cameraButtonAlpha }
- .shadow(
- elevation = 4.dp,
- shape = CircleShape,
- clip = false
- )
- .clip(CircleShape)
- .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4))
- .clickable { showPhotoPicker = true },
- contentAlignment = Alignment.Center
+ .graphicsLayer { alpha = floatingButtonsAlpha }
) {
- Icon(
- painter = TelegramIcons.AddPhoto,
- contentDescription = "Change avatar",
- tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White,
- modifier = Modifier.size(26.dp).offset(x = 2.dp)
- )
+ Box(
+ modifier = Modifier
+ .size(cameraButtonSize)
+ .shadow(
+ elevation = 4.dp,
+ shape = CircleShape,
+ clip = false
+ )
+ .clip(CircleShape)
+ .background(
+ if (isDarkTheme) Color(0xFF2A2A2A)
+ else Color(0xFF0D8CF4)
+ )
+ .clickable { showPhotoPicker = true },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = TelegramIcons.AddPhoto,
+ contentDescription = "Change avatar",
+ tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White,
+ modifier = Modifier.size(26.dp).offset(x = 2.dp)
+ )
+ }
}
}
}
@@ -1103,6 +1111,7 @@ private fun CollapsingProfileHeader(
hasAvatar: Boolean,
avatarRepository: AvatarRepository?,
backgroundBlurColorId: String = "avatar",
+ onQrCodeClick: () -> Unit = {},
onAvatarLongPress: () -> Unit = {}
) {
@Suppress("UNUSED_VARIABLE")
@@ -1379,6 +1388,10 @@ private fun CollapsingProfileHeader(
expanded = showAvatarMenu,
onDismiss = { onAvatarMenuChange(false) },
isDarkTheme = isDarkTheme,
+ onQrCodeClick = {
+ onAvatarMenuChange(false)
+ onQrCodeClick()
+ },
onSetPhotoClick = {
onAvatarMenuChange(false)
onSetPhotoClick()
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt
index d352958..c0f9b31 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt
@@ -99,6 +99,13 @@ fun ThemeScreen(
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf(null) }
+ var prewarmedBitmap by remember { mutableStateOf(null) }
+
+ // Prewarm bitmap on screen appear
+ LaunchedEffect(Unit) {
+ kotlinx.coroutines.delay(300)
+ prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
+ }
var lightOptionCenter by remember { mutableStateOf(null) }
var darkOptionCenter by remember { mutableStateOf(null) }
var systemOptionCenter by remember { mutableStateOf(null) }
@@ -130,7 +137,8 @@ fun ThemeScreen(
val center =
centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f)
- val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
+ val snapshotBitmap = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
+ prewarmedBitmap = null
if (snapshotBitmap == null) {
themeMode = targetMode
onThemeModeChange(targetMode)
diff --git a/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt
index 347f189..fde1308 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt
@@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.splash
import androidx.compose.animation.core.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.*
@@ -64,7 +66,11 @@ fun SplashScreen(
Box(
modifier = Modifier
.fillMaxSize()
- .background(backgroundColor),
+ .background(backgroundColor)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) { },
contentAlignment = Alignment.Center
) {
// Glow effect behind logo
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png b/app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png
new file mode 100644
index 0000000..e6fcf21
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png b/app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png
new file mode 100644
index 0000000..569e2cc
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png b/app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png
new file mode 100644
index 0000000..7638c9c
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png differ
diff --git a/app/src/main/res/drawable/ic_calc_background.xml b/app/src/main/res/drawable/ic_calc_background.xml
new file mode 100644
index 0000000..4a213bb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_calc_background.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_calc_foreground.xml b/app/src/main/res/drawable/ic_calc_foreground.xml
new file mode 100644
index 0000000..4f7b0bc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_calc_foreground.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notes_background.xml b/app/src/main/res/drawable/ic_notes_background.xml
new file mode 100644
index 0000000..4a213bb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notes_background.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notes_foreground.xml b/app/src/main/res/drawable/ic_notes_foreground.xml
new file mode 100644
index 0000000..311c95a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notes_foreground.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_weather_background.xml b/app/src/main/res/drawable/ic_weather_background.xml
new file mode 100644
index 0000000..4a213bb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_weather_background.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_weather_foreground.xml b/app/src/main/res/drawable/ic_weather_foreground.xml
new file mode 100644
index 0000000..14f4d2b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_weather_foreground.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml
new file mode 100644
index 0000000..d9ccec9
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml
new file mode 100644
index 0000000..87b115f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml
new file mode 100644
index 0000000..e7cd117
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/raw/chat_audio_record_delete_2.json b/app/src/main/res/raw/chat_audio_record_delete_2.json
new file mode 100644
index 0000000..58d3cbd
--- /dev/null
+++ b/app/src/main/res/raw/chat_audio_record_delete_2.json
@@ -0,0 +1 @@
+{"v":"5.6.1","fr":60,"ip":0,"op":52,"w":100,"h":100,"nm":"delete lottie grey to red","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Layer 22 Outlines 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[71.75,2914,0],"ix":2},"a":{"a":0,"k":[5.75,19,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-4.418,0],[0,0],[0,-4.418],[0,0]],"o":[[0,0],[0,-4.418],[0,0],[4.418,0],[0,0],[0,0]],"v":[[-12,6],[-12,2],[-4,-6],[4,-6],[12,2],[12,6]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.854901960784,0.337254901961,0.301960784314,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[18,12],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7200,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Layer 20 Outlines 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[52.25,2913,0],"ix":2},"a":{"a":0,"k":[3.25,2.8,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3,3],[67,3]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.854901960784,0.337254901961,0.301960784314,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7200,"st":0,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Layer 20 Outlines 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[52.25,2913,0],"ix":2},"a":{"a":0,"k":[3.25,2.8,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3,3],[67,3]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.556862745098,0.58431372549,0.607843137255,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7200,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Layer 22 Outlines 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[71.75,2914,0],"ix":2},"a":{"a":0,"k":[5.75,19,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-4.418,0],[0,0],[0,-4.418],[0,0]],"o":[[0,0],[0,-4.418],[0,0],[4.418,0],[0,0],[0,0]],"v":[[-12,6],[-12,2],[-4,-6],[4,-6],[12,2],[12,6]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.556862745098,0.58431372549,0.607843137255,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[18,12],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7200,"st":0,"bm":0}]},{"id":"comp_2","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[38,58,0],"to":[0,1,0],"ti":[0,-1,0]},{"t":14,"s":[38,64,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]},{"t":14,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.556999954523,0.583999992819,0.607999973671,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":54,"st":-61,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Line 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[50,58,0],"to":[0,1,0],"ti":[0,-1,0]},{"t":14,"s":[50,64,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]},{"t":14,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.556999954523,0.583999992819,0.607999973671,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":54,"st":-61,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Line 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[62,58,0],"to":[0,1,0],"ti":[0,-1,0]},{"t":14,"s":[62,64,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]},{"t":14,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.556999954523,0.583999992819,0.607999973671,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":54,"st":-61,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Box Grey","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50,62,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[0,0],[0,0],[-4.95,0],[0,0],[0,4.38],[0,0]],"o":[[0,0],[0,4.38],[0,0],[4.95,0],[0,0],[0,0]],"v":[[-27,-27],[-27,19.04],[-18,27],[18,27],[27,19.04],[27,-27]],"c":false}]},{"t":14,"s":[{"i":[[0,0],[0,0],[-4.95,0],[0,0],[0,4.38],[0,0]],"o":[[0,0],[0,4.38],[0,0],[4.95,0],[0,0],[0,0]],"v":[[-27,17.5],[-27,19.04],[-18,27],[18,27],[27,19.04],[27,17.5]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.556862984452,0.584313964844,0.60784295774,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6.6,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":54,"st":-61,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[0]},{"t":19,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[38,67.5,0],"to":[0,-1,0],"ti":[0,1,0]},{"t":23,"s":[38,61.5,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0,"y":0},"t":15,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]},{"t":23,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":14,"op":53,"st":-47,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Line 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[0]},{"t":19,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[50,67.5,0],"to":[0,-1,0],"ti":[0,1,0]},{"t":23,"s":[50,61.5,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0,"y":0},"t":15,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]},{"t":23,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":14,"op":53,"st":-47,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Line 3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[0]},{"t":19,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[62,67.5,0],"to":[0,-1,0],"ti":[0,1,0]},{"t":23,"s":[62,61.5,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0,"y":0},"t":15,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]},{"t":23,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":14,"op":53,"st":-47,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Cup Red","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":33,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":37,"s":[5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41,"s":[-5]},{"t":47,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[50.168,21,0],"to":[0,7.333,0],"ti":[0,0.833,0]},{"i":{"x":0.445,"y":1},"o":{"x":0.228,"y":0},"t":14,"s":[50.168,65,0],"to":[0,-0.833,0],"ti":[0,7.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":25,"s":[50.168,16,0],"to":[0,-7.333,0],"ti":[0,-0.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[50.168,21,0],"to":[0,0.333,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":33,"s":[50.168,18,0],"to":[0,0,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":37,"s":[50.168,21,0],"to":[0,0,0],"ti":[0,0,0]},{"t":44,"s":[50.168,21,0]}],"ix":2},"a":{"a":0,"k":[83.918,2911,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1440,"h":3040,"ip":14,"op":53,"st":14,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Box Red","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50.25,72,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.3,"y":1},"o":{"x":0.3,"y":0},"t":14,"s":[{"i":[[-3.77,0],[-3.51,0],[0,6.431],[0,0],[0,0.705],[4.525,0.08],[3.46,0],[3.263,0],[0,-2.973],[0,0],[0,-1.223],[-6.47,0]],"o":[[3.78,0],[6.49,0],[0.002,-0.756],[0,0],[0,-3.045],[-3.23,-0.057],[-3.5,0],[-4.95,0],[0,0.575],[0,0],[0,6.37],[3.015,0]],"v":[[-0.016,20],[17.932,20.02],[30.271,8.762],[30.273,7.421],[30.271,5.555],[24.678,2.42],[-0.172,2.375],[-24.262,2.315],[-30.261,5.581],[-30.248,7.409],[-30.242,8.751],[-17.921,20.057]],"c":true}]},{"t":25,"s":[{"i":[[-3.77,0],[-3.51,0],[0,6.431],[0,0],[0,0.705],[4.525,0.08],[3.46,0],[3.263,0],[0,-2.972],[0,0],[0,-5.251],[-6.47,0]],"o":[[3.78,0],[6.49,0],[0,-5.137],[0,0],[0,-3.045],[-3.23,-0.057],[-3.5,0],[-4.95,0],[0,0.575],[0,0],[0,6.37],[3.015,0]],"v":[[-0.016,20],[17.932,20.02],[30.271,8.762],[30.226,-34.829],[30.223,-36.695],[24.63,-39.83],[-0.22,-39.875],[-24.31,-39.935],[-30.308,-36.669],[-30.295,-34.841],[-30.242,8.751],[-17.921,20.057]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.854901969433,0.337254911661,0.301960796118,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Oval","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":14,"op":53,"st":-329,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"Cup Grey","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":33,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":37,"s":[5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41,"s":[-5]},{"t":47,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[50.168,21,0],"to":[0,7.333,0],"ti":[0,0.833,0]},{"i":{"x":0.445,"y":1},"o":{"x":0.228,"y":0},"t":14,"s":[50.168,65,0],"to":[0,-0.833,0],"ti":[0,7.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":25,"s":[50.168,16,0],"to":[0,-7.333,0],"ti":[0,-0.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[50.168,21,0],"to":[0,0.333,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":33,"s":[50.168,18,0],"to":[0,0,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":37,"s":[50.168,21,0],"to":[0,0,0],"ti":[0,0,0]},{"t":44,"s":[50.168,21,0]}],"ix":2},"a":{"a":0,"k":[83.917,2911,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1440,"h":3040,"ip":0,"op":14,"st":-61,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"Box Grey","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":100,"h":100,"ip":0,"op":14,"st":0,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/app/src/main/res/raw/phone_duck.json b/app/src/main/res/raw/phone_duck.json
new file mode 100644
index 0000000..333e18d
--- /dev/null
+++ b/app/src/main/res/raw/phone_duck.json
@@ -0,0 +1 @@
+{"tgs":1,"v":"5.5.2","fr":60,"ip":0,"op":180,"w":512,"h":512,"nm":"Old Phone","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":2,"ty":3,"nm":"NULL SCALE ALL","sr":1,"ks":{"o":{"a":0,"k":0},"p":{"a":0,"k":[256,256,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.4,0.4,0.4],"y":[0,0,0]},"t":76,"s":[110,110,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":91,"s":[103,103,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":148,"s":[103,103,100]},{"t":164,"s":[110,110,100]}]}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 5","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[82.549,76.375,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":63,"s":[145,145,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":69,"s":[100,100,100]},{"t":74,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":65,"op":74,"st":50,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 4","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[-81.451,17.875,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":50,"s":[145,145,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":57,"s":[100,100,100]},{"t":63,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":52,"op":63,"st":37,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 3","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[0.549,76.375,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":37,"s":[145,145,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":44,"s":[100,100,100]},{"t":50,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":39,"op":50,"st":24,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 2","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[-78.951,-36.625,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":24,"s":[143,143,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":31,"s":[100,100,100]},{"t":37,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":26,"op":37,"st":10,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Shape Layer 1","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[0.049,17.375,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":11,"s":[143,143,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":18,"s":[100,100,100]},{"t":24,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":13,"op":24,"st":-2,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Shape Layer 6","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[77.549,-36.625,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":0,"s":[143,143,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":7,"s":[100,100,100]},{"t":13,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":2,"op":13,"st":-13,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"1","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[-74.047,-35.897,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-78.706,-35.897,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-74.047,-35.897,0]}]},"a":{"a":0,"k":[-74.047,-35.897,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.702,0],[0,0]],"o":[[0,0],[-0.03,2.474],[0,0],[0,0]],"v":[[-60.187,-49.978],[-60.503,-24.313],[-65.475,-19.806],[-95.54,-19.806]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.702,0],[0,0],[-0.212,2.474],[0,0],[-2.823,0],[0,0],[0.03,-2.638]],"o":[[-0.029,2.474],[0,0],[-2.701,0],[0,0],[0.22,-2.638],[0,0],[2.822,0],[0,0]],"v":[[-60.503,-24.313],[-65.475,-19.806],[-95.539,-19.806],[-100.079,-24.313],[-97.877,-49.977],[-92.404,-54.72],[-65.204,-54.72],[-60.187,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.139,0],[0,0],[0.26,2.091],[0,0]],"o":[[0,0],[-0.464,2.083],[0,0],[-2.146,0],[0,0],[0,0]],"v":[[-60.187,-49.978],[-64.283,-36.417],[-68.734,-32.766],[-91.022,-32.766],[-95.115,-36.439],[-97.877,-49.978]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.212,2.474],[0,0],[0.03,-2.638]],"o":[[-0.029,2.474],[0,0],[0.22,-2.638],[0,0]],"v":[[-60.503,-24.313],[-100.079,-24.313],[-97.877,-49.977],[-60.187,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-80.141,-37.263]},"a":{"a":0,"k":[-80.141,-37.263]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":24,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":31,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":37,"s":[105,105]},{"t":41,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":24,"s":[{"i":[[0,0],[2.783,0],[0,0],[-2.908,0],[0,0],[0.031,-2.6]],"o":[[-0.03,2.438],[0,0],[-2.783,0],[0,0],[2.907,0],[0,0]],"v":[[-48.326,-21.513],[-53.449,-17.074],[-84.428,-17.074],[-81.199,-51.478],[-53.17,-51.478],[-48,-46.804]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":31,"s":[{"i":[[0,0],[2.748,0],[0,0],[-2.871,0],[0,0],[0.031,-2.246]],"o":[[-0.03,2.106],[0,0],[-2.748,0],[0,0],[2.87,0],[0,0]],"v":[[-58.102,-24.787],[-63.161,-20.952],[-93.752,-20.952],[-90.563,-50.673],[-62.885,-50.673],[-57.781,-46.635]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":37,"s":[{"i":[[0,0],[2.896,0],[0,0],[-3.026,0],[0,0],[0.032,-2.83]],"o":[[-0.031,2.653],[0,0],[-2.896,0],[0,0],[3.025,0],[0,0]],"v":[[-46.746,-20.384],[-52.077,-15.552],[-84.315,-15.552],[-80.954,-53],[-51.787,-53],[-46.407,-47.913]],"c":true}]},{"t":41,"s":[{"i":[[0,0],[2.783,0],[0,0],[-2.908,0],[0,0],[0.031,-2.6]],"o":[[-0.03,2.438],[0,0],[-2.783,0],[0,0],[2.907,0],[0,0]],"v":[[-48.326,-21.513],[-53.449,-17.074],[-84.428,-17.074],[-81.199,-51.478],[-53.17,-51.478],[-48,-46.804]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-74.047,-35.897]},"a":{"a":0,"k":[-74.047,-35.897]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"2","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[5.404,-35.915,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-0.803,-35.915,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[5.404,-35.915,0]}]},"a":{"a":0,"k":[5.404,-35.915,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.646,0],[0,0]],"o":[[0,0],[0.061,2.474],[0,0],[0,0]],"v":[[18.126,-49.978],[18.766,-24.313],[14.061,-19.806],[-15.393,-19.806]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.646,0],[0,0],[-0.062,2.474],[0,0],[-2.821,0],[0,0],[-0.07,-2.638]],"o":[[0.062,2.474],[0,0],[-2.647,0],[0,0],[0.062,-2.638],[0,0],[2.821,0],[0,0]],"v":[[18.765,-24.313],[14.06,-19.806],[-15.393,-19.806],[-20.108,-24.313],[-19.46,-49.977],[-14.272,-54.72],[12.929,-54.72],[18.125,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.111,0],[0,0],[0.377,2.091],[0,0]],"o":[[0,0],[-0.376,2.083],[0,0],[-2.117,0],[0,0],[0,0]],"v":[[18.126,-49.978],[14.58,-36.417],[10.33,-32.766],[-11.672,-32.766],[-15.922,-36.439],[-19.459,-49.978]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.062,2.474],[0,0],[-0.07,-2.638]],"o":[[0.062,2.474],[0,0],[0.062,-2.638],[0,0]],"v":[[18.765,-24.313],[-20.108,-24.313],[-19.46,-49.977],[18.125,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-0.671,-37.263]},"a":{"a":0,"k":[-0.671,-37.263]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.733,0],[0,0],[-2.914,0],[0,0],[-0.072,-2.594]],"o":[[0.064,2.433],[0,0],[-2.734,0],[0,0],[2.914,0],[0,0]],"v":[[30.915,-21.54],[26.054,-17.11],[-4.369,-17.11],[-3.211,-51.442],[24.887,-51.442],[30.254,-46.778]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 7","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[5.404,-35.915]},"a":{"a":0,"k":[5.404,-35.915]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"3","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[83.249,-35.897,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[78.557,-35.897,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[83.249,-35.897,0]}]},"a":{"a":0,"k":[83.249,-35.897,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.212,2.474],[0,0],[0,0]],"o":[[0,0],[2.702,0],[0,0],[0,0],[0,0]],"v":[[62.536,-19.807],[92.6,-19.807],[97.14,-24.313],[96.269,-34.479],[94.939,-49.978]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-2.701,0],[0,0],[0.212,2.474],[0,0],[2.823,0],[0,0],[-0.03,-2.638]],"o":[[0.03,2.474],[0,0],[2.701,0],[0,0],[-0.22,-2.638],[0,0],[-2.821,0],[0,0]],"v":[[57.564,-24.313],[62.536,-19.806],[92.6,-19.806],[97.14,-24.313],[94.939,-49.977],[89.466,-54.72],[62.265,-54.72],[57.248,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[-2.139,0],[0,0],[-0.26,2.091],[0,0]],"o":[[0,0],[0.464,2.083],[0,0],[2.145,0],[0,0],[0,0]],"v":[[57.248,-49.978],[61.345,-36.417],[65.795,-32.766],[88.084,-32.766],[92.177,-36.439],[94.939,-49.978]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0.212,2.474],[0,0],[-0.03,-2.638]],"o":[[0.03,2.474],[0,0],[-0.22,-2.638],[0,0]],"v":[[57.564,-24.313],[97.14,-24.313],[94.939,-49.977],[57.248,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[77.201,-37.263]},"a":{"a":0,"k":[77.201,-37.263]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":0,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":7,"s":[70,70]},{"i":{"x":[0.3,0.3],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":13,"s":[105,105]},{"t":17,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 7","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[-2.777,0],[0,0],[0.218,2.438],[0,0],[2.901,0],[0,0]],"o":[[0,0],[2.777,0],[0,0],[-0.226,-2.6],[0,0],[-2.9,0]],"v":[[73.657,-17.074],[104.567,-17.074],[109.235,-21.513],[106.972,-46.804],[101.345,-51.478],[73.379,-51.478]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":7,"s":[{"i":[[-1.929,0],[0,0],[0.151,2.073],[0,0],[2.015,0],[0,0]],"o":[[0,0],[1.929,0],[0,0],[-0.157,-2.211],[0,0],[-2.014,0]],"v":[[75.139,-21.239],[96.607,-21.239],[99.849,-25.015],[98.277,-46.525],[94.369,-50.5],[74.945,-50.5]],"c":true}]},{"i":{"x":0.3,"y":1},"o":{"x":0.167,"y":0},"t":13,"s":[{"i":[[-2.923,0],[0,0],[0.229,2.547],[0,0],[3.054,0],[0,0]],"o":[[0,0],[2.923,0],[0,0],[-0.238,-2.716],[0,0],[-3.053,0]],"v":[[73.824,-16.302],[106.358,-16.302],[111.271,-20.94],[108.889,-47.367],[102.966,-52.25],[73.531,-52.25]],"c":true}]},{"t":17,"s":[{"i":[[-2.777,0],[0,0],[0.218,2.438],[0,0],[2.901,0],[0,0]],"o":[[0,0],[2.777,0],[0,0],[-0.226,-2.6],[0,0],[-2.9,0]],"v":[[73.657,-17.074],[104.567,-17.074],[109.235,-21.513],[106.972,-46.804],[101.345,-51.478],[73.379,-51.478]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[83.249,-35.897]},"a":{"a":0,"k":[83.249,-35.897]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"4","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[-76.709,17.921,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-93.319,17.921,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-76.709,17.921,0]}]},"a":{"a":0,"k":[-76.709,17.921,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.768,0],[0,0]],"o":[[0,0],[-0.031,2.636],[0,0],[0,0]],"v":[[-62.413,3.77],[-62.737,31.113],[-67.83,35.913],[-98.627,35.913]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.768,0],[0,0],[-0.217,2.636],[0,0],[-2.892,0],[0,0],[0.031,-2.81]],"o":[[-0.03,2.636],[0,0],[-2.767,0],[0,0],[0.225,-2.81],[0,0],[2.891,0],[0,0]],"v":[[-62.737,31.113],[-67.83,35.913],[-98.627,35.913],[-103.277,31.113],[-101.022,3.77],[-95.415,-1.283],[-67.553,-1.283],[-62.414,3.77]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.191,0],[0,0],[0.266,2.228],[0,0]],"o":[[0,0],[-0.475,2.219],[0,0],[-2.198,0],[0,0],[0,0]],"v":[[-62.413,3.77],[-66.609,18.217],[-71.168,22.107],[-93.999,22.107],[-98.192,18.194],[-101.021,3.77]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.217,2.636],[0,0],[0.031,-2.81]],"o":[[-0.03,2.636],[0,0],[0.225,-2.81],[0,0]],"v":[[-62.737,31.113],[-103.277,31.113],[-101.022,3.77],[-62.414,3.77]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-82.853,17.315]},"a":{"a":0,"k":[-82.853,17.315]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":50,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":57,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":63,"s":[105,105]},{"t":67,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":50,"s":[{"i":[[0,0],[2.894,0],[0,0],[-3.024,0],[0,0],[0.032,-2.749]],"o":[[-0.031,2.577],[0,0],[-2.894,0],[0,0],[3.023,0],[0,0]],"v":[[-50.464,32.431],[-55.791,37.125],[-88.004,37.125],[-84.646,0.75],[-55.501,0.75],[-50.125,5.691]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":57,"s":[{"i":[[0,0],[2.227,0],[0,0],[-2.327,0],[0,0],[0.025,-2.352]],"o":[[-0.024,2.205],[0,0],[-2.227,0],[0,0],[2.326,0],[0,0]],"v":[[-59.785,30.484],[-63.884,34.5],[-88.671,34.5],[-86.087,3.375],[-63.661,3.375],[-59.525,7.603]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":63,"s":[{"i":[[0,0],[3.06,0],[0,0],[-3.197,0],[0,0],[0.034,-2.994]],"o":[[-0.033,2.808],[0,0],[-3.06,0],[0,0],[3.196,0],[0,0]],"v":[[-48.142,33.637],[-53.775,38.75],[-87.838,38.75],[-84.287,-0.875],[-53.468,-0.875],[-47.784,4.508]],"c":true}]},{"t":67,"s":[{"i":[[0,0],[2.894,0],[0,0],[-3.024,0],[0,0],[0.032,-2.749]],"o":[[-0.031,2.577],[0,0],[-2.894,0],[0,0],[3.023,0],[0,0]],"v":[[-50.464,32.431],[-55.791,37.125],[-88.004,37.125],[-84.646,0.75],[-55.501,0.75],[-50.125,5.691]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-76.709,17.921]},"a":{"a":0,"k":[-76.709,17.921]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"5","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[5.46,17.65,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-10.699,17.65,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[5.46,17.65,0]}]},"a":{"a":0,"k":[5.46,17.65,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.622,0],[0,0]],"o":[[0,0],[0.06,2.606],[0,0],[0,0]],"v":[[18.302,4.174],[18.936,31.207],[14.274,35.953],[-14.91,35.953]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.622,0],[0,0],[-0.061,2.606],[0,0],[-2.795,0],[0,0],[-0.069,-2.779]],"o":[[0.061,2.606],[0,0],[-2.623,0],[0,0],[0.061,-2.779],[0,0],[2.795,0],[0,0]],"v":[[18.936,31.207],[14.274,35.953],[-14.91,35.953],[-19.582,31.207],[-18.94,4.174],[-13.799,-0.822],[13.153,-0.822],[18.302,4.174]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.092,0],[0,0],[0.374,2.202],[0,0]],"o":[[0,0],[-0.373,2.194],[0,0],[-2.098,0],[0,0],[0,0]],"v":[[18.302,4.174],[14.788,18.458],[10.577,22.303],[-11.224,22.303],[-15.435,18.435],[-18.939,4.174]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.061,2.606],[0,0],[-0.069,-2.779]],"o":[[0.061,2.606],[0,0],[0.061,-2.779],[0,0]],"v":[[18.936,31.207],[-19.582,31.207],[-18.94,4.174],[18.302,4.174]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-0.323,17.566]},"a":{"a":0,"k":[-0.323,17.566]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":11,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":18,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":24,"s":[105,105]},{"t":28,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":11,"s":[{"i":[[0,0],[2.729,0],[0,0],[-2.91,0],[0,0],[-0.072,-2.722]],"o":[[0.064,2.552],[0,0],[-2.73,0],[0,0],[2.91,0],[0,0]],"v":[[30.501,31.463],[25.75,36.122],[-4.691,36.122],[-3.577,1.441],[24.417,0.75],[29.839,6.334]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":18,"s":[{"i":[[0,0],[1.831,0],[0,0],[-1.952,0],[0,0],[-0.05,-2.164]],"o":[[0.045,2.029],[0,0],[-1.832,0],[0,0],[1.952,0],[0,0]],"v":[[17.996,28.795],[14.812,32.5],[-5.612,32.5],[-4.89,4.922],[13.892,4.372],[17.534,8.812]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":24,"s":[{"i":[[0,0],[2.958,0],[0,0],[-3.154,0],[0,0],[-0.078,-3.049]],"o":[[0.069,2.859],[0,0],[-2.959,0],[0,0],[3.154,0],[0,0]],"v":[[33.681,33.031],[28.531,38.25],[-4.462,38.25],[-3.255,-0.603],[27.086,-1.378],[32.964,4.878]],"c":true}]},{"t":28,"s":[{"i":[[0,0],[2.729,0],[0,0],[-2.91,0],[0,0],[-0.072,-2.722]],"o":[[0.064,2.552],[0,0],[-2.73,0],[0,0],[2.91,0],[0,0]],"v":[[30.501,31.463],[25.75,36.122],[-4.691,36.122],[-3.577,1.441],[24.417,0.75],[29.839,6.334]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[5.46,17.65]},"a":{"a":0,"k":[5.46,17.65]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"6","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[86.303,17.786,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[73.145,17.786,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[86.303,17.786,0]}]},"a":{"a":0,"k":[86.303,17.786,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.217,2.592],[0,0],[0,0]],"o":[[0,0],[2.764,0],[0,0],[0,0],[0,0]],"v":[[65.113,36.022],[95.87,36.022],[100.515,31.302],[99.624,20.653],[98.263,4.418]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-2.763,0],[0,0],[0.217,2.592],[0,0],[2.888,0],[0,0],[-0.031,-2.763]],"o":[[0.031,2.592],[0,0],[2.763,0],[0,0],[-0.225,-2.763],[0,0],[-2.886,0],[0,0]],"v":[[60.026,31.302],[65.113,36.022],[95.87,36.022],[100.515,31.302],[98.263,4.418],[92.664,-0.55],[64.836,-0.55],[59.703,4.418]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[-2.188,0],[0,0],[-0.266,2.19],[0,0]],"o":[[0,0],[0.475,2.182],[0,0],[2.194,0],[0,0],[0,0]],"v":[[59.703,4.418],[63.894,18.623],[68.447,22.448],[91.25,22.448],[95.437,18.6],[98.263,4.418]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0.217,2.592],[0,0],[-0.031,-2.763]],"o":[[0.031,2.592],[0,0],[-0.225,-2.763],[0,0]],"v":[[60.026,31.302],[100.515,31.302],[98.263,4.418],[59.703,4.418]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[80.117,17.736]},"a":{"a":0,"k":[80.117,17.736]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-2.307,0],[0,0],[0.181,2.307],[0,0],[2.411,0],[0,0]],"o":[[0,0],[2.307,0],[0,0],[-0.188,-2.461],[0,0],[-2.41,0]],"v":[[83.13,34.622],[108.811,34.622],[112.689,30.42],[110.809,6.479],[105.945,1.25],[82.898,2.055]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[86.303,17.786]},"a":{"a":0,"k":[86.303,17.786]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"7","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[-79.619,75.656,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-106.631,75.656,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-79.619,75.656,0]}]},"a":{"a":0,"k":[-79.619,75.656,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.861,0],[0,0]],"o":[[0,0],[-0.032,2.664],[0,0],[0,0]],"v":[[-64.353,61.964],[-64.687,89.604],[-69.952,94.456],[-101.79,94.456]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.861,0],[0,0],[-0.225,2.664],[0,0],[-2.989,0],[0,0],[0.032,-2.841]],"o":[[-0.031,2.664],[0,0],[-2.86,0],[0,0],[0.233,-2.841],[0,0],[2.988,0],[0,0]],"v":[[-64.688,89.604],[-69.953,94.456],[-101.789,94.456],[-106.597,89.604],[-104.265,61.964],[-98.469,56.856],[-69.666,56.856],[-64.353,61.964]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.265,0],[0,0],[0.275,2.252],[0,0]],"o":[[0,0],[-0.491,2.243],[0,0],[-2.273,0],[0,0],[0,0]],"v":[[-64.353,61.964],[-68.69,76.568],[-73.403,80.5],[-97.005,80.5],[-101.34,76.545],[-104.265,61.964]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.225,2.664],[0,0],[0.032,-2.841]],"o":[[-0.031,2.664],[0,0],[0.233,-2.841],[0,0]],"v":[[-64.688,89.604],[-106.597,89.604],[-104.265,61.964],[-64.353,61.964]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-85.483,75.656]},"a":{"a":0,"k":[-85.483,75.656]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.931,0],[0,0],[-3.062,0],[0,0],[0.033,-2.727]],"o":[[-0.031,2.557],[0,0],[-2.931,0],[0,0],[3.061,0],[0,0]],"v":[[-52.968,88.718],[-58.364,93.375],[-90.989,93.375],[-87.588,57.287],[-58.07,57.287],[-52.625,62.19]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-79.619,75.656]},"a":{"a":0,"k":[-79.619,75.656]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"8","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[5.332,75.798,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-21.728,75.798,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[5.332,75.798,0]}]},"a":{"a":0,"k":[5.332,75.798,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.666,0],[0,0]],"o":[[0,0],[0.061,2.647],[0,0],[0,0]],"v":[[18.686,62.362],[19.331,89.822],[14.59,94.644],[-15.087,94.644]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.666,0],[0,0],[-0.062,2.647],[0,0],[-2.842,0],[0,0],[-0.071,-2.823]],"o":[[0.062,2.647],[0,0],[-2.667,0],[0,0],[0.062,-2.823],[0,0],[2.842,0],[0,0]],"v":[[19.331,89.822],[14.59,94.644],[-15.087,94.644],[-19.837,89.822],[-19.185,62.362],[-13.957,57.287],[13.45,57.287],[18.686,62.362]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.127,0],[0,0],[0.38,2.237],[0,0]],"o":[[0,0],[-0.379,2.229],[0,0],[-2.133,0],[0,0],[0,0]],"v":[[18.686,62.362],[15.113,76.872],[10.831,80.778],[-11.338,80.778],[-15.62,76.848],[-19.184,62.362]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.062,2.647],[0,0],[-0.071,-2.823]],"o":[[0.062,2.647],[0,0],[0.062,-2.823],[0,0]],"v":[[19.331,89.822],[-19.837,89.822],[-19.185,62.362],[18.686,62.362]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-0.253,75.965]},"a":{"a":0,"k":[-0.253,75.965]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":37,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":44,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":50,"s":[105,105]},{"t":54,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":37,"s":[{"i":[[0,0],[2.655,0],[0,0],[-2.831,0],[0,0],[-0.07,-2.802]],"o":[[0.062,2.628],[0,0],[-2.656,0],[0,0],[2.831,0],[0,0]],"v":[[30.501,88.578],[25.879,93.375],[-3.735,93.375],[-2.652,57.664],[24.582,56.952],[29.857,62.702]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":44,"s":[{"i":[[0,0],[2.049,0],[0,0],[-2.184,0],[0,0],[-0.054,-2.283]],"o":[[0.048,2.141],[0,0],[-2.05,0],[0,0],[2.184,0],[0,0]],"v":[[22.077,86.092],[18.51,90],[-4.341,90],[-3.506,60.907],[17.51,60.327],[21.58,65.012]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":50,"s":[{"i":[[0,0],[2.92,0],[0,0],[-3.113,0],[0,0],[-0.077,-2.899]],"o":[[0.068,2.718],[0,0],[-2.921,0],[0,0],[3.113,0],[0,0]],"v":[[34.182,89.038],[29.099,94],[-3.47,94],[-2.279,57.064],[27.672,56.327],[33.474,62.274]],"c":true}]},{"t":54,"s":[{"i":[[0,0],[2.655,0],[0,0],[-2.831,0],[0,0],[-0.07,-2.802]],"o":[[0.062,2.628],[0,0],[-2.656,0],[0,0],[2.831,0],[0,0]],"v":[[30.501,88.578],[25.879,93.375],[-3.735,93.375],[-2.652,57.664],[24.582,56.952],[29.857,62.702]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[5.332,75.798]},"a":{"a":0,"k":[5.332,75.798]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"9","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[88.977,76.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[65.191,76.003,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[88.977,76.003,0]}]},"a":{"a":0,"k":[88.977,76.003,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.22,2.649],[0,0],[0,0]],"o":[[0,0],[2.798,0],[0,0],[0,0],[0,0]],"v":[[67.526,94.695],[98.661,94.695],[103.363,89.87],[102.461,78.985],[101.083,62.389]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-2.797,0],[0,0],[0.22,2.649],[0,0],[2.924,0],[0,0],[-0.031,-2.825]],"o":[[0.031,2.649],[0,0],[2.797,0],[0,0],[-0.228,-2.825],[0,0],[-2.922,0],[0,0]],"v":[[62.377,89.871],[67.526,94.696],[98.661,94.696],[103.363,89.871],[101.084,62.389],[95.415,57.31],[67.245,57.31],[62.049,62.389]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[-2.215,0],[0,0],[-0.269,2.239],[0,0]],"o":[[0,0],[0.481,2.23],[0,0],[2.221,0],[0,0],[0,0]],"v":[[62.049,62.389],[66.292,76.91],[70.901,80.819],[93.984,80.819],[98.223,76.886],[101.083,62.389]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0.22,2.649],[0,0],[-0.031,-2.825]],"o":[[0.031,2.649],[0,0],[-0.228,-2.825],[0,0]],"v":[[62.377,89.871],[103.363,89.871],[101.084,62.389],[62.049,62.389]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[82.714,76.003]},"a":{"a":0,"k":[82.714,76.003]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":63,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":69,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":74,"s":[105,105]},{"t":78,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":63,"s":[{"i":[[-2.876,0],[0,0],[0.226,2.52],[0,0],[3.005,0],[0,0]],"o":[[0,0],[2.876,0],[0,0],[-0.234,-2.687],[0,0],[-3.004,0]],"v":[[79.044,93.75],[111.054,93.75],[115.888,89.162],[113.545,63.02],[107.481,57.31],[78.755,58.189]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":69,"s":[{"i":[[-2.191,0],[0,0],[0.172,1.953],[0,0],[2.289,0],[0,0]],"o":[[0,0],[2.191,0],[0,0],[-0.178,-2.083],[0,0],[-2.288,0]],"v":[[78.259,90.25],[102.645,90.25],[106.328,86.693],[104.543,66.427],[99.923,62],[78.04,62.682]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":74,"s":[{"i":[[-3.099,0],[0,0],[0.243,2.623],[0,0],[3.238,0],[0,0]],"o":[[0,0],[3.099,0],[0,0],[-0.252,-2.798],[0,0],[-3.237,0]],"v":[[79.299,94.5],[113.797,94.5],[119.007,89.723],[116.481,62.505],[109.946,56.56],[78.988,57.475]],"c":true}]},{"t":78,"s":[{"i":[[-2.876,0],[0,0],[0.226,2.52],[0,0],[3.005,0],[0,0]],"o":[[0,0],[2.876,0],[0,0],[-0.234,-2.687],[0,0],[-3.004,0]],"v":[[79.044,93.75],[111.054,93.75],[115.888,89.162],[113.545,63.02],[107.481,57.31],[78.755,58.189]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[88.977,76.003]},"a":{"a":0,"k":[88.977,76.003]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"Panel Buttons","parent":19,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[-0.508,21.61,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-9.685,21.61,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-0.508,21.61,0]}]},"a":{"a":0,"k":[-0.508,21.61,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[123.447,-67.648],[-125.499,-67.648]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[120.545,-67.442],[-120.406,-67.469]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[123.447,-67.648],[-125.499,-67.648]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.937254905701,0.376470595598,0.376470595598,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0.42,-3.364],[0,0]],"o":[[-3.391,0],[0,0],[0,0]],"v":[[-125.499,-67.648],[-132.169,-61.759],[-152.789,103.312]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0.42,-3.364],[0,0]],"o":[[-3.391,0],[0,0],[0,0]],"v":[[-120.406,-67.469],[-127.076,-61.58],[-174.181,103.245]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[0.42,-3.364],[0,0]],"o":[[-3.391,0],[0,0],[0,0]],"v":[[-125.499,-67.648],[-132.169,-61.759],[-152.789,103.312]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.419607847929,0,0.090196080506,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0.42,-3.364],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.375,0],[0,0]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.439,-3.345],[0,0],[-3.391,0]],"v":[[-132.169,-61.759],[-152.789,103.312],[-146.119,110.868],[145.102,110.868],[151.768,103.271],[130.112,-61.801],[123.447,-67.648],[-125.499,-67.648]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0.42,-3.364],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.371,-0.121],[0,0]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.559,-3.325],[0,0],[-3.391,0]],"v":[[-127.076,-61.579],[-174.181,103.245],[-167.511,110.801],[111.486,110.801],[118.152,103.204],[127.411,-61.842],[120.544,-67.442],[-120.406,-67.468]],"c":true}]},{"t":164,"s":[{"i":[[0.42,-3.364],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.375,0],[0,0]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.439,-3.345],[0,0],[-3.391,0]],"v":[[-132.169,-61.759],[-152.789,103.312],[-146.119,110.868],[145.102,110.868],[151.768,103.271],[130.112,-61.801],[123.447,-67.648],[-125.499,-67.648]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.600000023842,0,0.113725490868,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.375,0],[0,0],[0.42,-3.364]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.439,-3.345],[0,0],[-3.391,0],[0,0]],"v":[[-132.169,-61.759],[-152.789,103.312],[-146.119,110.868],[145.102,110.868],[151.768,103.271],[130.112,-61.801],[123.447,-67.648],[-125.499,-67.648],[-132.169,-61.759]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.371,-0.121],[0,0],[0.42,-3.364]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.559,-3.325],[0,0],[-3.391,0],[0,0]],"v":[[-127.076,-61.579],[-174.181,103.245],[-167.511,110.801],[111.486,110.801],[118.152,103.204],[127.411,-61.842],[120.544,-67.442],[-120.406,-67.468],[-127.076,-61.579]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.375,0],[0,0],[0.42,-3.364]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.439,-3.345],[0,0],[-3.391,0],[0,0]],"v":[[-132.169,-61.759],[-152.789,103.312],[-146.119,110.868],[145.102,110.868],[151.768,103.271],[130.112,-61.801],[123.447,-67.648],[-125.499,-67.648],[-132.169,-61.759]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.698039233685,0.015686275437,0.164705887437,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"BODY","parent":2,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":76,"s":[-1.006,194.394,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.69,"y":1},"o":{"x":0.3,"y":0},"t":91,"s":[5.446,243.876,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.705,"y":0.705},"o":{"x":0.31,"y":0.31},"t":99,"s":[5.446,236.434,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[5.446,236.434,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-1.006,194.394,0]}]},"a":{"a":0,"k":[-1.006,194.394,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":10,"s":[101,99,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":20,"s":[100,100,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":30,"s":[101,99,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":39,"s":[100,100,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":49,"s":[101,99,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":59,"s":[100,100,100]},{"i":{"x":[0.72,0.72,0.72],"y":[1,1,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":69,"s":[101,99,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.4,0.4,0.4],"y":[0,0,0]},"t":76,"s":[106,92,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":91,"s":[98,100,100]},{"i":{"x":[0.726,0.726,0.726],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":99,"s":[100,100,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":155,"s":[100,100,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":164,"s":[104,96,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":171,"s":[98,101,100]},{"t":178,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[4.431,31.947],[10.576,11.804],[35.823,4.681],[35.123,0],[35.823,-4.68],[10.575,-11.805],[4.43,-31.948],[-5.571,-6.372],[-164.475,0],[-7.872,9.002]],"o":[[-4.431,-31.948],[-10.575,-11.805],[-35.821,-4.68],[-35.122,0],[-35.821,4.681],[-10.575,11.804],[-4.43,31.947],[7.872,9.002],[164.477,0],[5.571,-6.372]],"v":[[181.005,115.268],[145.783,-78.47],[83.158,-87.823],[-1.007,-103.653],[-85.172,-87.823],[-147.796,-78.47],[-183.018,115.268],[-180.971,181.116],[-1.007,185.559],[178.959,181.116]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[1.939,22.67],[2.811,24.113],[33.756,4.681],[33.096,0],[33.756,-4.681],[10.447,-11.805],[3.177,-22.943],[-6.911,-8.47],[-157.291,0.255],[-7.528,9.002]],"o":[[-1.622,-32.093],[-1.238,-9.591],[-33.754,-4.68],[-33.095,0],[-33.754,4.681],[-10.446,11.804],[-4.419,31.941],[6.776,8.143],[127.855,-0.157],[5.328,-6.372]],"v":[[137.373,122.908],[140.754,-78.47],[75.057,-87.823],[-4.251,-103.653],[-83.559,-87.823],[-149.255,-78.47],[-211.641,121.889],[-208.874,181.434],[-9.682,186.068],[134.717,181.116]],"c":true}]},{"t":164,"s":[{"i":[[4.431,31.947],[10.576,11.804],[35.823,4.681],[35.123,0],[35.823,-4.68],[10.575,-11.805],[4.43,-31.948],[-5.571,-6.372],[-164.475,0],[-7.872,9.002]],"o":[[-4.431,-31.948],[-10.575,-11.805],[-35.821,-4.68],[-35.122,0],[-35.821,4.681],[-10.575,11.804],[-4.43,31.947],[7.872,9.002],[164.477,0],[5.571,-6.372]],"v":[[181.005,115.268],[145.783,-78.47],[83.158,-87.823],[-1.007,-103.653],[-85.172,-87.823],[-147.796,-78.47],[-183.018,115.268],[-180.971,181.116],[-1.007,185.559],[178.959,181.116]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.600000023842,0,0.129411771894,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-8.13,4.064],[-12.601,0],[0.723,-19.192]],"o":[[-2.944,-22.066],[8.129,-4.064],[12.601,0],[0,0]],"v":[[-184.575,170.938],[-175.746,137.352],[168.339,137.352],[183.095,167.1]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-8.031,4.064],[-12.051,0],[0.369,-17.299]],"o":[[-1.407,-17.417],[8.03,-4.064],[12.051,0],[0,0]],"v":[[-213.676,168.2],[-205.949,136.589],[125.492,137.352],[138.091,166.591]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-8.13,4.064],[-12.601,0],[0.723,-19.192]],"o":[[-2.944,-22.066],[8.129,-4.064],[12.601,0],[0,0]],"v":[[-184.575,170.938],[-175.746,137.352],[168.339,137.352],[183.095,167.1]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.96862745285,0.474509805441,0.474509805441,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[7.577,-23.603],[164.472,0],[7.871,9.005],[-13.371,7.647],[-83.589,0],[-12.607,0]],"o":[[-7.872,9.005],[-164.472,0],[-3.241,-3.707],[7.889,-4.512],[83.597,0],[16.49,4.082]],"v":[[178.964,181.11],[-1.005,185.556],[-180.974,181.11],[-175.747,137.353],[-0.486,135.279],[168.343,137.353]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[7.246,-23.603],[130.878,-0.029],[7.776,9.005],[-13.208,7.647],[-79.938,0],[-12.056,0]],"o":[[-7.528,9.005],[-157.289,0.044],[-3.202,-3.707],[7.793,-4.512],[79.946,0],[15.266,0.55]],"v":[[134.721,181.11],[-9.448,185.556],[-209.374,181.11],[-204.21,137.353],[-8.952,135.279],[124.564,137.353]],"c":true}]},{"t":164,"s":[{"i":[[7.577,-23.603],[164.472,0],[7.871,9.005],[-13.371,7.647],[-83.589,0],[-12.607,0]],"o":[[-7.872,9.005],[-164.472,0],[-3.241,-3.707],[7.889,-4.512],[83.597,0],[16.49,4.082]],"v":[[178.964,181.11],[-1.005,185.556],[-180.974,181.11],[-175.747,137.353],[-0.486,135.279],[168.343,137.353]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.917647063732,0,0.04705882445,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-12.196,10.554],[-24.311,4.111],[-24.63,0],[-31.434,-1.864],[-9.754,-4.816],[0,0]],"o":[[0,0],[8.282,-7.166],[29.677,-5.017],[38.373,0],[22.459,1.331],[11.955,5.903],[0,0]],"v":[[-185.118,134.794],[-141.546,-76.246],[-77.68,-82.806],[-3.277,-96.956],[86.592,-81.548],[138.441,-77.383],[183.198,130.093]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-12.048,10.554],[-22.908,4.11],[-23.209,0],[-29.62,-1.863],[-8.626,-6.446],[-3.826,-53.702]],"o":[[0,0],[8.181,-7.166],[27.964,-5.017],[36.158,0],[21.163,1.331],[8.582,6.413],[0.84,3.136]],"v":[[-211.976,134.539],[-143.081,-75.991],[-76.5,-82.806],[-6.391,-96.956],[78.293,-81.548],[133.622,-77.001],[136.084,128.006]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-12.196,10.554],[-24.311,4.111],[-24.63,0],[-31.434,-1.864],[-9.754,-4.816],[0,0]],"o":[[0,0],[8.282,-7.166],[29.677,-5.017],[38.373,0],[22.459,1.331],[11.955,5.903],[0,0]],"v":[[-185.118,134.794],[-141.546,-76.246],[-77.68,-82.806],[-3.277,-96.956],[86.592,-81.548],[138.441,-77.383],[183.198,130.093]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":30},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[35.823,4.681],[35.123,0],[35.823,-4.68],[10.575,-11.805],[-17.029,-27.874],[-5.558,10.589]],"o":[[-10.575,-11.805],[-35.821,-4.68],[-35.122,0],[-35.821,4.681],[-10.575,11.804],[6.234,10.205],[18.956,-36.116]],"v":[[145.783,-78.47],[83.158,-87.823],[-1.007,-103.653],[-85.172,-87.823],[-147.796,-78.47],[-180.971,181.116],[178.959,181.116]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[33.756,4.681],[33.096,0],[33.756,-4.681],[7.942,-13.356],[-24.155,-32.836],[-2.941,7.429]],"o":[[-10.446,-11.805],[-33.754,-4.68],[-33.095,0],[-33.754,4.681],[-13.991,29.97],[6.896,9.66],[9.786,-50.632]],"v":[[140.754,-78.47],[75.057,-87.823],[-4.251,-103.653],[-83.559,-87.823],[-149.255,-78.47],[-209.371,181.116],[134.717,181.116]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[35.823,4.681],[35.123,0],[35.823,-4.68],[10.575,-11.805],[-17.029,-27.874],[-5.558,10.589]],"o":[[-10.575,-11.805],[-35.821,-4.68],[-35.122,0],[-35.821,4.681],[-10.575,11.804],[6.234,10.205],[18.956,-36.116]],"v":[[145.783,-78.47],[83.158,-87.823],[-1.007,-103.653],[-85.172,-87.823],[-147.796,-78.47],[-180.971,181.116],[178.959,181.116]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.800000011921,0,0.133333340287,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[148.507,194.394],[-149.477,194.394],[-149.477,167.98],[148.507,167.98],[148.507,178.804]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[110.043,194.232],[-185.22,194.286],[-185.22,167.872],[110.742,167.862],[110.435,179.067]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[148.507,194.394],[-149.477,194.394],[-149.477,167.98],[148.507,167.98],[148.507,178.804]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.117647059262,0.113725490868,0.113725490868,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8.12},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.117647059262,0.113725490868,0.113725490868,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"Back","parent":19,"sr":1,"ks":{"p":{"a":0,"k":[132.687,57.867,0]},"a":{"a":0,"k":[132.687,57.867,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[144.653,-78.458],[181.542,175.689],[166.286,102.142],[157.125,-5.544],[154.868,-18.184]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-14.771,-36.926],[0,0],[0,0],[0,0]],"o":[[0,0],[8.404,-5.093],[0,0],[0,0],[0,0]],"v":[[140.373,-78.225],[135.172,179.668],[180.402,92.138],[172.48,-4.59],[159.672,-17.324]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[144.653,-78.458],[181.542,175.689],[166.286,102.142],[157.125,-5.544],[154.868,-18.184]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.490196078431,0,0.105728568283,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.669019512102,0.008920172149,0.118936725691,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":76,"op":164,"st":0,"bm":0},{"ddd":0,"ind":21,"ty":4,"nm":"Top","parent":19,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.7,"y":1},"o":{"x":0.6,"y":0},"t":69,"s":[-1.254,-83.412,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":76,"s":[-1.254,-60.89,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.321,"y":0.65},"o":{"x":0.255,"y":0},"t":85,"s":[-2.689,-90.94,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.32,"y":1},"o":{"x":0.09,"y":0.425},"t":91,"s":[-0.042,-86.638,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":155,"s":[5.875,-86.638,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":164,"s":[-1.254,-65.638,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":171,"s":[-1.254,-87.432,0],"to":[0,0,0],"ti":[0,0,0]},{"t":178,"s":[-1.254,-83.412,0]}]},"a":{"a":0,"k":[-1.254,-83.412,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[4.117,0],[0,0],[0,-4.118],[0,0],[12.473,3.968],[0,0],[0,5.405],[0,0],[4.118,0],[0,0],[0,-4.118],[0,0]],"o":[[0,0],[0,-4.118],[0,0],[-4.117,0],[0,0],[0,5.405],[0,0],[-12.473,3.968],[0,0],[0,-4.118],[0,0],[-4.118,0],[0,0],[0,0]],"v":[[82.663,-87.823],[82.663,-136.302],[75.208,-143.758],[57.58,-143.758],[50.125,-136.302],[50.125,-107.465],[35.096,-96.893],[-37.604,-96.893],[-52.632,-107.465],[-52.632,-136.302],[-60.088,-143.758],[-77.715,-143.758],[-85.171,-136.302],[-85.171,-87.823]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0,0],[3.817,0.002],[0,0],[0.584,-3.531],[0,0],[11.607,3.722],[0,0],[-1.161,5.846],[0,0],[3.818,0.002],[0,0],[0.521,-3.599],[0,0]],"o":[[0,0],[0.009,-3.999],[0,0],[-3.817,-0.002],[0,0],[-2.13,6.169],[0,0],[-11.516,3.982],[0,0],[0.009,-3.999],[0,0],[-3.818,-0.002],[0,0],[0,0]],"v":[[76.536,-88.55],[81.195,-134.625],[74.939,-141.765],[57.958,-141.88],[51.031,-134.645],[46.662,-107.172],[32.34,-96.86],[-35.048,-96.102],[-46.757,-106.805],[-42.956,-134.451],[-49.852,-141.695],[-66.193,-141.706],[-73.121,-134.471],[-80.43,-86.527]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0,0],[4.117,0],[0,0],[0,-4.118],[0,0],[12.473,3.968],[0,0],[0,5.405],[0,0],[4.118,0],[0,0],[0,-4.118],[0,0]],"o":[[0,0],[0,-4.118],[0,0],[-4.117,0],[0,0],[0,5.405],[0,0],[-12.473,3.968],[0,0],[0,-4.118],[0,0],[-4.118,0],[0,0],[0,0]],"v":[[82.663,-87.823],[82.663,-136.302],[75.208,-143.758],[57.58,-143.758],[50.125,-136.302],[50.125,-107.465],[35.096,-96.893],[-37.604,-96.893],[-52.632,-107.465],[-52.632,-136.302],[-60.088,-143.758],[-77.715,-143.758],[-85.171,-136.302],[-85.171,-87.823]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.588235318661,0,0.113725490868,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-2.233,1.527],[-2.585,-0.94],[0,0]],"o":[[0,0],[2.233,-1.528],[2.585,0.94],[0,0]],"v":[[50.125,-122.633],[55.281,-137.99],[77.312,-137.99],[82.663,-117.362]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-2.073,1.482],[-2.394,-0.914],[0,0]],"o":[[0,0],[2.073,-1.482],[2.394,0.914],[0,0]],"v":[[50.059,-123.512],[55.814,-136.281],[76.238,-136.268],[78.788,-119.301]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-2.233,1.527],[-2.585,-0.94],[0,0]],"o":[[0,0],[2.233,-1.528],[2.585,0.94],[0,0]],"v":[[50.125,-122.633],[55.281,-137.99],[77.312,-137.99],[82.663,-117.362]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.952941179276,0.54509806633,0.54509806633,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-3.617,2.014],[-5.039,-0.025],[-1.565,-1.589],[0,0]],"o":[[0,0],[1.616,-0.899],[5.414,0.025],[2.248,2.285],[0,0]],"v":[[-85.171,-123.532],[-80.512,-138.588],[-69.287,-139.431],[-56.413,-137.71],[-52.632,-121.851]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-3.358,1.953],[-4.671,-0.026],[-1.447,-1.544],[0,0]],"o":[[0,0],[1.5,-0.872],[5.019,0.027],[2.079,2.22],[0,0]],"v":[[-74.202,-123.487],[-68.797,-136.687],[-58.389,-137.499],[-46.458,-135.821],[-44.931,-121.814]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-3.617,2.014],[-5.039,-0.025],[-1.565,-1.589],[0,0]],"o":[[0,0],[1.616,-0.899],[5.414,0.025],[2.248,2.285],[0,0]],"v":[[-85.171,-123.532],[-80.512,-138.588],[-69.287,-139.431],[-56.413,-137.71],[-52.632,-121.851]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.952941179276,0.54509806633,0.54509806633,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[1.608,9.505],[0.933,20.36],[13.17,-1.74],[0,0],[0,0]],"o":[[-13.17,-1.74],[3.909,23.115],[-9.28,2.61],[0,0],[0,0],[0,0]],"v":[[82.661,-99.566],[50.091,-106.526],[-52.6,-106.526],[-85.17,-99.567],[-85.17,-87.827],[82.661,-87.826]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0.623,9.785],[1.098,19.756],[8.819,2.905],[0,0],[0,0]],"o":[[-11.998,1.412],[3.975,21.514],[-7.271,3.881],[0,0],[0,0],[0,0]],"v":[[77.584,-101],[47.495,-107.842],[-47.52,-107.557],[-77.176,-102.753],[-79.034,-86.804],[76.534,-88.553]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[1.608,9.505],[0.933,20.36],[13.17,-1.74],[0,0],[0,0]],"o":[[-13.17,-1.74],[3.909,23.115],[-9.28,2.61],[0,0],[0,0],[0,0]],"v":[[82.661,-99.566],[50.091,-106.526],[-52.6,-106.526],[-85.17,-99.567],[-85.17,-87.827],[82.661,-87.826]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.800000011921,0,0.133333340287,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0.5,-8.669],[0,0],[0,21.465],[0,0],[0.796,-10.941],[0,0],[0,0]],"o":[[-0.663,-10.073],[0,0],[0,22.215],[0,0],[-1.243,-12.16],[0,0],[0,0],[0,0]],"v":[[82.663,-136.302],[50.125,-136.302],[50.125,-107.465],[-52.632,-107.465],[-52.632,-136.302],[-85.171,-136.302],[-85.171,-87.823],[82.663,-87.823]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0.483,-8.417],[0,0],[0.246,20.839],[0,0],[0.762,-10.623],[0,0],[0,0]],"o":[[-0.593,-9.781],[0,0],[0.255,21.567],[0,0],[-1.126,-11.808],[0,0],[0,0],[0,0]],"v":[[81.195,-134.625],[51.031,-134.645],[46.662,-107.172],[-47.31,-105.951],[-42.956,-134.451],[-73.121,-134.471],[-79.036,-86.801],[76.536,-88.55]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0.5,-8.669],[0,0],[0,21.465],[0,0],[0.796,-10.941],[0,0],[0,0]],"o":[[-0.663,-10.073],[0,0],[0,22.215],[0,0],[-1.243,-12.16],[0,0],[0,0],[0,0]],"v":[[82.663,-136.302],[50.125,-136.302],[50.125,-107.465],[-52.632,-107.465],[-52.632,-136.302],[-85.171,-136.302],[-85.171,-87.823],[82.663,-87.823]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.901960790157,0.031372550875,0.043137256056,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[63.112,0],[0,12.921],[-43.246,0],[0,0],[33.857,25.923]],"o":[[0,0],[-33.869,0],[0,0],[0,0],[18.504,0],[0,0]],"v":[[50.125,-134.869],[-1.006,-117.362],[-52.632,-134.869],[-52.632,-83.412],[52.753,-83.412],[50.125,-134.869]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[44.838,0.635],[1.854,9.43],[-40.086,0.451],[0,0],[31.331,25.191]],"o":[[0,0],[-31.383,-0.676],[0,0],[0,0],[17.152,-0.193],[0,0]],"v":[[50.264,-131.979],[2.316,-115.52],[-42.959,-133.059],[-48.824,-82.857],[48.862,-83.956],[51.028,-133.253]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[63.112,0],[0,12.921],[-43.246,0],[0,0],[33.857,25.923]],"o":[[0,0],[-33.869,0],[0,0],[0,0],[18.504,0],[0,0]],"v":[[50.125,-134.869],[-1.006,-117.362],[-52.632,-134.869],[-52.632,-83.412],[52.753,-83.412],[50.125,-134.869]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.517647087574,0.011764706112,0.168627455831,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.698039233685,0.015686275437,0.164705887437,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[79.75,-139.75],[78.632,-138.771],[68.5,-85],[82,-81.75]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[1.286,2.856],[4.922,0.427],[0,0],[0,0]],"o":[[-0.729,-1.619],[-2.393,-0.208],[0,0],[0,0]],"v":[[85.968,-135.541],[77.896,-141.58],[68.5,-85],[89.739,-81.75]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[79.75,-139.75],[78.632,-138.771],[68.5,-85],[82,-81.75]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.490196078431,0,0.105728568283,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.670588235294,0.007843137255,0.117647058824,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":22,"ty":4,"nm":"Phone","parent":2,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.72],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":66,"s":[0]},{"i":{"x":[0.677],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":75,"s":[2]},{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.28],"y":[0]},"t":81,"s":[-4]},{"i":{"x":[0.65],"y":[1.5]},"o":{"x":[0.3],"y":[0]},"t":93,"s":[1]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.35],"y":[0.286]},"t":100,"s":[2]},{"i":{"x":[0.65],"y":[1.107]},"o":{"x":[0.312],"y":[0]},"t":112,"s":[-1]},{"i":{"x":[0.664],"y":[0.828]},"o":{"x":[0.324],"y":[0.054]},"t":120,"s":[-2]},{"i":{"x":[0.684],"y":[1]},"o":{"x":[0.344],"y":[0.366]},"t":133,"s":[1]},{"i":{"x":[0.678],"y":[1]},"o":{"x":[0.344],"y":[0]},"t":142,"s":[1]},{"i":{"x":[0.755],"y":[1.055]},"o":{"x":[0.434],"y":[0]},"t":150,"s":[-1]},{"i":{"x":[0.597],"y":[1]},"o":{"x":[0.606],"y":[0.114]},"t":156,"s":[-4]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":166,"s":[2]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":173,"s":[-1]},{"t":179,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.28,"y":0},"t":0,"s":[-0.646,-171.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.3,"y":0},"t":10,"s":[-0.646,-168.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.28,"y":0},"t":20,"s":[-0.646,-171.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.3,"y":0},"t":30,"s":[-0.646,-168.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.28,"y":0},"t":39,"s":[-0.646,-171.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.3,"y":0},"t":49,"s":[-0.646,-168.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.28,"y":0},"t":59,"s":[-0.646,-171.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.72,"y":1},"o":{"x":0.5,"y":0},"t":69,"s":[-0.646,-168.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":76,"s":[-0.646,-122.153,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.3,"y":1},"o":{"x":0.28,"y":0},"t":91,"s":[14.408,-219.941,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.579,"y":0.956},"o":{"x":0.3,"y":0},"t":110,"s":[14.408,-191.708,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.726,"y":1},"o":{"x":0.354,"y":0.06},"t":129,"s":[14.408,-218.355,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.9,"y":0},"t":148,"s":[14.408,-202.043,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":164,"s":[-0.646,-139.244,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":171,"s":[-0.646,-179.88,0],"to":[0,0,0],"ti":[0,0,0]},{"t":178,"s":[-0.646,-171.88,0]}]},"a":{"a":0,"k":[-0.646,-171.88,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-100.514,4.581],[-20.462,-23.581],[11.96,-0.826],[10.39,13.069],[15.761,10.932],[48.792,-1.867],[22.887,-6.352],[7.682,-13.671],[11.074,0.87],[-21.052,21.228]],"o":[[9.833,-9.914],[94.558,-4.31],[3.468,3.998],[-12.503,0.864],[-4.407,-5.543],[-23.319,-16.176],[-33.08,1.265],[-6.851,1.901],[-12.411,22.085],[-8.112,-0.637],[0,0]],"v":[[-167.411,-137.44],[-14.224,-175.451],[172.789,-124.648],[175.058,-92.1],[114.091,-112.79],[99.391,-147.283],[-13.154,-167.691],[-84.798,-156.321],[-117.334,-129.238],[-177.715,-96.539],[-167.411,-137.44]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[-94.612,1.966],[-20.861,-23.851],[19.122,-6.061],[9.083,19.2],[15.463,11.112],[44.202,-0.328],[20.402,-5.835],[9.118,-10.735],[9.861,0.822],[-18.806,19.593]],"o":[[8.784,-9.151],[86.705,-1.801],[3.536,4.044],[-13.058,3.378],[-3.259,-6.239],[-20.232,-14.474],[-30.819,0.228],[-6.107,1.747],[-13.386,16.33],[-7.223,-0.602],[0,0]],"v":[[-143.922,-145.31],[-2.396,-179.199],[167.287,-132.627],[165.62,-94.438],[114.709,-118.878],[93.582,-150.766],[-1.422,-171.222],[-68.643,-158.885],[-101.398,-135.756],[-148.772,-109.573],[-144.555,-145.361]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-100.514,4.581],[-20.462,-23.581],[11.96,-0.826],[10.39,13.069],[15.761,10.932],[48.792,-1.867],[22.887,-6.352],[7.682,-13.671],[11.074,0.87],[-21.052,21.228]],"o":[[9.833,-9.914],[94.558,-4.31],[3.468,3.998],[-12.503,0.864],[-4.407,-5.543],[-23.319,-16.176],[-33.08,1.265],[-6.851,1.901],[-12.411,22.085],[-8.112,-0.637],[0,0]],"v":[[-167.411,-137.44],[-14.224,-175.451],[172.789,-124.648],[175.058,-92.1],[114.091,-112.79],[99.391,-147.283],[-13.154,-167.691],[-84.798,-156.321],[-117.334,-129.238],[-177.715,-96.539],[-167.411,-137.44]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"gf","o":{"a":0,"k":100},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,1,1,0.56,1,1,1,1,1,1,1,0,1,0.56,0.65,1,0.3]}},"s":{"a":0,"k":[-4,-176]},"e":{"a":0,"k":[-2.005,-111.148]},"t":1,"nm":"Gradient Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[7.125,11.053],[8.325,8.503],[68.056,-22.418],[4.316,-10.603],[22.234,-7.095]],"o":[[-21.635,-5.197],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-3.502,8.603],[0,0]],"v":[[144.075,-65.926],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945],[-139.145,-68.471]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[7.908,21.99],[4.953,7.034],[77.249,-23.351],[3.872,-9.988],[19.822,-6.651]],"o":[[-22.123,-5.289],[-6.488,-17.33],[-10.418,-10.778],[25.017,-10.47],[-3.142,8.104],[0,0]],"v":[[130.134,-67.359],[79.328,-105.991],[69.035,-139.37],[-91.618,-138.577],[-75.639,-98.536],[-115.515,-76.475]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[7.125,11.053],[8.325,8.503],[68.056,-22.418],[4.316,-10.603],[22.234,-7.095]],"o":[[-21.635,-5.197],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-3.502,8.603],[0,0]],"v":[[144.075,-65.926],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945],[-139.145,-68.471]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.419607847929,0,0.090196080506,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[21.571,6.62],[-3.223,30.439],[-58.631,15.531],[-52.908,-13.636],[-2.734,-32.192],[3.764,-1.958],[13.288,20.613],[8.325,8.503],[68.056,-22.418],[4.316,-10.603]],"o":[[-9.818,-3.013],[1.767,-16.692],[70.449,-18.661],[48.619,12.532],[2.735,32.191],[-14.112,7.337],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-6.218,15.275]],"v":[[-185.675,-61.341],[-202.748,-123.078],[-120.159,-180.789],[100.759,-183.77],[197.486,-117.598],[185.398,-62.058],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[19.195,6.277],[-4.445,29.314],[-52.262,14.542],[-54.295,-11.512],[-4.808,-36.22],[7.323,-2.426],[13.594,20.785],[8.492,8.786],[60.007,-20.064],[3.872,-9.988]],"o":[[-8.737,-2.857],[2.368,-15.62],[62.796,-17.473],[50.208,10.633],[4.099,32.038],[-24.487,8.301],[-10.097,-15.368],[-10.418,-10.778],[11.623,-1.595],[-5.578,14.389]],"v":[[-160.516,-71.015],[-171.505,-131.789],[-100.074,-183.506],[92.361,-189.253],[191.609,-120.043],[178.316,-64.692],[82.69,-94.466],[68.272,-140.09],[-82.787,-139.781],[-75.639,-98.536]],"c":true}]},{"t":164,"s":[{"i":[[21.571,6.62],[-3.223,30.439],[-58.631,15.531],[-52.908,-13.636],[-2.734,-32.192],[3.764,-1.958],[13.288,20.613],[8.325,8.503],[68.056,-22.418],[4.316,-10.603]],"o":[[-9.818,-3.013],[1.767,-16.692],[70.449,-18.661],[48.619,12.532],[2.735,32.191],[-14.112,7.337],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-6.218,15.275]],"v":[[-185.675,-61.341],[-202.748,-123.078],[-120.159,-180.789],[100.759,-183.77],[197.486,-117.598],[185.398,-62.058],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.600000023842,0,0.129411771894,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[4.893,20.992],[36.772,-0.09],[0.399,-14.989],[-9.858,-5.757],[0,0]],"o":[[-11.92,-1.602],[-5.807,-24.911],[0,0],[-0.4,14.989],[9.856,5.758],[0,0]],"v":[[160.369,-61.617],[105.003,-102.205],[62.619,-147.194],[89.711,-123.001],[95.165,-86.551],[160.369,-61.617]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[4.941,21.186],[28.374,3.218],[0.456,-15.455],[-10.069,-5.829],[0,0]],"o":[[-12.193,-1.641],[-5.863,-25.142],[0,0],[-0.457,15.455],[10.067,5.829],[0,0]],"v":[[147.871,-63.515],[90.455,-106.709],[53.216,-151.112],[75.642,-127.722],[86.298,-86.243],[145.029,-63.085]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[4.893,20.992],[36.772,-0.09],[0.399,-14.989],[-9.858,-5.757],[0,0]],"o":[[-11.92,-1.602],[-5.807,-24.911],[0,0],[-0.4,14.989],[9.856,5.758],[0,0]],"v":[[160.369,-61.617],[105.003,-102.205],[62.619,-147.194],[89.711,-123.001],[95.165,-86.551],[160.369,-61.617]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.419607847929,0,0.090196080506,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":45},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-3.883,17.793],[-41.501,-3.401],[-0.399,-14.989],[9.857,-5.757],[0,0]],"o":[[11.921,-1.602],[5.05,-23.136],[0,0],[0.401,14.989],[-9.857,5.758],[0,0]],"v":[[-163.082,-61.617],[-107.716,-102.205],[-65.332,-144.817],[-92.424,-123.001],[-97.877,-86.551],[-163.082,-61.617]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[-3.34,17.041],[-52.087,4.896],[-0.318,-14.131],[8.795,-5.411],[0,0]],"o":[[-1.885,-4.113],[3.87,-22.05],[0,0],[0.318,14.131],[-8.795,5.411],[0,0]],"v":[[-105.703,-81.409],[-107.326,-110.582],[-49.123,-149.122],[-75.524,-128.984],[-80.477,-90.632],[-138.619,-71.237]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-3.883,17.793],[-41.501,-3.401],[-0.399,-14.989],[9.857,-5.757],[0,0]],"o":[[11.921,-1.602],[5.05,-23.136],[0,0],[0.401,14.989],[-9.857,5.758],[0,0]],"v":[[-163.082,-61.617],[-107.716,-102.205],[-65.332,-144.817],[-92.424,-123.001],[-97.877,-86.551],[-163.082,-61.617]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.419607847929,0,0.090196080506,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":45},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[8.322,8.785],[68.059,-23.159],[4.324,-10.96],[5.262,-1.564],[-36.545,23.812],[-50.821,3.964],[-26.19,-9.101],[-12.873,-10.885],[40.508,13.836],[13.288,21.296]],"o":[[-6.504,-10.43],[-10.212,-10.78],[-12.536,4.274],[-6.214,15.782],[-49.508,14.711],[34.714,-22.619],[65.535,-5.113],[30.426,10.574],[29.07,24.58],[0,0],[0,0]],"v":[[92.143,-93.182],[83.197,-138.469],[-84.814,-139.707],[-92.475,-97.087],[-162.289,-65.301],[-162.144,-141.59],[-27.293,-174.724],[110.229,-159.483],[167.365,-129.639],[159.912,-63.813],[92.143,-93.182]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[8.488,9.077],[60.667,-21.755],[3.873,-10.327],[4.691,-1.465],[-34.965,21.265],[-46.411,2.668],[-26.836,-8.718],[-12.354,-12.093],[56.415,11.67],[13.53,21.511]],"o":[[-6.623,-10.535],[-10.416,-11.139],[-11.177,4.008],[-5.576,14.867],[-44.134,13.785],[32.088,-19.515],[55.106,-3.281],[31.335,10.177],[26.254,25.469],[0,0],[0,0]],"v":[[83.227,-92.939],[69.857,-140.376],[-65.135,-144.338],[-75.637,-100.556],[-137.902,-74.709],[-139.473,-148.79],[-11.792,-179.695],[105.977,-165.808],[166.954,-136.101],[145.447,-64.97],[83.227,-92.939]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[8.322,8.785],[68.059,-23.159],[4.324,-10.96],[5.262,-1.564],[-36.545,23.812],[-50.821,3.964],[-26.19,-9.101],[-12.873,-10.885],[40.508,13.836],[13.288,21.296]],"o":[[-6.504,-10.43],[-10.212,-10.78],[-12.536,4.274],[-6.214,15.782],[-49.508,14.711],[34.714,-22.619],[65.535,-5.113],[30.426,10.574],[29.07,24.58],[0,0],[0,0]],"v":[[92.143,-93.182],[83.197,-138.469],[-84.814,-139.707],[-92.475,-97.087],[-162.289,-65.301],[-162.144,-141.59],[-27.293,-174.724],[110.229,-159.483],[167.365,-129.639],[159.912,-63.813],[92.143,-93.182]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.901960790157,0.031372550875,0.043137256056,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 7","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-3.223,30.439],[-58.631,15.531],[-52.908,-13.636],[-2.734,-32.192],[3.864,-1.753],[13.288,20.613],[8.325,8.503],[68.056,-22.418],[4.316,-10.603],[25.798,3.798]],"o":[[-10.16,-1.496],[1.767,-16.692],[70.449,-18.661],[48.619,12.532],[2.735,32.191],[-10.88,4.935],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-6.218,15.275],[0,0]],"v":[[-185.675,-61.341],[-202.748,-123.078],[-120.159,-180.789],[100.759,-183.77],[197.486,-117.598],[185.398,-62.058],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945],[-185.675,-61.341]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[-2.95,28.689],[-52.262,14.542],[-54.25,-13.547],[-7.557,-38.134],[4.056,-1.523],[14.505,20.182],[8.492,8.786],[60.675,-21.018],[3.872,-9.988],[22.968,3.624]],"o":[[-9.045,-1.427],[1.618,-15.732],[62.796,-17.473],[52.826,13.17],[6.326,33.707],[-19.451,6.754],[-11.333,-15.217],[-10.418,-10.778],[-11.181,3.873],[-5.578,14.389],[0,0]],"v":[[-158.742,-71.015],[-173.786,-129.242],[-100.074,-183.506],[92.361,-189.253],[191.381,-120.422],[178.214,-64.069],[83.224,-90.902],[69.146,-139.867],[-68.71,-141.407],[-75.639,-98.536],[-158.742,-71.015]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-3.223,30.439],[-58.631,15.531],[-52.908,-13.636],[-2.734,-32.192],[3.864,-1.753],[13.288,20.613],[8.325,8.503],[68.056,-22.418],[4.316,-10.603],[25.798,3.798]],"o":[[-10.16,-1.496],[1.767,-16.692],[70.449,-18.661],[48.619,12.532],[2.735,32.191],[-10.88,4.935],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-6.218,15.275],[0,0]],"v":[[-185.675,-61.341],[-202.748,-123.078],[-120.159,-180.789],[100.759,-183.77],[197.486,-117.598],[185.398,-62.058],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945],[-185.675,-61.341]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.78823530674,0,0.188235297799,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 8","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":23,"ty":4,"nm":"Waves","sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.05,"y":0},"t":78,"s":[269.919,77.725,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":116,"s":[269.919,92.725,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.05,"y":0},"t":117,"s":[269.919,77.725,0],"to":[0,0,0],"ti":[0,0,0]},{"t":155,"s":[269.919,92.725,0]}]},"a":{"a":0,"k":[197.919,-247.275,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.05,0.05,0.05],"y":[0,0,0]},"t":78,"s":[90,60,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":116,"s":[90,100,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.05,0.05,0.05],"y":[0,0,0]},"t":117,"s":[90,60,100]},{"t":155,"s":[90,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-12.101,9.158]],"o":[[11.76,9.158],[0,0]],"v":[[176.273,-245.408],[219.565,-245.408]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843137255,0.458823559331,0.486274539723,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":1,"k":[{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":80,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":90,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":101,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":109,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":119,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":129,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":140,"s":[100]},{"t":148,"s":[0]}]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-22.322,16.893]],"o":[[21.693,16.893],[0,0]],"v":[[157.991,-226.643],[237.847,-226.643]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843137255,0.458823559331,0.486274539723,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":1,"k":[{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":85,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":95,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":106,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":113,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":124,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":134,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":145,"s":[100]},{"t":152,"s":[0]}]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-31.634,23.94]],"o":[[30.743,23.94],[0,0]],"v":[[141.334,-206.801],[254.504,-206.801]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843137255,0.458823559331,0.486274539723,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":1,"k":[{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":90,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":99,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":110,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":116,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":129,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":138,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":149,"s":[100]},{"t":155,"s":[0]}]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[197.919,-217.127]},"a":{"a":0,"k":[197.919,-217.127]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0}]}
\ No newline at end of file
diff --git a/app/src/test/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelperTest.kt b/app/src/test/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelperTest.kt
new file mode 100644
index 0000000..db746fc
--- /dev/null
+++ b/app/src/test/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelperTest.kt
@@ -0,0 +1,77 @@
+package com.rosetta.messenger.ui.chats.components
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class TextSelectionHelperTest {
+
+ private lateinit var helper: TextSelectionHelper
+
+ @Before
+ fun setup() {
+ helper = TextSelectionHelper()
+ }
+
+ @Test
+ fun `initial state is not active`() {
+ assertFalse(helper.isActive)
+ assertFalse(helper.isInSelectionMode)
+ assertEquals(-1, helper.selectionStart)
+ assertEquals(-1, helper.selectionEnd)
+ assertNull(helper.selectedMessageId)
+ }
+
+ @Test
+ fun `clear resets all state`() {
+ helper.clear()
+ assertFalse(helper.isActive)
+ assertEquals(-1, helper.selectionStart)
+ assertEquals(-1, helper.selectionEnd)
+ assertNull(helper.selectedMessageId)
+ assertNull(helper.layoutInfo)
+ assertFalse(helper.showToolbar)
+ assertFalse(helper.movingHandle)
+ }
+
+ @Test
+ fun `getSelectedText returns null when not active`() {
+ assertNull(helper.getSelectedText())
+ }
+
+ @Test
+ fun `updateSelectionEnd does not change when not active`() {
+ helper.updateSelectionEnd(5)
+ assertEquals(-1, helper.selectionEnd)
+ }
+
+ @Test
+ fun `updateSelectionStart does not change when not active`() {
+ helper.updateSelectionStart(0)
+ assertEquals(-1, helper.selectionStart)
+ }
+
+ @Test
+ fun `getCharOffsetFromCoords returns -1 when no layout`() {
+ assertEquals(-1, helper.getCharOffsetFromCoords(100, 100))
+ }
+
+ @Test
+ fun `selectAll does nothing when no layout`() {
+ helper.selectAll()
+ assertEquals(-1, helper.selectionStart)
+ assertEquals(-1, helper.selectionEnd)
+ }
+
+ @Test
+ fun `moveHandle does nothing when not moving`() {
+ helper.moveHandle(100f, 100f)
+ assertFalse(helper.movingHandle)
+ }
+
+ @Test
+ fun `endHandleDrag sets movingHandle to false`() {
+ helper.endHandleDrag()
+ assertFalse(helper.movingHandle)
+ }
+}
diff --git a/app/src/test/java/com/rosetta/messenger/ui/chats/input/VoiceRecordHelpersTest.kt b/app/src/test/java/com/rosetta/messenger/ui/chats/input/VoiceRecordHelpersTest.kt
new file mode 100644
index 0000000..74d1f0e
--- /dev/null
+++ b/app/src/test/java/com/rosetta/messenger/ui/chats/input/VoiceRecordHelpersTest.kt
@@ -0,0 +1,65 @@
+package com.rosetta.messenger.ui.chats.input
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class VoiceRecordHelpersTest {
+
+ @Test
+ fun `formatVoiceRecordTimer formats zero`() {
+ assertEquals("0:00,0", formatVoiceRecordTimer(0L))
+ }
+
+ @Test
+ fun `formatVoiceRecordTimer formats 12300ms`() {
+ assertEquals("0:12,3", formatVoiceRecordTimer(12300L))
+ }
+
+ @Test
+ fun `formatVoiceRecordTimer formats 61500ms`() {
+ assertEquals("1:01,5", formatVoiceRecordTimer(61500L))
+ }
+
+ @Test
+ fun `formatVoiceRecordTimer handles negative`() {
+ assertEquals("0:00,0", formatVoiceRecordTimer(-100L))
+ }
+
+ @Test
+ fun `compressVoiceWaves empty source returns zeros`() {
+ val result = compressVoiceWaves(emptyList(), 5)
+ assertEquals(5, result.size)
+ assertTrue(result.all { it == 0f })
+ }
+
+ @Test
+ fun `compressVoiceWaves same size returns same`() {
+ val source = listOf(0.1f, 0.5f, 0.9f)
+ assertEquals(source, compressVoiceWaves(source, 3))
+ }
+
+ @Test
+ fun `compressVoiceWaves downsamples by max`() {
+ val source = listOf(0.1f, 0.8f, 0.3f, 0.9f, 0.2f, 0.7f)
+ val result = compressVoiceWaves(source, 3)
+ assertEquals(3, result.size)
+ assertEquals(0.8f, result[0], 0.01f)
+ assertEquals(0.9f, result[1], 0.01f)
+ assertEquals(0.7f, result[2], 0.01f)
+ }
+
+ @Test
+ fun `compressVoiceWaves target zero returns empty`() {
+ assertEquals(emptyList(), compressVoiceWaves(listOf(1f), 0))
+ }
+
+ @Test
+ fun `compressVoiceWaves upsamples via interpolation`() {
+ val source = listOf(0.0f, 1.0f)
+ val result = compressVoiceWaves(source, 3)
+ assertEquals(3, result.size)
+ assertEquals(0.0f, result[0], 0.01f)
+ assertEquals(0.5f, result[1], 0.01f)
+ assertEquals(1.0f, result[2], 0.01f)
+ }
+}