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