Исправлена перемотка голосовых и устранены конфликты жестов
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.RECORD_AUDIO" />
<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.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

View File

@@ -53,8 +53,17 @@ fun AnimatedKeyboardTransition(
wasEmojiShown = true
}
if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) {
// Emoji закрылся после того как был открыт = переход emoji→keyboard
isTransitioningToKeyboard = true
// Keep reserved space only if keyboard is actually opening.
// For back-swipe/back-press close there is no keyboard open request,
// so we must drop the emoji box immediately to avoid an empty gap.
val keyboardIsComing =
coordinator.currentState == KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD ||
coordinator.isKeyboardVisible ||
coordinator.keyboardHeight > 0.dp
isTransitioningToKeyboard = keyboardIsComing
if (!keyboardIsComing) {
wasEmojiShown = false
}
}
// 🔥 КЛЮЧЕВОЕ: Сбрасываем флаг когда клавиатура достигла высоты близкой к emoji
@@ -63,6 +72,19 @@ fun AnimatedKeyboardTransition(
isTransitioningToKeyboard = false
wasEmojiShown = false
}
// Failsafe for interrupted gesture/back navigation: if keyboard never started opening,
// don't keep an invisible fixed-height box.
if (
isTransitioningToKeyboard &&
!showEmojiPicker &&
coordinator.currentState != KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD &&
!coordinator.isKeyboardVisible &&
coordinator.keyboardHeight == 0.dp
) {
isTransitioningToKeyboard = false
wasEmojiShown = false
}
// 🎯 Целевая прозрачность
val targetAlpha = if (showEmojiPicker) 1f else 0f
@@ -109,4 +131,4 @@ fun AnimatedKeyboardTransition(
content()
}
}
}
}

View File

@@ -44,6 +44,7 @@ import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallActionResult
@@ -225,7 +226,27 @@ class MainActivity : FragmentActivity() {
LaunchedEffect(Unit) {
val accounts = accountManager.getAllAccounts()
hasExistingAccount = accounts.isNotEmpty()
accountInfoList = accounts.map { it.toAccountInfo() }
val infos = accounts.map { it.toAccountInfo() }
accountInfoList = infos
// Reconcile process-cached account name with persisted profile data.
currentAccount?.let { cached ->
val persisted = infos.firstOrNull {
it.publicKey.equals(cached.publicKey, ignoreCase = true)
}
val persistedUsername = persisted?.username?.trim().orEmpty().ifBlank { null }
val normalizedCachedName =
resolveAccountDisplayName(
cached.publicKey,
persisted?.name ?: cached.name,
persistedUsername
)
if (normalizedCachedName != cached.name) {
val updated = cached.copy(name = normalizedCachedName)
currentAccount = updated
cacheSessionAccount(updated)
}
}
}
// Wait for initial load
@@ -305,15 +326,29 @@ class MainActivity : FragmentActivity() {
onAuthComplete = { account ->
startCreateAccountFlow = false
val normalizedAccount =
account?.let {
account?.let { decrypted ->
val persisted =
accountInfoList.firstOrNull {
it.publicKey.equals(
decrypted.publicKey,
ignoreCase = true
)
}
val persistedUsername =
persisted?.username
?.trim()
.orEmpty()
.ifBlank { null }
val normalizedName =
resolveAccountDisplayName(
it.publicKey,
it.name,
null
decrypted.publicKey,
persisted?.name
?: decrypted.name,
persistedUsername
)
if (it.name == normalizedName) it
else it.copy(name = normalizedName)
if (decrypted.name == normalizedName)
decrypted
else decrypted.copy(name = normalizedName)
}
currentAccount = normalizedAccount
cacheSessionAccount(normalizedAccount)
@@ -321,6 +356,14 @@ class MainActivity : FragmentActivity() {
// Save as last logged account
normalizedAccount?.let {
accountManager.setLastLoggedPublicKey(it.publicKey)
// Initialize protocol/message account context
// immediately after auth completion to avoid
// packet processing race before MainScreen
// composition.
ProtocolManager.initializeAccount(
it.publicKey,
it.privateKey
)
}
// Первый запуск после регистрации:
@@ -354,6 +397,27 @@ class MainActivity : FragmentActivity() {
runCatching {
accountManager.setCurrentAccount(it.publicKey)
}
// Force-refresh account title from persisted
// profile (name/username) to avoid temporary
// public-key alias in UI after login.
val persisted = accountManager.getAccount(it.publicKey)
val persistedUsername =
persisted?.username
?.trim()
.orEmpty()
.ifBlank { null }
val refreshedName =
resolveAccountDisplayName(
it.publicKey,
persisted?.name ?: it.name,
persistedUsername
)
if (refreshedName != it.name) {
val updated = it.copy(name = refreshedName)
currentAccount = updated
cacheSessionAccount(updated)
}
}
val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { it.toAccountInfo() }
@@ -367,9 +431,9 @@ class MainActivity : FragmentActivity() {
// lag
currentAccount = null
clearCachedSessionAccount()
com.rosetta.messenger.network.ProtocolManager
.disconnect()
scope.launch {
com.rosetta.messenger.network.ProtocolManager
.disconnect()
accountManager.logout()
}
}
@@ -416,9 +480,9 @@ class MainActivity : FragmentActivity() {
// lag
currentAccount = null
clearCachedSessionAccount()
com.rosetta.messenger.network.ProtocolManager
.disconnect()
scope.launch {
com.rosetta.messenger.network.ProtocolManager
.disconnect()
accountManager.logout()
}
},
@@ -509,8 +573,8 @@ class MainActivity : FragmentActivity() {
// Switch to another account: logout current, then show unlock.
currentAccount = null
clearCachedSessionAccount()
com.rosetta.messenger.network.ProtocolManager.disconnect()
scope.launch {
com.rosetta.messenger.network.ProtocolManager.disconnect()
accountManager.logout()
}
},
@@ -520,8 +584,8 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = ""
currentAccount = null
clearCachedSessionAccount()
com.rosetta.messenger.network.ProtocolManager.disconnect()
scope.launch {
com.rosetta.messenger.network.ProtocolManager.disconnect()
accountManager.logout()
}
}
@@ -535,8 +599,8 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = ""
currentAccount = null
clearCachedSessionAccount()
ProtocolManager.disconnect()
scope.launch {
ProtocolManager.disconnect()
accountManager.logout()
}
}
@@ -941,6 +1005,15 @@ fun MainScreen(
CallManager.bindAccount(accountPublicKey)
}
// Global account binding for protocol/message repository.
// Keeps init independent from ChatsList composition timing.
LaunchedEffect(accountPublicKey, accountPrivateKey) {
val normalizedPublicKey = accountPublicKey.trim()
val normalizedPrivateKey = accountPrivateKey.trim()
if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) return@LaunchedEffect
ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
}
LaunchedEffect(callUiState.isVisible) {
if (callUiState.isVisible) {
isCallOverlayExpanded = true
@@ -991,19 +1064,42 @@ fun MainScreen(
isCallOverlayExpanded = false
}
suspend fun refreshAccountIdentityState(accountKey: String) {
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountKey)
val cachedOwn = ProtocolManager.getCachedUserInfo(accountKey)
val persistedName = encryptedAccount?.name?.trim().orEmpty()
val persistedUsername = encryptedAccount?.username?.trim().orEmpty()
val cachedName = cachedOwn?.title?.trim().orEmpty()
val cachedUsername = cachedOwn?.username?.trim().orEmpty()
if (cachedName.isNotBlank() &&
!isPlaceholderAccountName(cachedName) &&
(persistedName.isBlank() || isPlaceholderAccountName(persistedName))) {
runCatching { accountManager.updateAccountName(accountKey, cachedName) }
}
if (cachedUsername.isNotBlank() && persistedUsername.isBlank()) {
runCatching { accountManager.updateAccountUsername(accountKey, cachedUsername) }
}
val finalUsername = persistedUsername.ifBlank { cachedUsername }
val preferredName =
when {
persistedName.isNotBlank() && !isPlaceholderAccountName(persistedName) ->
persistedName
cachedName.isNotBlank() && !isPlaceholderAccountName(cachedName) -> cachedName
else -> encryptedAccount?.name ?: accountName
}
accountUsername = finalUsername
accountVerified = cachedOwn?.verified ?: 0
accountName = resolveAccountDisplayName(accountKey, preferredName, finalUsername)
}
LaunchedEffect(accountPublicKey, reloadTrigger) {
if (accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountPublicKey)
val username = encryptedAccount?.username
accountUsername = username.orEmpty()
accountVerified = ProtocolManager.getCachedUserInfo(accountPublicKey)?.verified ?: 0
accountName =
resolveAccountDisplayName(
accountPublicKey,
encryptedAccount?.name ?: accountName,
username
)
refreshAccountIdentityState(accountPublicKey)
} else {
accountVerified = 0
}
@@ -1014,19 +1110,9 @@ fun MainScreen(
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
LaunchedEffect(ownProfileUpdated) {
LaunchedEffect(ownProfileUpdated, accountPublicKey) {
if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountPublicKey)
val username = encryptedAccount?.username
accountUsername = username.orEmpty()
accountVerified = ProtocolManager.getCachedUserInfo(accountPublicKey)?.verified ?: 0
accountName =
resolveAccountDisplayName(
accountPublicKey,
encryptedAccount?.name ?: accountName,
username
)
refreshAccountIdentityState(accountPublicKey)
}
}
@@ -1523,6 +1609,7 @@ fun MainScreen(
// 🔒 Lock swipe-back while chat overlays are open (image viewer/editor/media picker/camera).
var isChatSwipeLocked by remember { mutableStateOf(false) }
var isChatVoiceWaveGestureLocked by remember { mutableStateOf(false) }
// 🔴 Badge: total unread from OTHER chats (excluding current) for back-chevron badge
val totalUnreadFromOthers by remember(accountPublicKey, selectedUser?.publicKey) {
@@ -1537,6 +1624,9 @@ fun MainScreen(
var chatSelectionActive by remember { mutableStateOf(false) }
val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) }
LaunchedEffect(selectedUser?.publicKey) {
isChatVoiceWaveGestureLocked = false
}
SwipeBackContainer(
isVisible = selectedUser != null,
@@ -1549,7 +1639,7 @@ fun MainScreen(
},
isDarkTheme = isDarkTheme,
layer = 1,
swipeEnabled = !isChatSwipeLocked,
swipeEnabled = !(isChatSwipeLocked || isChatVoiceWaveGestureLocked),
enterAnimation = SwipeBackEnterAnimation.SlideFromRight,
propagateBackgroundProgress = false
) {
@@ -1593,7 +1683,10 @@ fun MainScreen(
isCallActive = callUiState.isVisible,
onOpenCallOverlay = { isCallOverlayExpanded = true },
onSelectionModeChange = { chatSelectionActive = it },
registerClearSelection = { fn -> chatClearSelectionRef.value = fn }
registerClearSelection = { fn -> chatClearSelectionRef.value = fn },
onVoiceWaveGestureChanged = { locked ->
isChatVoiceWaveGestureLocked = locked
}
)
}
}

View File

@@ -819,11 +819,19 @@ class MessageRepository private constructor(private val context: Context) {
}
if (isGroupMessage && groupKey.isNullOrBlank()) {
MessageLogger.debug(
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
val requiresGroupKey =
(packet.content.isNotBlank() && isProbablyEncryptedPayload(packet.content)) ||
packet.attachments.any { it.blob.isNotBlank() }
if (requiresGroupKey) {
MessageLogger.debug(
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
)
processedMessageIds.remove(messageId)
return false
}
ProtocolManager.addLog(
"⚠️ GROUP fallback without key: ${messageId.take(8)}..., contentLikelyPlain=true"
)
processedMessageIds.remove(messageId)
return false
}
val plainKeyAndNonce =
@@ -854,8 +862,9 @@ class MessageRepository private constructor(private val context: Context) {
if (isAttachmentOnly) {
""
} else if (isGroupMessage) {
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
?: throw IllegalStateException("Failed to decrypt group payload")
val decryptedGroupPayload =
groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) }
decryptedGroupPayload ?: safePlainMessageFallback(packet.content)
} else if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else {
@@ -1004,7 +1013,9 @@ class MessageRepository private constructor(private val context: Context) {
// 📝 LOG: Ошибка обработки
MessageLogger.logDecryptionError(messageId, e)
ProtocolManager.addLog(
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, " +
"group=$isGroupMessage, chachaLen=${packet.chachaKey.length}, " +
"aesLen=${packet.aesChachaKey.length}, reason=${e.javaClass.simpleName}:${e.message ?: "<no-message>"}"
)
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
processedMessageIds.remove(messageId)

View File

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

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()
currentSound = sound
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
val ringerMode = audioManager?.ringerMode ?: AudioManager.RINGER_MODE_NORMAL
val allowAudible = ringerMode == AudioManager.RINGER_MODE_NORMAL
val allowVibration =
sound == CallSound.RINGTONE &&
(ringerMode == AudioManager.RINGER_MODE_NORMAL ||
ringerMode == AudioManager.RINGER_MODE_VIBRATE)
if (!allowAudible) {
if (allowVibration) {
startVibration()
}
Log.i(TAG, "Skip audible $sound due to ringerMode=$ringerMode")
return
}
val resId = when (sound) {
CallSound.RINGTONE -> R.raw.call_ringtone
CallSound.CALLING -> R.raw.call_calling
@@ -86,7 +102,7 @@ object CallSoundManager {
mediaPlayer = player
// Vibrate for incoming calls
if (sound == CallSound.RINGTONE) {
if (allowVibration) {
startVibration()
}

View File

@@ -301,6 +301,32 @@ class Protocol(
startHeartbeat(packet.heartbeatInterval)
}
}
// Device verification resolution from primary device.
// Desktop typically continues after next handshake response; here we also
// add a safety re-handshake trigger on ACCEPT to avoid being stuck in
// DEVICE_VERIFICATION_REQUIRED if server doesn't immediately push 0x00.
waitPacket(0x18) { packet ->
val resolve = packet as? PacketDeviceResolve ?: return@waitPacket
when (resolve.solution) {
DeviceResolveSolution.ACCEPT -> {
log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(resolve.deviceId, 12)})")
if (_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED) {
setState(ProtocolState.CONNECTED, "Device verification accepted")
val publicKey = lastPublicKey
val privateHash = lastPrivateHash
if (!publicKey.isNullOrBlank() && !privateHash.isNullOrBlank()) {
startHandshake(publicKey, privateHash, lastDevice)
} else {
log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect")
}
}
}
DeviceResolveSolution.DECLINE -> {
log("⛔ DEVICE VERIFICATION DECLINED (deviceId=${shortKey(resolve.deviceId, 12)})")
}
}
}
}
/**
@@ -847,6 +873,11 @@ class Protocol(
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
)
if (isManuallyClosed) {
log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason")
return
}
if (!hasCredentials) return
if (currentState == ProtocolState.CONNECTING && isConnecting) {

View File

@@ -293,11 +293,28 @@ object ProtocolManager {
* Должен вызываться после авторизации пользователя
*/
fun initializeAccount(publicKey: String, privateKey: String) {
val normalizedPublicKey = publicKey.trim()
val normalizedPrivateKey = privateKey.trim()
if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) {
addLog("⚠️ initializeAccount skipped: missing account credentials")
return
}
addLog(
"🔐 initializeAccount pk=${shortKeyForLog(normalizedPublicKey)} keyLen=${normalizedPrivateKey.length} state=${getProtocol().state.value}"
)
setSyncInProgress(false)
clearTypingState()
messageRepository?.initialize(publicKey, privateKey)
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
messageRepository?.initialize(normalizedPublicKey, normalizedPrivateKey)
val shouldResync = resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true
if (shouldResync) {
// Late account init may happen while an old sync request flag is still set.
// Force a fresh synchronize request to recover dropped inbound packets.
resyncRequiredAfterAccountInit = false
syncRequestInFlight = false
clearSyncRequestTimeout()
addLog("🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync")
requestSynchronize()
}
// Send "Rosetta Updates" message on version change (like desktop useUpdateMessage)

View File

@@ -29,6 +29,7 @@ import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
@@ -308,6 +309,9 @@ fun SetPasswordScreen(
)
accountManager.saveAccount(account)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
// Initialize repository/account context before handshake completes to avoid
// "Sync postponed until account is initialized" race on first login.
ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey)
startAuthHandshakeFast(keyPair.publicKey, privateKeyHash)
accountManager.setCurrentAccount(keyPair.publicKey)
val decryptedAccount = DecryptedAccount(

View File

@@ -45,6 +45,7 @@ import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.getAvatarColor
@@ -116,6 +117,9 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
name = selectedAccount.name
)
// Initialize repository/account context before handshake completes to avoid
// "Sync postponed until account is initialized" race.
ProtocolManager.initializeAccount(account.publicKey, decryptedPrivateKey)
startAuthHandshakeFast(account.publicKey, privateKeyHash)
accountManager.setCurrentAccount(account.publicKey)

View File

@@ -327,7 +327,8 @@ fun ChatDetailScreen(
isCallActive: Boolean = false,
onOpenCallOverlay: () -> Unit = {},
onSelectionModeChange: (Boolean) -> Unit = {},
registerClearSelection: (() -> Unit) -> Unit = {}
registerClearSelection: (() -> Unit) -> Unit = {},
onVoiceWaveGestureChanged: (Boolean) -> Unit = {}
) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current
@@ -392,13 +393,20 @@ fun ChatDetailScreen(
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty()
var isVoiceWaveGestureActive by remember { mutableStateOf(false) }
// Notify parent about selection mode changes so it can intercept swipe-back
LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) }
LaunchedEffect(isVoiceWaveGestureActive) {
onVoiceWaveGestureChanged(isVoiceWaveGestureActive)
}
// Register selection-clear callback so parent can cancel selection on swipe-back
DisposableEffect(Unit) {
registerClearSelection { selectedMessages = emptySet() }
onDispose { registerClearSelection {} }
onDispose {
registerClearSelection {}
onVoiceWaveGestureChanged(false)
}
}
// После long press AndroidView текста может прислать tap на отпускание.
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
@@ -1586,6 +1594,27 @@ fun ChatDetailScreen(
label = "headerContent"
) { currentHeaderMode ->
if (currentHeaderMode == ChatHeaderMode.SELECTION) {
val selectedPinMessageId =
remember(selectedMessages, messages) {
if (selectedMessages.size != 1) {
null
} else {
messages
.firstOrNull {
selectedMessages.contains(it.id) &&
it.attachments.none { attachment ->
attachment.type == AttachmentType.AVATAR
}
}
?.id
}
}
val selectedPinMessageIsPinned =
remember(selectedPinMessageId, pinnedMessages) {
selectedPinMessageId != null &&
pinnedMessages.any { it.messageId == selectedPinMessageId }
}
val canToggleSelectedPin = selectedPinMessageId != null
// SELECTION MODE CONTENT
Row(
modifier =
@@ -1711,6 +1740,54 @@ fun ChatDetailScreen(
)
}
// Pin / Unpin button
IconButton(
onClick = {
val targetId =
selectedPinMessageId
?: return@IconButton
if (selectedPinMessageIsPinned) {
viewModel.unpinMessage(targetId)
} else {
viewModel.pinMessage(targetId)
isPinnedBannerDismissed =
false
}
selectedMessages =
emptySet()
},
enabled =
canToggleSelectedPin
) {
Icon(
painter =
if (selectedPinMessageIsPinned) {
TelegramIcons.Unpin
} else {
TelegramIcons.Pin
},
contentDescription =
if (selectedPinMessageIsPinned) {
"Unpin"
} else {
"Pin"
},
tint =
if (canToggleSelectedPin) {
headerIconColor
} else {
headerIconColor.copy(
alpha =
0.45f
)
},
modifier =
Modifier.size(
22.dp
)
)
}
// Delete button
IconButton(
onClick = {
@@ -2404,6 +2481,27 @@ fun ChatDetailScreen(
) { selectionMode ->
if (selectionMode) {
if (!isSystemAccount) {
val selectedPinMessageId =
remember(selectedMessages, messages) {
if (selectedMessages.size != 1) {
null
} else {
messages
.firstOrNull {
selectedMessages.contains(it.id) &&
it.attachments.none { attachment ->
attachment.type == AttachmentType.AVATAR
}
}
?.id
}
}
val selectedPinMessageIsPinned =
remember(selectedPinMessageId, pinnedMessages) {
selectedPinMessageId != null &&
pinnedMessages.any { it.messageId == selectedPinMessageId }
}
val canToggleSelectedPin = selectedPinMessageId != null
// SELECTION ACTION BAR - Reply/Forward
Column(
modifier =
@@ -2735,6 +2833,7 @@ fun ChatDetailScreen(
)
}
}
}
}
}
@@ -3396,9 +3495,12 @@ fun ChatDetailScreen(
listOf(
message
)
)
)
}
},
onVoiceWaveGestureActiveChanged = { active ->
isVoiceWaveGestureActive = active
},
onReplyClick = {
messageId
->

View File

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

View File

@@ -69,8 +69,37 @@ fun ForwardChatPickerBottomSheet(
val messagesCount = forwardMessages.size
// 🔥 Фильтруем системные аккаунты (Safe, Rosetta Updates)
val filteredDialogs = remember(dialogs) {
dialogs.filter { !MessageRepository.isSystemAccount(it.opponentKey) }
// и всегда добавляем Saved Messages, даже если self-диалог еще не создан.
val filteredDialogs = remember(dialogs, currentUserPublicKey) {
val base = dialogs.filter { !MessageRepository.isSystemAccount(it.opponentKey) }
val selfKey = currentUserPublicKey.trim()
if (selfKey.isBlank() || base.any { it.opponentKey == selfKey }) {
base
} else {
val savedMessagesDialog =
DialogUiModel(
id = Long.MIN_VALUE,
account = selfKey,
opponentKey = selfKey,
opponentTitle = "Saved Messages",
opponentUsername = "",
lastMessage = "",
lastMessageTimestamp = 0L,
unreadCount = 0,
isOnline = 0,
lastSeen = 0L,
verified = 0,
isSavedMessages = true,
lastMessageFromMe = 1,
lastMessageDelivered = 1,
lastMessageRead = 1,
lastMessageAttachmentType = null,
lastMessageSenderPrefix = null,
lastMessageSenderKey = null,
draftText = null
)
listOf(savedMessagesDialog) + base
}
}
// Мультивыбор чатов

View File

@@ -2,9 +2,11 @@ package com.rosetta.messenger.ui.chats.attach
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -20,6 +22,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -33,6 +36,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -355,11 +359,20 @@ fun ChatAttachAlert(
// ═══════════════════════════════════════════════════════════
// Permission handling
// ═══════════════════════════════════════════════════════════
val mediaPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO
)
} else {
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
var mediaPermissionRequestedOnce by rememberSaveable { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val granted = permissions.values.all { it }
val granted = mediaPermissions.all { permissions[it] == true }
viewModel.setPermissionGranted(granted)
if (granted) {
viewModel.loadMedia(context)
@@ -367,19 +380,35 @@ fun ChatAttachAlert(
}
fun requestPermissions() {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.CAMERA
)
} else {
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
val deniedPermissions = mediaPermissions.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
permissionLauncher.launch(permissions)
if (deniedPermissions.isEmpty()) {
viewModel.setPermissionGranted(true)
viewModel.loadMedia(context)
return
}
val activity = context as? Activity
val permanentlyDenied = activity != null &&
mediaPermissionRequestedOnce &&
deniedPermissions.all { permission ->
!ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
}
if (permanentlyDenied) {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
return
}
mediaPermissionRequestedOnce = true
permissionLauncher.launch(mediaPermissions)
}
// ═══════════════════════════════════════════════════════════
@@ -454,20 +483,7 @@ fun ChatAttachAlert(
resetPickerCaptionInput()
photoCaption = ""
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.CAMERA
)
} else {
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
}
val hasPermission = permissions.all {
val hasPermission = mediaPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
viewModel.setPermissionGranted(hasPermission)

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.chats.components
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -32,12 +33,16 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -54,9 +59,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
@@ -97,17 +106,31 @@ import java.io.ByteArrayInputStream
import java.io.File
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.min
import kotlin.math.PI
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.core.content.FileProvider
private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
private const val VOICE_WAVE_DEBUG_LOG = true
private val whitespaceRegex = "\\s+".toRegex()
private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} }
private fun rosettaDev1AttachmentLog(context: Context, tag: String, message: String) {
runCatching {
val dir = java.io.File(context.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
java.io.File(dir, "rosettadev1.txt").appendText("$ts [$tag] $message\n")
}.onFailure { err ->
android.util.Log.e(TAG, "rosettadev1 write failed: ${err.message}")
}
}
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
private fun canonicalGroupDialogKey(value: String): String {
@@ -173,12 +196,30 @@ private fun decodeVoicePayload(data: String): ByteArray? {
return decodeHexPayload(data) ?: decodeBase64Payload(data)
}
data class VoiceQueueEntry(
val attachmentId: String,
val dialogKey: String,
val timestampMs: Long,
val orderInMessage: Int,
val senderLabel: String,
val playedAtLabel: String,
val audioFilePath: String?,
val payload: String,
val downloadTag: String,
val transportServer: String,
val chachaKey: String,
val chachaKeyPlainHex: String,
val privateKey: String
)
object VoicePlaybackCoordinator {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val speedSteps = listOf(1f, 1.5f, 2f)
private var player: MediaPlayer? = null
private var currentAttachmentId: String? = null
private var appContext: Context? = null
private var progressJob: Job? = null
private val queueEntries = LinkedHashMap<String, VoiceQueueEntry>()
private val _playingAttachmentId = MutableStateFlow<String?>(null)
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
private val _playingDialogKey = MutableStateFlow<String?>(null)
@@ -196,6 +237,15 @@ object VoicePlaybackCoordinator {
private val _playingTimeLabel = MutableStateFlow("")
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(
attachmentId: String,
sourceFile: File,
@@ -228,7 +278,7 @@ object VoicePlaybackCoordinator {
.build()
)
mediaPlayer.setDataSource(sourceFile.absolutePath)
mediaPlayer.setOnCompletionListener { stop() }
mediaPlayer.setOnCompletionListener { handleTrackCompleted(attachmentId) }
mediaPlayer.prepare()
applyPlaybackSpeed(mediaPlayer)
mediaPlayer.start()
@@ -258,6 +308,61 @@ object VoicePlaybackCoordinator {
}
}
fun prepareForScrub(
attachmentId: String,
sourceFile: File,
dialogKey: String = "",
senderLabel: String = "",
playedAtLabel: String = "",
onError: (String) -> Unit = {}
) {
if (!sourceFile.exists()) {
onError("Voice file is missing")
return
}
if (currentAttachmentId == attachmentId && player != null) {
val active = player ?: return
_playingAttachmentId.value = attachmentId
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
_playingSenderLabel.value = senderLabel.trim()
_playingTimeLabel.value = playedAtLabel.trim()
_durationMs.value = runCatching { active.duration }.getOrDefault(0).coerceAtLeast(0)
_positionMs.value = runCatching { active.currentPosition }.getOrDefault(0).coerceAtLeast(0)
return
}
stop()
val mediaPlayer = MediaPlayer()
try {
mediaPlayer.setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
mediaPlayer.setDataSource(sourceFile.absolutePath)
mediaPlayer.setOnCompletionListener { handleTrackCompleted(attachmentId) }
mediaPlayer.prepare()
applyPlaybackSpeed(mediaPlayer)
player = mediaPlayer
currentAttachmentId = attachmentId
_playingAttachmentId.value = attachmentId
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
_playingSenderLabel.value = senderLabel.trim()
_playingTimeLabel.value = playedAtLabel.trim()
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
_isPlaying.value = false
progressJob?.cancel()
progressJob = null
} catch (e: Exception) {
runCatching { mediaPlayer.release() }
stop()
onError(e.message ?: "Playback failed")
}
}
fun pause() {
val active = player ?: return
runCatching {
@@ -293,6 +398,43 @@ object VoicePlaybackCoordinator {
setPlaybackSpeed(next)
}
fun seekTo(positionMs: Int, keepPaused: Boolean = false) {
val active = player ?: return
val duration = runCatching { active.duration }.getOrDefault(_durationMs.value).coerceAtLeast(0)
if (duration <= 0) return
val clampedPosition = positionMs.coerceIn(0, duration)
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
active.seekTo(clampedPosition.toLong(), MediaPlayer.SEEK_CLOSEST_SYNC)
} else {
@Suppress("DEPRECATION")
active.seekTo(clampedPosition)
}
}
if (keepPaused) {
// Some devices auto-resume after seek; for paused scrub we force pause every time.
runCatching {
if (active.isPlaying) active.pause()
}
_isPlaying.value = false
progressJob?.cancel()
progressJob = null
} else {
_isPlaying.value = runCatching { active.isPlaying }.getOrDefault(false)
}
_positionMs.value = clampedPosition
_durationMs.value = duration
}
fun seekToProgress(progress: Float, keepPaused: Boolean = false) {
val active = player ?: return
val duration = runCatching { active.duration }.getOrDefault(_durationMs.value).coerceAtLeast(0)
if (duration <= 0) return
val clampedProgress = progress.coerceIn(0f, 1f)
val targetMs = (duration.toFloat() * clampedProgress).toInt().coerceIn(0, duration)
seekTo(targetMs, keepPaused = keepPaused)
}
private fun setPlaybackSpeed(speed: Float) {
val normalized =
speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
@@ -348,6 +490,92 @@ object VoicePlaybackCoordinator {
runCatching { active.release() }
}
}
private fun normalizedDialogKey(value: String): String {
return value.trim()
}
private fun orderedEntriesForDialog(dialogKey: String): List<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 {
@@ -728,6 +956,7 @@ fun MessageAttachments(
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
onCancelUpload: (attachmentId: String) -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {},
modifier: Modifier = Modifier
) {
if (attachments.isEmpty()) return
@@ -762,6 +991,7 @@ fun MessageAttachments(
}
// Остальные attachments по отдельности
var voiceOrderInMessage = 0
otherAttachments.forEach { attachment ->
when (attachment.type) {
AttachmentType.FILE -> {
@@ -800,6 +1030,7 @@ fun MessageAttachments(
)
}
AttachmentType.VOICE -> {
val currentVoiceOrder = voiceOrderInMessage++
VoiceAttachment(
attachment = attachment,
chachaKey = chachaKey,
@@ -811,7 +1042,9 @@ fun MessageAttachments(
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus
messageStatus = messageStatus,
attachmentOrderInMessage = currentVoiceOrder,
onVoiceWaveGestureActiveChanged = onVoiceWaveGestureActiveChanged
)
}
AttachmentType.VIDEO_CIRCLE -> {
@@ -2040,6 +2273,8 @@ private fun formatVoicePlaybackSpeedLabel(speed: Float): String {
}
}
private const val VOICE_WAVEFORM_BAR_COUNT = 56
@Composable
private fun VoicePlaybackButtonBlob(
level: Float,
@@ -2237,7 +2472,9 @@ private fun VoiceAttachment(
isOutgoing: Boolean,
isDarkTheme: Boolean,
timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ
messageStatus: MessageStatus = MessageStatus.READ,
attachmentOrderInMessage: Int = 0,
onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {}
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -2261,10 +2498,10 @@ private fun VoiceAttachment(
remember(attachment.preview) { parseVoicePreview(attachment.preview) }
val previewDurationSec = previewDurationSecRaw.coerceAtLeast(1)
val previewWaves =
remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, 40) }
remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, VOICE_WAVEFORM_BAR_COUNT) }
val waves =
remember(previewWaves) {
if (previewWaves.isEmpty()) List(40) { 0f } else previewWaves
if (previewWaves.isEmpty()) List(VOICE_WAVEFORM_BAR_COUNT) { 0f } else previewWaves
}
var payload by
@@ -2301,6 +2538,23 @@ private fun VoiceAttachment(
} else {
0f
}
var isScrubbing by remember(attachment.id) { mutableStateOf(false) }
var scrubProgress by remember(attachment.id) { mutableFloatStateOf(0f) }
var waveformWidthPx by remember(attachment.id) { mutableFloatStateOf(0f) }
var isWaveformTouchLocked by remember(attachment.id) { mutableStateOf(false) }
var suppressMainActionUntilMs by remember(attachment.id) { mutableLongStateOf(0L) }
val smoothProgress by
animateFloatAsState(
targetValue = progress,
animationSpec =
if (isPlaying) {
tween(durationMillis = 140, easing = LinearEasing)
} else {
tween(durationMillis = 180, easing = FastOutSlowInEasing)
},
label = "voice_wave_progress"
)
val displayProgress = if (isScrubbing) scrubProgress else smoothProgress
val liveWaveLevel =
remember(isPlaying, progress, waves) {
if (!isPlaying || waves.isEmpty()) {
@@ -2311,9 +2565,18 @@ private fun VoiceAttachment(
waves[sampleIndex].coerceIn(0f, 1f)
}
}
val trackDurationMsForUi =
if (isActiveTrack && playbackDurationMs > 0) playbackDurationMs
else (effectiveDurationSec * 1000)
val displayPositionMs =
if (isScrubbing && trackDurationMsForUi > 0) {
(trackDurationMsForUi.toFloat() * scrubProgress.coerceIn(0f, 1f)).toInt()
} else {
playbackPositionMs.coerceAtLeast(0)
}
val timeText =
if (isActiveTrack && playbackDurationMs > 0) {
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
val leftSec = ((playbackDurationMs - displayPositionMs).coerceAtLeast(0) / 1000)
formatVoiceDuration(leftSec)
} else {
formatVoiceDuration(effectiveDurationSec)
@@ -2334,6 +2597,61 @@ private fun VoiceAttachment(
}
.getOrDefault("")
}
val initialPayload = remember(attachment.id, attachment.blob) { attachment.blob }
val normalizedDialogKey = remember(dialogPublicKey) { dialogPublicKey.trim() }
LaunchedEffect(
attachment.id,
normalizedDialogKey,
timestamp.time,
attachmentOrderInMessage,
playbackSenderLabel,
playbackTimeLabel,
audioFilePath,
attachment.transportTag,
attachment.transportServer,
chachaKey,
chachaKeyPlainHex,
privateKey
) {
VoicePlaybackCoordinator.upsertQueueEntry(
context = context,
entry = VoiceQueueEntry(
attachmentId = attachment.id,
dialogKey = normalizedDialogKey,
timestampMs = timestamp.time,
orderInMessage = attachmentOrderInMessage,
senderLabel = playbackSenderLabel,
playedAtLabel = playbackTimeLabel,
audioFilePath = audioFilePath,
payload = payload.ifBlank { initialPayload },
downloadTag = attachment.transportTag,
transportServer = attachment.transportServer,
chachaKey = chachaKey,
chachaKeyPlainHex = chachaKeyPlainHex,
privateKey = privateKey
)
)
}
DisposableEffect(attachment.id) {
onDispose {
onVoiceWaveGestureActiveChanged(false)
VoicePlaybackCoordinator.removeQueueEntry(attachment.id)
}
}
LaunchedEffect(attachment.id, downloadStatus, isActiveTrack, isPlaying) {
if (VOICE_WAVE_DEBUG_LOG) {
rosettaDev1AttachmentLog(
context = context,
tag = "VoiceWave",
message =
"INIT att=${attachment.id.take(8)} status=$downloadStatus " +
"active=$isActiveTrack playing=$isPlaying hasFile=${audioFilePath?.isNotBlank() == true}"
)
}
}
val triggerDownload: () -> Unit = download@{
if (attachment.transportTag.isBlank()) {
@@ -2391,7 +2709,11 @@ private fun VoiceAttachment(
}
}
val onMainAction: () -> Unit = {
val performMainAction: (Boolean) -> Unit = mainAction@{ ignoreWaveformTouchLock ->
val now = SystemClock.elapsedRealtime()
if (!ignoreWaveformTouchLock && (isWaveformTouchLocked || now < suppressMainActionUntilMs)) {
return@mainAction
}
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload()
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit
@@ -2442,6 +2764,25 @@ private fun VoiceAttachment(
}
}
}
val onMainAction: () -> Unit = { performMainAction(false) }
val canHandleWaveformTouch =
downloadStatus == DownloadStatus.DOWNLOADED
fun progressFromWaveX(x: Float): Float {
if (waveformWidthPx <= 0f) return 0f
return (x / waveformWidthPx).coerceIn(0f, 1f)
}
val viewConfiguration = LocalViewConfiguration.current
val waveformDragStartThresholdPx =
remember(viewConfiguration.touchSlop) {
// Slightly below touchSlop to make waveform drag feel responsive.
viewConfiguration.touchSlop * 0.75f
}
val waveformTapCancelThresholdPx =
remember(viewConfiguration.touchSlop) {
// If finger moved at least a bit, don't treat it as tap-to-toggle.
viewConfiguration.touchSlop * 0.35f
}
val barInactiveColor =
if (isOutgoing) Color.White.copy(alpha = 0.38f)
@@ -2456,23 +2797,27 @@ private fun VoiceAttachment(
val actionBackground =
when (downloadStatus) {
DownloadStatus.ERROR -> Color(0xFFE55757)
else -> if (isOutgoing) Color.White.copy(alpha = 0.2f) else PrimaryBlue
else ->
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else if (isDarkTheme) {
Color(0xFF4C5562)
} else {
Color(0xFFE3E7EC)
}
}
val actionTint =
when {
downloadStatus == DownloadStatus.ERROR -> Color.White
isOutgoing -> Color.White
else -> Color.White
else -> if (isDarkTheme) Color(0xFFF1F3F5) else Color(0xFF3B4652)
}
val actionButtonInteraction = remember { MutableInteractionSource() }
Row(
modifier =
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onMainAction() },
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
@@ -2496,7 +2841,12 @@ private fun VoiceAttachment(
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(actionBackground),
.background(actionBackground)
.clickable(
enabled = !isWaveformTouchLocked,
interactionSource = actionButtonInteraction,
indication = null
) { onMainAction() },
contentAlignment = Alignment.Center
) {
if (downloadStatus == DownloadStatus.DOWNLOADING ||
@@ -2528,7 +2878,8 @@ private fun VoiceAttachment(
else -> {
Icon(
imageVector =
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
if (isPlaying) Icons.Outlined.Pause
else Icons.Default.PlayArrow,
contentDescription = null,
tint = actionTint,
modifier = Modifier.size(20.dp)
@@ -2544,27 +2895,187 @@ private fun VoiceAttachment(
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth().height(28.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
horizontalArrangement = Arrangement.spacedBy(1.dp)
) {
Row(
modifier =
Modifier.fillMaxWidth()
.height(28.dp)
.onSizeChanged { size ->
waveformWidthPx = size.width.toFloat()
}
.pointerInput(
attachment.id,
canHandleWaveformTouch
) {
if (!canHandleWaveformTouch) return@pointerInput
awaitEachGesture {
val downChange =
awaitFirstDown(
requireUnconsumed = false,
pass = PointerEventPass.Main
)
val wasPlayingOnTouchDown = isPlaying
var preparedForInteraction = isActiveTrack
var isDraggingWaveform = false
var movedEnoughForTapCancel = false
val downPosition = downChange.position
var lastTouchX = downPosition.x
var lastWaveLogAtMs = 0L
fun waveLog(message: String) {
if (!VOICE_WAVE_DEBUG_LOG) return
val now = SystemClock.elapsedRealtime()
if (
now - lastWaveLogAtMs < 70L &&
!message.startsWith("DOWN") &&
!message.startsWith("DRAG_START") &&
!message.startsWith("DRAG_END") &&
!message.startsWith("TAP")
) {
return
}
lastWaveLogAtMs = now
rosettaDev1AttachmentLog(
context = context,
tag = "VoiceWave",
message =
"att=${attachment.id.take(8)} active=$isActiveTrack playing=$isPlaying " +
"paused=${!wasPlayingOnTouchDown} $message"
)
}
fun ensurePreparedForInteraction(): Boolean {
if (preparedForInteraction) return true
var sourceFile: File? =
audioFilePath
?.let { path -> File(path) }
?.takeIf { it.exists() && it.length() > 0L }
if (sourceFile == null && payload.isNotBlank()) {
sourceFile =
ensureVoiceAudioFile(
context = context,
attachmentId = attachment.id,
payload = payload
)
if (sourceFile != null) {
audioFilePath = sourceFile.absolutePath
}
}
if (sourceFile == null) return false
VoicePlaybackCoordinator.prepareForScrub(
attachmentId = attachment.id,
sourceFile = sourceFile,
dialogKey = dialogPublicKey,
senderLabel = playbackSenderLabel,
playedAtLabel = playbackTimeLabel
) { message ->
downloadStatus = DownloadStatus.ERROR
errorText = message
}
preparedForInteraction = true
waveLog("PREPARED file=${sourceFile.name}")
return true
}
try {
onVoiceWaveGestureActiveChanged(true)
isWaveformTouchLocked = true
suppressMainActionUntilMs =
SystemClock.elapsedRealtime() + 700L
downChange.consume()
waveLog(
"DOWN x=${downChange.position.x.toInt()} y=${downChange.position.y.toInt()}"
)
val pointerId = downChange.id
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
val trackedChange =
event.changes.firstOrNull { it.id == pointerId } ?: break
lastTouchX = trackedChange.position.x
val dx = trackedChange.position.x - downPosition.x
val dy = trackedChange.position.y - downPosition.y
if (!movedEnoughForTapCancel &&
(kotlin.math.abs(dx) > waveformTapCancelThresholdPx ||
kotlin.math.abs(dy) > waveformTapCancelThresholdPx)
) {
movedEnoughForTapCancel = true
}
if (!isDraggingWaveform &&
kotlin.math.abs(dx) > waveformDragStartThresholdPx &&
trackedChange.pressed
) {
if (!ensurePreparedForInteraction()) {
waveLog("DRAG_ABORT no-source")
trackedChange.consume()
break
}
isDraggingWaveform = true
isScrubbing = true
waveLog(
"DRAG_START dx=${dx.toInt()} threshold=${waveformDragStartThresholdPx.toInt()}"
)
}
if (isDraggingWaveform) {
val progressAtTouch =
progressFromWaveX(trackedChange.position.x)
scrubProgress = progressAtTouch
waveLog(
"DRAG_MOVE p=${(progressAtTouch * 100).toInt()}% pressed=${trackedChange.pressed}"
)
}
trackedChange.consume()
if (!trackedChange.pressed) break
}
if (isDraggingWaveform) {
val releaseProgress = progressFromWaveX(lastTouchX)
scrubProgress = releaseProgress
VoicePlaybackCoordinator.seekToProgress(
releaseProgress,
keepPaused = !wasPlayingOnTouchDown
)
waveLog(
"DRAG_END seek=${(releaseProgress * 100).toInt()}%"
)
} else if (movedEnoughForTapCancel) {
waveLog("TAP_SUPPRESSED moved-before-drag")
} else {
waveLog("TAP_ACTION")
performMainAction(true)
}
} finally {
isScrubbing = false
isWaveformTouchLocked = false
suppressMainActionUntilMs =
SystemClock.elapsedRealtime() + 280L
onVoiceWaveGestureActiveChanged(false)
}
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(1.dp)
) {
waves.forEachIndexed { index, value ->
val normalized = value.coerceIn(0f, 1f)
val passed = (progress * waves.size) - index
val fill = passed.coerceIn(0f, 1f)
val color =
if (fill > 0f) {
barActiveColor
} else {
barInactiveColor
}
val waveCount = waves.size.coerceAtLeast(1)
val barStart = index.toFloat() / waveCount.toFloat()
val barEnd = (index + 1).toFloat() / waveCount.toFloat()
val fill =
((displayProgress - barStart) / (barEnd - barStart))
.coerceIn(0f, 1f)
val color = lerp(barInactiveColor, barActiveColor, fill)
Box(
modifier =
Modifier.width(2.dp)
Modifier.weight(1f)
.height((4f + normalized * 18f).dp)
.clip(RoundedCornerShape(100))
.background(color)
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
@@ -2589,37 +3100,45 @@ private fun VoiceAttachment(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (isActiveTrack) {
val speedChipBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else if (isDarkTheme) {
Color(0xFF31435A)
} else {
Color(0xFFDCEBFD)
}
val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
Box(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
.background(speedChipBackground)
.clickable(
interactionSource =
remember { MutableInteractionSource() },
indication = null
) {
VoicePlaybackCoordinator.cycleSpeed()
}
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = formatVoicePlaybackSpeedLabel(playbackSpeed),
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
color = speedChipTextColor
)
}
// Speed chip — always reserve space so the bubble doesn't reflow
// when playback starts/stops; fixed width so cycling 1x/1.5x/2x
// doesn't jitter the row either.
val speedChipBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else if (isDarkTheme) {
Color(0xFF31435A)
} else {
Color(0xFFDCEBFD)
}
val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
Box(
modifier =
Modifier.width(32.dp)
.graphicsLayer { alpha = if (isActiveTrack) 1f else 0f }
.clip(RoundedCornerShape(10.dp))
.background(speedChipBackground)
.then(
if (isActiveTrack) {
Modifier.clickable(
interactionSource =
remember { MutableInteractionSource() },
indication = null
) {
VoicePlaybackCoordinator.cycleSpeed()
}
} else Modifier
)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = formatVoicePlaybackSpeedLabel(playbackSpeed),
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
color = speedChipTextColor,
maxLines = 1
)
}
Text(
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),

View File

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

View File

@@ -2,12 +2,14 @@ package com.rosetta.messenger.ui.chats.components
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.ContentUris
import android.content.Context
import android.util.Log
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.provider.MediaStore
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
@@ -29,6 +31,7 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -58,6 +61,7 @@ import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import coil.compose.AsyncImage
@@ -224,12 +228,22 @@ fun MediaPickerBottomSheet(
selectedItemOrder
}
}
val mediaPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO
)
} else {
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
var mediaPermissionRequestedOnce by rememberSaveable { mutableStateOf(false) }
// Permission launcher
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
hasPermission = permissions.values.all { it }
hasPermission = mediaPermissions.all { permissions[it] == true }
if (hasPermission) {
scope.launch {
val loaded = loadMediaPickerData(context)
@@ -240,6 +254,37 @@ fun MediaPickerBottomSheet(
}
}
}
fun requestMediaPermissions() {
val deniedPermissions = mediaPermissions.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
if (deniedPermissions.isEmpty()) {
hasPermission = true
return
}
val activity = context as? Activity
val permanentlyDenied = activity != null &&
mediaPermissionRequestedOnce &&
deniedPermissions.all { permission ->
!ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
}
if (permanentlyDenied) {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
return
}
mediaPermissionRequestedOnce = true
permissionLauncher.launch(mediaPermissions)
}
// Check permission on show
LaunchedEffect(isVisible) {
@@ -249,20 +294,7 @@ fun MediaPickerBottomSheet(
resetPickerCaptionInput()
selectedAlbumId = ALL_MEDIA_ALBUM_ID
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.CAMERA
)
} else {
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
}
hasPermission = permissions.all {
hasPermission = mediaPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
@@ -274,7 +306,7 @@ fun MediaPickerBottomSheet(
selectedAlbumId = loaded.albums.firstOrNull()?.id ?: ALL_MEDIA_ALBUM_ID
isLoading = false
} else {
permissionLauncher.launch(permissions)
requestMediaPermissions()
}
}
}
@@ -975,19 +1007,7 @@ fun MediaPickerBottomSheet(
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onRequestPermission = {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.CAMERA
)
} else {
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
}
permissionLauncher.launch(permissions)
requestMediaPermissions()
}
)
} else if (isLoading) {

View File

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