Compare commits

...

9 Commits

Author SHA1 Message Date
8dac52c2eb Голосовые сообщения: lock UI как в Telegram — замок, пауза, slide-to-cancel, анимации 2026-04-11 22:17:46 +05:00
946ba7838c fix: переписать LockIcon 1:1 с Telegram — правильный замок с keyhole и idle animation
Telegram-exact lock icon:
- Body: 16×16dp прямоугольник, radius 3dp (заливка)
- Shackle: 8×8dp полукруг (stroke 1.7dp) + две ножки
- Левая ножка: idle "breathing" animation (1.2s cycle)
- Левая ножка: удлиняется при snap lock
- Keyhole: 4dp точка в центре body (цвет фона)
- Pause transform: body раздваивается с gap 1.66dp (Telegram exact)
- Pill background: 36×50dp с тенью
- Lock виден сразу при начале записи (не ждёт свайпа)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:01:53 +05:00
78fbe0b3c8 fix: переписать recording layout 1:1 с Telegram — правильные пропорции и overlay
Telegram dimensions:
- Circle: 48dp layout → 82dp visual (scale 1.71x), как circleRadius=41dp
- Lock: 50dp→36dp pill, 70dp выше центра круга
- Panel bar: full width Row с end=52dp для overlap
- Blob: 1.7x scale = 82dp visual (Telegram blob minRadius)
- Controls: 36dp (delete + pause)
- Tooltip: 90dp левее, 70dp выше

Layout architecture:
- Layer 1: Panel bar (Row с clip RoundedCornerShape)
- Layer 2: Circle overlay (graphicsLayer scale, NO clip)
- Layer 3: Lock overlay (graphicsLayer translationY, NO clip)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:59:03 +05:00
b13cdb7ea1 fix: переделать layout записи — layered архитектура вместо cramming в 40dp panel
- Panel bar (timer + slide-to-cancel/waveform) как Layer 1
- Mic/Send circle (48dp) как overlay Layer 2 поверх панели
- LockIcon как Layer 3 над кругом через graphicsLayer (без clip)
- Убран padding(end=94dp), заменён на padding(end=44dp)
- Убран offset(x=8dp) который толкал круг за экран
- Controls увеличены 28dp→36dp для лучшей тач-зоны
- Blob scale 2.05→1.8 пропорционально новому 48dp размеру

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:41:29 +05:00
b6055c98a5 polish: анимации записи 1:1 с Telegram — lock growth, staggered snap, EaseOutQuint, exit animation
- LockIcon: размер 50dp→36dp при свайпе, Y-позиция анимируется
- Staggered snap: rotation(250ms EASE_OUT_QUINT) + translate(350ms, delay 100ms)
- Двухфазный snap rotation с snapRotateBackProgress (порог 40%)
- SlideToCancel: пульсация ±6dp при >80%, демпфирование 0.3
- Send кнопка: scale-анимация 0→1 (150ms)
- Exit: AnimatedVisibility с fadeOut+shrinkVertically (300ms)
- Cancel distance: 92dp→140dp
- Фикс прыжка инпута при смене Voice/Video

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:26:40 +05:00
3e3f501b9b test: добавить unit тесты для helper функций записи голоса, очистка старого UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:47:18 +05:00
620200ca44 feat: интегрировать LockIcon, SlideToCancel, waveform и controls в панель записи
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:44:36 +05:00
47a6e20834 feat: добавить composable компоненты LockIcon, SlideToCancel, LockTooltip, VoiceWaveformBar, RecordLockedControls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:39:30 +05:00
fad8bfb1d1 feat: добавить состояние PAUSED и функции pause/resume для голосовых сообщений
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:36:51 +05:00
8 changed files with 1900 additions and 82 deletions

View File

@@ -10,6 +10,7 @@ enum class AttachmentType(val value: Int) {
AVATAR(3), // Аватар пользователя AVATAR(3), // Аватар пользователя
CALL(4), // Событие звонка (пропущен/принят/завершен) CALL(4), // Событие звонка (пропущен/принят/завершен)
VOICE(5), // Голосовое сообщение VOICE(5), // Голосовое сообщение
VIDEO_CIRCLE(6), // Видео-кружок (video note)
UNKNOWN(-1); // Неизвестный тип UNKNOWN(-1); // Неизвестный тип
companion object { companion object {

View File

@@ -3705,16 +3705,32 @@ fun ChatDetailScreen(
onMediaSelected = { selectedMedia, caption -> onMediaSelected = { selectedMedia, caption ->
val imageUris = val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri } selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) { val videoUris =
selectedMedia.filter { it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) {
showMediaPicker = false showMediaPicker = false
inputFocusTrigger++ inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption) if (imageUris.isNotEmpty()) {
viewModel.sendImageGroupFromUris(
imageUris,
caption
)
}
if (videoUris.isNotEmpty()) {
videoUris.forEach { uri ->
viewModel.sendVideoCircleFromUri(uri)
}
}
} }
}, },
onMediaSelectedWithCaption = { mediaItem, caption -> onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false showMediaPicker = false
inputFocusTrigger++ inputFocusTrigger++
viewModel.sendImageFromUri(mediaItem.uri, caption) if (mediaItem.isVideo) {
viewModel.sendVideoCircleFromUri(mediaItem.uri)
} else {
viewModel.sendImageFromUri(mediaItem.uri, caption)
}
}, },
onOpenCamera = { onOpenCamera = {
val imm = val imm =
@@ -3806,16 +3822,32 @@ fun ChatDetailScreen(
onMediaSelected = { selectedMedia, caption -> onMediaSelected = { selectedMedia, caption ->
val imageUris = val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri } selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) { val videoUris =
selectedMedia.filter { it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) {
showMediaPicker = false showMediaPicker = false
inputFocusTrigger++ inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption) if (imageUris.isNotEmpty()) {
viewModel.sendImageGroupFromUris(
imageUris,
caption
)
}
if (videoUris.isNotEmpty()) {
videoUris.forEach { uri ->
viewModel.sendVideoCircleFromUri(uri)
}
}
} }
}, },
onMediaSelectedWithCaption = { mediaItem, caption -> onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false showMediaPicker = false
inputFocusTrigger++ inputFocusTrigger++
viewModel.sendImageFromUri(mediaItem.uri, caption) if (mediaItem.isVideo) {
viewModel.sendVideoCircleFromUri(mediaItem.uri)
} else {
viewModel.sendImageFromUri(mediaItem.uri, caption)
}
}, },
onOpenCamera = { onOpenCamera = {
val imm = val imm =

View File

@@ -3,7 +3,9 @@ package com.rosetta.messenger.ui.chats
import android.app.Application import android.app.Application
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.util.Base64 import android.util.Base64
import android.webkit.MimeTypeMap
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
@@ -656,7 +658,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
when (parseAttachmentType(attachment)) { when (parseAttachmentType(attachment)) {
AttachmentType.IMAGE, AttachmentType.IMAGE,
AttachmentType.FILE, AttachmentType.FILE,
AttachmentType.AVATAR -> { AttachmentType.AVATAR,
AttachmentType.VIDEO_CIRCLE -> {
hasMediaAttachment = true hasMediaAttachment = true
if (attachment.optString("localUri", "").isNotBlank()) { if (attachment.optString("localUri", "").isNotBlank()) {
// Локальный URI ещё есть => загрузка/подготовка не завершена. // Локальный URI ещё есть => загрузка/подготовка не завершена.
@@ -1626,6 +1629,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
"avatar" -> AttachmentType.AVATAR.value "avatar" -> AttachmentType.AVATAR.value
"call" -> AttachmentType.CALL.value "call" -> AttachmentType.CALL.value
"voice" -> AttachmentType.VOICE.value "voice" -> AttachmentType.VOICE.value
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" ->
AttachmentType.VIDEO_CIRCLE.value
else -> -1 else -> -1
} }
} }
@@ -1796,7 +1801,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой // 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой
if ((effectiveType == AttachmentType.IMAGE || if ((effectiveType == AttachmentType.IMAGE ||
effectiveType == AttachmentType.AVATAR || effectiveType == AttachmentType.AVATAR ||
effectiveType == AttachmentType.VOICE) && effectiveType == AttachmentType.VOICE ||
effectiveType == AttachmentType.VIDEO_CIRCLE) &&
blob.isEmpty() && blob.isEmpty() &&
attachmentId.isNotEmpty() attachmentId.isNotEmpty()
) { ) {
@@ -2569,6 +2575,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
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.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message"
message.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video 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"
@@ -4809,6 +4816,344 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
private data class VideoCircleMeta(
val durationSec: Int,
val width: Int,
val height: Int,
val mimeType: String
)
private fun bytesToHex(bytes: ByteArray): String {
val hexChars = "0123456789abcdef".toCharArray()
val output = CharArray(bytes.size * 2)
var index = 0
bytes.forEach { byte ->
val value = byte.toInt() and 0xFF
output[index++] = hexChars[value ushr 4]
output[index++] = hexChars[value and 0x0F]
}
return String(output)
}
private fun resolveVideoCircleMeta(
application: Application,
videoUri: android.net.Uri
): VideoCircleMeta {
var durationSec = 1
var width = 0
var height = 0
val mimeType =
application.contentResolver.getType(videoUri)?.trim().orEmpty().ifBlank {
val ext =
MimeTypeMap.getFileExtensionFromUrl(videoUri.toString())
?.lowercase(Locale.ROOT)
?: ""
MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "video/mp4"
}
runCatching {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(application, videoUri)
val durationMs =
retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_DURATION
)
?.toLongOrNull()
?: 0L
val rawWidth =
retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH
)
?.toIntOrNull()
?: 0
val rawHeight =
retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT
)
?.toIntOrNull()
?: 0
val rotation =
retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION
)
?.toIntOrNull()
?: 0
retriever.release()
durationSec = ((durationMs + 999L) / 1000L).toInt().coerceAtLeast(1)
val rotated = rotation == 90 || rotation == 270
width = if (rotated) rawHeight else rawWidth
height = if (rotated) rawWidth else rawHeight
}
return VideoCircleMeta(
durationSec = durationSec,
width = width.coerceAtLeast(0),
height = height.coerceAtLeast(0),
mimeType = mimeType
)
}
private suspend fun encodeVideoUriToHex(
application: Application,
videoUri: android.net.Uri
): String? {
return withContext(Dispatchers.IO) {
runCatching {
application.contentResolver.openInputStream(videoUri)?.use { stream ->
val bytes = stream.readBytes()
if (bytes.isEmpty()) null else bytesToHex(bytes)
}
}.getOrNull()
}
}
/**
* 🎥 Отправка видео-кружка (video note) из URI.
* Использует такой же transport + шифрование пайплайн, как voice attachment.
*/
fun sendVideoCircleFromUri(videoUri: android.net.Uri) {
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
val context = getApplication<Application>()
if (recipient == null || sender == null || privateKey == null) {
return
}
if (isSending) {
return
}
val fileSize = runCatching { com.rosetta.messenger.utils.MediaUtils.getFileSize(context, videoUri) }
.getOrDefault(0L)
val maxBytes = com.rosetta.messenger.utils.MediaUtils.MAX_FILE_SIZE_MB * 1024L * 1024L
if (fileSize > 0L && fileSize > maxBytes) {
return
}
isSending = true
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val attachmentId = "video_circle_$timestamp"
val meta = resolveVideoCircleMeta(context, videoUri)
val preview = "${meta.durationSec}::${meta.mimeType}"
val optimisticMessage =
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.VIDEO_CIRCLE,
preview = preview,
width = meta.width,
height = meta.height,
localUri = videoUri.toString()
)
)
)
addMessageSafely(optimisticMessage)
_inputText.value = ""
backgroundUploadScope.launch {
try {
val optimisticAttachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.VIDEO_CIRCLE.value)
put("preview", preview)
put("blob", "")
put("width", meta.width)
put("height", meta.height)
put("localUri", videoUri.toString())
}
)
}
.toString()
saveMessageToDatabase(
messageId = messageId,
text = "",
encryptedContent = "",
encryptedKey = "",
timestamp = timestamp,
isFromMe = true,
delivered = 0,
attachmentsJson = optimisticAttachmentsJson,
opponentPublicKey = recipient
)
saveDialog("Video message", timestamp, opponentPublicKey = recipient)
} catch (_: Exception) {
}
try {
val videoHex = encodeVideoUriToHex(context, videoUri)
if (videoHex.isNullOrBlank()) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
return@launch
}
sendVideoCircleMessageInternal(
messageId = messageId,
attachmentId = attachmentId,
timestamp = timestamp,
videoHex = videoHex,
preview = preview,
width = meta.width,
height = meta.height,
recipient = recipient,
sender = sender,
privateKey = privateKey
)
} catch (_: Exception) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
} finally {
isSending = false
}
}
}
private suspend fun sendVideoCircleMessageInternal(
messageId: String,
attachmentId: String,
timestamp: Long,
videoHex: String,
preview: String,
width: Int,
height: Int,
recipient: String,
sender: String,
privateKey: String
) {
var packetSentToProtocol = false
try {
val application = getApplication<Application>()
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 encryptedVideoBlob = encryptAttachmentPayload(videoHex, encryptionContext)
val isSavedMessages = (sender == recipient)
val uploadTag =
if (!isSavedMessages) {
TransportManager.uploadFile(attachmentId, encryptedVideoBlob)
} else {
""
}
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val videoAttachment =
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.VIDEO_CIRCLE,
preview = preview,
width = width,
height = height,
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(videoAttachment)
}
if (!isSavedMessages) {
ProtocolManager.send(packet)
packetSentToProtocol = true
}
runCatching {
AttachmentFileManager.saveAttachment(
context = application,
blob = videoHex,
attachmentId = attachmentId,
publicKey = sender,
privateKey = privateKey
)
}
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.VIDEO_CIRCLE.value)
put("preview", preview)
put("blob", "")
put("width", width)
put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
.toString()
updateMessageStatusAndAttachmentsInDb(
messageId = messageId,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson
)
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
updateMessageAttachments(messageId, null)
}
saveDialog("Video message", timestamp, opponentPublicKey = recipient)
} catch (_: Exception) {
if (packetSentToProtocol) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
} else {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
}
}
}
/** /**
* 🎙️ Отправка голосового сообщения. * 🎙️ Отправка голосового сообщения.
* blob хранится как HEX строка opus/webm байт (desktop parity). * blob хранится как HEX строка opus/webm байт (desktop parity).

View File

@@ -584,6 +584,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
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 5 -> "Voice message" // AttachmentType.VOICE = 5
6 -> "Video message" // AttachmentType.VIDEO_CIRCLE = 6
else -> if (inferredCall) "Call" else null else -> if (inferredCall) "Call" else null
} }
} }
@@ -607,6 +608,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
"avatar" -> 3 "avatar" -> 3
"call" -> 4 "call" -> 4
"voice" -> 5 "voice" -> 5
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> 6
else -> -1 else -> -1
} }
} }

View File

@@ -6,10 +6,12 @@ import android.graphics.BitmapFactory
import android.graphics.Matrix import android.graphics.Matrix
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import android.net.Uri
import android.os.SystemClock 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 android.webkit.MimeTypeMap
import android.widget.VideoView
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
@@ -58,6 +60,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
@@ -686,6 +689,19 @@ fun MessageAttachments(
messageStatus = messageStatus messageStatus = messageStatus
) )
} }
AttachmentType.VIDEO_CIRCLE -> {
VideoCircleAttachment(
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)
@@ -1804,10 +1820,54 @@ private fun parseVoicePreview(preview: String): Pair<Int, List<Float>> {
wavesPart.split(",") wavesPart.split(",")
.mapNotNull { it.trim().toFloatOrNull() } .mapNotNull { it.trim().toFloatOrNull() }
.map { it.coerceIn(0f, 1f) } .map { it.coerceIn(0f, 1f) }
} }
return duration to waves return duration to waves
} }
private data class VideoCirclePreviewMeta(
val durationSec: Int,
val mimeType: String
)
private fun parseVideoCirclePreview(preview: String): VideoCirclePreviewMeta {
if (preview.isBlank()) {
return VideoCirclePreviewMeta(durationSec = 1, mimeType = "video/mp4")
}
val durationPart = preview.substringBefore("::", preview).trim()
val mimePart = preview.substringAfter("::", "").trim()
val duration = durationPart.toIntOrNull()?.coerceAtLeast(1) ?: 1
val mime =
if (mimePart.contains("/")) {
mimePart
} else {
"video/mp4"
}
return VideoCirclePreviewMeta(durationSec = duration, mimeType = mime)
}
private fun decodeVideoCirclePayload(data: String): ByteArray? {
return decodeHexPayload(data) ?: decodeBase64Payload(data)
}
private fun ensureVideoCirclePlaybackUri(
context: android.content.Context,
attachmentId: String,
payload: String,
mimeType: String,
localUri: String = ""
): Uri? {
if (localUri.isNotBlank()) {
return runCatching { Uri.parse(localUri) }.getOrNull()
}
val bytes = decodeVideoCirclePayload(payload) ?: return null
val extension =
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.ifBlank { null } ?: "mp4"
val directory = File(context.cacheDir, "video_circles").apply { mkdirs() }
val file = File(directory, "$attachmentId.$extension")
runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
return Uri.fromFile(file)
}
private fun normalizeVoiceWaves(source: List<Float>, targetLength: Int): List<Float> { private fun normalizeVoiceWaves(source: List<Float>, targetLength: Int): List<Float> {
if (targetLength <= 0) return emptyList() if (targetLength <= 0) return emptyList()
if (source.isEmpty()) return List(targetLength) { 0f } if (source.isEmpty()) return List(targetLength) { 0f }
@@ -2320,6 +2380,373 @@ private fun VoiceAttachment(
} }
} }
@Composable
private fun VideoCircleAttachment(
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 previewMeta = remember(attachment.preview) { parseVideoCirclePreview(getPreview(attachment)) }
val fallbackDurationMs = previewMeta.durationSec.coerceAtLeast(1) * 1000
var payload by
remember(attachment.id, attachment.blob) {
mutableStateOf(attachment.blob.trim())
}
var downloadStatus by
remember(attachment.id, attachment.blob, attachment.transportTag, attachment.localUri) {
mutableStateOf(
when {
attachment.localUri.isNotBlank() -> DownloadStatus.DOWNLOADED
attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
else -> DownloadStatus.ERROR
}
)
}
var errorText by remember { mutableStateOf("") }
var playbackUri by remember(attachment.id, attachment.localUri) {
mutableStateOf(
runCatching {
if (attachment.localUri.isNotBlank()) Uri.parse(attachment.localUri) else null
}.getOrNull()
)
}
var boundUri by remember(attachment.id) { mutableStateOf<String?>(null) }
var isPrepared by remember(attachment.id) { mutableStateOf(false) }
var isPlaying by remember(attachment.id) { mutableStateOf(false) }
var playbackPositionMs by remember(attachment.id) { mutableIntStateOf(0) }
var playbackDurationMs by remember(attachment.id) { mutableIntStateOf(fallbackDurationMs) }
var videoViewRef by remember(attachment.id) { mutableStateOf<VideoView?>(null) }
LaunchedEffect(payload, attachment.localUri, attachment.id, previewMeta.mimeType) {
if (playbackUri != null) return@LaunchedEffect
if (attachment.localUri.isNotBlank()) {
playbackUri = runCatching { Uri.parse(attachment.localUri) }.getOrNull()
return@LaunchedEffect
}
if (payload.isBlank()) return@LaunchedEffect
val prepared =
ensureVideoCirclePlaybackUri(
context = context,
attachmentId = attachment.id,
payload = payload,
mimeType = previewMeta.mimeType
)
if (prepared != null) {
playbackUri = prepared
if (downloadStatus != DownloadStatus.DOWNLOADING &&
downloadStatus != DownloadStatus.DECRYPTING
) {
downloadStatus = DownloadStatus.DOWNLOADED
}
if (errorText.isNotBlank()) errorText = ""
} else {
downloadStatus = DownloadStatus.ERROR
if (errorText.isBlank()) errorText = "Cannot decode video"
}
}
LaunchedEffect(isPlaying, videoViewRef) {
val player = videoViewRef ?: return@LaunchedEffect
while (isPlaying) {
playbackPositionMs = runCatching { player.currentPosition }.getOrDefault(0).coerceAtLeast(0)
delay(120)
}
}
DisposableEffect(attachment.id) {
onDispose {
runCatching {
videoViewRef?.stopPlayback()
}
videoViewRef = null
isPlaying = false
isPrepared = false
boundUri = null
}
}
val triggerDownload: () -> Unit = download@{
if (attachment.transportTag.isBlank()) {
downloadStatus = DownloadStatus.ERROR
errorText = "Video is 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
playbackUri =
ensureVideoCirclePlaybackUri(
context = context,
attachmentId = attachment.id,
payload = decrypted,
mimeType = previewMeta.mimeType
)
if (!saved) {
runCatching { android.util.Log.w(TAG, "Video circle cache save failed: ${attachment.id}") }
}
if (playbackUri == null) {
downloadStatus = DownloadStatus.ERROR
errorText = "Cannot decode video"
} else {
downloadStatus = DownloadStatus.DOWNLOADED
}
}
}
val onMainAction: () -> Unit = {
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload()
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit
DownloadStatus.DOWNLOADED, DownloadStatus.PENDING -> {
if (playbackUri == null) {
triggerDownload()
} else {
isPlaying = !isPlaying
}
}
}
}
val durationToShowSec =
if (isPrepared && playbackDurationMs > 0) {
(playbackDurationMs / 1000).coerceAtLeast(1)
} else {
previewMeta.durationSec.coerceAtLeast(1)
}
val secondaryTextColor =
if (isOutgoing) Color.White.copy(alpha = 0.82f)
else if (isDarkTheme) Color(0xFFCCD3E0)
else Color(0xFF5F6D82)
Box(
modifier =
Modifier.padding(vertical = 4.dp)
.size(220.dp)
.clip(CircleShape)
.background(
if (isOutgoing) {
Color(0xFF3A9DFB)
} else if (isDarkTheme) {
Color(0xFF22252B)
} else {
Color(0xFFE8EEF7)
}
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onMainAction() }
) {
val uri = playbackUri
if (uri != null && (downloadStatus == DownloadStatus.DOWNLOADED || downloadStatus == DownloadStatus.PENDING)) {
AndroidView(
factory = { ctx -> VideoView(ctx) },
modifier = Modifier.fillMaxSize(),
update = { videoView ->
videoViewRef = videoView
val targetUri = uri.toString()
if (boundUri != targetUri) {
boundUri = targetUri
isPrepared = false
playbackPositionMs = 0
runCatching {
videoView.setVideoURI(uri)
videoView.setOnPreparedListener { mediaPlayer ->
mediaPlayer.isLooping = false
playbackDurationMs =
mediaPlayer.duration
.coerceAtLeast(fallbackDurationMs)
isPrepared = true
if (isPlaying) {
runCatching { videoView.start() }
}
}
videoView.setOnCompletionListener {
isPlaying = false
playbackPositionMs = playbackDurationMs
}
}
}
if (isPrepared) {
if (isPlaying && !videoView.isPlaying) {
runCatching { videoView.start() }
} else if (!isPlaying && videoView.isPlaying) {
runCatching { videoView.pause() }
}
}
}
)
} else {
Box(
modifier =
Modifier.fillMaxSize()
.background(
Brush.radialGradient(
colors =
if (isOutgoing) {
listOf(
Color(0x6637A7FF),
Color(0x3337A7FF),
Color(0x0037A7FF)
)
} else if (isDarkTheme) {
listOf(
Color(0x553A4150),
Color(0x33262C39),
Color(0x00262C39)
)
} else {
listOf(
Color(0x5593B4E8),
Color(0x338AB0E5),
Color(0x008AB0E5)
)
}
)
)
)
}
Box(
modifier =
Modifier.align(Alignment.Center)
.size(52.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.38f)),
contentAlignment = Alignment.Center
) {
when (downloadStatus) {
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
CircularProgressIndicator(
modifier = Modifier.size(26.dp),
color = Color.White,
strokeWidth = 2.2.dp
)
}
DownloadStatus.NOT_DOWNLOADED -> {
Icon(
painter = painterResource(R.drawable.msg_download),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
DownloadStatus.ERROR -> {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color(0xFFFF8A8A),
modifier = Modifier.size(24.dp)
)
}
else -> {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
}
Row(
modifier =
Modifier.align(Alignment.BottomEnd)
.padding(end = 10.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = formatVoiceDuration(durationToShowSec),
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(

View File

@@ -684,7 +684,18 @@ fun MessageBubble(
message.attachments.all { message.attachments.all {
it.type == it.type ==
com.rosetta.messenger.network.AttachmentType com.rosetta.messenger.network.AttachmentType
.IMAGE .IMAGE ||
it.type ==
com.rosetta.messenger.network
.AttachmentType
.VIDEO_CIRCLE
}
val hasOnlyVideoCircle =
hasOnlyMedia &&
message.attachments.all {
it.type ==
com.rosetta.messenger.network.AttachmentType
.VIDEO_CIRCLE
} }
// Фото + caption (как в Telegram) // Фото + caption (как в Telegram)
@@ -725,7 +736,8 @@ fun MessageBubble(
hasImageWithCaption -> PaddingValues(0.dp) hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp) else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
} }
val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp val bubbleBorderWidth =
if (hasOnlyMedia && !hasOnlyVideoCircle) 1.dp else 0.dp
// Telegram-style: ширина пузырька = ширина фото // Telegram-style: ширина пузырька = ширина фото
// Caption переносится на новые строки, не расширяя пузырёк // Caption переносится на новые строки, не расширяя пузырёк
@@ -743,7 +755,9 @@ fun MessageBubble(
// Вычисляем ширину фото для ограничения пузырька // Вычисляем ширину фото для ограничения пузырька
val photoWidth = val photoWidth =
if (hasImageWithCaption || hasOnlyMedia) { if (hasImageWithCaption || hasOnlyMedia) {
if (isImageCollage) { if (hasOnlyVideoCircle) {
220.dp
} else if (isImageCollage) {
maxCollageWidth maxCollageWidth
} else { } else {
val firstImage = val firstImage =

View File

@@ -0,0 +1,65 @@
package com.rosetta.messenger.ui.chats.input
import org.junit.Assert.*
import org.junit.Test
class VoiceRecordHelpersTest {
@Test
fun `formatVoiceRecordTimer formats zero`() {
assertEquals("0:00,0", formatVoiceRecordTimer(0L))
}
@Test
fun `formatVoiceRecordTimer formats 12300ms`() {
assertEquals("0:12,3", formatVoiceRecordTimer(12300L))
}
@Test
fun `formatVoiceRecordTimer formats 61500ms`() {
assertEquals("1:01,5", formatVoiceRecordTimer(61500L))
}
@Test
fun `formatVoiceRecordTimer handles negative`() {
assertEquals("0:00,0", formatVoiceRecordTimer(-100L))
}
@Test
fun `compressVoiceWaves empty source returns zeros`() {
val result = compressVoiceWaves(emptyList(), 5)
assertEquals(5, result.size)
assertTrue(result.all { it == 0f })
}
@Test
fun `compressVoiceWaves same size returns same`() {
val source = listOf(0.1f, 0.5f, 0.9f)
assertEquals(source, compressVoiceWaves(source, 3))
}
@Test
fun `compressVoiceWaves downsamples by max`() {
val source = listOf(0.1f, 0.8f, 0.3f, 0.9f, 0.2f, 0.7f)
val result = compressVoiceWaves(source, 3)
assertEquals(3, result.size)
assertEquals(0.8f, result[0], 0.01f)
assertEquals(0.9f, result[1], 0.01f)
assertEquals(0.7f, result[2], 0.01f)
}
@Test
fun `compressVoiceWaves target zero returns empty`() {
assertEquals(emptyList<Float>(), compressVoiceWaves(listOf(1f), 0))
}
@Test
fun `compressVoiceWaves upsamples via interpolation`() {
val source = listOf(0.0f, 1.0f)
val result = compressVoiceWaves(source, 3)
assertEquals(3, result.size)
assertEquals(0.0f, result[0], 0.01f)
assertEquals(0.5f, result[1], 0.01f)
assertEquals(1.0f, result[2], 0.01f)
}
}