Исправлена перемотка голосовых и устранены конфликты жестов
Some checks failed
Android Kernel Build / build (push) Has been cancelled
Some checks failed
Android Kernel Build / build (push) Has been cancelled
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
@@ -64,6 +73,19 @@ fun AnimatedKeyboardTransition(
|
||||
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
|
||||
|
||||
|
||||
@@ -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()
|
||||
scope.launch {
|
||||
com.rosetta.messenger.network.ProtocolManager
|
||||
.disconnect()
|
||||
scope.launch {
|
||||
accountManager.logout()
|
||||
}
|
||||
}
|
||||
@@ -416,9 +480,9 @@ class MainActivity : FragmentActivity() {
|
||||
// lag
|
||||
currentAccount = null
|
||||
clearCachedSessionAccount()
|
||||
scope.launch {
|
||||
com.rosetta.messenger.network.ProtocolManager
|
||||
.disconnect()
|
||||
scope.launch {
|
||||
accountManager.logout()
|
||||
}
|
||||
},
|
||||
@@ -509,8 +573,8 @@ class MainActivity : FragmentActivity() {
|
||||
// Switch to another account: logout current, then show unlock.
|
||||
currentAccount = null
|
||||
clearCachedSessionAccount()
|
||||
scope.launch {
|
||||
com.rosetta.messenger.network.ProtocolManager.disconnect()
|
||||
scope.launch {
|
||||
accountManager.logout()
|
||||
}
|
||||
},
|
||||
@@ -520,8 +584,8 @@ class MainActivity : FragmentActivity() {
|
||||
preservedMainNavAccountKey = ""
|
||||
currentAccount = null
|
||||
clearCachedSessionAccount()
|
||||
scope.launch {
|
||||
com.rosetta.messenger.network.ProtocolManager.disconnect()
|
||||
scope.launch {
|
||||
accountManager.logout()
|
||||
}
|
||||
}
|
||||
@@ -535,8 +599,8 @@ class MainActivity : FragmentActivity() {
|
||||
preservedMainNavAccountKey = ""
|
||||
currentAccount = null
|
||||
clearCachedSessionAccount()
|
||||
scope.launch {
|
||||
ProtocolManager.disconnect()
|
||||
scope.launch {
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,12 +819,20 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
if (isGroupMessage && groupKey.isNullOrBlank()) {
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
val plainKeyAndNonce =
|
||||
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3399,6 +3498,9 @@ fun ChatDetailScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
onVoiceWaveGestureActiveChanged = { active ->
|
||||
isVoiceWaveGestureActive = active
|
||||
},
|
||||
onReplyClick = {
|
||||
messageId
|
||||
->
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// Мультивыбор чатов
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,28 +2895,188 @@ private fun VoiceAttachment(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(28.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(2.dp)
|
||||
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(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -2589,7 +3100,9 @@ private fun VoiceAttachment(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (isActiveTrack) {
|
||||
// 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)
|
||||
@@ -2601,15 +3114,21 @@ private fun VoiceAttachment(
|
||||
val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
Modifier.width(32.dp)
|
||||
.graphicsLayer { alpha = if (isActiveTrack) 1f else 0f }
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(speedChipBackground)
|
||||
.clickable(
|
||||
.then(
|
||||
if (isActiveTrack) {
|
||||
Modifier.clickable(
|
||||
interactionSource =
|
||||
remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
VoicePlaybackCoordinator.cycleSpeed()
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -2617,10 +3136,10 @@ private fun VoiceAttachment(
|
||||
text = formatVoicePlaybackSpeedLabel(playbackSpeed),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = speedChipTextColor
|
||||
color = speedChipTextColor,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
|
||||
fontSize = 11.sp,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
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)
|
||||
@@ -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
|
||||
LaunchedEffect(isVisible) {
|
||||
if (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) {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user