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) + } +}