diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index e6d1bca..5a101c6 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -56,6 +56,7 @@ import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.DeviceConfirmScreen +import com.rosetta.messenger.ui.auth.startAuthHandshakeFast import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer @@ -91,6 +92,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch class MainActivity : FragmentActivity() { @@ -302,16 +304,57 @@ class MainActivity : FragmentActivity() { startInCreateMode = startCreateAccountFlow, onAuthComplete = { account -> 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() } } @@ -1492,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, @@ -1539,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/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 18366d9..b06b8ba 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -598,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) 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 5b14678..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 @@ -325,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 @@ -390,6 +392,14 @@ 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) } @@ -1360,10 +1370,10 @@ fun ChatDetailScreen( // 🔥 Обработка системной кнопки назад BackHandler { - if (isInChatSearchMode) { - closeInChatSearch() - } else { - handleBackWithInputPriority() + when { + isSelectionMode -> selectedMessages = emptySet() + isInChatSearchMode -> closeInChatSearch() + else -> handleBackWithInputPriority() } } @@ -2326,11 +2336,21 @@ fun ChatDetailScreen( } // Voice mini player — shown right under the chat header when audio is playing val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() - if (!playingVoiceAttachmentId.isNullOrBlank()) { - val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() - val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState() - val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() - val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.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" 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 bf37bfe..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 @@ -5882,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 ebcbda7..fe09658 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 @@ -66,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 @@ -254,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 @@ -314,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() @@ -491,22 +498,37 @@ fun ChatsListScreen( 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 @@ -604,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) } @@ -625,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 → сбросить @@ -656,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) { @@ -806,10 +888,6 @@ fun ChatsListScreen( Modifier.fillMaxSize() .onSizeChanged { rootSize = it } .background(backgroundColor) - .then( - if (hasNativeNavigationBar) Modifier.navigationBarsPadding() - else Modifier - ) ) { ModalNavigationDrawer( drawerState = drawerState, @@ -892,16 +970,16 @@ fun ChatsListScreen( ) { AvatarImage( publicKey = - accountPublicKey, + effectiveCurrentPublicKey, avatarRepository = avatarRepository, size = 72.dp, isDarkTheme = isDarkTheme, displayName = - accountName + sidebarAccountName .ifEmpty { - accountUsername + sidebarAccountUsername } ) } @@ -953,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 @@ -978,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) ) @@ -1022,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, @@ -1310,9 +1390,12 @@ fun ChatsListScreen( Column( modifier = Modifier.fillMaxWidth() - .then( - if (hasNativeNavigationBar) Modifier.navigationBarsPadding() - else Modifier + .windowInsetsPadding( + WindowInsets + .navigationBars + .only( + WindowInsetsSides.Bottom + ) ) ) { // Telegram-style update banner @@ -2842,7 +2925,17 @@ fun ChatsListScreen( onOpenCall = onOpenCallOverlay ) } - if (showVoiceMiniPlayer) { + 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, 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 cf0349c..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 = 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/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 3e53b9f..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 @@ -14,6 +14,7 @@ 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 @@ -42,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 @@ -96,6 +99,7 @@ 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 @@ -2027,6 +2031,76 @@ private fun formatVoiceDuration(seconds: Int): String { 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 @@ -2178,6 +2252,9 @@ private fun VoiceAttachment( 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) = @@ -2224,6 +2301,16 @@ private fun VoiceAttachment( } 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) @@ -2389,12 +2476,29 @@ private fun VoiceAttachment( verticalAlignment = Alignment.CenterVertically ) { Box( - modifier = - Modifier.size(40.dp) - .clip(CircleShape) - .background(actionBackground), + 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 ) { @@ -2432,6 +2536,7 @@ private fun VoiceAttachment( } } } + } } Spacer(modifier = Modifier.width(10.dp)) @@ -2484,6 +2589,38 @@ private fun VoiceAttachment( 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, 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 02f634c..fa8f664 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 @@ -735,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() && @@ -874,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( @@ -1094,7 +1111,8 @@ fun MessageBubble( info = info, touchX = touchX, touchY = touchY, - view = textViewRef + view = textViewRef, + isOwnMessage = message.isOutgoing ) } } else null, @@ -1209,7 +1227,8 @@ fun MessageBubble( info = info, touchX = touchX, touchY = touchY, - view = textViewRef + view = textViewRef, + isOwnMessage = message.isOutgoing ) } } else null @@ -1322,7 +1341,8 @@ fun MessageBubble( info = info, touchX = touchX, touchY = touchY, - view = textViewRef + view = textViewRef, + isOwnMessage = message.isOutgoing ) } } else null, 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 index cbb3430..65cdf2f 100644 --- 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 @@ -31,8 +31,10 @@ 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 @@ -62,6 +64,10 @@ class TextSelectionHelper { 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) @@ -99,8 +105,10 @@ class TextSelectionHelper { info: LayoutInfo, touchX: Int, touchY: Int, - view: View? + view: View?, + isOwnMessage: Boolean = false ) { + this.isOwnMessage = isOwnMessage val layout = info.layout val localX = touchX - info.windowX val localY = touchY - info.windowY @@ -398,6 +406,10 @@ fun TextSelectionOverlay( 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+ @@ -515,9 +527,16 @@ fun TextSelectionOverlay( 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) { - val lineTop = layout.getLineTop(line).toFloat() + offsetY - padV - val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + padV + // 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 { @@ -528,13 +547,14 @@ fun TextSelectionOverlay( } else { layout.getLineRight(line) + offsetX + padH } - drawRoundRect( - color = HighlightColor, - topLeft = Offset(left, lineTop), - size = Size(right - left, lineBottom - lineTop), - cornerRadius = CornerRadius(highlightCornerPx) + 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 @@ -546,35 +566,35 @@ fun TextSelectionOverlay( helper.endHandleX = endHx helper.endHandleY = endHy - drawStartHandle(startHx, startHy, handleSizePx) - drawEndHandle(endHx, endHy, handleSizePx) + drawStartHandle(startHx, startHy, handleSizePx, handleColor) + drawEndHandle(endHx, endHy, handleSizePx, handleColor) } } } -private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float) { +private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float, color: Color) { val half = size / 2f drawCircle( - color = HandleColor, + color = color, radius = half, center = Offset(x, y + half) ) drawRect( - color = HandleColor, + color = color, topLeft = Offset(x, y), size = Size(half, half) ) } -private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float) { +private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float, color: Color) { val half = size / 2f drawCircle( - color = HandleColor, + color = color, radius = half, center = Offset(x, y + half) ) drawRect( - color = HandleColor, + 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 2acacdf..a29101b 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 @@ -8,6 +8,12 @@ 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 @@ -42,6 +48,7 @@ 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.graphics.toArgb import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput @@ -72,6 +79,7 @@ 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 @@ -225,130 +233,83 @@ private fun TelegramVoiceDeleteIndicator( isDarkTheme: Boolean, modifier: Modifier = Modifier ) { - val density = LocalDensity.current val progress = cancelProgress.coerceIn(0f, 1f) - val appear = FastOutSlowInEasing.transform(progress) - val openPhase = FastOutSlowInEasing.transform((progress / 0.45f).coerceIn(0f, 1f)) - val closePhase = FastOutSlowInEasing.transform(((progress - 0.55f) / 0.45f).coerceIn(0f, 1f)) - val lidAngle = -26f * openPhase * (1f - closePhase) - val dotFlight = FastOutSlowInEasing.transform((progress / 0.82f).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 dotStartX = with(density) { (-8).dp.toPx() } - val dotEndX = with(density) { 0.dp.toPx() } - val dotEndY = with(density) { 6.dp.toPx() } - val dotX = lerpFloat(dotStartX, dotEndX, dotFlight) - val dotY = dotEndY * dotFlight * dotFlight - val dotScale = (1f - 0.72f * dotFlight).coerceAtLeast(0f) - val dotAlpha = (1f - dotFlight).coerceIn(0f, 1f) + 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(28.dp), contentAlignment = Alignment.Center ) { + // Single recording dot (no duplicate red indicators). RecordBlinkDot( isDarkTheme = isDarkTheme, modifier = Modifier.graphicsLayer { - alpha = 1f - appear - scaleX = 1f - 0.14f * appear - scaleY = 1f - 0.14f * appear + alpha = 1f - lottieAlpha + scaleX = 1f - 0.12f * lottieAlpha + scaleY = 1f - 0.12f * lottieAlpha } ) - - Canvas( - modifier = Modifier - .matchParentSize() - .graphicsLayer { - alpha = appear - scaleX = 0.84f + 0.16f * appear - scaleY = 0.84f + 0.16f * appear - } - ) { - val stroke = 1.7.dp.toPx() - val cx = size.width / 2f - val bodyW = size.width * 0.36f - val bodyH = size.height * 0.34f - val bodyLeft = cx - bodyW / 2f - val bodyTop = size.height * 0.45f - val bodyRadius = bodyW * 0.16f - val bodyRight = bodyLeft + bodyW - - drawRoundRect( - color = dangerColor, - topLeft = Offset(bodyLeft, bodyTop), - size = androidx.compose.ui.geometry.Size(bodyW, bodyH), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(bodyRadius), - style = androidx.compose.ui.graphics.drawscope.Stroke( - width = stroke, - cap = androidx.compose.ui.graphics.StrokeCap.Round, - join = androidx.compose.ui.graphics.StrokeJoin.Round - ) + 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 + } ) - - val slatYStart = bodyTop + bodyH * 0.18f - val slatYEnd = bodyTop + bodyH * 0.82f - drawLine( - color = dangerColor, - start = Offset(cx - bodyW * 0.18f, slatYStart), - end = Offset(cx - bodyW * 0.18f, slatYEnd), - strokeWidth = stroke * 0.85f, - cap = androidx.compose.ui.graphics.StrokeCap.Round - ) - drawLine( - color = dangerColor, - start = Offset(cx + bodyW * 0.18f, slatYStart), - end = Offset(cx + bodyW * 0.18f, slatYEnd), - strokeWidth = stroke * 0.85f, - cap = androidx.compose.ui.graphics.StrokeCap.Round - ) - - val rimY = bodyTop - 2.4.dp.toPx() - drawLine( - color = dangerColor, - start = Offset(bodyLeft - bodyW * 0.09f, rimY), - end = Offset(bodyRight + bodyW * 0.09f, rimY), - strokeWidth = stroke, - cap = androidx.compose.ui.graphics.StrokeCap.Round - ) - - val lidY = rimY - 1.4.dp.toPx() - val lidLeft = bodyLeft - bodyW * 0.05f - val lidRight = bodyRight + bodyW * 0.05f - val lidPivot = Offset(bodyLeft + bodyW * 0.22f, lidY) - rotate( - degrees = lidAngle, - pivot = lidPivot - ) { - drawLine( - color = dangerColor, - start = Offset(lidLeft, lidY), - end = Offset(lidRight, lidY), - strokeWidth = stroke, - cap = androidx.compose.ui.graphics.StrokeCap.Round - ) - drawLine( - color = dangerColor, - start = Offset(cx - bodyW * 0.1f, lidY - 2.dp.toPx()), - end = Offset(cx + bodyW * 0.1f, lidY - 2.dp.toPx()), - strokeWidth = stroke, - cap = androidx.compose.ui.graphics.StrokeCap.Round - ) - } } - - Box( - modifier = Modifier - .size(10.dp) - .graphicsLayer { - translationX = dotX - translationY = dotY - alpha = if (progress > 0f) dotAlpha else 0f - scaleX = dotScale - scaleY = dotScale - } - .clip(CircleShape) - .background(dangerColor) - ) } } @@ -2348,12 +2309,13 @@ fun MessageInputBar( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(horizontal = 8.dp, vertical = 8.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): @@ -2371,7 +2333,7 @@ fun MessageInputBar( targetState = isLockedOrPaused, modifier = Modifier .fillMaxWidth() - .height(48.dp) + .height(40.dp) .padding(end = recordingActionInset), // keep panel under large circle (Telegram-like overlap) transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) @@ -2431,6 +2393,16 @@ fun MessageInputBar( (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 + val timerToTrashShiftPx = collapseShiftPx * 0.35f + val timerSpacerDp = lerpFloat(10f, 2f, collapseToTrash).dp Row( modifier = Modifier .fillMaxSize() @@ -2465,12 +2437,18 @@ fun MessageInputBar( fontSize = 15.sp, fontWeight = FontWeight.Bold, modifier = Modifier.graphicsLayer { - alpha = recordUiAlpha * (1f - leftDeleteProgress * 0.22f) - translationX = with(density) { recordUiShift.toPx() } + alpha = + recordUiAlpha * + (1f - leftDeleteProgress * 0.22f) * + collapseAlpha + translationX = + with(density) { recordUiShift.toPx() } + timerToTrashShiftPx + scaleX = collapseScale + scaleY = collapseScale } ) - Spacer(modifier = Modifier.width(10.dp)) + Spacer(modifier = Modifier.width(timerSpacerDp)) // Slide to cancel SlideToCancel( @@ -2479,8 +2457,14 @@ fun MessageInputBar( modifier = Modifier .weight(1f) .graphicsLayer { - alpha = recordUiAlpha * (1f - leftDeleteProgress) - translationX = with(density) { recordUiShift.toPx() } + alpha = + recordUiAlpha * + (1f - leftDeleteProgress) * + collapseAlpha + translationX = + with(density) { recordUiShift.toPx() } + collapseShiftPx + scaleX = collapseScale + scaleY = collapseScale } ) } @@ -2614,12 +2598,20 @@ fun MessageInputBar( } } } + val gestureCaptureOnly = isRecordingPanelComposed && keepMicGestureCapture if (!isRecordingPanelComposed || keepMicGestureCapture) { Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp) + .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. @@ -2908,9 +2900,11 @@ fun MessageInputBar( didCancelHaptic = true view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) } - if (rawDx <= -cancelDragThresholdPx) { + // 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=${cancelDragThresholdPx.toInt()} mode=$recordMode" + "gesture CANCEL dx=${rawDx.toInt()} threshold=${releaseCancelThresholdPx.toInt()} mode=$recordMode" ) cancelVoiceRecordingWithAnimation("slide-cancel") finished = true 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 15ef92d..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 @@ -139,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. @@ -523,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/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