diff --git a/Architecture.md b/Architecture.md index b15674c..05cb986 100644 --- a/Architecture.md +++ b/Architecture.md @@ -32,8 +32,7 @@ flowchart TB end subgraph DI["Hilt Singleton Graph"] - D1["ProtocolGateway"] - D1A["ProtocolRuntime"] + D1["ProtocolGateway -> ProtocolRuntime"] D2["SessionCoordinator"] D3["IdentityGateway"] D4["AccountManager / PreferencesManager"] @@ -42,54 +41,57 @@ flowchart TB subgraph CHAT_UI["Chat UI Orchestration"] C1["ChatDetailScreen / ChatsListScreen"] - C2["ChatViewModel (host)"] - C3["Messages/Voice/Attachments/Typing ViewModel"] - C4["Messages/Forward/Attachments Coordinator"] + C2["ChatViewModel (host-state)"] + C3["Feature VM: Messages/Voice/Attachments/Typing"] + C4["Coordinators: Messages/Forward/Attachments"] end subgraph CHAT_DOMAIN["Chat Domain UseCases"] U1["SendText / SendMedia / SendForward"] U2["SendVoice / SendTyping / SendReadReceipt"] - U3["CreateAttachment / EncryptAndUpload"] + U3["CreateAttachment / EncryptAndUpload / VideoCircle"] end subgraph SESSION["Session / Identity Runtime"] - S1["SessionStore"] - S2["SessionReducer"] - S3["IdentityStore"] - S4["AppSessionCoordinator"] + S1["SessionStore / SessionReducer"] + S2["IdentityStore / AppSessionCoordinator"] end subgraph NET["Network Runtime"] N0["ProtocolRuntime"] - N1C["RuntimeComposition"] - N1A["ProtocolManager (compat facade)"] - N2["Protocol"] - N3["PacketSubscriptionRegistry"] - N4["ReadyPacketGate"] + N1["RuntimeComposition (wiring only)"] + N2["RuntimeConnectionControlFacade"] + N3["RuntimeDirectoryFacade"] + N4["RuntimePacketIoFacade"] + N5["Assemblies: Transport / Messaging / State / Routing"] + N6["ProtocolInstanceManager -> Protocol"] + N7["ProtocolManager (legacy compat)"] end subgraph DATA["Data + Persistence"] - R1["MessageRepository"] - R2["GroupRepository"] - R3["Room: RosettaDatabase"] + R1["MessageRepository / GroupRepository"] + R2["Room: RosettaDatabase"] end ENTRY --> DI DI --> SESSION - DI --> NET DI --> DATA DI --> CHAT_UI + DI --> N0 CHAT_UI --> CHAT_DOMAIN - CHAT_UI --> DATA + CHAT_UI --> R1 CHAT_DOMAIN --> D1 - D1 --> D1A - D1A --> N1C - N1A --> N1C - SESSION --> NET - DATA --> NET - DATA --> R3 - N1C --> N2 + D1 --> N0 + N0 --> N1 + N1 --> N2 + N1 --> N3 + N1 --> N4 + N1 --> N5 + N5 --> N6 + N7 --> N0 + SESSION --> N0 + R1 --> N0 + R1 --> R2 ``` --- @@ -216,41 +218,40 @@ stateDiagram-v2 На hot-path `ProtocolRuntime` берет runtime API (`RuntimeConnectionControlFacade`/`RuntimeDirectoryFacade`/`RuntimePacketIoFacade`) напрямую из `RuntimeComposition`, поэтому лишний proxy-hop через публичные методы composition не используется. ```mermaid - flowchart TB +flowchart TB PR["ProtocolRuntime (ProtocolGateway impl)"] --> RC["RuntimeComposition"] RC --> RCC["RuntimeConnectionControlFacade"] RC --> RDF["RuntimeDirectoryFacade"] RC --> RPF["RuntimePacketIoFacade"] - RC --> CO["ConnectionOrchestrator"] - RC --> PIM["ProtocolInstanceManager"] - RC --> RLSM["RuntimeLifecycleStateMachine"] - RC --> RIC["RuntimeInitializationCoordinator"] - RC --> PLSS["ProtocolLifecycleStateStoreImpl"] - RC --> OPFT["OwnProfileFallbackTimerService"] - RC --> ARS["AuthRestoreService"] - RC --> RSC["RuntimeShutdownCoordinator"] - RC --> CER["ConnectionEventRouter"] - RC --> NCF["NetworkConnectivityFacade"] - RC --> PLC["ProtocolLifecycleCoordinator"] - RC --> PAC["ProtocolAccountSessionCoordinator"] - RC --> RPDC["ReadyPacketDispatchCoordinator"] - RC --> PABC["ProtocolPostAuthBootstrapCoordinator"] - RC --> BC["BootstrapCoordinator"] - RC --> SC["SyncCoordinator"] - RC --> PT["PresenceTypingService"] - RC --> PR["PacketRouter"] - RC --> OPS["OwnProfileSyncService"] - RC --> RQ["RetryQueueService"] - RC --> ABC["AuthBootstrapCoordinator"] - RC --> NRW["NetworkReconnectWatcher"] - RC --> DVS["DeviceVerificationService"] - RC --> CSB["CallSignalBridge"] - RC --> PSF["PacketSubscriptionFacade"] - RC --> PSR["PacketSubscriptionRegistry"] - RC --> IPR["InboundPacketHandlerRegistrar"] - RC --> IQ["InboundTaskQueueService"] - RC --> SUP["ProtocolConnectionSupervisor"] - RC --> RPG["ReadyPacketGate"] + + RC --> RTA["RuntimeTransportAssembly"] + RC --> RMA["RuntimeMessagingAssembly"] + RC --> RSA["RuntimeStateAssembly"] + RC --> RRA["RuntimeRoutingAssembly"] + + RTA --> PIM["ProtocolInstanceManager"] + RTA --> PSF["PacketSubscriptionFacade"] + RTA --> NCF["NetworkConnectivityFacade"] + + RMA --> SC["SyncCoordinator"] + RMA --> PROUTER["PacketRouter"] + RMA --> OMPS["OutgoingMessagePipelineService"] + RMA --> CSB["CallSignalBridge"] + RMA --> IPR["InboundPacketHandlerRegistrar"] + + RSA --> RLSM["RuntimeLifecycleStateMachine"] + RSA --> BC["BootstrapCoordinator"] + RSA --> RPG["ReadyPacketGate"] + RSA --> PLSS["ProtocolLifecycleStateStoreImpl"] + + RRA --> SUP["ProtocolConnectionSupervisor"] + RRA --> CER["ConnectionEventRouter"] + + CER --> CO["ConnectionOrchestrator"] + CER --> PLC["ProtocolLifecycleCoordinator"] + CER --> PAC["ProtocolAccountSessionCoordinator"] + CER --> RPDC["ReadyPacketDispatchCoordinator"] + PIM --> P["Protocol (WebSocket + packet codec)"] ``` @@ -267,12 +268,16 @@ stateDiagram-v2 ```mermaid sequenceDiagram participant Feature as Feature/Service - participant PM as Runtime API (Core/Facade) + participant PR as ProtocolRuntime + participant RPF as RuntimePacketIoFacade + participant PSF as PacketSubscriptionFacade participant REG as PacketSubscriptionRegistry participant P as Protocol - Feature->>PM: waitPacket(0x03, callback) - PM->>REG: addCallback(0x03, callback) + Feature->>PR: waitPacket(0x03, callback) + PR->>RPF: waitPacket(0x03, callback) + RPF->>PSF: waitPacket(0x03, callback) + PSF->>REG: addCallback(0x03, callback) REG->>P: waitPacket(0x03, protocolBridge) [once per packetId] P-->>REG: Packet(0x03) @@ -319,12 +324,14 @@ flowchart LR CVM --> COORD["Messages/Forward/Attachments Coordinator"] CVM --> UC["domain/chats/usecase/*"] COORD --> UC - UC --> GW["ProtocolGateway.sendMessageWithRetry"] + UC --> GW["ProtocolGateway.send / sendMessageWithRetry"] GW --> PR["ProtocolRuntime"] - PR --> RC["RuntimeComposition"] - RC --> RQ["RetryQueueService"] - RC --> RG["ReadyPacketGate"] - RC --> P["Protocol.sendPacket"] + PR --> RPF["RuntimePacketIoFacade"] + RPF --> OMP["OutgoingMessagePipelineService"] + OMP --> RQ["RetryQueueService"] + OMP --> RR["RuntimeRoutingAssembly"] + RR --> RG["ReadyPacketGate / ReadyPacketDispatchCoordinator"] + RG --> P["Protocol.sendPacket"] ``` ### 7.2 Декомпозиция ChatViewModel (host + feature/coordinator слой) @@ -351,17 +358,19 @@ flowchart TB CD --> TVM["TypingViewModel"] CD --> VVM["VoiceRecordingViewModel"] CD --> AVM["AttachmentsViewModel"] - MVM --> CVM["ChatViewModel"] + MVM --> CVM["ChatViewModel (host-state)"] TVM --> CVM VVM --> CVM AVM --> CVM CVM --> MCO["MessagesCoordinator"] CVM --> FCO["ForwardCoordinator"] CVM --> ACO["AttachmentsCoordinator"] + AVM --> AFCO["AttachmentsFeatureCoordinator"] CVM --> U["domain/chats/usecase/*"] MCO --> U FCO --> U ACO --> U + AFCO --> U ``` Важно: после вынесения `MessagesCoordinator`, `ForwardCoordinator` и `AttachmentsCoordinator` `ChatViewModel` выступает как host-state и bridge для feature/coordinator подсистем. @@ -388,7 +397,10 @@ sequenceDiagram participant SC as SessionCoordinatorImpl participant SS as SessionStore participant PG as ProtocolGateway - participant RC as RuntimeComposition + participant PR as ProtocolRuntime + participant RCC as RuntimeConnectionControlFacade + participant RRA as RuntimeRoutingAssembly + participant RSA as RuntimeStateAssembly participant AM as AccountManager UI->>SC: bootstrapAuthenticatedSession(account, reason) @@ -400,9 +412,12 @@ sequenceDiagram SC->>AM: setCurrentAccount(public) SC->>SS: dispatch(Ready) - RC-->>RC: HANDSHAKE -> AUTHENTICATED -> BOOTSTRAPPING - RC-->>RC: SyncCompleted + OwnProfileResolved - RC-->>RC: connectionLifecycleState = READY + PG->>PR: runtime API calls + PR->>RCC: connection/auth commands + RCC->>RRA: post(ConnectionEvent.*) + RRA-->>RRA: Supervisor + Router route events + RRA-->>RSA: apply lifecycle transitions + RSA-->>RSA: AUTHENTICATED -> BOOTSTRAPPING -> READY ``` Важно: `SessionState.Ready` (app-session готова) и `connectionLifecycleState = READY` (сеть готова) — это разные state-модели. @@ -429,6 +444,9 @@ stateDiagram-v2 HANDSHAKING --> AUTHENTICATED AUTHENTICATED --> BOOTSTRAPPING BOOTSTRAPPING --> READY + READY --> HANDSHAKING + AUTHENTICATED --> DISCONNECTED + BOOTSTRAPPING --> DISCONNECTED READY --> DISCONNECTED DEVICE_VERIFICATION_REQUIRED --> CONNECTING ``` @@ -519,13 +537,12 @@ stateDiagram-v2 ## 11. Что осталось как технический долг Актуальные открытые хвосты: -- `RuntimeComposition` остается composition-root, но уже существенно сжат (около 501 строки) после выноса `RuntimeTransportAssembly`, `RuntimeMessagingAssembly`, `RuntimeStateAssembly`, `RuntimeRoutingAssembly` и удаления публичных proxy-методов; следующий шаг — перенос части lifecycle/orchestration helper-кода в отдельные domain-oriented service/adapters. -- `ProtocolRuntime` и `ProtocolRuntimePort` все еще имеют широкий proxy-surface; нужен audit методов и дальнейшее сужение публичного API по use-case группам. -- `ChatViewModel` остается крупным host-классом (state + bridge/proxy API к feature/coordinator/use-case слоям). -- High-level media сценарии теперь в `AttachmentsFeatureCoordinator`, но остаются крупными и требуют дальнейшей декомпозиции на более узкие coordinator/service/use-case блоки. -- Тестовое покрытие архитектурных слоев все еще недостаточно: -- сейчас в `app/src/test` всего 7 unit-тестов (в основном crypto/data/helpers), в `app/src/androidTest` — 1 тест; -- не покрыты network runtime/coordinator слои (`RuntimeComposition`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat orchestration (`Messages/Forward/Attachments*`). +- `RuntimeComposition` остается composition-root (около 501 строки): публичные proxy-методы уже убраны, но внутри все еще смешаны wiring и часть helper-логики (`setupStateMonitoring`, event-bridge, log helpers). Следующий шаг: вынести эти helper-блоки в отдельные adapters/services. +- `ProtocolRuntime` + `ProtocolRuntimePort` все еще имеют широкий API surface (connection + directory + packet IO + call signaling + debug). Нужен audit и сужение публичных контрактов по use-case группам. +- `ChatViewModel` остается очень крупным host-классом (около 4391 строки) с большим bridge/proxy surface к feature/coordinator/use-case слоям. +- `AttachmentsFeatureCoordinator` остается крупным (около 761 строки): high-level media сценарии стоит резать на более узкие upload/transform/packet-assembly сервисы. +- Тестовое покрытие архитектурно-критичных слоев недостаточно: `app/src/test` = 7, `app/src/androidTest` = 1; не покрыты runtime-routing/lifecycle компоненты (`RuntimeRoutingAssembly`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat coordinators (`Messages/Forward/Attachments*`). +- В runtime все еще несколько точек входа (`ProtocolRuntime`, `ProtocolRuntimeAccess`, `ProtocolManager` legacy), что повышает cognitive load; целевой шаг — дальнейшее сокращение legacy/static call-sites. Уже закрыто и больше не считается техдолгом: - `UiDependencyAccess.get(...)` удален из `ui/*`. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6639dde..9f660a5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,10 +27,8 @@ runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) } } - android.util.Log.i( + if (BuildConfig.DEBUG) android.util.Log.i( "MessageRepository", "✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED" ) @@ -1335,14 +1336,14 @@ class MessageRepository @Inject constructor( // Mark expired messages as ERROR (older than 80 seconds) val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS) if (expiredCount > 0) { - android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR") + if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR") } // Get remaining WAITING messages (younger than 80s) val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS) if (waitingMessages.isEmpty()) return - android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages") + if (BuildConfig.DEBUG) android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages") for (entity in waitingMessages) { // Skip saved messages (should not happen, but guard) @@ -1366,7 +1367,7 @@ class MessageRepository @Inject constructor( privateKey ) } catch (e: Exception) { - android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it") + if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it") "" } } @@ -1393,9 +1394,9 @@ class MessageRepository @Inject constructor( // iOS parity: use retry mechanism for reconnect-resent messages too protocolClient.sendMessageWithRetry(packet) - android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}") + if (BuildConfig.DEBUG) android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}") } catch (e: Exception) { - android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}") + if (BuildConfig.DEBUG) android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}") // Mark as ERROR if retry fails messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value) val dialogKey = getDialogKey(entity.toPublicKey) diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index 70427c2..eb9a11d 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -10,6 +10,7 @@ import android.graphics.BitmapFactory import android.os.Build import android.util.Base64 import android.util.Log +import com.rosetta.messenger.BuildConfig import androidx.core.app.NotificationCompat import androidx.core.graphics.drawable.IconCompat import com.google.firebase.messaging.FirebaseMessagingService @@ -136,7 +137,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { /** Вызывается когда получено push-уведомление */ override fun onMessageReceived(remoteMessage: RemoteMessage) { super.onMessageReceived(remoteMessage) - Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}") + if (BuildConfig.DEBUG) Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}") val data = remoteMessage.data val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty() @@ -153,7 +154,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { val hasNotificationContent = notificationTitle.isNotBlank() || notificationBody.isNotBlank() if (!hasDataContent && !hasNotificationContent) { - Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)") + if (BuildConfig.DEBUG) Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)") // Still trigger reconnect if WebSocket is disconnected protocolGateway.reconnectNowIfNeeded("silent_push") return @@ -226,14 +227,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { isReadEvent -> { val keysToClear = collectReadDialogKeys(data, dialogKey, senderPublicKey) if (keysToClear.isEmpty()) { - Log.d(TAG, "READ push received but no dialog key in payload: $data") + if (BuildConfig.DEBUG) Log.d(TAG, "READ push received but no dialog key in payload: $data") } else { keysToClear.forEach { key -> cancelNotificationForChat(applicationContext, key) } val titleHints = collectReadTitleHints(data, keysToClear) cancelMatchingActiveNotifications(keysToClear, titleHints) - Log.d( + if (BuildConfig.DEBUG) Log.d( TAG, "READ push cleared notifications for keys=$keysToClear titles=$titleHints" ) @@ -317,11 +318,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { val now = System.currentTimeMillis() val lastTs = lastNotifTimestamps[dedupKey] if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { - Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms") + if (BuildConfig.DEBUG) Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms") return // duplicate push — skip } lastNotifTimestamps[dedupKey] = now - Log.d(TAG, "\u2705 Showing notification for key=$dedupKey") + if (BuildConfig.DEBUG) Log.d(TAG, "\u2705 Showing notification for key=$dedupKey") val senderKey = senderPublicKey?.trim().orEmpty() if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) { return @@ -508,7 +509,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { } private fun pushCallLog(msg: String) { - Log.d(TAG, msg) + if (BuildConfig.DEBUG) Log.d(TAG, msg) try { val dir = java.io.File(applicationContext.filesDir, "crash_reports") if (!dir.exists()) dir.mkdirs() @@ -534,7 +535,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}…") protocolGateway.reconnectNowIfNeeded("push_$reason") }.onFailure { error -> - Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}") + if (BuildConfig.DEBUG) Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}") } } @@ -717,7 +718,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { if (matchesDeterministicId || matchesDialogKey || matchesHint) { manager.cancel(sbn.tag, sbn.id) - Log.d( + if (BuildConfig.DEBUG) Log.d( TAG, "READ push fallback cancel id=${sbn.id} tag=${sbn.tag} " + "channel=${notification.channelId} title='$title' " + @@ -726,7 +727,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { } } }.onFailure { error -> - Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}") + if (BuildConfig.DEBUG) Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}") } } 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 e2fa3d8..0e06d6e 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 @@ -2471,7 +2471,9 @@ fun CallAttachment( text = callUi.subtitle, fontSize = 12.sp, color = - if (callUi.isError) { + if (callUi.isError && isOutgoing) { + Color.White.copy(alpha = 0.72f) + } else if (callUi.isError) { Color(0xFFE55A5A) } else if (isOutgoing) { Color.White.copy(alpha = 0.72f) 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 d7642d4..37ea2e4 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 @@ -563,7 +563,6 @@ fun MessageBubble( Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive, isVoiceWaveGestureActive) { if (isSystemSafeChat) return@pointerInput if (textSelectionHelper?.isActive == true) return@pointerInput - if (hasVoiceAttachmentForGesture) return@pointerInput if (isVoiceWaveGestureActive) return@pointerInput // 🔥 Простой горизонтальный свайп для reply // Используем detectHorizontalDragGestures который лучше работает со 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 cac34b1..002e849 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 @@ -61,6 +61,7 @@ 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.withTransform import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight @@ -101,7 +102,10 @@ import java.io.File import java.util.Locale import java.util.UUID import kotlin.math.PI +import kotlin.math.cos import kotlin.math.sin +import kotlin.math.tan +import kotlin.random.Random private val EaseOutQuint = CubicBezierEasing(0.23f, 1f, 0.32f, 1f) private const val INPUT_JUMP_LOG_ENABLED = false @@ -183,7 +187,164 @@ private enum class RecordUiState { PRESSING, RECORDING, LOCKED, - PAUSED + PAUSED, + CANCELLING, + SENDING +} + +private const val TG_BLOB_MAX_SPEED = 8.2f +private const val TG_BLOB_MIN_SPEED = 0.8f +private const val TG_BLOB_SCALE_BIG_MIN = 0.878f +private const val TG_BLOB_SCALE_SMALL_MIN = 0.926f +private const val TG_BLOB_ANIM_SPEED_BIG = 0.35f +private const val TG_BLOB_ANIM_SPEED_SMALL = 0.55f + +private class TelegramBlobWave( + pointCount: Int, + private val speedScale: Float, + private val isBigWave: Boolean, + seed: Int +) { + private val random = Random(seed) + private val lFactor = ((4.0 / 3.0) * tan(PI / (2.0 * pointCount.toDouble()))).toFloat() + private val n = pointCount + + private val radius = FloatArray(pointCount) + private val angle = FloatArray(pointCount) + private val radiusNext = FloatArray(pointCount) + private val angleNext = FloatArray(pointCount) + private val progress = FloatArray(pointCount) + private val speed = FloatArray(pointCount) + + private var initialized = false + private var minRadius = 0f + private var maxRadius = 0f + + var amplitude: Float = 0f + private set + private var animateToAmplitude = 0f + private var animateAmplitudeDiff = 0f + + fun setRadiusBounds(minRadius: Float, maxRadius: Float) { + val safeMin = minRadius.coerceAtLeast(1f) + val safeMax = maxRadius.coerceAtLeast(safeMin + 0.1f) + if (!initialized || + kotlin.math.abs(this.minRadius - safeMin) > 0.01f || + kotlin.math.abs(this.maxRadius - safeMax) > 0.01f + ) { + this.minRadius = safeMin + this.maxRadius = safeMax + regenerate() + initialized = true + } + } + + fun setAmplitudeTarget(target: Float) { + val clamped = target.coerceIn(0f, 1f) + animateToAmplitude = clamped + animateAmplitudeDiff = + if (isBigWave) { + if (animateToAmplitude > amplitude) { + (animateToAmplitude - amplitude) / (100f + 300f * TG_BLOB_ANIM_SPEED_BIG) + } else { + (animateToAmplitude - amplitude) / (100f + 500f * TG_BLOB_ANIM_SPEED_BIG) + } + } else { + if (animateToAmplitude > amplitude) { + (animateToAmplitude - amplitude) / (100f + 400f * TG_BLOB_ANIM_SPEED_SMALL) + } else { + (animateToAmplitude - amplitude) / (100f + 500f * TG_BLOB_ANIM_SPEED_SMALL) + } + } + } + + fun update(dtMs: Float) { + if (!initialized) return + updateAmplitude(dtMs) + for (i in 0 until n) { + progress[i] += (speed[i] * TG_BLOB_MIN_SPEED) + amplitude * speed[i] * TG_BLOB_MAX_SPEED * speedScale + if (progress[i] >= 1f) { + progress[i] = 0f + radius[i] = radiusNext[i] + angle[i] = angleNext[i] + generatePoint(radiusNext, angleNext, i) + } + } + } + + fun buildPath(center: Offset): Path { + val path = Path() + if (!initialized) return path + + for (i in 0 until n) { + val nextIndex = if (i + 1 < n) i + 1 else 0 + val p1 = progress[i] + val p2 = progress[nextIndex] + + val r1 = radius[i] * (1f - p1) + radiusNext[i] * p1 + val r2 = radius[nextIndex] * (1f - p2) + radiusNext[nextIndex] * p2 + val a1 = angle[i] * (1f - p1) + angleNext[i] * p1 + val a2 = angle[nextIndex] * (1f - p2) + angleNext[nextIndex] * p2 + + val l = lFactor * ((r1 + r2) / 2f) + val start = rotateRelative(0f, -r1, a1, center) + val control1 = rotateRelative(l, -r1, a1, center) + val end = rotateRelative(0f, -r2, a2, center) + val control2 = rotateRelative(-l, -r2, a2, center) + + if (i == 0) { + path.moveTo(start.x, start.y) + } + path.cubicTo( + control1.x, control1.y, + control2.x, control2.y, + end.x, end.y + ) + } + + path.close() + return path + } + + private fun regenerate() { + for (i in 0 until n) { + generatePoint(radius, angle, i) + generatePoint(radiusNext, angleNext, i) + progress[i] = 0f + } + } + + private fun generatePoint(outRadius: FloatArray, outAngle: FloatArray, i: Int) { + val angleDiff = 360f / n * 0.05f + val radiusDiff = (maxRadius - minRadius).coerceAtLeast(0f) + val radiusRnd = random.nextInt(100) / 100f + val angleRnd = random.nextInt(100) / 100f + val speedRnd = random.nextInt(100) / 100f + + outRadius[i] = minRadius + radiusRnd * radiusDiff + outAngle[i] = 360f / n * i + angleRnd * angleDiff + speed[i] = 0.017f + 0.003f * speedRnd + } + + private fun updateAmplitude(dtMs: Float) { + if (animateToAmplitude == amplitude) return + amplitude += animateAmplitudeDiff * dtMs + if (animateAmplitudeDiff > 0) { + if (amplitude > animateToAmplitude) amplitude = animateToAmplitude + } else { + if (amplitude < animateToAmplitude) amplitude = animateToAmplitude + } + } + + private fun rotateRelative(x: Float, y: Float, angleDeg: Float, center: Offset): Offset { + val rad = Math.toRadians(angleDeg.toDouble()) + val cosV = cos(rad).toFloat() + val sinV = sin(rad).toFloat() + return Offset( + x = center.x + x * cosV - y * sinV, + y = center.y + x * sinV + y * cosV + ) + } } @Composable @@ -374,122 +535,75 @@ private fun VoiceMovingBlob( @Composable private fun VoiceButtonBlob( voiceLevel: Float, + slideToCancelProgress: 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) + val slideProgress = slideToCancelProgress.coerceIn(0f, 1f) + val slideProgress1 = if (slideProgress > 0.7f) 1f else (slideProgress / 0.7f).coerceIn(0f, 1f) + val bigWave = remember { TelegramBlobWave(pointCount = 12, speedScale = 1.01f, isBigWave = true, seed = 1207) } + val tinyWave = remember { TelegramBlobWave(pointCount = 11, speedScale = 1.02f, isBigWave = false, seed = 1109) } + var frameTick by remember { mutableLongStateOf(0L) } - 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 + LaunchedEffect(rawLevel) { + bigWave.setAmplitudeTarget(rawLevel) + tinyWave.setAmplitudeTarget(rawLevel) } + LaunchedEffect(Unit) { + var lastFrameNs = 0L + while (true) { + withFrameNanos { frameNs -> + if (lastFrameNs != 0L) { + val dtMs = ((frameNs - lastFrameNs) / 1_000_000f).coerceAtLeast(1f) + bigWave.update(dtMs) + tinyWave.update(dtMs) + frameTick++ + } + lastFrameNs = frameNs + } + } + } + + val blobColor = if (isDarkTheme) Color(0xFF52C3FF) else Color(0xFF2D9CFF) + Canvas(modifier = modifier) { + val tick = frameTick + if (tick < 0L) return@Canvas + if (slideProgress1 <= 0f) return@Canvas + val center = Offset( - x = size.width * 0.5f + size.width * 0.05f * driftX, + x = size.width * 0.5f, y = size.height * 0.5f ) - val baseRadius = size.minDimension * 0.25f + val side = size.minDimension + tinyWave.setRadiusBounds( + minRadius = side * 0.52f, + maxRadius = side * 0.62f + ) + bigWave.setRadiusBounds( + minRadius = side * 0.55f, + maxRadius = side * 0.63f + ) // 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 bigScale = slideProgress1 * (TG_BLOB_SCALE_BIG_MIN + 1.4f * bigWave.amplitude) + val smallScale = slideProgress1 * (TG_BLOB_SCALE_SMALL_MIN + 1.4f * tinyWave.amplitude) + val bigPath = bigWave.buildPath(center) + val smallPath = tinyWave.buildPath(center) - 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) - ) + withTransform({ + scale(scaleX = bigScale, scaleY = bigScale, pivot = center) + }) { + drawPath(path = bigPath, color = blobColor.copy(alpha = 0.30f)) + } + withTransform({ + scale(scaleX = smallScale, scaleY = smallScale, pivot = center) + }) { + drawPath(path = smallPath, color = blobColor.copy(alpha = 0.15f)) + } } } @@ -498,13 +612,13 @@ private const val LOCK_HINT_MAX_SHOWS = 3 @Composable private fun LockIcon( - lockProgress: Float, + movementProgress: Float, isLocked: Boolean, isPaused: Boolean, isDarkTheme: Boolean, modifier: Modifier = Modifier ) { - val progress = lockProgress.coerceIn(0f, 1f) + val movement = movementProgress.coerceIn(0f, 1f) val lockedOrPaused = isLocked || isPaused // Staggered snap animations — Telegram timing @@ -540,9 +654,15 @@ private fun LockIcon( 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 + val dp1 = (size.width / 36f).coerceAtLeast(0.001f) + val pillW = size.width + val pillH = size.height + val pillLeft = 0f + val pillTop = 0f + val cx = pillW / 2f + // Telegram moveProgress: + // 1f at rest/locked, 0f near lock threshold. + val moveProgress = movement // Telegram rotation: dual-phase snap val snapRotateBack = if (moveProgress > 0.4f) 1f else moveProgress / 0.4f @@ -554,10 +674,6 @@ private fun LockIcon( } // ── 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 @@ -581,7 +697,7 @@ private fun LockIcon( 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 bodyCy = pillTop + pillH * 0.5f + 2f * dp1 * moveProgress val bodyLeft = bodyCx - bodyW / 2f val bodyTop = bodyCy - bodyH / 2f @@ -590,10 +706,15 @@ private fun LockIcon( val shackleH = 8f * dp1 val shackleStroke = 1.7f * dp1 val shackleLeft = bodyCx - shackleW / 2f - val shackleTop = bodyTop - shackleH * 0.7f - 2f * dp1 + val shackleTop = + bodyTop - 6f * dp1 - + lerpFloat(2f * dp1, 1.5f * dp1 * (1f - idlePhase), moveProgress) + + 2f * dp1 * snapAnim val lockIconAlpha = 1f - pauseTransform - val idleOffset = idlePhase * 2f * dp1 * (1f - moveProgress) // breathing on left leg + val idleOffset = + 4f * dp1 * idlePhase * moveProgress * if (lockedOrPaused) 0f else 1f + val snapOffset = 4f * dp1 * snapAnim * (1f - moveProgress) if (lockIconAlpha > 0.01f) { rotate(degrees = rotation, pivot = Offset(bodyCx, bodyCy)) { @@ -619,8 +740,7 @@ private fun LockIcon( 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) + val leftLegEnd = bodyTop + 2f * dp1 + idleOffset + snapOffset drawLine( color = iconColor.copy(alpha = lockIconAlpha), start = Offset(shackleLeft, shackleTop + shackleH / 2f), @@ -646,24 +766,41 @@ private fun LockIcon( } } - // ── Pause transform: body splits into two bars ── + // ── Pause/Resume transform in locked mode ── 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) - ) + if (isPaused) { + val triW = 10f * dp1 + val triH = 12f * dp1 + val left = bodyCx - triW * 0.35f + val top = bodyCy - triH / 2f + val playPath = Path().apply { + moveTo(left, top) + lineTo(left + triW, bodyCy) + lineTo(left, top + triH) + close() + } + drawPath( + path = playPath, + color = iconColor.copy(alpha = pauseTransform) + ) + } else { + 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) + ) + } } } } @@ -1127,6 +1264,9 @@ fun MessageInputBar( var slideDx by remember { mutableFloatStateOf(0f) } var slideDy by remember { mutableFloatStateOf(0f) } var lockProgress by remember { mutableFloatStateOf(0f) } + var isLockTransitioning by remember { mutableStateOf(false) } + var lockTransitionStartDx by remember { mutableFloatStateOf(0f) } + var lockTransitionStartLockProgress by remember { mutableFloatStateOf(0f) } var dragVelocityX by remember { mutableFloatStateOf(0f) } var dragVelocityY by remember { mutableFloatStateOf(0f) } var lastDragDx by remember { mutableFloatStateOf(0f) } @@ -1142,6 +1282,7 @@ fun MessageInputBar( var isVoicePaused by remember { mutableStateOf(false) } var voicePausedElapsedMs by remember { mutableLongStateOf(0L) } var inputPanelHeightPx by remember { mutableIntStateOf(0) } + var inputPanelWidthPx by remember { mutableIntStateOf(0) } var inputPanelY by remember { mutableFloatStateOf(0f) } var normalInputRowHeightPx by remember { mutableIntStateOf(0) } var normalInputRowY by remember { mutableFloatStateOf(0f) } @@ -1159,15 +1300,10 @@ fun MessageInputBar( } 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 + if (recordUiState == newState) return val oldState = recordUiState - recordUiState = normalizedState - if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("recordState $oldState -> $normalizedState reason=$reason mode=$recordMode") + recordUiState = newState + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("recordState $oldState -> $newState reason=$reason mode=$recordMode") } fun resetGestureState() { @@ -1175,6 +1311,9 @@ fun MessageInputBar( rawSlideDy = 0f slideDx = 0f slideDy = 0f + isLockTransitioning = false + lockTransitionStartDx = 0f + lockTransitionStartLockProgress = 0f dragVelocityX = 0f dragVelocityY = 0f lastDragDx = 0f @@ -1189,6 +1328,14 @@ fun MessageInputBar( pendingLongPressJob = null } + fun startLockTransition(reason: String) { + if (recordUiState == RecordUiState.LOCKED && !isLockTransitioning) return + lockTransitionStartDx = slideDx + lockTransitionStartLockProgress = lockProgress.coerceIn(0f, 1f) + isLockTransitioning = true + setRecordUiState(RecordUiState.LOCKED, reason) + } + fun toggleRecordModeByTap() { recordMode = if (recordMode == RecordMode.VOICE) RecordMode.VIDEO else RecordMode.VOICE if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("recordMode toggled -> $recordMode (short tap)") @@ -1204,6 +1351,8 @@ fun MessageInputBar( send: Boolean, preserveCancelAnimation: Boolean = false ) { + val terminalState = if (send) RecordUiState.SENDING else RecordUiState.CANCELLING + setRecordUiState(terminalState, "stop-init(send=$send)") isVoiceRecordTransitioning = false if (!preserveCancelAnimation) { isVoiceCancelAnimating = false @@ -1238,7 +1387,10 @@ fun MessageInputBar( voiceElapsedMs = 0L voiceWaves = emptyList() resetGestureState() - setRecordUiState(RecordUiState.IDLE, "stop(send=$send)") + if (!preserveCancelAnimation) { + // Keep terminal states real, but don't block UI while recorder.stop() runs on IO. + setRecordUiState(RecordUiState.IDLE, "stop-ui-finished(send=$send)") + } // Heavy I/O off main thread to prevent ANR scope.launch(kotlinx.coroutines.Dispatchers.IO) { @@ -1516,17 +1668,38 @@ fun MessageInputBar( } } - // iOS parity (RecordingMicButton.swift / VoiceRecordingParityMath.swift): - // hold=0.19s, cancel=-150, cancel-on-release=-100, velocityGate=-400. - val holdToRecordDelayMs = 190L + // Telegram parity: + // hold=150ms, slide cancel progress based on dynamic distCanMove=min(width*0.35, 140dp), + // release-cancel threshold alpha<0.45, lock threshold=57dp (only when slide progress>=0.7). + val holdToRecordDelayMs = 150L 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 maxCancelDragDistancePx = with(density) { 140.dp.toPx() } + val lockDragThresholdPx = with(density) { 57.dp.toPx() } + val releaseCancelAlphaThreshold = 0.45f + val slideToCancelLockGate = 0.7f val dragSmoothingPrev = 0.7f val dragSmoothingNew = 0.3f + var cancelDragDistancePx by remember { mutableFloatStateOf(maxCancelDragDistancePx) } + + fun resolveCancelDragDistancePx(): Float { + if (inputPanelWidthPx <= 0) return maxCancelDragDistancePx + val byWidth = inputPanelWidthPx.toFloat() * 0.35f + return byWidth.coerceAtMost(maxCancelDragDistancePx).coerceAtLeast(1f) + } + + fun slideToCancelProgressForDx(dx: Float): Float { + val distance = cancelDragDistancePx.coerceAtLeast(1f) + return (1f + dx.coerceAtMost(0f) / distance).coerceIn(0f, 1f) + } var showLockTooltip by remember { mutableStateOf(false) } + val lockHintPrefs = remember(context) { + context.getSharedPreferences("chat_input_hints", Context.MODE_PRIVATE) + } + var lockHintShownCount by remember { + mutableIntStateOf(lockHintPrefs.getInt(LOCK_HINT_PREF_KEY, 0)) + } + var lockHintShownForCurrentRecord by remember { mutableStateOf(false) } fun tryStartRecordingForCurrentMode(): Boolean { return if (recordMode == RecordMode.VOICE) { @@ -1564,8 +1737,73 @@ fun MessageInputBar( } } - LaunchedEffect(recordUiState, lockProgress) { - showLockTooltip = false + val liveSlideToCancelProgress = slideToCancelProgressForDx(slideDx) + + LaunchedEffect(recordUiState) { + if (recordUiState != RecordUiState.RECORDING) { + showLockTooltip = false + lockHintShownForCurrentRecord = false + } + } + + LaunchedEffect( + recordUiState, + liveSlideToCancelProgress, + isLockTransitioning, + isVoiceCancelAnimating, + lockHintShownCount + ) { + val canShowTooltipNow = + recordUiState == RecordUiState.RECORDING && + !isLockTransitioning && + !isVoiceCancelAnimating && + liveSlideToCancelProgress >= 0.8f && + lockHintShownCount < LOCK_HINT_MAX_SHOWS + if (!canShowTooltipNow) { + showLockTooltip = false + return@LaunchedEffect + } + + delay(200) + val canStillShowTooltip = + recordUiState == RecordUiState.RECORDING && + !isLockTransitioning && + !isVoiceCancelAnimating && + slideToCancelProgressForDx(slideDx) >= 0.8f && + lockHintShownCount < LOCK_HINT_MAX_SHOWS + if (!canStillShowTooltip) return@LaunchedEffect + + showLockTooltip = true + if (!lockHintShownForCurrentRecord) { + lockHintShownForCurrentRecord = true + val newCount = (lockHintShownCount + 1).coerceAtMost(LOCK_HINT_MAX_SHOWS) + lockHintShownCount = newCount + lockHintPrefs.edit().putInt(LOCK_HINT_PREF_KEY, newCount).apply() + } + } + + val lockTranslateTransitionProgress by animateFloatAsState( + targetValue = if (isLockTransitioning) 1f else 0f, + animationSpec = tween(durationMillis = 350, delayMillis = 100, easing = LinearOutSlowInEasing), + label = "lock_translate_transition_progress" + ) + + val lockSlideTransitionProgress by animateFloatAsState( + targetValue = if (isLockTransitioning) 1f else 0f, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "lock_slide_transition_progress" + ) + + LaunchedEffect(isLockTransitioning, lockTranslateTransitionProgress) { + if (!isLockTransitioning) return@LaunchedEffect + if (lockTranslateTransitionProgress >= 0.999f) { + isLockTransitioning = false + rawSlideDx = 0f + rawSlideDy = 0f + slideDx = 0f + slideDy = 0f + lockProgress = 0f + } } // Deterministic cancel commit: after animation, always finalize recording stop. @@ -1869,6 +2107,7 @@ fun MessageInputBar( .then(if (shouldAddNavBarPadding) Modifier.navigationBarsPadding() else Modifier) .onGloballyPositioned { coordinates -> inputPanelHeightPx = coordinates.size.height + inputPanelWidthPx = coordinates.size.width inputPanelY = coordinates.positionInWindow().y } ) { @@ -2296,15 +2535,18 @@ fun MessageInputBar( if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD) val recordingTextColor = if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF1E2A37) + val baseInputRowHeightDp = + if (normalInputRowHeightPx > 0) with(density) { normalInputRowHeightPx.toDp() } else 56.dp + val fixedRecordingRowHeightDp = baseInputRowHeightDp.coerceAtLeast(56.dp) // 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 + // Telegram-like proportions: 40dp base grows to ~57dp visually. + val recordingActionVisualScaleMax = 1.42f val recordingActionInset = 34.dp // Keep scaled circle safely inside screen bounds (right/bottom). val recordingActionVisualOverflow = - (recordingActionButtonBaseSize * (recordingActionVisualScale - 1f)) / 2f + (recordingActionButtonBaseSize * (recordingActionVisualScaleMax - 1f)) / 2f val recordingActionOverflowX = -recordingActionVisualOverflow + 2.dp val recordingActionOverflowY = -recordingActionVisualOverflow + 2.dp val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f } @@ -2335,6 +2577,14 @@ fun MessageInputBar( animationSpec = tween(durationMillis = 180, easing = FastOutLinearInEasing), label = "record_ui_shift" ) + val recordingActionVisualScale by animateFloatAsState( + targetValue = if (recordUiEntered) recordingActionVisualScaleMax else 1f, + animationSpec = spring( + dampingRatio = 0.72f, + stiffness = 420f + ), + label = "record_action_visual_scale" + ) // ── Telegram-exact recording layout ── // RECORDING: [dot][timer] [◀ Slide to cancel] ... [Circle+Blob] @@ -2344,8 +2594,8 @@ fun MessageInputBar( Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp) + .height(fixedRecordingRowHeightDp) + .padding(horizontal = 12.dp) .zIndex(2f) .onGloballyPositioned { coordinates -> recordingInputRowHeightPx = coordinates.size.height @@ -2354,15 +2604,67 @@ fun MessageInputBar( 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 + val effectiveSlideDx = + if (isLockTransitioning) { + lerpFloat(lockTransitionStartDx, 0f, lockSlideTransitionProgress) + } else { + slideDx + } + val lockDragProgress = + if (isLockTransitioning) { + lerpFloat(lockTransitionStartLockProgress, 0f, lockTranslateTransitionProgress) + } else if (recordUiState == RecordUiState.RECORDING) { + lockProgress.coerceIn(0f, 1f) + } else { + 0f + } + val lockMoveProgress = (1f - lockDragProgress).coerceIn(0f, 1f) + val lockControlsScale by animateFloatAsState( + targetValue = + if ( + recordUiState == RecordUiState.RECORDING || + recordUiState == RecordUiState.LOCKED || + recordUiState == RecordUiState.PAUSED + ) 1f else 0f, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + label = "lock_controls_scale" + ) + // Telegram parity: + // slideToCancelScale = 0.7 + progress*0.3, + // slideDelta = -distCanMove * (1 - progress), where progress in [0..1]. + val slideDistanceX = effectiveSlideDx.coerceAtMost(0f) + val slideToCancelProgress = slideToCancelProgressForDx(slideDistanceX) + val circleSlideCancelScale = + if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) { + 0.7f * EaseOut.transform(1f - slideToCancelProgress) + } else { + 0.7f + slideToCancelProgress * 0.3f + } + val circleSlideDelta = + -cancelDragDistancePx.coerceAtLeast(1f) * (1f - slideToCancelProgress) + val lockSlideToCancelTarget = + when (recordUiState) { + RecordUiState.RECORDING -> + if (slideToCancelProgress >= slideToCancelLockGate && !isVoiceCancelAnimating) 1f else 0f + RecordUiState.LOCKED, + RecordUiState.PAUSED -> 1f + else -> 0f + } + val lockSlideToCancelProgress by animateFloatAsState( + targetValue = lockSlideToCancelTarget, + animationSpec = tween(durationMillis = 140, easing = LinearOutSlowInEasing), + label = "lock_slide_to_cancel_progress" + ) + val lockIdleProgress by rememberInfiniteTransition(label = "lock_idle_offset") + .animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "lock_idle_offset_phase" + ) // Crossfade between RECORDING panel and LOCKED panel AnimatedContent( @@ -2420,7 +2722,7 @@ fun MessageInputBar( // ── RECORDING panel ── // [attach-slot => dot/trash morph][timer] [◀ Slide to cancel] val dragCancelProgress = - ((-slideDx).coerceAtLeast(0f) / cancelDragThresholdPx) + ((-effectiveSlideDx).coerceAtLeast(0f) / cancelDragDistancePx.coerceAtLeast(1f)) .coerceIn(0f, 1f) val seededCancelProgress = if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) { @@ -2518,7 +2820,7 @@ fun MessageInputBar( // Slide to cancel SlideToCancel( - slideDx = slideDx, + slideDx = effectiveSlideDx, isDarkTheme = isDarkTheme, modifier = Modifier .weight(1f) @@ -2537,7 +2839,95 @@ fun MessageInputBar( } } - // ── Layer 2: Circle + Lock overlay ── + // Lock icon above circle (independent from circle scale/translation) + if (recordUiState == RecordUiState.RECORDING || + recordUiState == RecordUiState.LOCKED || + recordUiState == RecordUiState.PAUSED + ) { + val lockPillHeightDp = + if (recordUiState == RecordUiState.RECORDING) { + 36.dp + 14.dp * lockMoveProgress + } else { + 36.dp + } + val lockBaseRecordingYPx = with(density) { (-80).dp.toPx() } + val lockDragLiftPx = with(density) { 57.dp.toPx() } * lockDragProgress + val lockIdleOffsetPx = + if (recordUiState == RecordUiState.RECORDING) { + with(density) { (-8).dp.toPx() } * lockMoveProgress * lockIdleProgress + } else { + 0f + } + val lockYTrajectoryPx = + if (recordUiState == RecordUiState.RECORDING) { + lockBaseRecordingYPx - lockDragLiftPx + lockIdleOffsetPx + } else { + lockBaseRecordingYPx + + with(density) { 14.dp.toPx() } * lockMoveProgress - + lockDragLiftPx + } + val maxLockTranslationPx = with(density) { 72.dp.toPx() } + val lockTranslationDyPx = + ( + maxLockTranslationPx * (1f - lockControlsScale) + + maxLockTranslationPx * (1f - lockSlideToCancelProgress) + ).coerceAtMost(maxLockTranslationPx) + val lockLayerScale = + (lockControlsScale * lockSlideToCancelProgress).coerceIn(0f, 1f) + val canTogglePause = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED + LockIcon( + movementProgress = lockMoveProgress, + isLocked = recordUiState == RecordUiState.LOCKED, + isPaused = recordUiState == RecordUiState.PAUSED, + isDarkTheme = isDarkTheme, + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = recordingActionOverflowX, y = recordingActionOverflowY) + .width(36.dp) + .height(lockPillHeightDp) + .graphicsLayer { + translationY = lockYTrajectoryPx + lockTranslationDyPx + alpha = lockLayerScale + scaleX = lockLayerScale + scaleY = lockLayerScale + clip = false + } + .clickable( + enabled = canTogglePause, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) + if (recordUiState == RecordUiState.PAUSED) { + resumeVoiceRecording() + } else { + pauseVoiceRecording() + } + } + .zIndex(10f) + ) + + if (showLockTooltip && + recordUiState == RecordUiState.RECORDING && + lockSlideToCancelProgress > 0.8f + ) { + LockTooltip( + visible = showLockTooltip, + isDarkTheme = isDarkTheme, + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = recordingActionOverflowX, y = recordingActionOverflowY) + .graphicsLayer { + translationX = with(density) { (-90).dp.toPx() } + translationY = lockYTrajectoryPx + lockTranslationDyPx + with(density) { (-80).dp.toPx() } + clip = false + } + .zIndex(11f) + ) + } + } + + // ── Layer 2: Circle overlay ── Box( modifier = Modifier .size(recordingActionButtonBaseSize) @@ -2553,45 +2943,11 @@ fun MessageInputBar( .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, + slideToCancelProgress = slideToCancelProgress, isDarkTheme = isDarkTheme, modifier = Modifier .size(recordingActionButtonBaseSize) @@ -2790,7 +3146,14 @@ fun MessageInputBar( .size(40.dp) .pointerInput(Unit) { awaitEachGesture { - if (canSend || isSending || isVoiceRecording || isVoiceRecordTransitioning) { + if ( + canSend || + isSending || + isVoiceRecording || + isVoiceRecordTransitioning || + recordUiState == RecordUiState.SENDING || + recordUiState == RecordUiState.CANCELLING + ) { return@awaitEachGesture } @@ -2802,6 +3165,7 @@ fun MessageInputBar( var maxAbsDy = 0f pressStartX = down.position.x pressStartY = down.position.y + cancelDragDistancePx = resolveCancelDragDistancePx() keepMicGestureCapture = true rawSlideDx = 0f rawSlideDy = 0f @@ -2857,10 +3221,8 @@ fun MessageInputBar( } } RecordUiState.RECORDING -> { - // iOS parity: - // - dominant-axis release evaluation - // - velocity gate (-400 px/s) - // - fallback to distance thresholds. + // Telegram parity: + // evaluate horizontal cancel by normalized slide progress alpha. var releaseDx = rawReleaseDx.coerceAtMost(0f) var releaseDy = rawReleaseDy.coerceAtMost(0f) if (kotlin.math.abs(releaseDx) > kotlin.math.abs(releaseDy)) { @@ -2868,13 +3230,17 @@ fun MessageInputBar( } else { releaseDx = 0f } + val releaseAlpha = slideToCancelProgressForDx(releaseDx) val cancelOnRelease = - dragVelocityX <= velocityGatePxPerSec || - releaseDx <= -releaseCancelThresholdPx + releaseAlpha < releaseCancelAlphaThreshold + if (cancelOnRelease && !didCancelHaptic) { + didCancelHaptic = true + view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) + } 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" + "alpha=${"%.2f".format(java.util.Locale.US, releaseAlpha)} cancel=$cancelOnRelease" ) if (cancelOnRelease) { if (isVoiceRecording || voiceRecorder != null) { @@ -2900,6 +3266,16 @@ fun MessageInputBar( "$source while PAUSED -> stay paused mode=$recordMode state=$recordUiState" ) } + RecordUiState.CANCELLING -> { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "$source while CANCELLING -> ignore release mode=$recordMode state=$recordUiState" + ) + } + RecordUiState.SENDING -> { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "$source while SENDING -> ignore release mode=$recordMode state=$recordUiState" + ) + } RecordUiState.IDLE -> Unit } resetGestureState() @@ -2947,8 +3323,9 @@ fun MessageInputBar( ) } } else if (recordUiState == RecordUiState.RECORDING) { - // iOS parity: - // raw drag from touch + smoothed drag for UI (0.7 / 0.3). + // Telegram parity: + // raw drag + smoothed UI drag for horizontal cancel progress + // and vertical lock threshold. val rawDx = (change.position.x - pressStartX).coerceAtMost(0f) val rawDy = (change.position.y - pressStartY).coerceAtMost(0f) rawSlideDx = rawDx @@ -2964,17 +3341,38 @@ fun MessageInputBar( slideDx = (slideDx * dragSmoothingPrev) + (rawDx * dragSmoothingNew) slideDy = (slideDy * dragSmoothingPrev) + (rawDy * dragSmoothingNew) - lockProgress = 0f + val slideToCancelProgress = slideToCancelProgressForDx(rawDx) + val lockDistancePx = (-rawDy).coerceAtLeast(0f) + lockProgress = + if (slideToCancelProgress >= slideToCancelLockGate) { + (lockDistancePx / lockDragThresholdPx).coerceIn(0f, 1f) + } else { + 0f + } - if (!didCancelHaptic && rawDx <= -releaseCancelThresholdPx) { + if (!didCancelHaptic && slideToCancelProgress < releaseCancelAlphaThreshold) { 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) { + val lockReached = + slideToCancelProgress >= slideToCancelLockGate && + lockDistancePx >= lockDragThresholdPx + if (lockReached) { + if (!didLockHaptic) { + didLockHaptic = true + view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) + } if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( - "gesture CANCEL dx=${rawDx.toInt()} threshold=${releaseCancelThresholdPx.toInt()} mode=$recordMode" + "gesture LOCK dy=${lockDistancePx.toInt()} threshold=${lockDragThresholdPx.toInt()} " + + "slideProgress=${"%.2f".format(java.util.Locale.US, slideToCancelProgress)} mode=$recordMode" + ) + keepMicGestureCapture = false + pointerIsDown = false + startLockTransition("gesture-lock") + finished = true + } else if (slideToCancelProgress <= 0f) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "gesture CANCEL alpha=0.00 dx=${rawDx.toInt()} distCanMove=${cancelDragDistancePx.toInt()} mode=$recordMode" ) cancelVoiceRecordingWithAnimation("slide-cancel") finished = true