Доработал голосовые сообщения и Telegram-подобный UI ввода
This commit is contained in:
@@ -9,6 +9,7 @@ enum class AttachmentType(val value: Int) {
|
|||||||
FILE(2), // Файл
|
FILE(2), // Файл
|
||||||
AVATAR(3), // Аватар пользователя
|
AVATAR(3), // Аватар пользователя
|
||||||
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
||||||
|
VOICE(5), // Голосовое сообщение
|
||||||
UNKNOWN(-1); // Неизвестный тип
|
UNKNOWN(-1); // Неизвестный тип
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -2679,6 +2679,20 @@ fun ChatDetailScreen(
|
|||||||
isSendingMessage = false
|
isSendingMessage = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSendVoiceMessage = { voiceHex, durationSec, waves ->
|
||||||
|
isSendingMessage = true
|
||||||
|
viewModel.sendVoiceMessage(
|
||||||
|
voiceHex = voiceHex,
|
||||||
|
durationSec = durationSec,
|
||||||
|
waves = waves
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
delay(120)
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
delay(220)
|
||||||
|
isSendingMessage = false
|
||||||
|
}
|
||||||
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
backgroundColor = backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
@@ -4258,6 +4272,7 @@ private fun ChatInputBarSection(
|
|||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
isSavedMessages: Boolean,
|
isSavedMessages: Boolean,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
|
onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List<Float>) -> Unit,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
@@ -4295,6 +4310,7 @@ private fun ChatInputBarSection(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSend = onSend,
|
onSend = onSend,
|
||||||
|
onSendVoiceMessage = onSendVoiceMessage,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
backgroundColor = backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
|
|||||||
@@ -1625,6 +1625,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
"file" -> AttachmentType.FILE.value
|
"file" -> AttachmentType.FILE.value
|
||||||
"avatar" -> AttachmentType.AVATAR.value
|
"avatar" -> AttachmentType.AVATAR.value
|
||||||
"call" -> AttachmentType.CALL.value
|
"call" -> AttachmentType.CALL.value
|
||||||
|
"voice" -> AttachmentType.VOICE.value
|
||||||
else -> -1
|
else -> -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1792,9 +1793,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
|
// 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой
|
||||||
if ((effectiveType == AttachmentType.IMAGE ||
|
if ((effectiveType == AttachmentType.IMAGE ||
|
||||||
effectiveType == AttachmentType.AVATAR) &&
|
effectiveType == AttachmentType.AVATAR ||
|
||||||
|
effectiveType == AttachmentType.VOICE) &&
|
||||||
blob.isEmpty() &&
|
blob.isEmpty() &&
|
||||||
attachmentId.isNotEmpty()
|
attachmentId.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
@@ -2566,6 +2568,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||||
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
|
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
|
||||||
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||||
|
message.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message"
|
||||||
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
|
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
|
||||||
message.replyData != null -> "Reply"
|
message.replyData != null -> "Reply"
|
||||||
else -> "Pinned message"
|
else -> "Pinned message"
|
||||||
@@ -4806,6 +4809,192 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎙️ Отправка голосового сообщения.
|
||||||
|
* blob хранится как HEX строка opus/webm байт (desktop parity).
|
||||||
|
* preview формат: "<durationSec>::<wave1,wave2,...>"
|
||||||
|
*/
|
||||||
|
fun sendVoiceMessage(
|
||||||
|
voiceHex: String,
|
||||||
|
durationSec: Int,
|
||||||
|
waves: List<Float>
|
||||||
|
) {
|
||||||
|
val recipient = opponentKey
|
||||||
|
val sender = myPublicKey
|
||||||
|
val privateKey = myPrivateKey
|
||||||
|
|
||||||
|
if (recipient == null || sender == null || privateKey == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedVoiceHex = voiceHex.trim()
|
||||||
|
if (normalizedVoiceHex.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedDuration = durationSec.coerceAtLeast(1)
|
||||||
|
val normalizedWaves =
|
||||||
|
waves.asSequence()
|
||||||
|
.map { it.coerceIn(0f, 1f) }
|
||||||
|
.take(120)
|
||||||
|
.toList()
|
||||||
|
val wavesPreview =
|
||||||
|
normalizedWaves.joinToString(",") {
|
||||||
|
String.format(Locale.US, "%.3f", it)
|
||||||
|
}
|
||||||
|
val preview = "$normalizedDuration::$wavesPreview"
|
||||||
|
|
||||||
|
isSending = true
|
||||||
|
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val attachmentId = "voice_$timestamp"
|
||||||
|
|
||||||
|
// 1. 🚀 Optimistic UI
|
||||||
|
val optimisticMessage =
|
||||||
|
ChatMessage(
|
||||||
|
id = messageId,
|
||||||
|
text = "",
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = Date(timestamp),
|
||||||
|
status = MessageStatus.SENDING,
|
||||||
|
attachments =
|
||||||
|
listOf(
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
type = AttachmentType.VOICE,
|
||||||
|
preview = preview,
|
||||||
|
blob = normalizedVoiceHex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
addMessageSafely(optimisticMessage)
|
||||||
|
_inputText.value = ""
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val encryptionContext =
|
||||||
|
buildEncryptionContext(
|
||||||
|
plaintext = "",
|
||||||
|
recipient = recipient,
|
||||||
|
privateKey = privateKey
|
||||||
|
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||||
|
val encryptedContent = encryptionContext.encryptedContent
|
||||||
|
val encryptedKey = encryptionContext.encryptedKey
|
||||||
|
val aesChachaKey = encryptionContext.aesChachaKey
|
||||||
|
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
|
val encryptedVoiceBlob =
|
||||||
|
encryptAttachmentPayload(normalizedVoiceHex, encryptionContext)
|
||||||
|
|
||||||
|
val isSavedMessages = (sender == recipient)
|
||||||
|
var uploadTag = ""
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
uploadTag = TransportManager.uploadFile(attachmentId, encryptedVoiceBlob)
|
||||||
|
}
|
||||||
|
val attachmentTransportServer =
|
||||||
|
if (uploadTag.isNotEmpty()) {
|
||||||
|
TransportManager.getTransportServer().orEmpty()
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
val voiceAttachment =
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
blob = "",
|
||||||
|
type = AttachmentType.VOICE,
|
||||||
|
preview = preview,
|
||||||
|
transportTag = uploadTag,
|
||||||
|
transportServer = attachmentTransportServer
|
||||||
|
)
|
||||||
|
|
||||||
|
val packet =
|
||||||
|
PacketMessage().apply {
|
||||||
|
fromPublicKey = sender
|
||||||
|
toPublicKey = recipient
|
||||||
|
content = encryptedContent
|
||||||
|
chachaKey = encryptedKey
|
||||||
|
this.aesChachaKey = aesChachaKey
|
||||||
|
this.timestamp = timestamp
|
||||||
|
this.privateKey = privateKeyHash
|
||||||
|
this.messageId = messageId
|
||||||
|
attachments = listOf(voiceAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для отправителя сохраняем voice blob локально в encrypted cache.
|
||||||
|
runCatching {
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = getApplication(),
|
||||||
|
blob = normalizedVoiceHex,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
publicKey = sender,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val attachmentsJson =
|
||||||
|
JSONArray()
|
||||||
|
.apply {
|
||||||
|
put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", attachmentId)
|
||||||
|
put("type", AttachmentType.VOICE.value)
|
||||||
|
put("preview", preview)
|
||||||
|
put("blob", "")
|
||||||
|
put("transportTag", uploadTag)
|
||||||
|
put("transportServer", attachmentTransportServer)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
saveMessageToDatabase(
|
||||||
|
messageId = messageId,
|
||||||
|
text = "",
|
||||||
|
encryptedContent = encryptedContent,
|
||||||
|
encryptedKey =
|
||||||
|
if (encryptionContext.isGroup) {
|
||||||
|
buildStoredGroupKey(
|
||||||
|
encryptionContext.attachmentPassword,
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
encryptedKey
|
||||||
|
},
|
||||||
|
timestamp = timestamp,
|
||||||
|
isFromMe = true,
|
||||||
|
delivered = if (isSavedMessages) 1 else 0,
|
||||||
|
attachmentsJson = attachmentsJson
|
||||||
|
)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (isSavedMessages) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDialog("Voice message", timestamp)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||||
|
saveDialog("Voice message", timestamp)
|
||||||
|
} finally {
|
||||||
|
isSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение
|
* Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4549,6 +4549,8 @@ fun DialogItemContent(
|
|||||||
"Avatar" -> "Avatar"
|
"Avatar" -> "Avatar"
|
||||||
dialog.lastMessageAttachmentType ==
|
dialog.lastMessageAttachmentType ==
|
||||||
"Call" -> "Call"
|
"Call" -> "Call"
|
||||||
|
dialog.lastMessageAttachmentType ==
|
||||||
|
"Voice message" -> "Voice message"
|
||||||
dialog.lastMessageAttachmentType ==
|
dialog.lastMessageAttachmentType ==
|
||||||
"Forwarded" ->
|
"Forwarded" ->
|
||||||
"Forwarded message"
|
"Forwarded message"
|
||||||
|
|||||||
@@ -573,16 +573,50 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
lastMessageAttachments: String
|
lastMessageAttachments: String
|
||||||
): String? {
|
): String? {
|
||||||
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
|
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
|
||||||
return when (attachmentType) {
|
val effectiveType =
|
||||||
|
if (attachmentType >= 0) attachmentType
|
||||||
|
else inferAttachmentTypeFromJson(lastMessageAttachments)
|
||||||
|
|
||||||
|
return when (effectiveType) {
|
||||||
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
|
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
|
||||||
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
|
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
|
||||||
2 -> "File" // AttachmentType.FILE = 2
|
2 -> "File" // AttachmentType.FILE = 2
|
||||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||||
4 -> "Call" // AttachmentType.CALL = 4
|
4 -> "Call" // AttachmentType.CALL = 4
|
||||||
|
5 -> "Voice message" // AttachmentType.VOICE = 5
|
||||||
else -> if (inferredCall) "Call" else null
|
else -> if (inferredCall) "Call" else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun inferAttachmentTypeFromJson(rawAttachments: String): Int {
|
||||||
|
if (rawAttachments.isBlank() || rawAttachments == "[]") return -1
|
||||||
|
return try {
|
||||||
|
val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
|
||||||
|
if (attachments.length() <= 0) return -1
|
||||||
|
val first = attachments.optJSONObject(0) ?: return -1
|
||||||
|
val rawType = first.opt("type")
|
||||||
|
when (rawType) {
|
||||||
|
is Number -> rawType.toInt()
|
||||||
|
is String -> {
|
||||||
|
val normalized = rawType.trim()
|
||||||
|
normalized.toIntOrNull()
|
||||||
|
?: when (normalized.lowercase(Locale.ROOT)) {
|
||||||
|
"image" -> 0
|
||||||
|
"messages", "reply", "forward" -> 1
|
||||||
|
"file" -> 2
|
||||||
|
"avatar" -> 3
|
||||||
|
"call" -> 4
|
||||||
|
"voice" -> 5
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
|
private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
|
||||||
if (rawAttachments.isBlank() || rawAttachments == "[]") return false
|
if (rawAttachments.isBlank() || rawAttachments == "[]") return false
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package com.rosetta.messenger.ui.chats.components
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.os.SystemClock
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
@@ -61,27 +66,31 @@ import com.rosetta.messenger.network.AttachmentType
|
|||||||
import com.rosetta.messenger.network.MessageAttachment
|
import com.rosetta.messenger.network.MessageAttachment
|
||||||
import com.rosetta.messenger.network.TransportManager
|
import com.rosetta.messenger.network.TransportManager
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
|
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
import com.vanniktech.blurhash.BlurHash
|
import com.vanniktech.blurhash.BlurHash
|
||||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.SystemClock
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
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
|
||||||
@@ -126,6 +135,96 @@ private fun decodeBase64Payload(data: String): ByteArray? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decodeHexPayload(data: String): ByteArray? {
|
||||||
|
val raw = data.trim().removePrefix("0x")
|
||||||
|
if (raw.isBlank() || raw.length % 2 != 0) return null
|
||||||
|
if (!raw.all { ch -> ch.isDigit() || ch.lowercaseChar() in 'a'..'f' }) return null
|
||||||
|
return runCatching {
|
||||||
|
ByteArray(raw.length / 2) { index ->
|
||||||
|
raw.substring(index * 2, index * 2 + 2).toInt(16).toByte()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeVoicePayload(data: String): ByteArray? {
|
||||||
|
return decodeHexPayload(data) ?: decodeBase64Payload(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object VoicePlaybackCoordinator {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
private var player: MediaPlayer? = null
|
||||||
|
private var currentAttachmentId: String? = null
|
||||||
|
private var progressJob: Job? = null
|
||||||
|
private val _playingAttachmentId = MutableStateFlow<String?>(null)
|
||||||
|
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
|
||||||
|
private val _positionMs = MutableStateFlow(0)
|
||||||
|
val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
|
||||||
|
private val _durationMs = MutableStateFlow(0)
|
||||||
|
val durationMs: StateFlow<Int> = _durationMs.asStateFlow()
|
||||||
|
|
||||||
|
fun toggle(attachmentId: String, sourceFile: File, onError: (String) -> Unit = {}) {
|
||||||
|
if (!sourceFile.exists()) {
|
||||||
|
onError("Voice file is missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentAttachmentId == attachmentId && player?.isPlaying == true) {
|
||||||
|
stop()
|
||||||
|
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 { stop() }
|
||||||
|
mediaPlayer.prepare()
|
||||||
|
mediaPlayer.start()
|
||||||
|
player = mediaPlayer
|
||||||
|
currentAttachmentId = attachmentId
|
||||||
|
_playingAttachmentId.value = attachmentId
|
||||||
|
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
|
||||||
|
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
|
||||||
|
progressJob?.cancel()
|
||||||
|
progressJob =
|
||||||
|
scope.launch {
|
||||||
|
while (isActive && currentAttachmentId == attachmentId) {
|
||||||
|
val active = player
|
||||||
|
if (active == null || !active.isPlaying) break
|
||||||
|
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
||||||
|
delay(120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { mediaPlayer.release() }
|
||||||
|
stop()
|
||||||
|
onError(e.message ?: "Playback failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
val active = player
|
||||||
|
player = null
|
||||||
|
currentAttachmentId = null
|
||||||
|
progressJob?.cancel()
|
||||||
|
progressJob = null
|
||||||
|
_playingAttachmentId.value = null
|
||||||
|
_positionMs.value = 0
|
||||||
|
_durationMs.value = 0
|
||||||
|
if (active != null) {
|
||||||
|
runCatching {
|
||||||
|
if (active.isPlaying) active.stop()
|
||||||
|
}
|
||||||
|
runCatching { active.release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun shortDebugId(value: String): String {
|
private fun shortDebugId(value: String): String {
|
||||||
if (value.isBlank()) return "empty"
|
if (value.isBlank()) return "empty"
|
||||||
val clean = value.trim()
|
val clean = value.trim()
|
||||||
@@ -486,6 +585,7 @@ private fun TelegramFileActionButton(
|
|||||||
fun MessageAttachments(
|
fun MessageAttachments(
|
||||||
attachments: List<MessageAttachment>,
|
attachments: List<MessageAttachment>,
|
||||||
chachaKey: String,
|
chachaKey: String,
|
||||||
|
chachaKeyPlainHex: String = "",
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
isOutgoing: Boolean,
|
isOutgoing: Boolean,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
@@ -573,6 +673,19 @@ fun MessageAttachments(
|
|||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AttachmentType.VOICE -> {
|
||||||
|
VoiceAttachment(
|
||||||
|
attachment = attachment,
|
||||||
|
chachaKey = chachaKey,
|
||||||
|
chachaKeyPlainHex = chachaKeyPlainHex,
|
||||||
|
privateKey = privateKey,
|
||||||
|
senderPublicKey = senderPublicKey,
|
||||||
|
isOutgoing = isOutgoing,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
timestamp = timestamp,
|
||||||
|
messageStatus = messageStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
|
// Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
|
||||||
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
|
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
|
||||||
@@ -1679,6 +1792,60 @@ private fun parseCallDurationSeconds(preview: String): Int {
|
|||||||
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
|
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseVoicePreview(preview: String): Pair<Int, List<Float>> {
|
||||||
|
if (preview.isBlank()) return 0 to emptyList()
|
||||||
|
val durationPart = preview.substringBefore("::", preview).trim()
|
||||||
|
val wavesPart = preview.substringAfter("::", "").trim()
|
||||||
|
val duration = durationPart.toIntOrNull()?.coerceAtLeast(0) ?: 0
|
||||||
|
val waves =
|
||||||
|
if (wavesPart.isBlank()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
wavesPart.split(",")
|
||||||
|
.mapNotNull { it.trim().toFloatOrNull() }
|
||||||
|
.map { it.coerceIn(0f, 1f) }
|
||||||
|
}
|
||||||
|
return duration to waves
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeVoiceWaves(source: List<Float>, targetLength: Int): List<Float> {
|
||||||
|
if (targetLength <= 0) return emptyList()
|
||||||
|
if (source.isEmpty()) return List(targetLength) { 0f }
|
||||||
|
if (source.size == targetLength) return source
|
||||||
|
if (source.size > targetLength) {
|
||||||
|
val bucket = source.size.toFloat() / targetLength.toFloat()
|
||||||
|
return List(targetLength) { idx ->
|
||||||
|
val start = kotlin.math.floor(idx * bucket).toInt()
|
||||||
|
val end = kotlin.math.max(start + 1, kotlin.math.floor((idx + 1) * bucket).toInt())
|
||||||
|
var maxValue = 0f
|
||||||
|
var i = start
|
||||||
|
while (i < end && i < source.size) {
|
||||||
|
if (source[i] > maxValue) maxValue = source[i]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
maxValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetLength == 1) return listOf(source.first())
|
||||||
|
val lastIndex = source.lastIndex.toFloat()
|
||||||
|
return List(targetLength) { idx ->
|
||||||
|
val pos = idx * lastIndex / (targetLength - 1).toFloat()
|
||||||
|
val left = kotlin.math.floor(pos).toInt()
|
||||||
|
val right = kotlin.math.min(kotlin.math.ceil(pos).toInt(), source.lastIndex)
|
||||||
|
if (left == right) source[left] else {
|
||||||
|
val t = pos - left.toFloat()
|
||||||
|
source[left] * (1f - t) + source[right] * t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatVoiceDuration(seconds: Int): String {
|
||||||
|
val safe = seconds.coerceAtLeast(0)
|
||||||
|
val minutes = (safe / 60).toString().padStart(2, '0')
|
||||||
|
val rem = (safe % 60).toString().padStart(2, '0')
|
||||||
|
return "$minutes:$rem"
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatDesktopCallDuration(durationSec: Int): String {
|
private fun formatDesktopCallDuration(durationSec: Int): String {
|
||||||
val minutes = durationSec / 60
|
val minutes = durationSec / 60
|
||||||
val seconds = durationSec % 60
|
val seconds = durationSec % 60
|
||||||
@@ -1790,6 +1957,369 @@ fun CallAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ensureVoiceAudioFile(
|
||||||
|
context: android.content.Context,
|
||||||
|
attachmentId: String,
|
||||||
|
payload: String
|
||||||
|
): File? {
|
||||||
|
val bytes = decodeVoicePayload(payload) ?: return null
|
||||||
|
val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() }
|
||||||
|
val file = File(directory, "$attachmentId.webm")
|
||||||
|
runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceAttachment(
|
||||||
|
attachment: MessageAttachment,
|
||||||
|
chachaKey: String,
|
||||||
|
chachaKeyPlainHex: String,
|
||||||
|
privateKey: String,
|
||||||
|
senderPublicKey: String,
|
||||||
|
isOutgoing: Boolean,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
timestamp: java.util.Date,
|
||||||
|
messageStatus: MessageStatus = MessageStatus.READ
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||||
|
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState()
|
||||||
|
val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState()
|
||||||
|
val isPlaying = playingAttachmentId == attachment.id
|
||||||
|
|
||||||
|
val (previewDurationSecRaw, previewWavesRaw) =
|
||||||
|
remember(attachment.preview) { parseVoicePreview(attachment.preview) }
|
||||||
|
val previewDurationSec = previewDurationSecRaw.coerceAtLeast(1)
|
||||||
|
val previewWaves =
|
||||||
|
remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, 40) }
|
||||||
|
val waves =
|
||||||
|
remember(previewWaves) {
|
||||||
|
if (previewWaves.isEmpty()) List(40) { 0f } else previewWaves
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload by
|
||||||
|
remember(attachment.id, attachment.blob) {
|
||||||
|
mutableStateOf(attachment.blob.trim())
|
||||||
|
}
|
||||||
|
var downloadStatus by
|
||||||
|
remember(attachment.id, attachment.blob, attachment.transportTag) {
|
||||||
|
mutableStateOf(
|
||||||
|
when {
|
||||||
|
attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
|
||||||
|
attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
|
||||||
|
else -> DownloadStatus.ERROR
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var errorText by remember { mutableStateOf("") }
|
||||||
|
var audioFilePath by remember(attachment.id) { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val effectiveDurationSec =
|
||||||
|
remember(isPlaying, playbackDurationMs, previewDurationSec) {
|
||||||
|
val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
|
||||||
|
if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec
|
||||||
|
}
|
||||||
|
val progress =
|
||||||
|
if (isPlaying && playbackDurationMs > 0) {
|
||||||
|
(playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
val timeText =
|
||||||
|
if (isPlaying && playbackDurationMs > 0) {
|
||||||
|
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
|
||||||
|
"-${formatVoiceDuration(leftSec)}"
|
||||||
|
} else {
|
||||||
|
formatVoiceDuration(effectiveDurationSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(payload, attachment.id) {
|
||||||
|
if (payload.isBlank()) return@LaunchedEffect
|
||||||
|
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
|
||||||
|
if (prepared != null) {
|
||||||
|
audioFilePath = prepared.absolutePath
|
||||||
|
if (downloadStatus != DownloadStatus.DOWNLOADING &&
|
||||||
|
downloadStatus != DownloadStatus.DECRYPTING
|
||||||
|
) {
|
||||||
|
downloadStatus = DownloadStatus.DOWNLOADED
|
||||||
|
}
|
||||||
|
if (errorText.isNotBlank()) errorText = ""
|
||||||
|
} else {
|
||||||
|
audioFilePath = null
|
||||||
|
downloadStatus = DownloadStatus.ERROR
|
||||||
|
if (errorText.isBlank()) errorText = "Cannot decode voice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(attachment.id) {
|
||||||
|
onDispose {
|
||||||
|
if (playingAttachmentId == attachment.id) {
|
||||||
|
VoicePlaybackCoordinator.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val triggerDownload: () -> Unit = download@{
|
||||||
|
if (attachment.transportTag.isBlank()) {
|
||||||
|
downloadStatus = DownloadStatus.ERROR
|
||||||
|
errorText = "Voice not available"
|
||||||
|
return@download
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
downloadStatus = DownloadStatus.DOWNLOADING
|
||||||
|
errorText = ""
|
||||||
|
val decrypted =
|
||||||
|
downloadAndDecryptVoicePayload(
|
||||||
|
attachmentId = attachment.id,
|
||||||
|
downloadTag = attachment.transportTag,
|
||||||
|
chachaKey = chachaKey,
|
||||||
|
privateKey = privateKey,
|
||||||
|
transportServer = attachment.transportServer,
|
||||||
|
chachaKeyPlainHex = chachaKeyPlainHex
|
||||||
|
)
|
||||||
|
if (decrypted.isNullOrBlank()) {
|
||||||
|
downloadStatus = DownloadStatus.ERROR
|
||||||
|
errorText = "Failed to decrypt"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val saved =
|
||||||
|
runCatching {
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = context,
|
||||||
|
blob = decrypted,
|
||||||
|
attachmentId = attachment.id,
|
||||||
|
publicKey = senderPublicKey,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.getOrDefault(false)
|
||||||
|
payload = decrypted
|
||||||
|
if (!saved) {
|
||||||
|
// Не блокируем UI, но оставляем маркер в логе.
|
||||||
|
runCatching { android.util.Log.w(TAG, "Voice cache save failed: ${attachment.id}") }
|
||||||
|
}
|
||||||
|
downloadStatus = DownloadStatus.DOWNLOADED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onMainAction: () -> Unit = {
|
||||||
|
when (downloadStatus) {
|
||||||
|
DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload()
|
||||||
|
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit
|
||||||
|
DownloadStatus.DOWNLOADED, DownloadStatus.PENDING -> {
|
||||||
|
val file = audioFilePath?.let { File(it) }
|
||||||
|
if (file == null || !file.exists()) {
|
||||||
|
if (payload.isNotBlank()) {
|
||||||
|
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
|
||||||
|
if (prepared != null) {
|
||||||
|
audioFilePath = prepared.absolutePath
|
||||||
|
VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message ->
|
||||||
|
downloadStatus = DownloadStatus.ERROR
|
||||||
|
errorText = message
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
downloadStatus = DownloadStatus.ERROR
|
||||||
|
errorText = "Cannot decode voice"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
triggerDownload()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VoicePlaybackCoordinator.toggle(attachment.id, file) { message ->
|
||||||
|
downloadStatus = DownloadStatus.ERROR
|
||||||
|
errorText = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val barInactiveColor =
|
||||||
|
if (isOutgoing) Color.White.copy(alpha = 0.38f)
|
||||||
|
else if (isDarkTheme) Color(0xFF5D6774)
|
||||||
|
else Color(0xFFB6C0CC)
|
||||||
|
val barActiveColor = if (isOutgoing) Color.White else PrimaryBlue
|
||||||
|
val secondaryTextColor =
|
||||||
|
if (isOutgoing) Color.White.copy(alpha = 0.72f)
|
||||||
|
else if (isDarkTheme) Color(0xFF9EAABD)
|
||||||
|
else Color(0xFF667283)
|
||||||
|
|
||||||
|
val actionBackground =
|
||||||
|
when (downloadStatus) {
|
||||||
|
DownloadStatus.ERROR -> Color(0xFFE55757)
|
||||||
|
else -> if (isOutgoing) Color.White.copy(alpha = 0.2f) else PrimaryBlue
|
||||||
|
}
|
||||||
|
val actionTint =
|
||||||
|
when {
|
||||||
|
downloadStatus == DownloadStatus.ERROR -> Color.White
|
||||||
|
isOutgoing -> Color.White
|
||||||
|
else -> Color.White
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { onMainAction() },
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(actionBackground),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||||
|
downloadStatus == DownloadStatus.DECRYPTING
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
when (downloadStatus) {
|
||||||
|
DownloadStatus.NOT_DOWNLOADED -> {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.msg_download),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = actionTint,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DownloadStatus.ERROR -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = actionTint,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = actionTint,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(28.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
waves.forEachIndexed { index, value ->
|
||||||
|
val normalized = value.coerceIn(0f, 1f)
|
||||||
|
val passed = (progress * waves.size) - index
|
||||||
|
val fill = passed.coerceIn(0f, 1f)
|
||||||
|
val color =
|
||||||
|
if (fill > 0f) {
|
||||||
|
barActiveColor
|
||||||
|
} else {
|
||||||
|
barInactiveColor
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.width(2.dp)
|
||||||
|
.height((4f + normalized * 18f).dp)
|
||||||
|
.clip(RoundedCornerShape(100))
|
||||||
|
.background(color)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (downloadStatus == DownloadStatus.ERROR && errorText.isNotBlank())
|
||||||
|
errorText
|
||||||
|
else timeText,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color =
|
||||||
|
if (downloadStatus == DownloadStatus.ERROR) {
|
||||||
|
Color(0xFFE55757)
|
||||||
|
} else {
|
||||||
|
secondaryTextColor
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = secondaryTextColor
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isOutgoing) {
|
||||||
|
when (messageStatus) {
|
||||||
|
MessageStatus.SENDING -> {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Clock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MessageStatus.SENT,
|
||||||
|
MessageStatus.DELIVERED -> {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MessageStatus.READ -> {
|
||||||
|
Box(modifier = Modifier.height(14.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(14.dp).offset(x = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageStatus.ERROR -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Error,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFE55757),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** File attachment - Telegram style */
|
/** File attachment - Telegram style */
|
||||||
@Composable
|
@Composable
|
||||||
fun FileAttachment(
|
fun FileAttachment(
|
||||||
@@ -2933,6 +3463,60 @@ private suspend fun processDownloadedImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CDN download + decrypt helper for voice payload (hex string).
|
||||||
|
*/
|
||||||
|
internal suspend fun downloadAndDecryptVoicePayload(
|
||||||
|
attachmentId: String,
|
||||||
|
downloadTag: String,
|
||||||
|
chachaKey: String,
|
||||||
|
privateKey: String,
|
||||||
|
transportServer: String = "",
|
||||||
|
chachaKeyPlainHex: String = ""
|
||||||
|
): String? {
|
||||||
|
if (downloadTag.isBlank() || privateKey.isBlank()) return null
|
||||||
|
if (chachaKeyPlainHex.isBlank() && chachaKey.isBlank()) return null
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val encryptedContent =
|
||||||
|
TransportManager.downloadFile(
|
||||||
|
attachmentId,
|
||||||
|
downloadTag,
|
||||||
|
transportServer.ifBlank { null }
|
||||||
|
)
|
||||||
|
if (encryptedContent.isBlank()) return@withContext null
|
||||||
|
|
||||||
|
when {
|
||||||
|
chachaKeyPlainHex.isNotBlank() -> {
|
||||||
|
val plainKey =
|
||||||
|
chachaKeyPlainHex.chunked(2)
|
||||||
|
.mapNotNull { part -> part.toIntOrNull(16)?.toByte() }
|
||||||
|
.toByteArray()
|
||||||
|
if (plainKey.isEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
|
||||||
|
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey)
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isGroupStoredKey(chachaKey) -> {
|
||||||
|
val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
|
||||||
|
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
||||||
|
?: run {
|
||||||
|
val hexKey =
|
||||||
|
groupPassword.toByteArray(Charsets.ISO_8859_1)
|
||||||
|
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
CryptoManager.decryptWithPassword(encryptedContent, hexKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> MessageCrypto.decryptAttachmentBlob(encryptedContent, chachaKey, privateKey)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CDN download + decrypt + cache + save.
|
* CDN download + decrypt + cache + save.
|
||||||
* Shared between ReplyBubble and ForwardedImagePreview.
|
* Shared between ReplyBubble and ForwardedImagePreview.
|
||||||
|
|||||||
@@ -979,6 +979,7 @@ fun MessageBubble(
|
|||||||
MessageAttachments(
|
MessageAttachments(
|
||||||
attachments = message.attachments,
|
attachments = message.attachments,
|
||||||
chachaKey = message.chachaKey,
|
chachaKey = message.chachaKey,
|
||||||
|
chachaKeyPlainHex = message.chachaKeyPlainHex,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
isOutgoing = message.isOutgoing,
|
isOutgoing = message.isOutgoing,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
package com.rosetta.messenger.ui.chats.input
|
package com.rosetta.messenger.ui.chats.input
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.os.Build
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
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.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -34,6 +44,8 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -44,6 +56,7 @@ import androidx.compose.ui.window.Popup
|
|||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||||
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
@@ -60,7 +73,11 @@ import com.rosetta.messenger.ui.chats.ChatViewModel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
private fun truncateEmojiSafe(text: String, maxLen: Int): String {
|
private fun truncateEmojiSafe(text: String, maxLen: Int): String {
|
||||||
if (text.length <= maxLen) return text
|
if (text.length <= maxLen) return text
|
||||||
@@ -75,6 +92,284 @@ private fun truncateEmojiSafe(text: String, maxLen: Int): String {
|
|||||||
return text.substring(0, cutAt).trimEnd() + "..."
|
return text.substring(0, cutAt).trimEnd() + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun bytesToHexLower(bytes: ByteArray): String {
|
||||||
|
if (bytes.isEmpty()) return ""
|
||||||
|
val out = StringBuilder(bytes.size * 2)
|
||||||
|
bytes.forEach { out.append(String.format("%02x", it.toInt() and 0xff)) }
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compressVoiceWaves(source: List<Float>, targetLength: Int): List<Float> {
|
||||||
|
if (targetLength <= 0) return emptyList()
|
||||||
|
if (source.isEmpty()) return List(targetLength) { 0f }
|
||||||
|
if (source.size == targetLength) return source
|
||||||
|
|
||||||
|
if (source.size > targetLength) {
|
||||||
|
val bucketSize = source.size / targetLength.toFloat()
|
||||||
|
return List(targetLength) { index ->
|
||||||
|
val start = kotlin.math.floor(index * bucketSize).toInt()
|
||||||
|
val end = kotlin.math.max(start + 1, kotlin.math.floor((index + 1) * bucketSize).toInt())
|
||||||
|
var maxValue = 0f
|
||||||
|
for (i in start until end.coerceAtMost(source.size)) {
|
||||||
|
if (source[i] > maxValue) maxValue = source[i]
|
||||||
|
}
|
||||||
|
maxValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLength == 1) return listOf(source.first())
|
||||||
|
|
||||||
|
val lastIndex = source.lastIndex.toFloat()
|
||||||
|
return List(targetLength) { index ->
|
||||||
|
val pos = index * lastIndex / (targetLength - 1).toFloat()
|
||||||
|
val left = kotlin.math.floor(pos).toInt()
|
||||||
|
val right = kotlin.math.min(kotlin.math.ceil(pos).toInt(), source.lastIndex)
|
||||||
|
if (left == right) {
|
||||||
|
source[left]
|
||||||
|
} else {
|
||||||
|
val t = pos - left.toFloat()
|
||||||
|
source[left] * (1f - t) + source[right] * t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatVoiceRecordTimer(elapsedMs: Long): String {
|
||||||
|
val safeTenths = (elapsedMs.coerceAtLeast(0L) / 100L).toInt()
|
||||||
|
val totalSeconds = safeTenths / 10
|
||||||
|
val tenths = safeTenths % 10
|
||||||
|
val minutes = totalSeconds / 60
|
||||||
|
val seconds = (totalSeconds % 60).toString().padStart(2, '0')
|
||||||
|
return "$minutes:$seconds,$tenths"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordBlinkDot(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var entered by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) { entered = true }
|
||||||
|
|
||||||
|
val enterScale by animateFloatAsState(
|
||||||
|
targetValue = if (entered) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 180, easing = LinearOutSlowInEasing),
|
||||||
|
label = "record_dot_enter_scale"
|
||||||
|
)
|
||||||
|
val blinkAlpha by rememberInfiniteTransition(label = "record_dot_blink").animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 600, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "record_dot_alpha"
|
||||||
|
)
|
||||||
|
|
||||||
|
val dotColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(28.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = enterScale
|
||||||
|
scaleY = enterScale
|
||||||
|
alpha = blinkAlpha
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp) // Telegram fallback dot radius = 5dp
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(dotColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceMovingBlob(
|
||||||
|
voiceLevel: Float,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val rawLevel = voiceLevel.coerceIn(0f, 1f)
|
||||||
|
val level by animateFloatAsState(
|
||||||
|
targetValue = rawLevel,
|
||||||
|
animationSpec = tween(durationMillis = 100, easing = LinearOutSlowInEasing),
|
||||||
|
label = "voice_blob_level"
|
||||||
|
)
|
||||||
|
val transition = rememberInfiniteTransition(label = "voice_blob_motion")
|
||||||
|
val phase by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1400, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
),
|
||||||
|
label = "voice_blob_phase"
|
||||||
|
)
|
||||||
|
|
||||||
|
val waveColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier
|
||||||
|
.width(36.dp)
|
||||||
|
.height(18.dp)
|
||||||
|
) {
|
||||||
|
val cxBase = size.width * 0.5f
|
||||||
|
val cy = size.height * 0.5f
|
||||||
|
val waveShift = (size.width * 0.16f * sin(phase * 2f * PI.toFloat()))
|
||||||
|
val cx = cxBase + waveShift
|
||||||
|
val base = size.minDimension * 0.22f
|
||||||
|
|
||||||
|
drawCircle(
|
||||||
|
color = waveColor.copy(alpha = 0.2f + level * 0.15f),
|
||||||
|
radius = base * (2.1f + level * 1.0f),
|
||||||
|
center = Offset(cx - waveShift * 0.55f, cy)
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = waveColor.copy(alpha = 0.38f + level * 0.20f),
|
||||||
|
radius = base * (1.55f + level * 0.75f),
|
||||||
|
center = Offset(cx + waveShift * 0.35f, cy)
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = waveColor.copy(alpha = 0.95f),
|
||||||
|
radius = base * (0.88f + level * 0.15f),
|
||||||
|
center = Offset(cx, cy)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceButtonBlob(
|
||||||
|
voiceLevel: Float,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val rawLevel = voiceLevel.coerceIn(0f, 1f)
|
||||||
|
val bigLevel by animateFloatAsState(
|
||||||
|
targetValue = rawLevel,
|
||||||
|
animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing),
|
||||||
|
label = "voice_btn_blob_big_level"
|
||||||
|
)
|
||||||
|
val smallLevel by animateFloatAsState(
|
||||||
|
targetValue = rawLevel * 0.88f,
|
||||||
|
animationSpec = tween(durationMillis = 220, easing = LinearOutSlowInEasing),
|
||||||
|
label = "voice_btn_blob_small_level"
|
||||||
|
)
|
||||||
|
val transition = rememberInfiniteTransition(label = "voice_btn_blob_motion")
|
||||||
|
val morphPhase by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = (2f * PI.toFloat()),
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 2200, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
),
|
||||||
|
label = "voice_btn_blob_morph"
|
||||||
|
)
|
||||||
|
val driftX by transition.animateFloat(
|
||||||
|
initialValue = -1f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1650, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "voice_btn_blob_drift_x"
|
||||||
|
)
|
||||||
|
val driftY by transition.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = -1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1270, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "voice_btn_blob_drift_y"
|
||||||
|
)
|
||||||
|
|
||||||
|
val blobColor = if (isDarkTheme) Color(0xFF52C3FF) else Color(0xFF2D9CFF)
|
||||||
|
|
||||||
|
fun createBlobPath(
|
||||||
|
center: Offset,
|
||||||
|
baseRadius: Float,
|
||||||
|
points: Int,
|
||||||
|
phase: Float,
|
||||||
|
seed: Float,
|
||||||
|
level: Float,
|
||||||
|
formMax: Float
|
||||||
|
): Path {
|
||||||
|
val coords = ArrayList<Offset>(points)
|
||||||
|
val step = (2f * PI.toFloat()) / points.toFloat()
|
||||||
|
val deform = (0.08f + formMax * 0.34f) * level
|
||||||
|
for (i in 0 until points) {
|
||||||
|
val angle = i * step
|
||||||
|
val p = angle + phase * (0.6f + seed * 0.22f)
|
||||||
|
val n1 = sin(p * (2.2f + seed * 0.45f))
|
||||||
|
val n2 = sin(p * (3.4f + seed * 0.28f) - phase * (0.7f + seed * 0.18f))
|
||||||
|
val mix = n1 * 0.65f + n2 * 0.35f
|
||||||
|
val radius = baseRadius * (1f + mix * deform)
|
||||||
|
coords += Offset(
|
||||||
|
x = center.x + radius * kotlin.math.cos(angle),
|
||||||
|
y = center.y + radius * kotlin.math.sin(angle)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val path = Path()
|
||||||
|
if (coords.isEmpty()) return path
|
||||||
|
val firstMid = Offset(
|
||||||
|
(coords.last().x + coords.first().x) * 0.5f,
|
||||||
|
(coords.last().y + coords.first().y) * 0.5f
|
||||||
|
)
|
||||||
|
path.moveTo(firstMid.x, firstMid.y)
|
||||||
|
for (i in coords.indices) {
|
||||||
|
val current = coords[i]
|
||||||
|
val next = coords[(i + 1) % coords.size]
|
||||||
|
val mid = Offset((current.x + next.x) * 0.5f, (current.y + next.y) * 0.5f)
|
||||||
|
path.quadraticBezierTo(current.x, current.y, mid.x, mid.y)
|
||||||
|
}
|
||||||
|
path.close()
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
val center = Offset(
|
||||||
|
x = size.width * 0.5f + size.width * 0.05f * driftX,
|
||||||
|
y = size.height * 0.5f + size.height * 0.04f * driftY
|
||||||
|
)
|
||||||
|
val baseRadius = size.minDimension * 0.25f
|
||||||
|
|
||||||
|
// Telegram-like constants:
|
||||||
|
// SCALE_BIG_MIN=0.878, SCALE_SMALL_MIN=0.926, +1.4*amplitude
|
||||||
|
val bigScale = 0.878f + 1.4f * bigLevel
|
||||||
|
val smallScale = 0.926f + 1.4f * smallLevel
|
||||||
|
|
||||||
|
val bigPath = createBlobPath(
|
||||||
|
center = center,
|
||||||
|
baseRadius = baseRadius * bigScale,
|
||||||
|
points = 12,
|
||||||
|
phase = morphPhase,
|
||||||
|
seed = 0.23f,
|
||||||
|
level = bigLevel,
|
||||||
|
formMax = 0.6f
|
||||||
|
)
|
||||||
|
val smallPath = createBlobPath(
|
||||||
|
center = Offset(center.x - size.width * 0.01f, center.y + size.height * 0.01f),
|
||||||
|
baseRadius = baseRadius * smallScale,
|
||||||
|
points = 11,
|
||||||
|
phase = morphPhase + 0.7f,
|
||||||
|
seed = 0.61f,
|
||||||
|
level = smallLevel,
|
||||||
|
formMax = 0.6f
|
||||||
|
)
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = bigPath,
|
||||||
|
color = blobColor.copy(alpha = 0.30f)
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
path = smallPath,
|
||||||
|
color = blobColor.copy(alpha = 0.15f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message input bar and related components
|
* Message input bar and related components
|
||||||
* Extracted from ChatDetailScreen.kt for better organization
|
* Extracted from ChatDetailScreen.kt for better organization
|
||||||
@@ -102,6 +397,7 @@ fun MessageInputBar(
|
|||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
|
onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List<Float>) -> Unit = { _, _, _ -> },
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
@@ -245,6 +541,156 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var voiceRecorder by remember { mutableStateOf<MediaRecorder?>(null) }
|
||||||
|
var voiceOutputFile by remember { mutableStateOf<File?>(null) }
|
||||||
|
var isVoiceRecording by remember { mutableStateOf(false) }
|
||||||
|
var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) }
|
||||||
|
var voiceElapsedMs by remember { mutableLongStateOf(0L) }
|
||||||
|
var voiceWaves by remember { mutableStateOf<List<Float>>(emptyList()) }
|
||||||
|
|
||||||
|
fun stopVoiceRecording(send: Boolean) {
|
||||||
|
val recorder = voiceRecorder
|
||||||
|
val outputFile = voiceOutputFile
|
||||||
|
val elapsedSnapshot =
|
||||||
|
if (voiceRecordStartedAtMs > 0L) {
|
||||||
|
maxOf(voiceElapsedMs, System.currentTimeMillis() - voiceRecordStartedAtMs)
|
||||||
|
} else {
|
||||||
|
voiceElapsedMs
|
||||||
|
}
|
||||||
|
val durationSnapshot = ((elapsedSnapshot + 999L) / 1000L).toInt().coerceAtLeast(1)
|
||||||
|
val wavesSnapshot = voiceWaves
|
||||||
|
|
||||||
|
voiceRecorder = null
|
||||||
|
voiceOutputFile = null
|
||||||
|
isVoiceRecording = false
|
||||||
|
voiceRecordStartedAtMs = 0L
|
||||||
|
voiceElapsedMs = 0L
|
||||||
|
voiceWaves = emptyList()
|
||||||
|
|
||||||
|
var recordedOk = false
|
||||||
|
if (recorder != null) {
|
||||||
|
recordedOk = runCatching {
|
||||||
|
recorder.stop()
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
runCatching { recorder.reset() }
|
||||||
|
runCatching { recorder.release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) {
|
||||||
|
val voiceHex =
|
||||||
|
runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("")
|
||||||
|
if (voiceHex.isNotBlank()) {
|
||||||
|
onSendVoiceMessage(
|
||||||
|
voiceHex,
|
||||||
|
durationSnapshot,
|
||||||
|
compressVoiceWaves(wavesSnapshot, 35)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching { outputFile?.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startVoiceRecording() {
|
||||||
|
if (isVoiceRecording) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val voiceDir = File(context.cacheDir, "voice_recordings").apply { mkdirs() }
|
||||||
|
val output = File(voiceDir, "voice_${UUID.randomUUID()}.webm")
|
||||||
|
|
||||||
|
val recorder =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaRecorder(context)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
MediaRecorder()
|
||||||
|
}
|
||||||
|
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
recorder.setOutputFormat(MediaRecorder.OutputFormat.WEBM)
|
||||||
|
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
|
||||||
|
recorder.setAudioEncodingBitRate(32_000)
|
||||||
|
recorder.setAudioSamplingRate(48_000)
|
||||||
|
recorder.setOutputFile(output.absolutePath)
|
||||||
|
recorder.prepare()
|
||||||
|
recorder.start()
|
||||||
|
|
||||||
|
voiceRecorder = recorder
|
||||||
|
voiceOutputFile = output
|
||||||
|
voiceRecordStartedAtMs = System.currentTimeMillis()
|
||||||
|
voiceElapsedMs = 0L
|
||||||
|
voiceWaves = emptyList()
|
||||||
|
isVoiceRecording = true
|
||||||
|
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
stopVoiceRecording(send = false)
|
||||||
|
android.widget.Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Voice recording is not supported on this device",
|
||||||
|
android.widget.Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val recordAudioPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
startVoiceRecording()
|
||||||
|
} else {
|
||||||
|
android.widget.Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Microphone permission is required for voice messages",
|
||||||
|
android.widget.Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestVoiceRecording() {
|
||||||
|
val granted =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.RECORD_AUDIO
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
startVoiceRecording()
|
||||||
|
} else {
|
||||||
|
recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isVoiceRecording, voiceRecorder) {
|
||||||
|
if (!isVoiceRecording) return@LaunchedEffect
|
||||||
|
while (isVoiceRecording && voiceRecorder != null) {
|
||||||
|
if (voiceRecordStartedAtMs > 0L) {
|
||||||
|
voiceElapsedMs =
|
||||||
|
(System.currentTimeMillis() - voiceRecordStartedAtMs).coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isVoiceRecording, voiceRecorder) {
|
||||||
|
if (!isVoiceRecording) return@LaunchedEffect
|
||||||
|
while (isVoiceRecording && voiceRecorder != null) {
|
||||||
|
val amplitude = runCatching { voiceRecorder?.maxAmplitude ?: 0 }.getOrDefault(0)
|
||||||
|
val normalized = (amplitude.toFloat() / 32_767f).coerceIn(0f, 1f)
|
||||||
|
voiceWaves = (voiceWaves + normalized).takeLast(120)
|
||||||
|
delay(90)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
if (isVoiceRecording) {
|
||||||
|
stopVoiceRecording(send = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
|
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
|
||||||
var isSending by remember { mutableStateOf(false) }
|
var isSending by remember { mutableStateOf(false) }
|
||||||
var showForwardCancelDialog by remember { mutableStateOf(false) }
|
var showForwardCancelDialog by remember { mutableStateOf(false) }
|
||||||
@@ -329,6 +775,9 @@ fun MessageInputBar(
|
|||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
|
if (isVoiceRecording) {
|
||||||
|
stopVoiceRecording(send = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +788,7 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun toggleEmojiPicker() {
|
fun toggleEmojiPicker() {
|
||||||
if (suppressKeyboard) return
|
if (suppressKeyboard || isVoiceRecording) return
|
||||||
|
|
||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
val timeSinceLastToggle = currentTime - lastToggleTime
|
val timeSinceLastToggle = currentTime - lastToggleTime
|
||||||
@@ -389,6 +838,10 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun handleSend() {
|
fun handleSend() {
|
||||||
|
if (isVoiceRecording) {
|
||||||
|
stopVoiceRecording(send = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (value.isNotBlank() || hasReply) {
|
if (value.isNotBlank() || hasReply) {
|
||||||
isSending = true
|
isSending = true
|
||||||
onSend()
|
onSend()
|
||||||
@@ -870,6 +1323,123 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isVoiceRecording) {
|
||||||
|
val recordingPanelColor =
|
||||||
|
if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD)
|
||||||
|
val recordingTextColor =
|
||||||
|
if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF1E2A37)
|
||||||
|
val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f }
|
||||||
|
var recordUiEntered by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(isVoiceRecording) {
|
||||||
|
if (isVoiceRecording) {
|
||||||
|
recordUiEntered = false
|
||||||
|
delay(16)
|
||||||
|
recordUiEntered = true
|
||||||
|
} else {
|
||||||
|
recordUiEntered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val recordUiAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (recordUiEntered) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 180, easing = LinearOutSlowInEasing),
|
||||||
|
label = "record_ui_alpha"
|
||||||
|
)
|
||||||
|
val recordUiShift by animateDpAsState(
|
||||||
|
targetValue = if (recordUiEntered) 0.dp else 20.dp,
|
||||||
|
animationSpec = tween(durationMillis = 180, easing = FastOutLinearInEasing),
|
||||||
|
label = "record_ui_shift"
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.CenterEnd
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 40.dp)
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(recordingPanelColor)
|
||||||
|
.padding(start = 13.dp, end = 94.dp) // record panel paddings
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = recordUiAlpha
|
||||||
|
translationX = with(density) { recordUiShift.toPx() }
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RecordBlinkDot(isDarkTheme = isDarkTheme)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp)) // TimerView margin from RecordDot
|
||||||
|
Text(
|
||||||
|
text = formatVoiceRecordTimer(voiceElapsedMs),
|
||||||
|
color = recordingTextColor,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "CANCEL",
|
||||||
|
color = PrimaryBlue,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = recordUiAlpha
|
||||||
|
translationX = with(density) { recordUiShift.toPx() }
|
||||||
|
}
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { stopVoiceRecording(send = false) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.requiredSize(104.dp) // do not affect input row height
|
||||||
|
.offset(x = 8.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
VoiceButtonBlob(
|
||||||
|
voiceLevel = voiceLevel,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(82.dp) // Telegram RecordCircle radius 41dp
|
||||||
|
.shadow(
|
||||||
|
elevation = 10.dp,
|
||||||
|
shape = CircleShape,
|
||||||
|
clip = false
|
||||||
|
)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(PrimaryBlue)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { stopVoiceRecording(send = true) },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = TelegramSendIcon,
|
||||||
|
contentDescription = "Send voice message",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(30.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -957,6 +1527,25 @@ fun MessageInputBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !canSend && !isSending,
|
||||||
|
enter = scaleIn(tween(140)) + fadeIn(tween(140)),
|
||||||
|
exit = scaleOut(tween(100)) + fadeOut(tween(100))
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { requestVoiceRecording() },
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Mic,
|
||||||
|
contentDescription = "Record voice message",
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user