From 5e5c4c11ac05b8207880177e367e79e50978dfa6 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 02:06:15 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B8=20Telegram-=D0=BF=D0=BE=D0=B4=D0=BE=D0=B1=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20UI=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/network/AttachmentType.kt | 1 + .../messenger/ui/chats/ChatDetailScreen.kt | 16 + .../messenger/ui/chats/ChatViewModel.kt | 193 ++++- .../messenger/ui/chats/ChatsListScreen.kt | 2 + .../messenger/ui/chats/ChatsListViewModel.kt | 36 +- .../chats/components/AttachmentComponents.kt | 596 +++++++++++++- .../chats/components/ChatDetailComponents.kt | 1 + .../ui/chats/input/ChatDetailInput.kt | 761 ++++++++++++++++-- 8 files changed, 1511 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt index a6e610d..7500650 100644 --- a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt +++ b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt @@ -9,6 +9,7 @@ enum class AttachmentType(val value: Int) { FILE(2), // Файл AVATAR(3), // Аватар пользователя CALL(4), // Событие звонка (пропущен/принят/завершен) + VOICE(5), // Голосовое сообщение UNKNOWN(-1); // Неизвестный тип companion object { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 5c084a2..fc7c39c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -2679,6 +2679,20 @@ fun ChatDetailScreen( 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, backgroundColor = backgroundColor, textColor = textColor, @@ -4258,6 +4272,7 @@ private fun ChatInputBarSection( viewModel: ChatViewModel, isSavedMessages: Boolean, onSend: () -> Unit, + onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List) -> Unit, isDarkTheme: Boolean, backgroundColor: Color, textColor: Color, @@ -4295,6 +4310,7 @@ private fun ChatInputBarSection( } }, onSend = onSend, + onSendVoiceMessage = onSendVoiceMessage, isDarkTheme = isDarkTheme, backgroundColor = backgroundColor, textColor = textColor, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index d557597..d8dfe1e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -1625,6 +1625,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { "file" -> AttachmentType.FILE.value "avatar" -> AttachmentType.AVATAR.value "call" -> AttachmentType.CALL.value + "voice" -> AttachmentType.VOICE.value else -> -1 } } @@ -1792,9 +1793,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) ) - // 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой + // 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой if ((effectiveType == AttachmentType.IMAGE || - effectiveType == AttachmentType.AVATAR) && + effectiveType == AttachmentType.AVATAR || + effectiveType == AttachmentType.VOICE) && blob.isEmpty() && 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.AVATAR } -> "Avatar" message.attachments.any { it.type == AttachmentType.CALL } -> "Call" + message.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message" message.forwardedMessages.isNotEmpty() -> "Forwarded message" message.replyData != null -> "Reply" else -> "Pinned message" @@ -4806,6 +4809,192 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * 🎙️ Отправка голосового сообщения. + * blob хранится как HEX строка opus/webm байт (desktop parity). + * preview формат: "::" + */ + fun sendVoiceMessage( + voiceHex: String, + durationSec: Int, + waves: List + ) { + 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 - отправляет текущий аватар как вложение */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index aec7712..8c6e7d0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -4549,6 +4549,8 @@ fun DialogItemContent( "Avatar" -> "Avatar" dialog.lastMessageAttachmentType == "Call" -> "Call" + dialog.lastMessageAttachmentType == + "Voice message" -> "Voice message" dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message" diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 7f9ee46..a06d219 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -573,16 +573,50 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio lastMessageAttachments: String ): String? { 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 1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward) 2 -> "File" // AttachmentType.FILE = 2 3 -> "Avatar" // AttachmentType.AVATAR = 3 4 -> "Call" // AttachmentType.CALL = 4 + 5 -> "Voice message" // AttachmentType.VOICE = 5 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 { if (rawAttachments.isBlank() || rawAttachments == "[]") return false return try { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index b355d91..720f5f0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -1,10 +1,15 @@ package com.rosetta.messenger.ui.chats.components +import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.os.SystemClock import android.util.Base64 import android.util.LruCache +import android.webkit.MimeTypeMap import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing 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.TransportManager 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.onboarding.PrimaryBlue import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AvatarFileManager import com.vanniktech.blurhash.BlurHash -import com.rosetta.messenger.ui.icons.TelegramIcons +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob 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.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withPermit 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.File import java.security.MessageDigest import kotlin.math.min +import androidx.compose.ui.platform.LocalConfiguration +import androidx.core.content.FileProvider private const val TAG = "AttachmentComponents" 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(null) + val playingAttachmentId: StateFlow = _playingAttachmentId.asStateFlow() + private val _positionMs = MutableStateFlow(0) + val positionMs: StateFlow = _positionMs.asStateFlow() + private val _durationMs = MutableStateFlow(0) + val durationMs: StateFlow = _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 { if (value.isBlank()) return "empty" val clean = value.trim() @@ -486,6 +585,7 @@ private fun TelegramFileActionButton( fun MessageAttachments( attachments: List, chachaKey: String, + chachaKeyPlainHex: String = "", privateKey: String, isOutgoing: Boolean, isDarkTheme: Boolean, @@ -573,6 +673,19 @@ fun MessageAttachments( isDarkTheme = isDarkTheme ) } + AttachmentType.VOICE -> { + VoiceAttachment( + attachment = attachment, + chachaKey = chachaKey, + chachaKeyPlainHex = chachaKeyPlainHex, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus + ) + } else -> { // Desktop parity: unsupported/legacy attachment gets explicit compatibility card. LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme) @@ -1679,6 +1792,60 @@ private fun parseCallDurationSeconds(preview: String): Int { return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0 } +private fun parseVoicePreview(preview: String): Pair> { + 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, targetLength: Int): List { + 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 { val minutes = 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(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 */ @Composable 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. * Shared between ReplyBubble and ForwardedImagePreview. diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 2436452..56310ee 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -979,6 +979,7 @@ fun MessageBubble( MessageAttachments( attachments = message.attachments, chachaKey = message.chachaKey, + chachaKeyPlainHex = message.chachaKeyPlainHex, privateKey = privateKey, isOutgoing = message.isOutgoing, isDarkTheme = isDarkTheme, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 29da07e..96ed95e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -1,9 +1,19 @@ package com.rosetta.messenger.ui.chats.input +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.media.MediaRecorder +import android.os.Build 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.core.* +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border 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.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -44,6 +56,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator +import androidx.core.content.ContextCompat import coil.compose.AsyncImage import coil.request.ImageRequest import com.rosetta.messenger.network.AttachmentType @@ -60,7 +73,11 @@ import com.rosetta.messenger.ui.chats.ChatViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import java.io.File import java.util.Locale +import java.util.UUID +import kotlin.math.PI +import kotlin.math.sin private fun truncateEmojiSafe(text: String, maxLen: Int): String { if (text.length <= maxLen) return text @@ -75,6 +92,284 @@ private fun truncateEmojiSafe(text: String, maxLen: Int): String { 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, targetLength: Int): List { + 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(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 * Extracted from ChatDetailScreen.kt for better organization @@ -102,6 +397,7 @@ fun MessageInputBar( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, + onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List) -> Unit = { _, _, _ -> }, isDarkTheme: Boolean, backgroundColor: Color, textColor: Color, @@ -245,6 +541,156 @@ fun MessageInputBar( } } + var voiceRecorder by remember { mutableStateOf(null) } + var voiceOutputFile by remember { mutableStateOf(null) } + var isVoiceRecording by remember { mutableStateOf(false) } + var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) } + var voiceElapsedMs by remember { mutableLongStateOf(0L) } + var voiceWaves by remember { mutableStateOf>(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 } var isSending 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 imm.hideSoftInputFromWindow(view.windowToken, 0) focusManager.clearFocus(force = true) + if (isVoiceRecording) { + stopVoiceRecording(send = false) + } } } @@ -339,7 +788,7 @@ fun MessageInputBar( } fun toggleEmojiPicker() { - if (suppressKeyboard) return + if (suppressKeyboard || isVoiceRecording) return val currentTime = System.currentTimeMillis() val timeSinceLastToggle = currentTime - lastToggleTime @@ -389,6 +838,10 @@ fun MessageInputBar( } fun handleSend() { + if (isVoiceRecording) { + stopVoiceRecording(send = true) + return + } if (value.isNotBlank() || hasReply) { isSending = true onSend() @@ -870,93 +1323,229 @@ fun MessageInputBar( } } - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom - ) { - IconButton( - onClick = onAttachClick, - modifier = Modifier.size(40.dp) - ) { - Icon( - painter = TelegramIcons.Attach, - contentDescription = "Attach", - tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) - else Color(0xFF8E8E93).copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - Box( - modifier = Modifier - .weight(1f) - .heightIn(min = 40.dp, max = 150.dp) - .background(color = backgroundColor) - .padding(horizontal = 12.dp, vertical = 8.dp), - contentAlignment = Alignment.TopStart - ) { - AppleEmojiTextField( - value = value, - onValueChange = { newValue -> onValueChange(newValue) }, - textColor = textColor, - textSize = 16f, - hint = "Type message...", - hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93), - modifier = Modifier.fillMaxWidth(), - requestFocus = hasReply, - onViewCreated = { view -> editTextView = view }, - onFocusChanged = { hasFocus -> - if (hasFocus && showEmojiPicker) { - onToggleEmojiPicker(false) - } - }, - onSelectionChanged = { start, end -> - selectionStart = start - selectionEnd = end + 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( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + IconButton( + onClick = onAttachClick, + modifier = Modifier.size(40.dp) + ) { + Icon( + painter = TelegramIcons.Attach, + contentDescription = "Attach", + tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) + else Color(0xFF8E8E93).copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + Box( + modifier = Modifier + .weight(1f) + .heightIn(min = 40.dp, max = 150.dp) + .background(color = backgroundColor) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.TopStart + ) { + AppleEmojiTextField( + value = value, + onValueChange = { newValue -> onValueChange(newValue) }, + textColor = textColor, + textSize = 16f, + hint = "Type message...", + hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93), + modifier = Modifier.fillMaxWidth(), + requestFocus = hasReply, + onViewCreated = { view -> editTextView = view }, + onFocusChanged = { hasFocus -> + if (hasFocus && showEmojiPicker) { + onToggleEmojiPicker(false) + } + }, + onSelectionChanged = { start, end -> + selectionStart = start + selectionEnd = end + } + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + IconButton( + onClick = { toggleEmojiPicker() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + painter = if (showEmojiPicker) TelegramIcons.Keyboard + else TelegramIcons.Smile, + contentDescription = "Emoji", + tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) + else Color(0xFF8E8E93).copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(2.dp)) + + AnimatedVisibility( + visible = canSend || isSending, + enter = scaleIn(tween(150)) + fadeIn(tween(150)), + exit = scaleOut(tween(100)) + fadeOut(tween(100)) + ) { + IconButton( + onClick = { handleSend() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = TelegramSendIcon, + contentDescription = "Send", + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) + ) + } + } + + 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) + ) + } } - ) - } - - Spacer(modifier = Modifier.width(6.dp)) - - IconButton( - onClick = { toggleEmojiPicker() }, - modifier = Modifier.size(40.dp) - ) { - Icon( - painter = if (showEmojiPicker) TelegramIcons.Keyboard - else TelegramIcons.Smile, - contentDescription = "Emoji", - tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) - else Color(0xFF8E8E93).copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(2.dp)) - - AnimatedVisibility( - visible = canSend || isSending, - enter = scaleIn(tween(150)) + fadeIn(tween(150)), - exit = scaleOut(tween(100)) + fadeOut(tween(100)) - ) { - IconButton( - onClick = { handleSend() }, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = TelegramSendIcon, - contentDescription = "Send", - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) - ) } - } } } }