diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2ad9cf4..34ab15f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + diff --git a/app/src/main/java/app/rosette/android/ui/keyboard/AnimatedKeyboardTransition.kt b/app/src/main/java/app/rosette/android/ui/keyboard/AnimatedKeyboardTransition.kt index 8ef167c..e972be4 100644 --- a/app/src/main/java/app/rosette/android/ui/keyboard/AnimatedKeyboardTransition.kt +++ b/app/src/main/java/app/rosette/android/ui/keyboard/AnimatedKeyboardTransition.kt @@ -53,8 +53,17 @@ fun AnimatedKeyboardTransition( wasEmojiShown = true } if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) { - // Emoji закрылся после того как был открыт = переход emoji→keyboard - isTransitioningToKeyboard = true + // Keep reserved space only if keyboard is actually opening. + // For back-swipe/back-press close there is no keyboard open request, + // so we must drop the emoji box immediately to avoid an empty gap. + val keyboardIsComing = + coordinator.currentState == KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD || + coordinator.isKeyboardVisible || + coordinator.keyboardHeight > 0.dp + isTransitioningToKeyboard = keyboardIsComing + if (!keyboardIsComing) { + wasEmojiShown = false + } } // 🔥 КЛЮЧЕВОЕ: Сбрасываем флаг когда клавиатура достигла высоты близкой к emoji @@ -63,6 +72,19 @@ fun AnimatedKeyboardTransition( isTransitioningToKeyboard = false wasEmojiShown = false } + + // Failsafe for interrupted gesture/back navigation: if keyboard never started opening, + // don't keep an invisible fixed-height box. + if ( + isTransitioningToKeyboard && + !showEmojiPicker && + coordinator.currentState != KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD && + !coordinator.isKeyboardVisible && + coordinator.keyboardHeight == 0.dp + ) { + isTransitioningToKeyboard = false + wasEmojiShown = false + } // 🎯 Целевая прозрачность val targetAlpha = if (showEmojiPicker) 1f else 0f @@ -109,4 +131,4 @@ fun AnimatedKeyboardTransition( content() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 5a101c6..b5a9f61 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -44,6 +44,7 @@ import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.crypto.CryptoManager 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.CallActionResult @@ -225,7 +226,27 @@ class MainActivity : FragmentActivity() { LaunchedEffect(Unit) { val accounts = accountManager.getAllAccounts() hasExistingAccount = accounts.isNotEmpty() - accountInfoList = accounts.map { it.toAccountInfo() } + val infos = accounts.map { it.toAccountInfo() } + accountInfoList = infos + + // Reconcile process-cached account name with persisted profile data. + currentAccount?.let { cached -> + val persisted = infos.firstOrNull { + it.publicKey.equals(cached.publicKey, ignoreCase = true) + } + val persistedUsername = persisted?.username?.trim().orEmpty().ifBlank { null } + val normalizedCachedName = + resolveAccountDisplayName( + cached.publicKey, + persisted?.name ?: cached.name, + persistedUsername + ) + if (normalizedCachedName != cached.name) { + val updated = cached.copy(name = normalizedCachedName) + currentAccount = updated + cacheSessionAccount(updated) + } + } } // Wait for initial load @@ -305,15 +326,29 @@ class MainActivity : FragmentActivity() { onAuthComplete = { account -> startCreateAccountFlow = false val normalizedAccount = - account?.let { + account?.let { decrypted -> + val persisted = + accountInfoList.firstOrNull { + it.publicKey.equals( + decrypted.publicKey, + ignoreCase = true + ) + } + val persistedUsername = + persisted?.username + ?.trim() + .orEmpty() + .ifBlank { null } val normalizedName = resolveAccountDisplayName( - it.publicKey, - it.name, - null + decrypted.publicKey, + persisted?.name + ?: decrypted.name, + persistedUsername ) - if (it.name == normalizedName) it - else it.copy(name = normalizedName) + if (decrypted.name == normalizedName) + decrypted + else decrypted.copy(name = normalizedName) } currentAccount = normalizedAccount cacheSessionAccount(normalizedAccount) @@ -321,6 +356,14 @@ class MainActivity : FragmentActivity() { // Save as last logged account normalizedAccount?.let { accountManager.setLastLoggedPublicKey(it.publicKey) + // Initialize protocol/message account context + // immediately after auth completion to avoid + // packet processing race before MainScreen + // composition. + ProtocolManager.initializeAccount( + it.publicKey, + it.privateKey + ) } // Первый запуск после регистрации: @@ -354,6 +397,27 @@ class MainActivity : FragmentActivity() { runCatching { accountManager.setCurrentAccount(it.publicKey) } + + // Force-refresh account title from persisted + // profile (name/username) to avoid temporary + // public-key alias in UI after login. + val persisted = accountManager.getAccount(it.publicKey) + val persistedUsername = + persisted?.username + ?.trim() + .orEmpty() + .ifBlank { null } + val refreshedName = + resolveAccountDisplayName( + it.publicKey, + persisted?.name ?: it.name, + persistedUsername + ) + if (refreshedName != it.name) { + val updated = it.copy(name = refreshedName) + currentAccount = updated + cacheSessionAccount(updated) + } } val accounts = accountManager.getAllAccounts() accountInfoList = accounts.map { it.toAccountInfo() } @@ -367,9 +431,9 @@ class MainActivity : FragmentActivity() { // lag currentAccount = null clearCachedSessionAccount() + com.rosetta.messenger.network.ProtocolManager + .disconnect() scope.launch { - com.rosetta.messenger.network.ProtocolManager - .disconnect() accountManager.logout() } } @@ -416,9 +480,9 @@ class MainActivity : FragmentActivity() { // lag currentAccount = null clearCachedSessionAccount() + com.rosetta.messenger.network.ProtocolManager + .disconnect() scope.launch { - com.rosetta.messenger.network.ProtocolManager - .disconnect() accountManager.logout() } }, @@ -509,8 +573,8 @@ class MainActivity : FragmentActivity() { // Switch to another account: logout current, then show unlock. currentAccount = null clearCachedSessionAccount() + com.rosetta.messenger.network.ProtocolManager.disconnect() scope.launch { - com.rosetta.messenger.network.ProtocolManager.disconnect() accountManager.logout() } }, @@ -520,8 +584,8 @@ class MainActivity : FragmentActivity() { preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() + com.rosetta.messenger.network.ProtocolManager.disconnect() scope.launch { - com.rosetta.messenger.network.ProtocolManager.disconnect() accountManager.logout() } } @@ -535,8 +599,8 @@ class MainActivity : FragmentActivity() { preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() + ProtocolManager.disconnect() scope.launch { - ProtocolManager.disconnect() accountManager.logout() } } @@ -941,6 +1005,15 @@ fun MainScreen( CallManager.bindAccount(accountPublicKey) } + // Global account binding for protocol/message repository. + // Keeps init independent from ChatsList composition timing. + LaunchedEffect(accountPublicKey, accountPrivateKey) { + val normalizedPublicKey = accountPublicKey.trim() + val normalizedPrivateKey = accountPrivateKey.trim() + if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) return@LaunchedEffect + ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey) + } + LaunchedEffect(callUiState.isVisible) { if (callUiState.isVisible) { isCallOverlayExpanded = true @@ -991,19 +1064,42 @@ fun MainScreen( isCallOverlayExpanded = false } + suspend fun refreshAccountIdentityState(accountKey: String) { + val accountManager = AccountManager(context) + val encryptedAccount = accountManager.getAccount(accountKey) + val cachedOwn = ProtocolManager.getCachedUserInfo(accountKey) + + val persistedName = encryptedAccount?.name?.trim().orEmpty() + val persistedUsername = encryptedAccount?.username?.trim().orEmpty() + val cachedName = cachedOwn?.title?.trim().orEmpty() + val cachedUsername = cachedOwn?.username?.trim().orEmpty() + + if (cachedName.isNotBlank() && + !isPlaceholderAccountName(cachedName) && + (persistedName.isBlank() || isPlaceholderAccountName(persistedName))) { + runCatching { accountManager.updateAccountName(accountKey, cachedName) } + } + if (cachedUsername.isNotBlank() && persistedUsername.isBlank()) { + runCatching { accountManager.updateAccountUsername(accountKey, cachedUsername) } + } + + val finalUsername = persistedUsername.ifBlank { cachedUsername } + val preferredName = + when { + persistedName.isNotBlank() && !isPlaceholderAccountName(persistedName) -> + persistedName + cachedName.isNotBlank() && !isPlaceholderAccountName(cachedName) -> cachedName + else -> encryptedAccount?.name ?: accountName + } + + accountUsername = finalUsername + accountVerified = cachedOwn?.verified ?: 0 + accountName = resolveAccountDisplayName(accountKey, preferredName, finalUsername) + } + LaunchedEffect(accountPublicKey, reloadTrigger) { if (accountPublicKey.isNotBlank()) { - val accountManager = AccountManager(context) - val encryptedAccount = accountManager.getAccount(accountPublicKey) - val username = encryptedAccount?.username - accountUsername = username.orEmpty() - accountVerified = ProtocolManager.getCachedUserInfo(accountPublicKey)?.verified ?: 0 - accountName = - resolveAccountDisplayName( - accountPublicKey, - encryptedAccount?.name ?: accountName, - username - ) + refreshAccountIdentityState(accountPublicKey) } else { accountVerified = 0 } @@ -1014,19 +1110,9 @@ fun MainScreen( // Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile() val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() - LaunchedEffect(ownProfileUpdated) { + LaunchedEffect(ownProfileUpdated, accountPublicKey) { if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) { - val accountManager = AccountManager(context) - val encryptedAccount = accountManager.getAccount(accountPublicKey) - val username = encryptedAccount?.username - accountUsername = username.orEmpty() - accountVerified = ProtocolManager.getCachedUserInfo(accountPublicKey)?.verified ?: 0 - accountName = - resolveAccountDisplayName( - accountPublicKey, - encryptedAccount?.name ?: accountName, - username - ) + refreshAccountIdentityState(accountPublicKey) } } @@ -1523,6 +1609,7 @@ fun MainScreen( // 🔒 Lock swipe-back while chat overlays are open (image viewer/editor/media picker/camera). var isChatSwipeLocked by remember { mutableStateOf(false) } + var isChatVoiceWaveGestureLocked by remember { mutableStateOf(false) } // 🔴 Badge: total unread from OTHER chats (excluding current) for back-chevron badge val totalUnreadFromOthers by remember(accountPublicKey, selectedUser?.publicKey) { @@ -1537,6 +1624,9 @@ fun MainScreen( var chatSelectionActive by remember { mutableStateOf(false) } val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) } + LaunchedEffect(selectedUser?.publicKey) { + isChatVoiceWaveGestureLocked = false + } SwipeBackContainer( isVisible = selectedUser != null, @@ -1549,7 +1639,7 @@ fun MainScreen( }, isDarkTheme = isDarkTheme, layer = 1, - swipeEnabled = !isChatSwipeLocked, + swipeEnabled = !(isChatSwipeLocked || isChatVoiceWaveGestureLocked), enterAnimation = SwipeBackEnterAnimation.SlideFromRight, propagateBackgroundProgress = false ) { @@ -1593,7 +1683,10 @@ fun MainScreen( isCallActive = callUiState.isVisible, onOpenCallOverlay = { isCallOverlayExpanded = true }, onSelectionModeChange = { chatSelectionActive = it }, - registerClearSelection = { fn -> chatClearSelectionRef.value = fn } + registerClearSelection = { fn -> chatClearSelectionRef.value = fn }, + onVoiceWaveGestureChanged = { locked -> + isChatVoiceWaveGestureLocked = locked + } ) } } 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 b06b8ba..51c1e30 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -819,11 +819,19 @@ class MessageRepository private constructor(private val context: Context) { } if (isGroupMessage && groupKey.isNullOrBlank()) { - MessageLogger.debug( - "📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..." + val requiresGroupKey = + (packet.content.isNotBlank() && isProbablyEncryptedPayload(packet.content)) || + packet.attachments.any { it.blob.isNotBlank() } + if (requiresGroupKey) { + MessageLogger.debug( + "📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..." + ) + processedMessageIds.remove(messageId) + return false + } + ProtocolManager.addLog( + "⚠️ GROUP fallback without key: ${messageId.take(8)}..., contentLikelyPlain=true" ) - processedMessageIds.remove(messageId) - return false } val plainKeyAndNonce = @@ -854,8 +862,9 @@ class MessageRepository private constructor(private val context: Context) { if (isAttachmentOnly) { "" } else if (isGroupMessage) { - CryptoManager.decryptWithPassword(packet.content, groupKey!!) - ?: throw IllegalStateException("Failed to decrypt group payload") + val decryptedGroupPayload = + groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) } + decryptedGroupPayload ?: safePlainMessageFallback(packet.content) } else if (plainKeyAndNonce != null) { MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce) } else { @@ -1004,7 +1013,9 @@ class MessageRepository private constructor(private val context: Context) { // 📝 LOG: Ошибка обработки MessageLogger.logDecryptionError(messageId, e) ProtocolManager.addLog( - "❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}" + "❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, " + + "group=$isGroupMessage, chachaLen=${packet.chachaKey.length}, " + + "aesLen=${packet.aesChachaKey.length}, reason=${e.javaClass.simpleName}:${e.message ?: ""}" ) // Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить. processedMessageIds.remove(messageId) 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 ef90b1e..97fbc6b 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -162,6 +162,7 @@ object CallManager { initialized = true appContext = context.applicationContext CallSoundManager.initialize(context) + CallProximityManager.initialize(context) XChaCha20E2EE.initWithContext(context) signalWaiter = ProtocolManager.waitCallSignal { packet -> @@ -1068,12 +1069,10 @@ object CallManager { attachments = listOf(callAttachment) ) } else { - // CALLEE: save call event locally (incoming from peer) - // CALLER will send their own message which may arrive later - MessageRepository.getInstance(context).saveIncomingCallEvent( - fromPublicKey = peerPublicKey, - durationSec = durationSec - ) + // CALLEE: do not create local fallback call message. + // Caller sends a single canonical CALL attachment; local fallback here + // caused duplicates (local + remote) in direct dialogs. + breadcrumb("CALL ATTACHMENT: CALLEE skip local fallback, waiting caller message") } }.onFailure { error -> Log.w(TAG, "Failed to emit call attachment", error) @@ -1086,6 +1085,7 @@ object CallManager { disarmConnectingTimeout("resetSession") breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}") breadcrumbState("resetSession") + appContext?.let { CallProximityManager.setEnabled(it, false) } val snapshot = _state.value val wasActive = snapshot.phase != CallPhase.IDLE val peerToNotify = snapshot.peerPublicKey @@ -1616,6 +1616,13 @@ object CallManager { val old = _state.value _state.update(reducer) val newState = _state.value + // Proximity is needed only while call is connecting/active and speaker is off. + appContext?.let { context -> + val shouldEnableProximity = + (newState.phase == CallPhase.CONNECTING || newState.phase == CallPhase.ACTIVE) && + !newState.isSpeakerOn + CallProximityManager.setEnabled(context, shouldEnableProximity) + } // Синхронизируем ForegroundService при смене фазы или имени if (newState.phase != CallPhase.IDLE && (newState.phase != old.phase || newState.displayName != old.displayName)) { diff --git a/app/src/main/java/com/rosetta/messenger/network/CallProximityManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallProximityManager.kt new file mode 100644 index 0000000..d8aca4a --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/CallProximityManager.kt @@ -0,0 +1,140 @@ +package com.rosetta.messenger.network + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.PowerManager +import android.util.Log + +/** + * Controls proximity screen-off behavior during active calls. + * Uses PROXIMITY_SCREEN_OFF_WAKE_LOCK to mimic phone-call UX. + */ +object CallProximityManager : SensorEventListener { + + private const val TAG = "CallProximityManager" + private const val WAKE_LOCK_TAG = "Rosetta:CallProximity" + + private val lock = Any() + + private var sensorManager: SensorManager? = null + private var proximitySensor: Sensor? = null + private var wakeLock: PowerManager.WakeLock? = null + + private var enabled: Boolean = false + private var listenerRegistered: Boolean = false + private var lastNearState: Boolean? = null + + fun initialize(context: Context) { + synchronized(lock) { + if (sensorManager != null) return + val app = context.applicationContext + + sensorManager = app.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + proximitySensor = sensorManager?.getDefaultSensor(Sensor.TYPE_PROXIMITY) + + val powerManager = app.getSystemService(Context.POWER_SERVICE) as? PowerManager + val wakeSupported = + powerManager?.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) == true + wakeLock = + if (wakeSupported) { + powerManager + ?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, WAKE_LOCK_TAG) + ?.apply { setReferenceCounted(false) } + } else { + null + } + + Log.i( + TAG, + "initialize: sensor=${proximitySensor != null} wakeLockSupported=$wakeSupported" + ) + } + } + + fun setEnabled(context: Context, shouldEnable: Boolean) { + initialize(context) + synchronized(lock) { + if (enabled == shouldEnable) return + enabled = shouldEnable + if (shouldEnable) { + registerListenerLocked() + } else { + unregisterListenerLocked() + releaseWakeLockLocked() + lastNearState = null + } + } + } + + fun shutdown() { + synchronized(lock) { + enabled = false + unregisterListenerLocked() + releaseWakeLockLocked() + lastNearState = null + } + } + + override fun onSensorChanged(event: SensorEvent?) { + val ev = event ?: return + val near = isNear(ev) + synchronized(lock) { + if (!enabled) return + if (lastNearState == near) return + lastNearState = near + if (near) { + acquireWakeLockLocked() + } else { + releaseWakeLockLocked() + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + + private fun registerListenerLocked() { + if (listenerRegistered) return + val sm = sensorManager + val sensor = proximitySensor + if (sm == null || sensor == null) { + Log.w(TAG, "register skipped: no proximity sensor") + return + } + listenerRegistered = sm.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL) + Log.i(TAG, "registerListener: ok=$listenerRegistered") + } + + private fun unregisterListenerLocked() { + if (!listenerRegistered) return + runCatching { sensorManager?.unregisterListener(this) } + listenerRegistered = false + Log.i(TAG, "unregisterListener") + } + + private fun acquireWakeLockLocked() { + val wl = wakeLock ?: return + if (wl.isHeld) return + runCatching { wl.acquire() } + .onSuccess { Log.i(TAG, "wakeLock acquired (near)") } + .onFailure { Log.w(TAG, "wakeLock acquire failed: ${it.message}") } + } + + private fun releaseWakeLockLocked() { + val wl = wakeLock ?: return + if (!wl.isHeld) return + runCatching { wl.release() } + .onSuccess { Log.i(TAG, "wakeLock released (far/disabled)") } + .onFailure { Log.w(TAG, "wakeLock release failed: ${it.message}") } + } + + private fun isNear(event: SensorEvent): Boolean { + val value = event.values.firstOrNull() ?: return false + val maxRange = event.sensor.maximumRange + // Treat as "near" if below max range and below a common 5cm threshold. + return value < maxRange && value < 5f + } +} + diff --git a/app/src/main/java/com/rosetta/messenger/network/CallSoundManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallSoundManager.kt index 966d2df..b42f1a8 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallSoundManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallSoundManager.kt @@ -48,6 +48,22 @@ object CallSoundManager { stop() currentSound = sound + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + val ringerMode = audioManager?.ringerMode ?: AudioManager.RINGER_MODE_NORMAL + val allowAudible = ringerMode == AudioManager.RINGER_MODE_NORMAL + val allowVibration = + sound == CallSound.RINGTONE && + (ringerMode == AudioManager.RINGER_MODE_NORMAL || + ringerMode == AudioManager.RINGER_MODE_VIBRATE) + + if (!allowAudible) { + if (allowVibration) { + startVibration() + } + Log.i(TAG, "Skip audible $sound due to ringerMode=$ringerMode") + return + } + val resId = when (sound) { CallSound.RINGTONE -> R.raw.call_ringtone CallSound.CALLING -> R.raw.call_calling @@ -86,7 +102,7 @@ object CallSoundManager { mediaPlayer = player // Vibrate for incoming calls - if (sound == CallSound.RINGTONE) { + if (allowVibration) { startVibration() } 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 65943f4..e7d8f4d 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -301,6 +301,32 @@ class Protocol( startHeartbeat(packet.heartbeatInterval) } } + + // Device verification resolution from primary device. + // Desktop typically continues after next handshake response; here we also + // add a safety re-handshake trigger on ACCEPT to avoid being stuck in + // DEVICE_VERIFICATION_REQUIRED if server doesn't immediately push 0x00. + waitPacket(0x18) { packet -> + val resolve = packet as? PacketDeviceResolve ?: return@waitPacket + when (resolve.solution) { + DeviceResolveSolution.ACCEPT -> { + log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(resolve.deviceId, 12)})") + if (_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED) { + setState(ProtocolState.CONNECTED, "Device verification accepted") + val publicKey = lastPublicKey + val privateHash = lastPrivateHash + if (!publicKey.isNullOrBlank() && !privateHash.isNullOrBlank()) { + startHandshake(publicKey, privateHash, lastDevice) + } else { + log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect") + } + } + } + DeviceResolveSolution.DECLINE -> { + log("⛔ DEVICE VERIFICATION DECLINED (deviceId=${shortKey(resolve.deviceId, 12)})") + } + } + } } /** @@ -847,6 +873,11 @@ class Protocol( "⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason" ) + if (isManuallyClosed) { + log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason") + return + } + if (!hasCredentials) return if (currentState == ProtocolState.CONNECTING && isConnecting) { diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index c665119..2f2d427 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -293,11 +293,28 @@ object ProtocolManager { * Должен вызываться после авторизации пользователя */ fun initializeAccount(publicKey: String, privateKey: String) { + val normalizedPublicKey = publicKey.trim() + val normalizedPrivateKey = privateKey.trim() + if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) { + addLog("⚠️ initializeAccount skipped: missing account credentials") + return + } + + addLog( + "🔐 initializeAccount pk=${shortKeyForLog(normalizedPublicKey)} keyLen=${normalizedPrivateKey.length} state=${getProtocol().state.value}" + ) setSyncInProgress(false) clearTypingState() - messageRepository?.initialize(publicKey, privateKey) - if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) { + messageRepository?.initialize(normalizedPublicKey, normalizedPrivateKey) + + val shouldResync = resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true + if (shouldResync) { + // Late account init may happen while an old sync request flag is still set. + // Force a fresh synchronize request to recover dropped inbound packets. resyncRequiredAfterAccountInit = false + syncRequestInFlight = false + clearSyncRequestTimeout() + addLog("🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync") requestSynchronize() } // Send "Rosetta Updates" message on version change (like desktop useUpdateMessage) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index 0cec146..2d0459d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -29,6 +29,7 @@ import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount +import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.launch @@ -308,6 +309,9 @@ fun SetPasswordScreen( ) accountManager.saveAccount(account) val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey) + // Initialize repository/account context before handshake completes to avoid + // "Sync postponed until account is initialized" race on first login. + ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey) startAuthHandshakeFast(keyPair.publicKey, privateKeyHash) accountManager.setCurrentAccount(keyPair.publicKey) val decryptedAccount = DecryptedAccount( diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index f9b4c87..b7365f4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -45,6 +45,7 @@ import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.chats.getAvatarColor @@ -116,6 +117,9 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword( name = selectedAccount.name ) + // Initialize repository/account context before handshake completes to avoid + // "Sync postponed until account is initialized" race. + ProtocolManager.initializeAccount(account.publicKey, decryptedPrivateKey) startAuthHandshakeFast(account.publicKey, privateKeyHash) accountManager.setCurrentAccount(account.publicKey) 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 f3861bf..03caafb 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 @@ -327,7 +327,8 @@ fun ChatDetailScreen( isCallActive: Boolean = false, onOpenCallOverlay: () -> Unit = {}, onSelectionModeChange: (Boolean) -> Unit = {}, - registerClearSelection: (() -> Unit) -> Unit = {} + registerClearSelection: (() -> Unit) -> Unit = {}, + onVoiceWaveGestureChanged: (Boolean) -> Unit = {} ) { val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val context = LocalContext.current @@ -392,13 +393,20 @@ fun ChatDetailScreen( // 🔥 MESSAGE SELECTION STATE - для Reply/Forward var selectedMessages by remember { mutableStateOf>(emptySet()) } val isSelectionMode = selectedMessages.isNotEmpty() + var isVoiceWaveGestureActive by remember { mutableStateOf(false) } // Notify parent about selection mode changes so it can intercept swipe-back LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) } + LaunchedEffect(isVoiceWaveGestureActive) { + onVoiceWaveGestureChanged(isVoiceWaveGestureActive) + } // Register selection-clear callback so parent can cancel selection on swipe-back DisposableEffect(Unit) { registerClearSelection { selectedMessages = emptySet() } - onDispose { registerClearSelection {} } + onDispose { + registerClearSelection {} + onVoiceWaveGestureChanged(false) + } } // После long press AndroidView текста может прислать tap на отпускание. // В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался. @@ -1586,6 +1594,27 @@ fun ChatDetailScreen( label = "headerContent" ) { currentHeaderMode -> if (currentHeaderMode == ChatHeaderMode.SELECTION) { + val selectedPinMessageId = + remember(selectedMessages, messages) { + if (selectedMessages.size != 1) { + null + } else { + messages + .firstOrNull { + selectedMessages.contains(it.id) && + it.attachments.none { attachment -> + attachment.type == AttachmentType.AVATAR + } + } + ?.id + } + } + val selectedPinMessageIsPinned = + remember(selectedPinMessageId, pinnedMessages) { + selectedPinMessageId != null && + pinnedMessages.any { it.messageId == selectedPinMessageId } + } + val canToggleSelectedPin = selectedPinMessageId != null // SELECTION MODE CONTENT Row( modifier = @@ -1711,6 +1740,54 @@ fun ChatDetailScreen( ) } + // Pin / Unpin button + IconButton( + onClick = { + val targetId = + selectedPinMessageId + ?: return@IconButton + if (selectedPinMessageIsPinned) { + viewModel.unpinMessage(targetId) + } else { + viewModel.pinMessage(targetId) + isPinnedBannerDismissed = + false + } + selectedMessages = + emptySet() + }, + enabled = + canToggleSelectedPin + ) { + Icon( + painter = + if (selectedPinMessageIsPinned) { + TelegramIcons.Unpin + } else { + TelegramIcons.Pin + }, + contentDescription = + if (selectedPinMessageIsPinned) { + "Unpin" + } else { + "Pin" + }, + tint = + if (canToggleSelectedPin) { + headerIconColor + } else { + headerIconColor.copy( + alpha = + 0.45f + ) + }, + modifier = + Modifier.size( + 22.dp + ) + ) + } + // Delete button IconButton( onClick = { @@ -2404,6 +2481,27 @@ fun ChatDetailScreen( ) { selectionMode -> if (selectionMode) { if (!isSystemAccount) { + val selectedPinMessageId = + remember(selectedMessages, messages) { + if (selectedMessages.size != 1) { + null + } else { + messages + .firstOrNull { + selectedMessages.contains(it.id) && + it.attachments.none { attachment -> + attachment.type == AttachmentType.AVATAR + } + } + ?.id + } + } + val selectedPinMessageIsPinned = + remember(selectedPinMessageId, pinnedMessages) { + selectedPinMessageId != null && + pinnedMessages.any { it.messageId == selectedPinMessageId } + } + val canToggleSelectedPin = selectedPinMessageId != null // SELECTION ACTION BAR - Reply/Forward Column( modifier = @@ -2735,6 +2833,7 @@ fun ChatDetailScreen( ) } } + } } } @@ -3396,9 +3495,12 @@ fun ChatDetailScreen( listOf( message ) - ) + ) } }, + onVoiceWaveGestureActiveChanged = { active -> + isVoiceWaveGestureActive = active + }, onReplyClick = { messageId -> 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 7e6b998..a3e2271 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 @@ -458,6 +458,7 @@ fun ChatsListScreen( // Protocol connection state val protocolState by ProtocolManager.state.collectAsState() val syncInProgress by ProtocolManager.syncInProgress.collectAsState() + val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() // 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads) @@ -511,11 +512,8 @@ fun ChatsListScreen( // Устанавливаем аккаунт для RecentSearchesManager RecentSearchesManager.setAccount(normalizedPublicKey) - // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих - // сообщений только когда приватный ключ уже доступен. - if (normalizedPrivateKey.isNotEmpty()) { - ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey) - } + // Protocol/account initialization is handled globally in MainScreen. + // ChatsList keeps only dialog/account-dependent UI state binding. android.util.Log.d( "ChatsListScreen", @@ -623,13 +621,45 @@ fun ChatsListScreen( // 👥 Load all accounts for sidebar (current account always first) var allAccounts by remember { mutableStateOf>(emptyList()) } - LaunchedEffect(accountPublicKey) { + LaunchedEffect(accountPublicKey, ownProfileUpdated) { val accountManager = AccountManager(context) - val accounts = accountManager.getAllAccounts() + var accounts = accountManager.getAllAccounts() val preferredPublicKey = accountPublicKey.trim().ifBlank { accountManager.getLastLoggedPublicKey().orEmpty() } + + if (preferredPublicKey.isNotBlank()) { + val cachedOwn = ProtocolManager.getCachedUserInfo(preferredPublicKey) + val cachedTitle = cachedOwn?.title?.trim().orEmpty() + val cachedUsername = cachedOwn?.username?.trim().orEmpty() + val existing = + accounts.firstOrNull { + it.publicKey.equals(preferredPublicKey, ignoreCase = true) + } + var changed = false + + if (existing != null && + cachedTitle.isNotBlank() && + !isPlaceholderAccountName(cachedTitle) && + (existing.name.isBlank() || isPlaceholderAccountName(existing.name))) { + runCatching { + accountManager.updateAccountName(preferredPublicKey, cachedTitle) + }.onSuccess { changed = true } + } + + if (existing != null && + cachedUsername.isNotBlank() && + existing.username.orEmpty().isBlank()) { + runCatching { + accountManager.updateAccountUsername(preferredPublicKey, cachedUsername) + }.onSuccess { changed = true } + } + + if (changed) { + accounts = accountManager.getAllAccounts() + } + } allAccounts = accounts.sortedByDescending { it.publicKey == preferredPublicKey } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt index c490391..329007c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt @@ -69,8 +69,37 @@ fun ForwardChatPickerBottomSheet( val messagesCount = forwardMessages.size // 🔥 Фильтруем системные аккаунты (Safe, Rosetta Updates) - val filteredDialogs = remember(dialogs) { - dialogs.filter { !MessageRepository.isSystemAccount(it.opponentKey) } + // и всегда добавляем Saved Messages, даже если self-диалог еще не создан. + val filteredDialogs = remember(dialogs, currentUserPublicKey) { + val base = dialogs.filter { !MessageRepository.isSystemAccount(it.opponentKey) } + val selfKey = currentUserPublicKey.trim() + if (selfKey.isBlank() || base.any { it.opponentKey == selfKey }) { + base + } else { + val savedMessagesDialog = + DialogUiModel( + id = Long.MIN_VALUE, + account = selfKey, + opponentKey = selfKey, + opponentTitle = "Saved Messages", + opponentUsername = "", + lastMessage = "", + lastMessageTimestamp = 0L, + unreadCount = 0, + isOnline = 0, + lastSeen = 0L, + verified = 0, + isSavedMessages = true, + lastMessageFromMe = 1, + lastMessageDelivered = 1, + lastMessageRead = 1, + lastMessageAttachmentType = null, + lastMessageSenderPrefix = null, + lastMessageSenderKey = null, + draftText = null + ) + listOf(savedMessagesDialog) + base + } } // Мультивыбор чатов diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index ca7e8f3..d3232ad 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -2,9 +2,11 @@ package com.rosetta.messenger.ui.chats.attach import android.Manifest import android.app.Activity +import android.content.Intent import android.content.Context import android.content.pm.PackageManager import android.os.Build +import android.provider.Settings import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -20,6 +22,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -33,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.lifecycle.viewmodel.compose.viewModel @@ -355,11 +359,20 @@ fun ChatAttachAlert( // ═══════════════════════════════════════════════════════════ // Permission handling // ═══════════════════════════════════════════════════════════ + val mediaPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO + ) + } else { + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + var mediaPermissionRequestedOnce by rememberSaveable { mutableStateOf(false) } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> - val granted = permissions.values.all { it } + val granted = mediaPermissions.all { permissions[it] == true } viewModel.setPermissionGranted(granted) if (granted) { viewModel.loadMedia(context) @@ -367,19 +380,35 @@ fun ChatAttachAlert( } fun requestPermissions() { - val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.CAMERA - ) - } else { - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.CAMERA - ) + val deniedPermissions = mediaPermissions.filter { + ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED } - permissionLauncher.launch(permissions) + if (deniedPermissions.isEmpty()) { + viewModel.setPermissionGranted(true) + viewModel.loadMedia(context) + return + } + + val activity = context as? Activity + val permanentlyDenied = activity != null && + mediaPermissionRequestedOnce && + deniedPermissions.all { permission -> + !ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + } + + if (permanentlyDenied) { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + return + } + + mediaPermissionRequestedOnce = true + permissionLauncher.launch(mediaPermissions) } // ═══════════════════════════════════════════════════════════ @@ -454,20 +483,7 @@ fun ChatAttachAlert( resetPickerCaptionInput() photoCaption = "" - val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.CAMERA - ) - } else { - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.CAMERA - ) - } - - val hasPermission = permissions.all { + val hasPermission = mediaPermissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } viewModel.setPermissionGranted(hasPermission) 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 e1b25bb..f486bac 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -1,5 +1,6 @@ package com.rosetta.messenger.ui.chats.components +import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -32,12 +33,16 @@ import androidx.compose.foundation.border import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -54,9 +59,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -97,17 +106,31 @@ import java.io.ByteArrayInputStream import java.io.File import java.security.MessageDigest import java.text.SimpleDateFormat +import java.util.Date import java.util.Locale import kotlin.math.min import kotlin.math.PI import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.core.content.FileProvider private const val TAG = "AttachmentComponents" private const val MAX_BITMAP_DECODE_DIMENSION = 4096 +private const val VOICE_WAVE_DEBUG_LOG = true private val whitespaceRegex = "\\s+".toRegex() private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} } +private fun rosettaDev1AttachmentLog(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") + }.onFailure { err -> + android.util.Log.e(TAG, "rosettadev1 write failed: ${err.message}") + } +} + private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:") private fun canonicalGroupDialogKey(value: String): String { @@ -173,12 +196,30 @@ private fun decodeVoicePayload(data: String): ByteArray? { return decodeHexPayload(data) ?: decodeBase64Payload(data) } +data class VoiceQueueEntry( + val attachmentId: String, + val dialogKey: String, + val timestampMs: Long, + val orderInMessage: Int, + val senderLabel: String, + val playedAtLabel: String, + val audioFilePath: String?, + val payload: String, + val downloadTag: String, + val transportServer: String, + val chachaKey: String, + val chachaKeyPlainHex: String, + val privateKey: String +) + object VoicePlaybackCoordinator { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val speedSteps = listOf(1f, 1.5f, 2f) private var player: MediaPlayer? = null private var currentAttachmentId: String? = null + private var appContext: Context? = null private var progressJob: Job? = null + private val queueEntries = LinkedHashMap() private val _playingAttachmentId = MutableStateFlow(null) val playingAttachmentId: StateFlow = _playingAttachmentId.asStateFlow() private val _playingDialogKey = MutableStateFlow(null) @@ -196,6 +237,15 @@ object VoicePlaybackCoordinator { private val _playingTimeLabel = MutableStateFlow("") val playingTimeLabel: StateFlow = _playingTimeLabel.asStateFlow() + fun upsertQueueEntry(context: Context, entry: VoiceQueueEntry) { + appContext = context.applicationContext + queueEntries[entry.attachmentId] = entry + } + + fun removeQueueEntry(attachmentId: String) { + queueEntries.remove(attachmentId) + } + fun toggle( attachmentId: String, sourceFile: File, @@ -228,7 +278,7 @@ object VoicePlaybackCoordinator { .build() ) mediaPlayer.setDataSource(sourceFile.absolutePath) - mediaPlayer.setOnCompletionListener { stop() } + mediaPlayer.setOnCompletionListener { handleTrackCompleted(attachmentId) } mediaPlayer.prepare() applyPlaybackSpeed(mediaPlayer) mediaPlayer.start() @@ -258,6 +308,61 @@ object VoicePlaybackCoordinator { } } + fun prepareForScrub( + attachmentId: String, + sourceFile: File, + dialogKey: String = "", + senderLabel: String = "", + playedAtLabel: String = "", + onError: (String) -> Unit = {} + ) { + if (!sourceFile.exists()) { + onError("Voice file is missing") + return + } + + if (currentAttachmentId == attachmentId && player != null) { + val active = player ?: return + _playingAttachmentId.value = attachmentId + _playingDialogKey.value = dialogKey.trim().ifBlank { null } + _playingSenderLabel.value = senderLabel.trim() + _playingTimeLabel.value = playedAtLabel.trim() + _durationMs.value = runCatching { active.duration }.getOrDefault(0).coerceAtLeast(0) + _positionMs.value = runCatching { active.currentPosition }.getOrDefault(0).coerceAtLeast(0) + return + } + + stop() + val mediaPlayer = MediaPlayer() + try { + mediaPlayer.setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + mediaPlayer.setDataSource(sourceFile.absolutePath) + mediaPlayer.setOnCompletionListener { handleTrackCompleted(attachmentId) } + mediaPlayer.prepare() + applyPlaybackSpeed(mediaPlayer) + player = mediaPlayer + currentAttachmentId = attachmentId + _playingAttachmentId.value = attachmentId + _playingDialogKey.value = dialogKey.trim().ifBlank { null } + _playingSenderLabel.value = senderLabel.trim() + _playingTimeLabel.value = playedAtLabel.trim() + _durationMs.value = mediaPlayer.duration.coerceAtLeast(0) + _positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0) + _isPlaying.value = false + progressJob?.cancel() + progressJob = null + } catch (e: Exception) { + runCatching { mediaPlayer.release() } + stop() + onError(e.message ?: "Playback failed") + } + } + fun pause() { val active = player ?: return runCatching { @@ -293,6 +398,43 @@ object VoicePlaybackCoordinator { setPlaybackSpeed(next) } + fun seekTo(positionMs: Int, keepPaused: Boolean = false) { + val active = player ?: return + val duration = runCatching { active.duration }.getOrDefault(_durationMs.value).coerceAtLeast(0) + if (duration <= 0) return + val clampedPosition = positionMs.coerceIn(0, duration) + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + active.seekTo(clampedPosition.toLong(), MediaPlayer.SEEK_CLOSEST_SYNC) + } else { + @Suppress("DEPRECATION") + active.seekTo(clampedPosition) + } + } + if (keepPaused) { + // Some devices auto-resume after seek; for paused scrub we force pause every time. + runCatching { + if (active.isPlaying) active.pause() + } + _isPlaying.value = false + progressJob?.cancel() + progressJob = null + } else { + _isPlaying.value = runCatching { active.isPlaying }.getOrDefault(false) + } + _positionMs.value = clampedPosition + _durationMs.value = duration + } + + fun seekToProgress(progress: Float, keepPaused: Boolean = false) { + val active = player ?: return + val duration = runCatching { active.duration }.getOrDefault(_durationMs.value).coerceAtLeast(0) + if (duration <= 0) return + val clampedProgress = progress.coerceIn(0f, 1f) + val targetMs = (duration.toFloat() * clampedProgress).toInt().coerceIn(0, duration) + seekTo(targetMs, keepPaused = keepPaused) + } + private fun setPlaybackSpeed(speed: Float) { val normalized = speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first() @@ -348,6 +490,92 @@ object VoicePlaybackCoordinator { runCatching { active.release() } } } + + private fun normalizedDialogKey(value: String): String { + return value.trim() + } + + private fun orderedEntriesForDialog(dialogKey: String): List { + val normalized = normalizedDialogKey(dialogKey) + return queueEntries.values + .asSequence() + .filter { normalizedDialogKey(it.dialogKey) == normalized } + .sortedWith( + // Telegram-like "next voice": move forward in timeline (old -> new). + compareBy { it.timestampMs } + .thenBy { it.orderInMessage } + .thenBy { it.attachmentId } + ) + .toList() + } + + private suspend fun prepareFileForEntry(entry: VoiceQueueEntry): File? { + entry.audioFilePath?.trim()?.takeIf { it.isNotBlank() }?.let { path -> + val file = File(path) + if (file.exists() && file.length() > 0L) return file + } + + val context = appContext ?: return null + + if (entry.payload.isNotBlank()) { + val fromPayload = + withContext(Dispatchers.IO) { + ensureVoiceAudioFile(context, entry.attachmentId, entry.payload) + } + if (fromPayload != null) { + queueEntries[entry.attachmentId] = + entry.copy(audioFilePath = fromPayload.absolutePath) + return fromPayload + } + } + + if (entry.downloadTag.isBlank()) return null + + val decrypted = + downloadAndDecryptVoicePayload( + attachmentId = entry.attachmentId, + downloadTag = entry.downloadTag, + chachaKey = entry.chachaKey, + privateKey = entry.privateKey, + transportServer = entry.transportServer, + chachaKeyPlainHex = entry.chachaKeyPlainHex + ) ?: return null + + val prepared = + withContext(Dispatchers.IO) { + ensureVoiceAudioFile(context, entry.attachmentId, decrypted) + } ?: return null + + queueEntries[entry.attachmentId] = + entry.copy(payload = decrypted, audioFilePath = prepared.absolutePath) + return prepared + } + + private fun handleTrackCompleted(completedAttachmentId: String) { + val completedDialog = _playingDialogKey.value.orEmpty() + val ordered = orderedEntriesForDialog(completedDialog) + val currentIndex = ordered.indexOfFirst { it.attachmentId == completedAttachmentId } + + stop() + if (currentIndex < 0) return + + val candidates = ordered.drop(currentIndex + 1) + if (candidates.isEmpty()) return + + scope.launch { + for (entry in candidates) { + val file = prepareFileForEntry(entry) ?: continue + toggle( + attachmentId = entry.attachmentId, + sourceFile = file, + dialogKey = entry.dialogKey, + senderLabel = entry.senderLabel, + playedAtLabel = entry.playedAtLabel + ) + return@launch + } + } + } } private fun shortDebugId(value: String): String { @@ -728,6 +956,7 @@ fun MessageAttachments( onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode onCancelUpload: (attachmentId: String) -> Unit = {}, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, + onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {}, modifier: Modifier = Modifier ) { if (attachments.isEmpty()) return @@ -762,6 +991,7 @@ fun MessageAttachments( } // Остальные attachments по отдельности + var voiceOrderInMessage = 0 otherAttachments.forEach { attachment -> when (attachment.type) { AttachmentType.FILE -> { @@ -800,6 +1030,7 @@ fun MessageAttachments( ) } AttachmentType.VOICE -> { + val currentVoiceOrder = voiceOrderInMessage++ VoiceAttachment( attachment = attachment, chachaKey = chachaKey, @@ -811,7 +1042,9 @@ fun MessageAttachments( isOutgoing = isOutgoing, isDarkTheme = isDarkTheme, timestamp = timestamp, - messageStatus = messageStatus + messageStatus = messageStatus, + attachmentOrderInMessage = currentVoiceOrder, + onVoiceWaveGestureActiveChanged = onVoiceWaveGestureActiveChanged ) } AttachmentType.VIDEO_CIRCLE -> { @@ -2040,6 +2273,8 @@ private fun formatVoicePlaybackSpeedLabel(speed: Float): String { } } +private const val VOICE_WAVEFORM_BAR_COUNT = 56 + @Composable private fun VoicePlaybackButtonBlob( level: Float, @@ -2237,7 +2472,9 @@ private fun VoiceAttachment( isOutgoing: Boolean, isDarkTheme: Boolean, timestamp: java.util.Date, - messageStatus: MessageStatus = MessageStatus.READ + messageStatus: MessageStatus = MessageStatus.READ, + attachmentOrderInMessage: Int = 0, + onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {} ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -2261,10 +2498,10 @@ private fun VoiceAttachment( remember(attachment.preview) { parseVoicePreview(attachment.preview) } val previewDurationSec = previewDurationSecRaw.coerceAtLeast(1) val previewWaves = - remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, 40) } + remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, VOICE_WAVEFORM_BAR_COUNT) } val waves = remember(previewWaves) { - if (previewWaves.isEmpty()) List(40) { 0f } else previewWaves + if (previewWaves.isEmpty()) List(VOICE_WAVEFORM_BAR_COUNT) { 0f } else previewWaves } var payload by @@ -2301,6 +2538,23 @@ private fun VoiceAttachment( } else { 0f } + var isScrubbing by remember(attachment.id) { mutableStateOf(false) } + var scrubProgress by remember(attachment.id) { mutableFloatStateOf(0f) } + var waveformWidthPx by remember(attachment.id) { mutableFloatStateOf(0f) } + var isWaveformTouchLocked by remember(attachment.id) { mutableStateOf(false) } + var suppressMainActionUntilMs by remember(attachment.id) { mutableLongStateOf(0L) } + val smoothProgress by + animateFloatAsState( + targetValue = progress, + animationSpec = + if (isPlaying) { + tween(durationMillis = 140, easing = LinearEasing) + } else { + tween(durationMillis = 180, easing = FastOutSlowInEasing) + }, + label = "voice_wave_progress" + ) + val displayProgress = if (isScrubbing) scrubProgress else smoothProgress val liveWaveLevel = remember(isPlaying, progress, waves) { if (!isPlaying || waves.isEmpty()) { @@ -2311,9 +2565,18 @@ private fun VoiceAttachment( waves[sampleIndex].coerceIn(0f, 1f) } } + val trackDurationMsForUi = + if (isActiveTrack && playbackDurationMs > 0) playbackDurationMs + else (effectiveDurationSec * 1000) + val displayPositionMs = + if (isScrubbing && trackDurationMsForUi > 0) { + (trackDurationMsForUi.toFloat() * scrubProgress.coerceIn(0f, 1f)).toInt() + } else { + playbackPositionMs.coerceAtLeast(0) + } val timeText = if (isActiveTrack && playbackDurationMs > 0) { - val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000) + val leftSec = ((playbackDurationMs - displayPositionMs).coerceAtLeast(0) / 1000) formatVoiceDuration(leftSec) } else { formatVoiceDuration(effectiveDurationSec) @@ -2334,6 +2597,61 @@ private fun VoiceAttachment( } .getOrDefault("") } + val initialPayload = remember(attachment.id, attachment.blob) { attachment.blob } + val normalizedDialogKey = remember(dialogPublicKey) { dialogPublicKey.trim() } + + LaunchedEffect( + attachment.id, + normalizedDialogKey, + timestamp.time, + attachmentOrderInMessage, + playbackSenderLabel, + playbackTimeLabel, + audioFilePath, + attachment.transportTag, + attachment.transportServer, + chachaKey, + chachaKeyPlainHex, + privateKey + ) { + VoicePlaybackCoordinator.upsertQueueEntry( + context = context, + entry = VoiceQueueEntry( + attachmentId = attachment.id, + dialogKey = normalizedDialogKey, + timestampMs = timestamp.time, + orderInMessage = attachmentOrderInMessage, + senderLabel = playbackSenderLabel, + playedAtLabel = playbackTimeLabel, + audioFilePath = audioFilePath, + payload = payload.ifBlank { initialPayload }, + downloadTag = attachment.transportTag, + transportServer = attachment.transportServer, + chachaKey = chachaKey, + chachaKeyPlainHex = chachaKeyPlainHex, + privateKey = privateKey + ) + ) + } + + DisposableEffect(attachment.id) { + onDispose { + onVoiceWaveGestureActiveChanged(false) + VoicePlaybackCoordinator.removeQueueEntry(attachment.id) + } + } + + LaunchedEffect(attachment.id, downloadStatus, isActiveTrack, isPlaying) { + if (VOICE_WAVE_DEBUG_LOG) { + rosettaDev1AttachmentLog( + context = context, + tag = "VoiceWave", + message = + "INIT att=${attachment.id.take(8)} status=$downloadStatus " + + "active=$isActiveTrack playing=$isPlaying hasFile=${audioFilePath?.isNotBlank() == true}" + ) + } + } val triggerDownload: () -> Unit = download@{ if (attachment.transportTag.isBlank()) { @@ -2391,7 +2709,11 @@ private fun VoiceAttachment( } } - val onMainAction: () -> Unit = { + val performMainAction: (Boolean) -> Unit = mainAction@{ ignoreWaveformTouchLock -> + val now = SystemClock.elapsedRealtime() + if (!ignoreWaveformTouchLock && (isWaveformTouchLocked || now < suppressMainActionUntilMs)) { + return@mainAction + } when (downloadStatus) { DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload() DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit @@ -2442,6 +2764,25 @@ private fun VoiceAttachment( } } } + val onMainAction: () -> Unit = { performMainAction(false) } + + val canHandleWaveformTouch = + downloadStatus == DownloadStatus.DOWNLOADED + fun progressFromWaveX(x: Float): Float { + if (waveformWidthPx <= 0f) return 0f + return (x / waveformWidthPx).coerceIn(0f, 1f) + } + val viewConfiguration = LocalViewConfiguration.current + val waveformDragStartThresholdPx = + remember(viewConfiguration.touchSlop) { + // Slightly below touchSlop to make waveform drag feel responsive. + viewConfiguration.touchSlop * 0.75f + } + val waveformTapCancelThresholdPx = + remember(viewConfiguration.touchSlop) { + // If finger moved at least a bit, don't treat it as tap-to-toggle. + viewConfiguration.touchSlop * 0.35f + } val barInactiveColor = if (isOutgoing) Color.White.copy(alpha = 0.38f) @@ -2456,23 +2797,27 @@ private fun VoiceAttachment( val actionBackground = when (downloadStatus) { DownloadStatus.ERROR -> Color(0xFFE55757) - else -> if (isOutgoing) Color.White.copy(alpha = 0.2f) else PrimaryBlue + else -> + if (isOutgoing) { + Color.White.copy(alpha = 0.2f) + } else if (isDarkTheme) { + Color(0xFF4C5562) + } else { + Color(0xFFE3E7EC) + } } val actionTint = when { downloadStatus == DownloadStatus.ERROR -> Color.White isOutgoing -> Color.White - else -> Color.White + else -> if (isDarkTheme) Color(0xFFF1F3F5) else Color(0xFF3B4652) } + val actionButtonInteraction = remember { MutableInteractionSource() } Row( modifier = Modifier.fillMaxWidth() - .padding(vertical = 4.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { onMainAction() }, + .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { Box( @@ -2496,7 +2841,12 @@ private fun VoiceAttachment( modifier = Modifier.size(40.dp) .clip(CircleShape) - .background(actionBackground), + .background(actionBackground) + .clickable( + enabled = !isWaveformTouchLocked, + interactionSource = actionButtonInteraction, + indication = null + ) { onMainAction() }, contentAlignment = Alignment.Center ) { if (downloadStatus == DownloadStatus.DOWNLOADING || @@ -2528,7 +2878,8 @@ private fun VoiceAttachment( else -> { Icon( imageVector = - if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + if (isPlaying) Icons.Outlined.Pause + else Icons.Default.PlayArrow, contentDescription = null, tint = actionTint, modifier = Modifier.size(20.dp) @@ -2544,27 +2895,187 @@ private fun VoiceAttachment( Column(modifier = Modifier.weight(1f)) { Row( modifier = Modifier.fillMaxWidth().height(28.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) + horizontalArrangement = Arrangement.spacedBy(1.dp) ) { + Row( + modifier = + Modifier.fillMaxWidth() + .height(28.dp) + .onSizeChanged { size -> + waveformWidthPx = size.width.toFloat() + } + .pointerInput( + attachment.id, + canHandleWaveformTouch + ) { + if (!canHandleWaveformTouch) return@pointerInput + awaitEachGesture { + val downChange = + awaitFirstDown( + requireUnconsumed = false, + pass = PointerEventPass.Main + ) + val wasPlayingOnTouchDown = isPlaying + var preparedForInteraction = isActiveTrack + var isDraggingWaveform = false + var movedEnoughForTapCancel = false + val downPosition = downChange.position + var lastTouchX = downPosition.x + var lastWaveLogAtMs = 0L + fun waveLog(message: String) { + if (!VOICE_WAVE_DEBUG_LOG) return + val now = SystemClock.elapsedRealtime() + if ( + now - lastWaveLogAtMs < 70L && + !message.startsWith("DOWN") && + !message.startsWith("DRAG_START") && + !message.startsWith("DRAG_END") && + !message.startsWith("TAP") + ) { + return + } + lastWaveLogAtMs = now + rosettaDev1AttachmentLog( + context = context, + tag = "VoiceWave", + message = + "att=${attachment.id.take(8)} active=$isActiveTrack playing=$isPlaying " + + "paused=${!wasPlayingOnTouchDown} $message" + ) + } + fun ensurePreparedForInteraction(): Boolean { + if (preparedForInteraction) return true + var sourceFile: File? = + audioFilePath + ?.let { path -> File(path) } + ?.takeIf { it.exists() && it.length() > 0L } + if (sourceFile == null && payload.isNotBlank()) { + sourceFile = + ensureVoiceAudioFile( + context = context, + attachmentId = attachment.id, + payload = payload + ) + if (sourceFile != null) { + audioFilePath = sourceFile.absolutePath + } + } + if (sourceFile == null) return false + VoicePlaybackCoordinator.prepareForScrub( + attachmentId = attachment.id, + sourceFile = sourceFile, + dialogKey = dialogPublicKey, + senderLabel = playbackSenderLabel, + playedAtLabel = playbackTimeLabel + ) { message -> + downloadStatus = DownloadStatus.ERROR + errorText = message + } + preparedForInteraction = true + waveLog("PREPARED file=${sourceFile.name}") + return true + } + + try { + onVoiceWaveGestureActiveChanged(true) + isWaveformTouchLocked = true + suppressMainActionUntilMs = + SystemClock.elapsedRealtime() + 700L + downChange.consume() + waveLog( + "DOWN x=${downChange.position.x.toInt()} y=${downChange.position.y.toInt()}" + ) + val pointerId = downChange.id + + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + val trackedChange = + event.changes.firstOrNull { it.id == pointerId } ?: break + lastTouchX = trackedChange.position.x + + val dx = trackedChange.position.x - downPosition.x + val dy = trackedChange.position.y - downPosition.y + if (!movedEnoughForTapCancel && + (kotlin.math.abs(dx) > waveformTapCancelThresholdPx || + kotlin.math.abs(dy) > waveformTapCancelThresholdPx) + ) { + movedEnoughForTapCancel = true + } + if (!isDraggingWaveform && + kotlin.math.abs(dx) > waveformDragStartThresholdPx && + trackedChange.pressed + ) { + if (!ensurePreparedForInteraction()) { + waveLog("DRAG_ABORT no-source") + trackedChange.consume() + break + } + isDraggingWaveform = true + isScrubbing = true + waveLog( + "DRAG_START dx=${dx.toInt()} threshold=${waveformDragStartThresholdPx.toInt()}" + ) + } + + if (isDraggingWaveform) { + val progressAtTouch = + progressFromWaveX(trackedChange.position.x) + scrubProgress = progressAtTouch + waveLog( + "DRAG_MOVE p=${(progressAtTouch * 100).toInt()}% pressed=${trackedChange.pressed}" + ) + } + + trackedChange.consume() + if (!trackedChange.pressed) break + } + + if (isDraggingWaveform) { + val releaseProgress = progressFromWaveX(lastTouchX) + scrubProgress = releaseProgress + VoicePlaybackCoordinator.seekToProgress( + releaseProgress, + keepPaused = !wasPlayingOnTouchDown + ) + waveLog( + "DRAG_END seek=${(releaseProgress * 100).toInt()}%" + ) + } else if (movedEnoughForTapCancel) { + waveLog("TAP_SUPPRESSED moved-before-drag") + } else { + waveLog("TAP_ACTION") + performMainAction(true) + } + } finally { + isScrubbing = false + isWaveformTouchLocked = false + suppressMainActionUntilMs = + SystemClock.elapsedRealtime() + 280L + onVoiceWaveGestureActiveChanged(false) + } + } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp) + ) { waves.forEachIndexed { index, value -> val normalized = value.coerceIn(0f, 1f) - val passed = (progress * waves.size) - index - val fill = passed.coerceIn(0f, 1f) - val color = - if (fill > 0f) { - barActiveColor - } else { - barInactiveColor - } + val waveCount = waves.size.coerceAtLeast(1) + val barStart = index.toFloat() / waveCount.toFloat() + val barEnd = (index + 1).toFloat() / waveCount.toFloat() + val fill = + ((displayProgress - barStart) / (barEnd - barStart)) + .coerceIn(0f, 1f) + val color = lerp(barInactiveColor, barActiveColor, fill) Box( modifier = - Modifier.width(2.dp) + Modifier.weight(1f) .height((4f + normalized * 18f).dp) .clip(RoundedCornerShape(100)) .background(color) ) } + } } Row( modifier = Modifier.fillMaxWidth(), @@ -2589,37 +3100,45 @@ 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 - ) - } + // Speed chip — always reserve space so the bubble doesn't reflow + // when playback starts/stops; fixed width so cycling 1x/1.5x/2x + // doesn't jitter the row either. + 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.width(32.dp) + .graphicsLayer { alpha = if (isActiveTrack) 1f else 0f } + .clip(RoundedCornerShape(10.dp)) + .background(speedChipBackground) + .then( + if (isActiveTrack) { + Modifier.clickable( + interactionSource = + remember { MutableInteractionSource() }, + indication = null + ) { + VoicePlaybackCoordinator.cycleSpeed() + } + } else Modifier + ) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = formatVoicePlaybackSpeedLabel(playbackSpeed), + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = speedChipTextColor, + maxLines = 1 + ) } Text( text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(), 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 1598a93..9738eb3 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 @@ -353,6 +353,7 @@ fun MessageBubble( onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onMentionClick: (username: String) -> Unit = {}, onGroupInviteOpen: (SearchUser) -> Unit = {}, + onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {}, contextMenuContent: @Composable () -> Unit = {} ) { val isTextSelectionOnThisMessage = @@ -364,6 +365,7 @@ fun MessageBubble( textSelectionHelper?.isInSelectionMode == true && textSelectionHelper.selectedMessageId == message.id } + var isVoiceWaveGestureActive by remember(message.id) { mutableStateOf(false) } // Swipe-to-reply state val hapticFeedback = LocalHapticFeedback.current @@ -427,6 +429,8 @@ fun MessageBubble( val suppressBubbleTapFromSpan: () -> Unit = { suppressBubbleTapUntilMs = System.currentTimeMillis() + 450L } + val hasVoiceAttachmentForGesture = + remember(message.attachments) { message.attachments.any { it.type == AttachmentType.VOICE } } val timeColor = remember(message.isOutgoing, isDarkTheme) { @@ -463,6 +467,17 @@ fun MessageBubble( return } + // Guard against malformed/empty payloads: don't render sender label without any content. + val isVisuallyEmptyMessage = + !isSafeSystemMessage && + message.replyData == null && + message.forwardedMessages.isEmpty() && + message.attachments.isEmpty() && + message.text.isBlank() + if (isVisuallyEmptyMessage) { + return + } + // Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp val bubbleShape = remember(message.isOutgoing, showTail, isSafeSystemMessage) { @@ -490,15 +505,27 @@ fun MessageBubble( Box( modifier = - Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) { + 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 который лучше работает со // скроллом detectHorizontalDragGestures( - onDragStart = {}, + onDragStart = { + if (isVoiceWaveGestureActive) { + swipeOffset = 0f + hapticTriggered = false + } + }, onDragEnd = { + if (isVoiceWaveGestureActive) { + swipeOffset = 0f + hapticTriggered = false + return@detectHorizontalDragGestures + } // Если свайпнули достаточно влево - reply if (swipeOffset <= -swipeThreshold) { onSwipeToReply() @@ -511,6 +538,11 @@ fun MessageBubble( hapticTriggered = false }, onHorizontalDrag = { change, dragAmount -> + if (isVoiceWaveGestureActive) { + swipeOffset = 0f + hapticTriggered = false + return@detectHorizontalDragGestures + } // Только свайп влево (отрицательное значение) if (dragAmount < 0 || swipeOffset < 0) { change.consume() @@ -1046,7 +1078,11 @@ fun MessageBubble( onLongClick = onLongClick, onCancelUpload = onCancelPhotoUpload, // В selection mode блокируем открытие фото - onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick + onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick, + onVoiceWaveGestureActiveChanged = { active -> + isVoiceWaveGestureActive = active + onVoiceWaveGestureActiveChanged(active) + } ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index 80f4532..3a7d165 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -2,12 +2,14 @@ package com.rosetta.messenger.ui.chats.components import android.Manifest import android.app.Activity +import android.content.Intent import android.content.ContentUris import android.content.Context import android.util.Log import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.provider.Settings import android.provider.MediaStore import android.view.WindowManager import android.view.inputmethod.InputMethodManager @@ -29,6 +31,7 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -58,6 +61,7 @@ import androidx.camera.core.CameraSelector import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import coil.compose.AsyncImage @@ -224,12 +228,22 @@ fun MediaPickerBottomSheet( selectedItemOrder } } - + + val mediaPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO + ) + } else { + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + var mediaPermissionRequestedOnce by rememberSaveable { mutableStateOf(false) } + // Permission launcher val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> - hasPermission = permissions.values.all { it } + hasPermission = mediaPermissions.all { permissions[it] == true } if (hasPermission) { scope.launch { val loaded = loadMediaPickerData(context) @@ -240,6 +254,37 @@ fun MediaPickerBottomSheet( } } } + + fun requestMediaPermissions() { + val deniedPermissions = mediaPermissions.filter { + ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED + } + if (deniedPermissions.isEmpty()) { + hasPermission = true + return + } + + val activity = context as? Activity + val permanentlyDenied = activity != null && + mediaPermissionRequestedOnce && + deniedPermissions.all { permission -> + !ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + } + + if (permanentlyDenied) { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + return + } + + mediaPermissionRequestedOnce = true + permissionLauncher.launch(mediaPermissions) + } // Check permission on show LaunchedEffect(isVisible) { @@ -249,20 +294,7 @@ fun MediaPickerBottomSheet( resetPickerCaptionInput() selectedAlbumId = ALL_MEDIA_ALBUM_ID - val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.CAMERA - ) - } else { - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.CAMERA - ) - } - - hasPermission = permissions.all { + hasPermission = mediaPermissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } @@ -274,7 +306,7 @@ fun MediaPickerBottomSheet( selectedAlbumId = loaded.albums.firstOrNull()?.id ?: ALL_MEDIA_ALBUM_ID isLoading = false } else { - permissionLauncher.launch(permissions) + requestMediaPermissions() } } } @@ -975,19 +1007,7 @@ fun MediaPickerBottomSheet( textColor = textColor, secondaryTextColor = secondaryTextColor, onRequestPermission = { - val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.CAMERA - ) - } else { - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.CAMERA - ) - } - permissionLauncher.launch(permissions) + requestMediaPermissions() } ) } else if (isLoading) { 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 ef66da1..214a795 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 @@ -17,8 +17,6 @@ import com.airbnb.lottie.compose.rememberLottieComposition import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.Videocam import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas @@ -78,6 +76,9 @@ import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator import androidx.core.content.ContextCompat import coil.compose.AsyncImage import coil.request.ImageRequest +import compose.icons.TablerIcons +import compose.icons.tablericons.Microphone +import compose.icons.tablericons.Video import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.R @@ -2547,6 +2548,7 @@ fun MessageInputBar( scaleX = recordingActionVisualScale * circleSlideCancelScale scaleY = recordingActionVisualScale * circleSlideCancelScale transformOrigin = TransformOrigin(0.5f, 0.5f) + clip = false } .zIndex(5f), contentAlignment = Alignment.Center @@ -2616,6 +2618,7 @@ fun MessageInputBar( scaleY = sendScale * recordingActionVisualScale shadowElevation = 8f shape = CircleShape + clip = false } .clip(CircleShape) .background(PrimaryBlue) @@ -2647,13 +2650,14 @@ fun MessageInputBar( scaleY = recordingActionVisualScale shadowElevation = 8f shape = CircleShape + clip = false } .clip(CircleShape) .background(PrimaryBlue), contentAlignment = Alignment.Center ) { Icon( - imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam, + imageVector = if (recordMode == RecordMode.VOICE) TablerIcons.Microphone else TablerIcons.Video, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp) @@ -2812,6 +2816,7 @@ fun MessageInputBar( didLockHaptic = false pendingRecordAfterPermission = false setRecordUiState(RecordUiState.PRESSING, "mic-down") + view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "mic DOWN mode=$recordMode state=$recordUiState " + "voice=$isVoiceRecording kb=$isKeyboardVisible ${inputHeightsSnapshot()}" @@ -2990,9 +2995,9 @@ fun MessageInputBar( contentAlignment = Alignment.Center ) { Icon( - imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam, + imageVector = if (recordMode == RecordMode.VOICE) TablerIcons.Microphone else TablerIcons.Video, contentDescription = "Record message", - tint = PrimaryBlue, + tint = Color(0xFF8E8E93).copy(alpha = 0.6f), modifier = Modifier.size(24.dp) ) }