Исправлена перемотка голосовых и устранены конфликты жестов
Some checks failed
Android Kernel Build / build (push) Has been cancelled

This commit is contained in:
2026-04-16 22:32:03 +05:00
parent 45134665b3
commit 6242e3c34f
19 changed files with 1296 additions and 193 deletions

View File

@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

View File

@@ -53,8 +53,17 @@ fun AnimatedKeyboardTransition(
wasEmojiShown = true wasEmojiShown = true
} }
if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) { if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) {
// Emoji закрылся после того как был открыт = переход emoji→keyboard // Keep reserved space only if keyboard is actually opening.
isTransitioningToKeyboard = true // 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 // 🔥 КЛЮЧЕВОЕ: Сбрасываем флаг когда клавиатура достигла высоты близкой к emoji
@@ -64,6 +73,19 @@ fun AnimatedKeyboardTransition(
wasEmojiShown = 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 val targetAlpha = if (showEmojiPicker) 1f else 0f

View File

@@ -44,6 +44,7 @@ import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallActionResult import com.rosetta.messenger.network.CallActionResult
@@ -225,7 +226,27 @@ class MainActivity : FragmentActivity() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
hasExistingAccount = accounts.isNotEmpty() 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 // Wait for initial load
@@ -305,15 +326,29 @@ class MainActivity : FragmentActivity() {
onAuthComplete = { account -> onAuthComplete = { account ->
startCreateAccountFlow = false startCreateAccountFlow = false
val normalizedAccount = 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 = val normalizedName =
resolveAccountDisplayName( resolveAccountDisplayName(
it.publicKey, decrypted.publicKey,
it.name, persisted?.name
null ?: decrypted.name,
persistedUsername
) )
if (it.name == normalizedName) it if (decrypted.name == normalizedName)
else it.copy(name = normalizedName) decrypted
else decrypted.copy(name = normalizedName)
} }
currentAccount = normalizedAccount currentAccount = normalizedAccount
cacheSessionAccount(normalizedAccount) cacheSessionAccount(normalizedAccount)
@@ -321,6 +356,14 @@ class MainActivity : FragmentActivity() {
// Save as last logged account // Save as last logged account
normalizedAccount?.let { normalizedAccount?.let {
accountManager.setLastLoggedPublicKey(it.publicKey) 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 { runCatching {
accountManager.setCurrentAccount(it.publicKey) 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() val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { it.toAccountInfo() } accountInfoList = accounts.map { it.toAccountInfo() }
@@ -367,9 +431,9 @@ class MainActivity : FragmentActivity() {
// lag // lag
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
com.rosetta.messenger.network.ProtocolManager
.disconnect()
scope.launch { scope.launch {
com.rosetta.messenger.network.ProtocolManager
.disconnect()
accountManager.logout() accountManager.logout()
} }
} }
@@ -416,9 +480,9 @@ class MainActivity : FragmentActivity() {
// lag // lag
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
com.rosetta.messenger.network.ProtocolManager
.disconnect()
scope.launch { scope.launch {
com.rosetta.messenger.network.ProtocolManager
.disconnect()
accountManager.logout() accountManager.logout()
} }
}, },
@@ -509,8 +573,8 @@ class MainActivity : FragmentActivity() {
// Switch to another account: logout current, then show unlock. // Switch to another account: logout current, then show unlock.
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
com.rosetta.messenger.network.ProtocolManager.disconnect()
scope.launch { scope.launch {
com.rosetta.messenger.network.ProtocolManager.disconnect()
accountManager.logout() accountManager.logout()
} }
}, },
@@ -520,8 +584,8 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = "" preservedMainNavAccountKey = ""
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
com.rosetta.messenger.network.ProtocolManager.disconnect()
scope.launch { scope.launch {
com.rosetta.messenger.network.ProtocolManager.disconnect()
accountManager.logout() accountManager.logout()
} }
} }
@@ -535,8 +599,8 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = "" preservedMainNavAccountKey = ""
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
ProtocolManager.disconnect()
scope.launch { scope.launch {
ProtocolManager.disconnect()
accountManager.logout() accountManager.logout()
} }
} }
@@ -941,6 +1005,15 @@ fun MainScreen(
CallManager.bindAccount(accountPublicKey) 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) { LaunchedEffect(callUiState.isVisible) {
if (callUiState.isVisible) { if (callUiState.isVisible) {
isCallOverlayExpanded = true isCallOverlayExpanded = true
@@ -991,19 +1064,42 @@ fun MainScreen(
isCallOverlayExpanded = false 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) { LaunchedEffect(accountPublicKey, reloadTrigger) {
if (accountPublicKey.isNotBlank()) { if (accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context) refreshAccountIdentityState(accountPublicKey)
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
)
} else { } else {
accountVerified = 0 accountVerified = 0
} }
@@ -1014,19 +1110,9 @@ fun MainScreen(
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile() // Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
LaunchedEffect(ownProfileUpdated) { LaunchedEffect(ownProfileUpdated, accountPublicKey) {
if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) { if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context) refreshAccountIdentityState(accountPublicKey)
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
)
} }
} }
@@ -1523,6 +1609,7 @@ fun MainScreen(
// 🔒 Lock swipe-back while chat overlays are open (image viewer/editor/media picker/camera). // 🔒 Lock swipe-back while chat overlays are open (image viewer/editor/media picker/camera).
var isChatSwipeLocked by remember { mutableStateOf(false) } var isChatSwipeLocked by remember { mutableStateOf(false) }
var isChatVoiceWaveGestureLocked by remember { mutableStateOf(false) }
// 🔴 Badge: total unread from OTHER chats (excluding current) for back-chevron badge // 🔴 Badge: total unread from OTHER chats (excluding current) for back-chevron badge
val totalUnreadFromOthers by remember(accountPublicKey, selectedUser?.publicKey) { val totalUnreadFromOthers by remember(accountPublicKey, selectedUser?.publicKey) {
@@ -1537,6 +1624,9 @@ fun MainScreen(
var chatSelectionActive by remember { mutableStateOf(false) } var chatSelectionActive by remember { mutableStateOf(false) }
val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) } val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) }
LaunchedEffect(selectedUser?.publicKey) {
isChatVoiceWaveGestureLocked = false
}
SwipeBackContainer( SwipeBackContainer(
isVisible = selectedUser != null, isVisible = selectedUser != null,
@@ -1549,7 +1639,7 @@ fun MainScreen(
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1, layer = 1,
swipeEnabled = !isChatSwipeLocked, swipeEnabled = !(isChatSwipeLocked || isChatVoiceWaveGestureLocked),
enterAnimation = SwipeBackEnterAnimation.SlideFromRight, enterAnimation = SwipeBackEnterAnimation.SlideFromRight,
propagateBackgroundProgress = false propagateBackgroundProgress = false
) { ) {
@@ -1593,7 +1683,10 @@ fun MainScreen(
isCallActive = callUiState.isVisible, isCallActive = callUiState.isVisible,
onOpenCallOverlay = { isCallOverlayExpanded = true }, onOpenCallOverlay = { isCallOverlayExpanded = true },
onSelectionModeChange = { chatSelectionActive = it }, onSelectionModeChange = { chatSelectionActive = it },
registerClearSelection = { fn -> chatClearSelectionRef.value = fn } registerClearSelection = { fn -> chatClearSelectionRef.value = fn },
onVoiceWaveGestureChanged = { locked ->
isChatVoiceWaveGestureLocked = locked
}
) )
} }
} }

View File

@@ -819,11 +819,19 @@ class MessageRepository private constructor(private val context: Context) {
} }
if (isGroupMessage && groupKey.isNullOrBlank()) { if (isGroupMessage && groupKey.isNullOrBlank()) {
MessageLogger.debug( val requiresGroupKey =
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..." (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 = val plainKeyAndNonce =
@@ -854,8 +862,9 @@ class MessageRepository private constructor(private val context: Context) {
if (isAttachmentOnly) { if (isAttachmentOnly) {
"" ""
} else if (isGroupMessage) { } else if (isGroupMessage) {
CryptoManager.decryptWithPassword(packet.content, groupKey!!) val decryptedGroupPayload =
?: throw IllegalStateException("Failed to decrypt group payload") groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) }
decryptedGroupPayload ?: safePlainMessageFallback(packet.content)
} else if (plainKeyAndNonce != null) { } else if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce) MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else { } else {
@@ -1004,7 +1013,9 @@ class MessageRepository private constructor(private val context: Context) {
// 📝 LOG: Ошибка обработки // 📝 LOG: Ошибка обработки
MessageLogger.logDecryptionError(messageId, e) MessageLogger.logDecryptionError(messageId, e)
ProtocolManager.addLog( 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 ?: "<no-message>"}"
) )
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить. // Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
processedMessageIds.remove(messageId) processedMessageIds.remove(messageId)

View File

@@ -162,6 +162,7 @@ object CallManager {
initialized = true initialized = true
appContext = context.applicationContext appContext = context.applicationContext
CallSoundManager.initialize(context) CallSoundManager.initialize(context)
CallProximityManager.initialize(context)
XChaCha20E2EE.initWithContext(context) XChaCha20E2EE.initWithContext(context)
signalWaiter = ProtocolManager.waitCallSignal { packet -> signalWaiter = ProtocolManager.waitCallSignal { packet ->
@@ -1068,12 +1069,10 @@ object CallManager {
attachments = listOf(callAttachment) attachments = listOf(callAttachment)
) )
} else { } else {
// CALLEE: save call event locally (incoming from peer) // CALLEE: do not create local fallback call message.
// CALLER will send their own message which may arrive later // Caller sends a single canonical CALL attachment; local fallback here
MessageRepository.getInstance(context).saveIncomingCallEvent( // caused duplicates (local + remote) in direct dialogs.
fromPublicKey = peerPublicKey, breadcrumb("CALL ATTACHMENT: CALLEE skip local fallback, waiting caller message")
durationSec = durationSec
)
} }
}.onFailure { error -> }.onFailure { error ->
Log.w(TAG, "Failed to emit call attachment", error) Log.w(TAG, "Failed to emit call attachment", error)
@@ -1086,6 +1085,7 @@ object CallManager {
disarmConnectingTimeout("resetSession") disarmConnectingTimeout("resetSession")
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}") breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
breadcrumbState("resetSession") breadcrumbState("resetSession")
appContext?.let { CallProximityManager.setEnabled(it, false) }
val snapshot = _state.value val snapshot = _state.value
val wasActive = snapshot.phase != CallPhase.IDLE val wasActive = snapshot.phase != CallPhase.IDLE
val peerToNotify = snapshot.peerPublicKey val peerToNotify = snapshot.peerPublicKey
@@ -1616,6 +1616,13 @@ object CallManager {
val old = _state.value val old = _state.value
_state.update(reducer) _state.update(reducer)
val newState = _state.value 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 при смене фазы или имени // Синхронизируем ForegroundService при смене фазы или имени
if (newState.phase != CallPhase.IDLE && if (newState.phase != CallPhase.IDLE &&
(newState.phase != old.phase || newState.displayName != old.displayName)) { (newState.phase != old.phase || newState.displayName != old.displayName)) {

View File

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

View File

@@ -48,6 +48,22 @@ object CallSoundManager {
stop() stop()
currentSound = sound 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) { val resId = when (sound) {
CallSound.RINGTONE -> R.raw.call_ringtone CallSound.RINGTONE -> R.raw.call_ringtone
CallSound.CALLING -> R.raw.call_calling CallSound.CALLING -> R.raw.call_calling
@@ -86,7 +102,7 @@ object CallSoundManager {
mediaPlayer = player mediaPlayer = player
// Vibrate for incoming calls // Vibrate for incoming calls
if (sound == CallSound.RINGTONE) { if (allowVibration) {
startVibration() startVibration()
} }

View File

@@ -301,6 +301,32 @@ class Protocol(
startHeartbeat(packet.heartbeatInterval) 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" "⚡ 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 (!hasCredentials) return
if (currentState == ProtocolState.CONNECTING && isConnecting) { if (currentState == ProtocolState.CONNECTING && isConnecting) {

View File

@@ -293,11 +293,28 @@ object ProtocolManager {
* Должен вызываться после авторизации пользователя * Должен вызываться после авторизации пользователя
*/ */
fun initializeAccount(publicKey: String, privateKey: String) { 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) setSyncInProgress(false)
clearTypingState() clearTypingState()
messageRepository?.initialize(publicKey, privateKey) messageRepository?.initialize(normalizedPublicKey, normalizedPrivateKey)
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
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 resyncRequiredAfterAccountInit = false
syncRequestInFlight = false
clearSyncRequestTimeout()
addLog("🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync")
requestSynchronize() requestSynchronize()
} }
// Send "Rosetta Updates" message on version change (like desktop useUpdateMessage) // Send "Rosetta Updates" message on version change (like desktop useUpdateMessage)

View File

@@ -29,6 +29,7 @@ import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -308,6 +309,9 @@ fun SetPasswordScreen(
) )
accountManager.saveAccount(account) accountManager.saveAccount(account)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey) 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) startAuthHandshakeFast(keyPair.publicKey, privateKeyHash)
accountManager.setCurrentAccount(keyPair.publicKey) accountManager.setCurrentAccount(keyPair.publicKey)
val decryptedAccount = DecryptedAccount( val decryptedAccount = DecryptedAccount(

View File

@@ -45,6 +45,7 @@ import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarColor
@@ -116,6 +117,9 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
name = selectedAccount.name 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) startAuthHandshakeFast(account.publicKey, privateKeyHash)
accountManager.setCurrentAccount(account.publicKey) accountManager.setCurrentAccount(account.publicKey)

View File

@@ -327,7 +327,8 @@ fun ChatDetailScreen(
isCallActive: Boolean = false, isCallActive: Boolean = false,
onOpenCallOverlay: () -> Unit = {}, onOpenCallOverlay: () -> Unit = {},
onSelectionModeChange: (Boolean) -> Unit = {}, onSelectionModeChange: (Boolean) -> Unit = {},
registerClearSelection: (() -> Unit) -> Unit = {} registerClearSelection: (() -> Unit) -> Unit = {},
onVoiceWaveGestureChanged: (Boolean) -> Unit = {}
) { ) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current val context = LocalContext.current
@@ -392,13 +393,20 @@ fun ChatDetailScreen(
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward // 🔥 MESSAGE SELECTION STATE - для Reply/Forward
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) } var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty() val isSelectionMode = selectedMessages.isNotEmpty()
var isVoiceWaveGestureActive by remember { mutableStateOf(false) }
// Notify parent about selection mode changes so it can intercept swipe-back // Notify parent about selection mode changes so it can intercept swipe-back
LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) } LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) }
LaunchedEffect(isVoiceWaveGestureActive) {
onVoiceWaveGestureChanged(isVoiceWaveGestureActive)
}
// Register selection-clear callback so parent can cancel selection on swipe-back // Register selection-clear callback so parent can cancel selection on swipe-back
DisposableEffect(Unit) { DisposableEffect(Unit) {
registerClearSelection { selectedMessages = emptySet() } registerClearSelection { selectedMessages = emptySet() }
onDispose { registerClearSelection {} } onDispose {
registerClearSelection {}
onVoiceWaveGestureChanged(false)
}
} }
// После long press AndroidView текста может прислать tap на отпускание. // После long press AndroidView текста может прислать tap на отпускание.
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался. // В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
@@ -1586,6 +1594,27 @@ fun ChatDetailScreen(
label = "headerContent" label = "headerContent"
) { currentHeaderMode -> ) { currentHeaderMode ->
if (currentHeaderMode == ChatHeaderMode.SELECTION) { 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 // SELECTION MODE CONTENT
Row( Row(
modifier = 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 // Delete button
IconButton( IconButton(
onClick = { onClick = {
@@ -2404,6 +2481,27 @@ fun ChatDetailScreen(
) { selectionMode -> ) { selectionMode ->
if (selectionMode) { if (selectionMode) {
if (!isSystemAccount) { 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 // SELECTION ACTION BAR - Reply/Forward
Column( Column(
modifier = modifier =
@@ -2735,6 +2833,7 @@ fun ChatDetailScreen(
) )
} }
} }
} }
} }
} }
@@ -3396,9 +3495,12 @@ fun ChatDetailScreen(
listOf( listOf(
message message
) )
) )
} }
}, },
onVoiceWaveGestureActiveChanged = { active ->
isVoiceWaveGestureActive = active
},
onReplyClick = { onReplyClick = {
messageId messageId
-> ->

View File

@@ -458,6 +458,7 @@ fun ChatsListScreen(
// Protocol connection state // Protocol connection state
val protocolState by ProtocolManager.state.collectAsState() val protocolState by ProtocolManager.state.collectAsState()
val syncInProgress by ProtocolManager.syncInProgress.collectAsState() val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads) // 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads)
@@ -511,11 +512,8 @@ fun ChatsListScreen(
// Устанавливаем аккаунт для RecentSearchesManager // Устанавливаем аккаунт для RecentSearchesManager
RecentSearchesManager.setAccount(normalizedPublicKey) RecentSearchesManager.setAccount(normalizedPublicKey)
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих // Protocol/account initialization is handled globally in MainScreen.
// сообщений только когда приватный ключ уже доступен. // ChatsList keeps only dialog/account-dependent UI state binding.
if (normalizedPrivateKey.isNotEmpty()) {
ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
}
android.util.Log.d( android.util.Log.d(
"ChatsListScreen", "ChatsListScreen",
@@ -623,13 +621,45 @@ fun ChatsListScreen(
// 👥 Load all accounts for sidebar (current account always first) // 👥 Load all accounts for sidebar (current account always first)
var allAccounts by remember { mutableStateOf<List<EncryptedAccount>>(emptyList()) } var allAccounts by remember { mutableStateOf<List<EncryptedAccount>>(emptyList()) }
LaunchedEffect(accountPublicKey) { LaunchedEffect(accountPublicKey, ownProfileUpdated) {
val accountManager = AccountManager(context) val accountManager = AccountManager(context)
val accounts = accountManager.getAllAccounts() var accounts = accountManager.getAllAccounts()
val preferredPublicKey = val preferredPublicKey =
accountPublicKey.trim().ifBlank { accountPublicKey.trim().ifBlank {
accountManager.getLastLoggedPublicKey().orEmpty() 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 } allAccounts = accounts.sortedByDescending { it.publicKey == preferredPublicKey }
} }

View File

@@ -69,8 +69,37 @@ fun ForwardChatPickerBottomSheet(
val messagesCount = forwardMessages.size val messagesCount = forwardMessages.size
// 🔥 Фильтруем системные аккаунты (Safe, Rosetta Updates) // 🔥 Фильтруем системные аккаунты (Safe, Rosetta Updates)
val filteredDialogs = remember(dialogs) { // и всегда добавляем Saved Messages, даже если self-диалог еще не создан.
dialogs.filter { !MessageRepository.isSystemAccount(it.opponentKey) } 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
}
} }
// Мультивыбор чатов // Мультивыбор чатов

View File

@@ -2,9 +2,11 @@ package com.rosetta.messenger.ui.chats.attach
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.provider.Settings
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -20,6 +22,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.unit.sp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -355,11 +359,20 @@ fun ChatAttachAlert(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Permission handling // 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( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions() contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions -> ) { permissions ->
val granted = permissions.values.all { it } val granted = mediaPermissions.all { permissions[it] == true }
viewModel.setPermissionGranted(granted) viewModel.setPermissionGranted(granted)
if (granted) { if (granted) {
viewModel.loadMedia(context) viewModel.loadMedia(context)
@@ -367,19 +380,35 @@ fun ChatAttachAlert(
} }
fun requestPermissions() { fun requestPermissions() {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val deniedPermissions = mediaPermissions.filter {
arrayOf( ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
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) 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() resetPickerCaptionInput()
photoCaption = "" photoCaption = ""
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val hasPermission = mediaPermissions.all {
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 {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
} }
viewModel.setPermissionGranted(hasPermission) viewModel.setPermissionGranted(hasPermission)

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -32,12 +33,16 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable 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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned 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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -97,17 +106,31 @@ import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.min import kotlin.math.min
import kotlin.math.PI import kotlin.math.PI
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
private const val TAG = "AttachmentComponents" private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096 private const val MAX_BITMAP_DECODE_DIMENSION = 4096
private const val VOICE_WAVE_DEBUG_LOG = true
private val whitespaceRegex = "\\s+".toRegex() private val whitespaceRegex = "\\s+".toRegex()
private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} } 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 isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
private fun canonicalGroupDialogKey(value: String): String { private fun canonicalGroupDialogKey(value: String): String {
@@ -173,12 +196,30 @@ private fun decodeVoicePayload(data: String): ByteArray? {
return decodeHexPayload(data) ?: decodeBase64Payload(data) 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 { object VoicePlaybackCoordinator {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val speedSteps = listOf(1f, 1.5f, 2f) private val speedSteps = listOf(1f, 1.5f, 2f)
private var player: MediaPlayer? = null private var player: MediaPlayer? = null
private var currentAttachmentId: String? = null private var currentAttachmentId: String? = null
private var appContext: Context? = null
private var progressJob: Job? = null private var progressJob: Job? = null
private val queueEntries = LinkedHashMap<String, VoiceQueueEntry>()
private val _playingAttachmentId = MutableStateFlow<String?>(null) private val _playingAttachmentId = MutableStateFlow<String?>(null)
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow() val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
private val _playingDialogKey = MutableStateFlow<String?>(null) private val _playingDialogKey = MutableStateFlow<String?>(null)
@@ -196,6 +237,15 @@ object VoicePlaybackCoordinator {
private val _playingTimeLabel = MutableStateFlow("") private val _playingTimeLabel = MutableStateFlow("")
val playingTimeLabel: StateFlow<String> = _playingTimeLabel.asStateFlow() val playingTimeLabel: StateFlow<String> = _playingTimeLabel.asStateFlow()
fun upsertQueueEntry(context: Context, entry: VoiceQueueEntry) {
appContext = context.applicationContext
queueEntries[entry.attachmentId] = entry
}
fun removeQueueEntry(attachmentId: String) {
queueEntries.remove(attachmentId)
}
fun toggle( fun toggle(
attachmentId: String, attachmentId: String,
sourceFile: File, sourceFile: File,
@@ -228,7 +278,7 @@ object VoicePlaybackCoordinator {
.build() .build()
) )
mediaPlayer.setDataSource(sourceFile.absolutePath) mediaPlayer.setDataSource(sourceFile.absolutePath)
mediaPlayer.setOnCompletionListener { stop() } mediaPlayer.setOnCompletionListener { handleTrackCompleted(attachmentId) }
mediaPlayer.prepare() mediaPlayer.prepare()
applyPlaybackSpeed(mediaPlayer) applyPlaybackSpeed(mediaPlayer)
mediaPlayer.start() 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() { fun pause() {
val active = player ?: return val active = player ?: return
runCatching { runCatching {
@@ -293,6 +398,43 @@ object VoicePlaybackCoordinator {
setPlaybackSpeed(next) 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) { private fun setPlaybackSpeed(speed: Float) {
val normalized = val normalized =
speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first() speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
@@ -348,6 +490,92 @@ object VoicePlaybackCoordinator {
runCatching { active.release() } runCatching { active.release() }
} }
} }
private fun normalizedDialogKey(value: String): String {
return value.trim()
}
private fun orderedEntriesForDialog(dialogKey: String): List<VoiceQueueEntry> {
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<VoiceQueueEntry> { 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 { private fun shortDebugId(value: String): String {
@@ -728,6 +956,7 @@ fun MessageAttachments(
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
onCancelUpload: (attachmentId: String) -> Unit = {}, onCancelUpload: (attachmentId: String) -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (attachments.isEmpty()) return if (attachments.isEmpty()) return
@@ -762,6 +991,7 @@ fun MessageAttachments(
} }
// Остальные attachments по отдельности // Остальные attachments по отдельности
var voiceOrderInMessage = 0
otherAttachments.forEach { attachment -> otherAttachments.forEach { attachment ->
when (attachment.type) { when (attachment.type) {
AttachmentType.FILE -> { AttachmentType.FILE -> {
@@ -800,6 +1030,7 @@ fun MessageAttachments(
) )
} }
AttachmentType.VOICE -> { AttachmentType.VOICE -> {
val currentVoiceOrder = voiceOrderInMessage++
VoiceAttachment( VoiceAttachment(
attachment = attachment, attachment = attachment,
chachaKey = chachaKey, chachaKey = chachaKey,
@@ -811,7 +1042,9 @@ fun MessageAttachments(
isOutgoing = isOutgoing, isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus messageStatus = messageStatus,
attachmentOrderInMessage = currentVoiceOrder,
onVoiceWaveGestureActiveChanged = onVoiceWaveGestureActiveChanged
) )
} }
AttachmentType.VIDEO_CIRCLE -> { AttachmentType.VIDEO_CIRCLE -> {
@@ -2040,6 +2273,8 @@ private fun formatVoicePlaybackSpeedLabel(speed: Float): String {
} }
} }
private const val VOICE_WAVEFORM_BAR_COUNT = 56
@Composable @Composable
private fun VoicePlaybackButtonBlob( private fun VoicePlaybackButtonBlob(
level: Float, level: Float,
@@ -2237,7 +2472,9 @@ private fun VoiceAttachment(
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
timestamp: java.util.Date, timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ messageStatus: MessageStatus = MessageStatus.READ,
attachmentOrderInMessage: Int = 0,
onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -2261,10 +2498,10 @@ private fun VoiceAttachment(
remember(attachment.preview) { parseVoicePreview(attachment.preview) } remember(attachment.preview) { parseVoicePreview(attachment.preview) }
val previewDurationSec = previewDurationSecRaw.coerceAtLeast(1) val previewDurationSec = previewDurationSecRaw.coerceAtLeast(1)
val previewWaves = val previewWaves =
remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, 40) } remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, VOICE_WAVEFORM_BAR_COUNT) }
val waves = val waves =
remember(previewWaves) { remember(previewWaves) {
if (previewWaves.isEmpty()) List(40) { 0f } else previewWaves if (previewWaves.isEmpty()) List(VOICE_WAVEFORM_BAR_COUNT) { 0f } else previewWaves
} }
var payload by var payload by
@@ -2301,6 +2538,23 @@ private fun VoiceAttachment(
} else { } else {
0f 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 = val liveWaveLevel =
remember(isPlaying, progress, waves) { remember(isPlaying, progress, waves) {
if (!isPlaying || waves.isEmpty()) { if (!isPlaying || waves.isEmpty()) {
@@ -2311,9 +2565,18 @@ private fun VoiceAttachment(
waves[sampleIndex].coerceIn(0f, 1f) 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 = val timeText =
if (isActiveTrack && playbackDurationMs > 0) { if (isActiveTrack && playbackDurationMs > 0) {
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000) val leftSec = ((playbackDurationMs - displayPositionMs).coerceAtLeast(0) / 1000)
formatVoiceDuration(leftSec) formatVoiceDuration(leftSec)
} else { } else {
formatVoiceDuration(effectiveDurationSec) formatVoiceDuration(effectiveDurationSec)
@@ -2334,6 +2597,61 @@ private fun VoiceAttachment(
} }
.getOrDefault("") .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@{ val triggerDownload: () -> Unit = download@{
if (attachment.transportTag.isBlank()) { 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) { when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload() DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload()
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit 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 = val barInactiveColor =
if (isOutgoing) Color.White.copy(alpha = 0.38f) if (isOutgoing) Color.White.copy(alpha = 0.38f)
@@ -2456,23 +2797,27 @@ private fun VoiceAttachment(
val actionBackground = val actionBackground =
when (downloadStatus) { when (downloadStatus) {
DownloadStatus.ERROR -> Color(0xFFE55757) 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 = val actionTint =
when { when {
downloadStatus == DownloadStatus.ERROR -> Color.White downloadStatus == DownloadStatus.ERROR -> Color.White
isOutgoing -> Color.White isOutgoing -> Color.White
else -> Color.White else -> if (isDarkTheme) Color(0xFFF1F3F5) else Color(0xFF3B4652)
} }
val actionButtonInteraction = remember { MutableInteractionSource() }
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.padding(vertical = 4.dp) .padding(vertical = 4.dp),
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onMainAction() },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( Box(
@@ -2496,7 +2841,12 @@ private fun VoiceAttachment(
modifier = modifier =
Modifier.size(40.dp) Modifier.size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(actionBackground), .background(actionBackground)
.clickable(
enabled = !isWaveformTouchLocked,
interactionSource = actionButtonInteraction,
indication = null
) { onMainAction() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (downloadStatus == DownloadStatus.DOWNLOADING || if (downloadStatus == DownloadStatus.DOWNLOADING ||
@@ -2528,7 +2878,8 @@ private fun VoiceAttachment(
else -> { else -> {
Icon( Icon(
imageVector = imageVector =
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, if (isPlaying) Icons.Outlined.Pause
else Icons.Default.PlayArrow,
contentDescription = null, contentDescription = null,
tint = actionTint, tint = actionTint,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
@@ -2544,27 +2895,187 @@ private fun VoiceAttachment(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row( Row(
modifier = Modifier.fillMaxWidth().height(28.dp), modifier = Modifier.fillMaxWidth().height(28.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(1.dp)
horizontalArrangement = Arrangement.spacedBy(2.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 -> waves.forEachIndexed { index, value ->
val normalized = value.coerceIn(0f, 1f) val normalized = value.coerceIn(0f, 1f)
val passed = (progress * waves.size) - index val waveCount = waves.size.coerceAtLeast(1)
val fill = passed.coerceIn(0f, 1f) val barStart = index.toFloat() / waveCount.toFloat()
val color = val barEnd = (index + 1).toFloat() / waveCount.toFloat()
if (fill > 0f) { val fill =
barActiveColor ((displayProgress - barStart) / (barEnd - barStart))
} else { .coerceIn(0f, 1f)
barInactiveColor val color = lerp(barInactiveColor, barActiveColor, fill)
}
Box( Box(
modifier = modifier =
Modifier.width(2.dp) Modifier.weight(1f)
.height((4f + normalized * 18f).dp) .height((4f + normalized * 18f).dp)
.clip(RoundedCornerShape(100)) .clip(RoundedCornerShape(100))
.background(color) .background(color)
) )
} }
}
} }
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -2589,37 +3100,45 @@ private fun VoiceAttachment(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (isActiveTrack) { // Speed chip — always reserve space so the bubble doesn't reflow
val speedChipBackground = // when playback starts/stops; fixed width so cycling 1x/1.5x/2x
if (isOutgoing) { // doesn't jitter the row either.
Color.White.copy(alpha = 0.2f) val speedChipBackground =
} else if (isDarkTheme) { if (isOutgoing) {
Color(0xFF31435A) Color.White.copy(alpha = 0.2f)
} else { } else if (isDarkTheme) {
Color(0xFFDCEBFD) Color(0xFF31435A)
} } else {
val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue Color(0xFFDCEBFD)
Box( }
modifier = val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
Modifier.clip(RoundedCornerShape(10.dp)) Box(
.background(speedChipBackground) modifier =
.clickable( Modifier.width(32.dp)
interactionSource = .graphicsLayer { alpha = if (isActiveTrack) 1f else 0f }
remember { MutableInteractionSource() }, .clip(RoundedCornerShape(10.dp))
indication = null .background(speedChipBackground)
) { .then(
VoicePlaybackCoordinator.cycleSpeed() if (isActiveTrack) {
} Modifier.clickable(
.padding(horizontal = 6.dp, vertical = 2.dp), interactionSource =
contentAlignment = Alignment.Center remember { MutableInteractionSource() },
) { indication = null
Text( ) {
text = formatVoicePlaybackSpeedLabel(playbackSpeed), VoicePlaybackCoordinator.cycleSpeed()
fontSize = 10.sp, }
fontWeight = FontWeight.SemiBold, } else Modifier
color = speedChipTextColor )
) .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(
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(), text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),

View File

@@ -353,6 +353,7 @@ fun MessageBubble(
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onMentionClick: (username: String) -> Unit = {}, onMentionClick: (username: String) -> Unit = {},
onGroupInviteOpen: (SearchUser) -> Unit = {}, onGroupInviteOpen: (SearchUser) -> Unit = {},
onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {} contextMenuContent: @Composable () -> Unit = {}
) { ) {
val isTextSelectionOnThisMessage = val isTextSelectionOnThisMessage =
@@ -364,6 +365,7 @@ fun MessageBubble(
textSelectionHelper?.isInSelectionMode == true && textSelectionHelper?.isInSelectionMode == true &&
textSelectionHelper.selectedMessageId == message.id textSelectionHelper.selectedMessageId == message.id
} }
var isVoiceWaveGestureActive by remember(message.id) { mutableStateOf(false) }
// Swipe-to-reply state // Swipe-to-reply state
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
@@ -427,6 +429,8 @@ fun MessageBubble(
val suppressBubbleTapFromSpan: () -> Unit = { val suppressBubbleTapFromSpan: () -> Unit = {
suppressBubbleTapUntilMs = System.currentTimeMillis() + 450L suppressBubbleTapUntilMs = System.currentTimeMillis() + 450L
} }
val hasVoiceAttachmentForGesture =
remember(message.attachments) { message.attachments.any { it.type == AttachmentType.VOICE } }
val timeColor = val timeColor =
remember(message.isOutgoing, isDarkTheme) { remember(message.isOutgoing, isDarkTheme) {
@@ -463,6 +467,17 @@ fun MessageBubble(
return 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 // Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp
val bubbleShape = val bubbleShape =
remember(message.isOutgoing, showTail, isSafeSystemMessage) { remember(message.isOutgoing, showTail, isSafeSystemMessage) {
@@ -490,15 +505,27 @@ fun MessageBubble(
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) { Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive, isVoiceWaveGestureActive) {
if (isSystemSafeChat) return@pointerInput if (isSystemSafeChat) return@pointerInput
if (textSelectionHelper?.isActive == true) return@pointerInput if (textSelectionHelper?.isActive == true) return@pointerInput
if (hasVoiceAttachmentForGesture) return@pointerInput
if (isVoiceWaveGestureActive) return@pointerInput
// 🔥 Простой горизонтальный свайп для reply // 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со // Используем detectHorizontalDragGestures который лучше работает со
// скроллом // скроллом
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = {}, onDragStart = {
if (isVoiceWaveGestureActive) {
swipeOffset = 0f
hapticTriggered = false
}
},
onDragEnd = { onDragEnd = {
if (isVoiceWaveGestureActive) {
swipeOffset = 0f
hapticTriggered = false
return@detectHorizontalDragGestures
}
// Если свайпнули достаточно влево - reply // Если свайпнули достаточно влево - reply
if (swipeOffset <= -swipeThreshold) { if (swipeOffset <= -swipeThreshold) {
onSwipeToReply() onSwipeToReply()
@@ -511,6 +538,11 @@ fun MessageBubble(
hapticTriggered = false hapticTriggered = false
}, },
onHorizontalDrag = { change, dragAmount -> onHorizontalDrag = { change, dragAmount ->
if (isVoiceWaveGestureActive) {
swipeOffset = 0f
hapticTriggered = false
return@detectHorizontalDragGestures
}
// Только свайп влево (отрицательное значение) // Только свайп влево (отрицательное значение)
if (dragAmount < 0 || swipeOffset < 0) { if (dragAmount < 0 || swipeOffset < 0) {
change.consume() change.consume()
@@ -1046,7 +1078,11 @@ fun MessageBubble(
onLongClick = onLongClick, onLongClick = onLongClick,
onCancelUpload = onCancelPhotoUpload, onCancelUpload = onCancelPhotoUpload,
// В selection mode блокируем открытие фото // В selection mode блокируем открытие фото
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick,
onVoiceWaveGestureActiveChanged = { active ->
isVoiceWaveGestureActive = active
onVoiceWaveGestureActiveChanged(active)
}
) )
} }

View File

@@ -2,12 +2,14 @@ package com.rosetta.messenger.ui.chats.components
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings
import android.provider.MediaStore import android.provider.MediaStore
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@@ -29,6 +31,7 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -58,6 +61,7 @@ import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -225,11 +229,21 @@ fun MediaPickerBottomSheet(
} }
} }
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 // Permission launcher
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions() contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions -> ) { permissions ->
hasPermission = permissions.values.all { it } hasPermission = mediaPermissions.all { permissions[it] == true }
if (hasPermission) { if (hasPermission) {
scope.launch { scope.launch {
val loaded = loadMediaPickerData(context) val loaded = loadMediaPickerData(context)
@@ -241,6 +255,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 // Check permission on show
LaunchedEffect(isVisible) { LaunchedEffect(isVisible) {
if (isVisible) { if (isVisible) {
@@ -249,20 +294,7 @@ fun MediaPickerBottomSheet(
resetPickerCaptionInput() resetPickerCaptionInput()
selectedAlbumId = ALL_MEDIA_ALBUM_ID selectedAlbumId = ALL_MEDIA_ALBUM_ID
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { hasPermission = mediaPermissions.all {
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 {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
} }
@@ -274,7 +306,7 @@ fun MediaPickerBottomSheet(
selectedAlbumId = loaded.albums.firstOrNull()?.id ?: ALL_MEDIA_ALBUM_ID selectedAlbumId = loaded.albums.firstOrNull()?.id ?: ALL_MEDIA_ALBUM_ID
isLoading = false isLoading = false
} else { } else {
permissionLauncher.launch(permissions) requestMediaPermissions()
} }
} }
} }
@@ -975,19 +1007,7 @@ fun MediaPickerBottomSheet(
textColor = textColor, textColor = textColor,
secondaryTextColor = secondaryTextColor, secondaryTextColor = secondaryTextColor,
onRequestPermission = { onRequestPermission = {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { requestMediaPermissions()
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)
} }
) )
} else if (isLoading) { } else if (isLoading) {

View File

@@ -17,8 +17,6 @@ import com.airbnb.lottie.compose.rememberLottieComposition
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete 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.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@@ -78,6 +76,9 @@ import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest 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.network.AttachmentType
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.R import com.rosetta.messenger.R
@@ -2547,6 +2548,7 @@ fun MessageInputBar(
scaleX = recordingActionVisualScale * circleSlideCancelScale scaleX = recordingActionVisualScale * circleSlideCancelScale
scaleY = recordingActionVisualScale * circleSlideCancelScale scaleY = recordingActionVisualScale * circleSlideCancelScale
transformOrigin = TransformOrigin(0.5f, 0.5f) transformOrigin = TransformOrigin(0.5f, 0.5f)
clip = false
} }
.zIndex(5f), .zIndex(5f),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -2616,6 +2618,7 @@ fun MessageInputBar(
scaleY = sendScale * recordingActionVisualScale scaleY = sendScale * recordingActionVisualScale
shadowElevation = 8f shadowElevation = 8f
shape = CircleShape shape = CircleShape
clip = false
} }
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue) .background(PrimaryBlue)
@@ -2647,13 +2650,14 @@ fun MessageInputBar(
scaleY = recordingActionVisualScale scaleY = recordingActionVisualScale
shadowElevation = 8f shadowElevation = 8f
shape = CircleShape shape = CircleShape
clip = false
} }
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue), .background(PrimaryBlue),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( 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, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(19.dp) modifier = Modifier.size(19.dp)
@@ -2812,6 +2816,7 @@ fun MessageInputBar(
didLockHaptic = false didLockHaptic = false
pendingRecordAfterPermission = false pendingRecordAfterPermission = false
setRecordUiState(RecordUiState.PRESSING, "mic-down") setRecordUiState(RecordUiState.PRESSING, "mic-down")
view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP)
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
"mic DOWN mode=$recordMode state=$recordUiState " + "mic DOWN mode=$recordMode state=$recordUiState " +
"voice=$isVoiceRecording kb=$isKeyboardVisible ${inputHeightsSnapshot()}" "voice=$isVoiceRecording kb=$isKeyboardVisible ${inputHeightsSnapshot()}"
@@ -2990,9 +2995,9 @@ fun MessageInputBar(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( 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", contentDescription = "Record message",
tint = PrimaryBlue, tint = Color(0xFF8E8E93).copy(alpha = 0.6f),
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }