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