Compare commits
9 Commits
5e5c4c11ac
...
8dac52c2eb
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dac52c2eb | |||
| 946ba7838c | |||
| 78fbe0b3c8 | |||
| b13cdb7ea1 | |||
| b6055c98a5 | |||
| 3e3f501b9b | |||
| 620200ca44 | |||
| 47a6e20834 | |||
| fad8bfb1d1 |
@@ -10,6 +10,7 @@ enum class AttachmentType(val value: Int) {
|
||||
AVATAR(3), // Аватар пользователя
|
||||
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
||||
VOICE(5), // Голосовое сообщение
|
||||
VIDEO_CIRCLE(6), // Видео-кружок (video note)
|
||||
UNKNOWN(-1); // Неизвестный тип
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -3705,16 +3705,32 @@ fun ChatDetailScreen(
|
||||
onMediaSelected = { selectedMedia, caption ->
|
||||
val imageUris =
|
||||
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
|
||||
inputFocusTrigger++
|
||||
viewModel.sendImageGroupFromUris(imageUris, caption)
|
||||
if (imageUris.isNotEmpty()) {
|
||||
viewModel.sendImageGroupFromUris(
|
||||
imageUris,
|
||||
caption
|
||||
)
|
||||
}
|
||||
if (videoUris.isNotEmpty()) {
|
||||
videoUris.forEach { uri ->
|
||||
viewModel.sendVideoCircleFromUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onMediaSelectedWithCaption = { mediaItem, caption ->
|
||||
showMediaPicker = false
|
||||
inputFocusTrigger++
|
||||
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||
if (mediaItem.isVideo) {
|
||||
viewModel.sendVideoCircleFromUri(mediaItem.uri)
|
||||
} else {
|
||||
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||
}
|
||||
},
|
||||
onOpenCamera = {
|
||||
val imm =
|
||||
@@ -3806,16 +3822,32 @@ fun ChatDetailScreen(
|
||||
onMediaSelected = { selectedMedia, caption ->
|
||||
val imageUris =
|
||||
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
|
||||
inputFocusTrigger++
|
||||
viewModel.sendImageGroupFromUris(imageUris, caption)
|
||||
if (imageUris.isNotEmpty()) {
|
||||
viewModel.sendImageGroupFromUris(
|
||||
imageUris,
|
||||
caption
|
||||
)
|
||||
}
|
||||
if (videoUris.isNotEmpty()) {
|
||||
videoUris.forEach { uri ->
|
||||
viewModel.sendVideoCircleFromUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onMediaSelectedWithCaption = { mediaItem, caption ->
|
||||
showMediaPicker = false
|
||||
inputFocusTrigger++
|
||||
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||
if (mediaItem.isVideo) {
|
||||
viewModel.sendVideoCircleFromUri(mediaItem.uri)
|
||||
} else {
|
||||
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||
}
|
||||
},
|
||||
onOpenCamera = {
|
||||
val imm =
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.rosetta.messenger.ui.chats
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.util.Base64
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
@@ -656,7 +658,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
when (parseAttachmentType(attachment)) {
|
||||
AttachmentType.IMAGE,
|
||||
AttachmentType.FILE,
|
||||
AttachmentType.AVATAR -> {
|
||||
AttachmentType.AVATAR,
|
||||
AttachmentType.VIDEO_CIRCLE -> {
|
||||
hasMediaAttachment = true
|
||||
if (attachment.optString("localUri", "").isNotBlank()) {
|
||||
// Локальный URI ещё есть => загрузка/подготовка не завершена.
|
||||
@@ -1626,6 +1629,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
"avatar" -> AttachmentType.AVATAR.value
|
||||
"call" -> AttachmentType.CALL.value
|
||||
"voice" -> AttachmentType.VOICE.value
|
||||
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" ->
|
||||
AttachmentType.VIDEO_CIRCLE.value
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
@@ -1796,7 +1801,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой
|
||||
if ((effectiveType == AttachmentType.IMAGE ||
|
||||
effectiveType == AttachmentType.AVATAR ||
|
||||
effectiveType == AttachmentType.VOICE) &&
|
||||
effectiveType == AttachmentType.VOICE ||
|
||||
effectiveType == AttachmentType.VIDEO_CIRCLE) &&
|
||||
blob.isEmpty() &&
|
||||
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.CALL } -> "Call"
|
||||
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.replyData != null -> "Reply"
|
||||
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).
|
||||
|
||||
@@ -584,6 +584,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||
4 -> "Call" // AttachmentType.CALL = 4
|
||||
5 -> "Voice message" // AttachmentType.VOICE = 5
|
||||
6 -> "Video message" // AttachmentType.VIDEO_CIRCLE = 6
|
||||
else -> if (inferredCall) "Call" else null
|
||||
}
|
||||
}
|
||||
@@ -607,6 +608,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
"avatar" -> 3
|
||||
"call" -> 4
|
||||
"voice" -> 5
|
||||
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> 6
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
import android.util.Base64
|
||||
import android.util.LruCache
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.VideoView
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
@@ -686,6 +689,19 @@ fun MessageAttachments(
|
||||
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 -> {
|
||||
// Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
|
||||
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
|
||||
@@ -1804,10 +1820,54 @@ private fun parseVoicePreview(preview: String): Pair<Int, List<Float>> {
|
||||
wavesPart.split(",")
|
||||
.mapNotNull { it.trim().toFloatOrNull() }
|
||||
.map { it.coerceIn(0f, 1f) }
|
||||
}
|
||||
}
|
||||
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> {
|
||||
if (targetLength <= 0) return emptyList()
|
||||
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 */
|
||||
@Composable
|
||||
fun FileAttachment(
|
||||
|
||||
@@ -684,7 +684,18 @@ fun MessageBubble(
|
||||
message.attachments.all {
|
||||
it.type ==
|
||||
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)
|
||||
@@ -725,7 +736,8 @@ fun MessageBubble(
|
||||
hasImageWithCaption -> PaddingValues(0.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: ширина пузырька = ширина фото
|
||||
// Caption переносится на новые строки, не расширяя пузырёк
|
||||
@@ -743,7 +755,9 @@ fun MessageBubble(
|
||||
// Вычисляем ширину фото для ограничения пузырька
|
||||
val photoWidth =
|
||||
if (hasImageWithCaption || hasOnlyMedia) {
|
||||
if (isImageCollage) {
|
||||
if (hasOnlyVideoCircle) {
|
||||
220.dp
|
||||
} else if (isImageCollage) {
|
||||
maxCollageWidth
|
||||
} else {
|
||||
val firstImage =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user