Проработан UI звонков и частичная реализация
This commit is contained in:
@@ -1552,6 +1552,7 @@ fun MainScreen(
|
||||
CallOverlay(
|
||||
state = callUiState,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
onAccept = { acceptCallWithPermission() },
|
||||
onDecline = { CallManager.declineIncomingCall() },
|
||||
onEnd = { CallManager.endCall() },
|
||||
|
||||
@@ -477,15 +477,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
scope.launch {
|
||||
val startTime = System.currentTimeMillis()
|
||||
try {
|
||||
// Шифрование
|
||||
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
// Шифрование (пропускаем для пустого текста — напр. CALL-сообщения)
|
||||
val hasContent = text.trim().isNotEmpty()
|
||||
val encryptResult = if (hasContent) MessageCrypto.encryptForSending(text.trim(), toPublicKey) else null
|
||||
val encryptedContent = encryptResult?.ciphertext ?: ""
|
||||
val encryptedKey = encryptResult?.encryptedKey ?: ""
|
||||
val aesChachaKey =
|
||||
CryptoManager.encryptWithPassword(
|
||||
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
privateKey
|
||||
)
|
||||
if (encryptResult != null) {
|
||||
CryptoManager.encryptWithPassword(
|
||||
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
privateKey
|
||||
)
|
||||
} else ""
|
||||
|
||||
// 📝 LOG: Шифрование успешно
|
||||
MessageLogger.logEncryptionSuccess(
|
||||
@@ -763,15 +766,26 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// Расшифровываем
|
||||
// Расшифровываем (CALL и attachment-only сообщения могут иметь пустой или
|
||||
// зашифрованный пустой content — обрабатываем оба случая безопасно)
|
||||
val isAttachmentOnly = packet.content.isBlank() ||
|
||||
(packet.attachments.isNotEmpty() && packet.chachaKey.isBlank())
|
||||
val plainText =
|
||||
if (isGroupMessage) {
|
||||
if (isAttachmentOnly) {
|
||||
""
|
||||
} else if (isGroupMessage) {
|
||||
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
|
||||
?: throw IllegalStateException("Failed to decrypt group payload")
|
||||
} else if (plainKeyAndNonce != null) {
|
||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||
} else {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
try {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
} catch (e: Exception) {
|
||||
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
|
||||
android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
// 📝 LOG: Расшифровка успешна
|
||||
|
||||
@@ -7,9 +7,11 @@ enum class AttachmentType(val value: Int) {
|
||||
IMAGE(0), // Изображение
|
||||
MESSAGES(1), // Reply (цитата сообщения)
|
||||
FILE(2), // Файл
|
||||
AVATAR(3); // Аватар пользователя
|
||||
AVATAR(3), // Аватар пользователя
|
||||
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
||||
UNKNOWN(-1); // Неизвестный тип
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ import org.bouncycastle.math.ec.rfc7748.X25519
|
||||
import org.json.JSONObject
|
||||
import org.webrtc.AudioSource
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.FrameCryptor
|
||||
import org.webrtc.FrameCryptorAlgorithm
|
||||
import org.webrtc.FrameCryptorFactory
|
||||
import org.webrtc.FrameCryptorKeyProvider
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.PeerConnection
|
||||
@@ -116,12 +120,18 @@ object CallManager {
|
||||
private var localAudioTrack: AudioTrack? = null
|
||||
private val bufferedRemoteCandidates = mutableListOf<IceCandidate>()
|
||||
|
||||
// E2EE (FrameCryptor AES-GCM)
|
||||
private var keyProvider: FrameCryptorKeyProvider? = null
|
||||
private var senderCryptor: FrameCryptor? = null
|
||||
private var receiverCryptor: FrameCryptor? = null
|
||||
|
||||
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
appContext = context.applicationContext
|
||||
CallSoundManager.initialize(context)
|
||||
|
||||
signalWaiter = ProtocolManager.waitCallSignal { packet ->
|
||||
scope.launch { handleSignalPacket(packet) }
|
||||
@@ -177,6 +187,7 @@ object CallManager {
|
||||
src = ownPublicKey,
|
||||
dst = targetKey
|
||||
)
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
||||
return CallActionResult.STARTED
|
||||
}
|
||||
|
||||
@@ -290,6 +301,7 @@ object CallManager {
|
||||
statusText = "Incoming call..."
|
||||
)
|
||||
}
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
|
||||
resolvePeerIdentity(incomingPeer)
|
||||
}
|
||||
SignalType.KEY_EXCHANGE -> {
|
||||
@@ -323,6 +335,7 @@ object CallManager {
|
||||
if (role == CallRole.CALLER) {
|
||||
generateSessionKeys()
|
||||
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
|
||||
setupE2EE(sharedKey)
|
||||
updateState { it.copy(keyCast = sharedKey.take(32), statusText = "Creating room...") }
|
||||
val localPublic = localPublicKey ?: return
|
||||
ProtocolManager.sendCallSignal(
|
||||
@@ -345,6 +358,7 @@ object CallManager {
|
||||
generateSessionKeys()
|
||||
}
|
||||
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
|
||||
setupE2EE(sharedKey)
|
||||
updateState { it.copy(keyCast = sharedKey.take(32), phase = CallPhase.CONNECTING) }
|
||||
}
|
||||
}
|
||||
@@ -422,6 +436,7 @@ object CallManager {
|
||||
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
|
||||
localAudioTrack?.setEnabled(!_state.value.isMuted)
|
||||
pc.addTrack(localAudioTrack, listOf(LOCAL_MEDIA_STREAM_ID))
|
||||
attachSenderE2EE(pc)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -468,7 +483,9 @@ object CallManager {
|
||||
override fun onDataChannel(dataChannel: org.webrtc.DataChannel?) = Unit
|
||||
override fun onRenegotiationNeeded() = Unit
|
||||
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
|
||||
override fun onTrack(transceiver: RtpTransceiver?) = Unit
|
||||
override fun onTrack(transceiver: RtpTransceiver?) {
|
||||
attachReceiverE2EE(transceiver)
|
||||
}
|
||||
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
||||
when (newState) {
|
||||
PeerConnection.PeerConnectionState.CONNECTED -> {
|
||||
@@ -489,6 +506,7 @@ object CallManager {
|
||||
}
|
||||
|
||||
private fun onCallConnected() {
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) }
|
||||
updateState { it.copy(phase = CallPhase.ACTIVE, statusText = "Call active") }
|
||||
durationJob?.cancel()
|
||||
durationJob =
|
||||
@@ -534,6 +552,7 @@ object CallManager {
|
||||
|
||||
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
||||
val snapshot = _state.value
|
||||
val wasActive = snapshot.phase != CallPhase.IDLE
|
||||
val peerToNotify = snapshot.peerPublicKey
|
||||
if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
@@ -542,6 +561,12 @@ object CallManager {
|
||||
dst = peerToNotify
|
||||
)
|
||||
}
|
||||
// Play end call sound, then stop all
|
||||
if (wasActive) {
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
||||
} else {
|
||||
CallSoundManager.stop()
|
||||
}
|
||||
if (!reason.isNullOrBlank()) {
|
||||
Log.d(TAG, reason)
|
||||
}
|
||||
@@ -567,6 +592,7 @@ object CallManager {
|
||||
localAudioTrack = null
|
||||
audioSource = null
|
||||
peerConnection = null
|
||||
teardownE2EE()
|
||||
}
|
||||
|
||||
private fun flushBufferedRemoteCandidates() {
|
||||
@@ -578,6 +604,63 @@ object CallManager {
|
||||
bufferedRemoteCandidates.clear()
|
||||
}
|
||||
|
||||
// ── E2EE (FrameCryptor AES-GCM) ─────────────────────────────────
|
||||
|
||||
private fun setupE2EE(sharedKeyHex: String) {
|
||||
val keyBytes = sharedKeyHex.hexToBytes()
|
||||
if (keyBytes == null || keyBytes.size < 32) {
|
||||
Log.e(TAG, "E2EE: invalid key (${keyBytes?.size ?: 0} bytes)")
|
||||
return
|
||||
}
|
||||
val kp = FrameCryptorFactory.createFrameCryptorKeyProvider(
|
||||
/* sharedKey */ true,
|
||||
/* ratchetSalt */ ByteArray(0),
|
||||
/* ratchetWindowSize */ 0,
|
||||
/* uncryptedMagicBytes */ ByteArray(0),
|
||||
/* failureTolerance */ 0,
|
||||
/* keyRingSize */ 1,
|
||||
/* discardFrameWhenCryptorNotReady */ false
|
||||
)
|
||||
kp.setSharedKey(0, keyBytes.copyOf(32))
|
||||
keyProvider = kp
|
||||
Log.i(TAG, "E2EE key provider created (AES-GCM)")
|
||||
}
|
||||
|
||||
private fun attachSenderE2EE(pc: PeerConnection) {
|
||||
val factory = peerConnectionFactory ?: return
|
||||
val kp = keyProvider ?: return
|
||||
val sender = pc.senders.firstOrNull() ?: return
|
||||
|
||||
senderCryptor = FrameCryptorFactory.createFrameCryptorForRtpSender(
|
||||
factory, sender, "caller", FrameCryptorAlgorithm.AES_GCM, kp
|
||||
)
|
||||
senderCryptor?.setEnabled(true)
|
||||
Log.i(TAG, "E2EE sender cryptor attached")
|
||||
}
|
||||
|
||||
private fun attachReceiverE2EE(transceiver: RtpTransceiver?) {
|
||||
val factory = peerConnectionFactory ?: return
|
||||
val kp = keyProvider ?: return
|
||||
val receiver = transceiver?.receiver ?: return
|
||||
|
||||
receiverCryptor = FrameCryptorFactory.createFrameCryptorForRtpReceiver(
|
||||
factory, receiver, "callee", FrameCryptorAlgorithm.AES_GCM, kp
|
||||
)
|
||||
receiverCryptor?.setEnabled(true)
|
||||
Log.i(TAG, "E2EE receiver cryptor attached")
|
||||
}
|
||||
|
||||
private fun teardownE2EE() {
|
||||
runCatching { senderCryptor?.setEnabled(false) }
|
||||
runCatching { senderCryptor?.dispose() }
|
||||
runCatching { receiverCryptor?.setEnabled(false) }
|
||||
runCatching { receiverCryptor?.dispose() }
|
||||
runCatching { keyProvider?.dispose() }
|
||||
senderCryptor = null
|
||||
receiverCryptor = null
|
||||
keyProvider = null
|
||||
}
|
||||
|
||||
private fun generateSessionKeys() {
|
||||
val privateKey = ByteArray(32)
|
||||
secureRandom.nextBytes(privateKey)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.R
|
||||
|
||||
/**
|
||||
* Manages call sounds (ringtone, calling, connected, end_call).
|
||||
* Matches desktop CallProvider.tsx sound behavior.
|
||||
*/
|
||||
object CallSoundManager {
|
||||
|
||||
private const val TAG = "CallSoundManager"
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var vibrator: Vibrator? = null
|
||||
private var currentSound: CallSound? = null
|
||||
|
||||
enum class CallSound {
|
||||
RINGTONE, // Incoming call — loops
|
||||
CALLING, // Outgoing call — loops
|
||||
CONNECTED, // Call connected — plays once
|
||||
END_CALL // Call ended — plays once
|
||||
}
|
||||
|
||||
fun initialize(context: Context) {
|
||||
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||
vm?.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a call sound. Stops any currently playing sound first.
|
||||
* RINGTONE and CALLING loop. CONNECTED and END_CALL play once.
|
||||
*/
|
||||
fun play(context: Context, sound: CallSound) {
|
||||
stop()
|
||||
currentSound = sound
|
||||
|
||||
val resId = when (sound) {
|
||||
CallSound.RINGTONE -> R.raw.call_ringtone
|
||||
CallSound.CALLING -> R.raw.call_calling
|
||||
CallSound.CONNECTED -> R.raw.call_connected
|
||||
CallSound.END_CALL -> R.raw.call_end
|
||||
}
|
||||
|
||||
val loop = sound == CallSound.RINGTONE || sound == CallSound.CALLING
|
||||
|
||||
try {
|
||||
val player = MediaPlayer.create(context, resId)
|
||||
if (player == null) {
|
||||
Log.e(TAG, "Failed to create MediaPlayer for $sound")
|
||||
return
|
||||
}
|
||||
|
||||
player.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(
|
||||
if (sound == CallSound.RINGTONE)
|
||||
AudioAttributes.USAGE_NOTIFICATION_RINGTONE
|
||||
else
|
||||
AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING
|
||||
)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
)
|
||||
|
||||
player.isLooping = loop
|
||||
player.setOnCompletionListener {
|
||||
if (!loop) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
player.start()
|
||||
mediaPlayer = player
|
||||
|
||||
// Vibrate for incoming calls
|
||||
if (sound == CallSound.RINGTONE) {
|
||||
startVibration()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Playing $sound (loop=$loop)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error playing $sound", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any currently playing sound and vibration.
|
||||
*/
|
||||
fun stop() {
|
||||
try {
|
||||
mediaPlayer?.let { player ->
|
||||
if (player.isPlaying) player.stop()
|
||||
player.release()
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
mediaPlayer = null
|
||||
currentSound = null
|
||||
stopVibration()
|
||||
}
|
||||
|
||||
private fun startVibration() {
|
||||
try {
|
||||
val v = vibrator ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val pattern = longArrayOf(0, 500, 300, 500, 300, 500, 1000)
|
||||
v.vibrate(VibrationEffect.createWaveform(pattern, 0))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
v.vibrate(longArrayOf(0, 500, 300, 500, 300, 500, 1000), 0)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun stopVibration() {
|
||||
try {
|
||||
vibrator?.cancel()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -2063,6 +2063,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
|
||||
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.forwardedMessages.isNotEmpty() -> "Forwarded message"
|
||||
message.replyData != null -> "Reply"
|
||||
else -> "Pinned message"
|
||||
|
||||
@@ -4367,6 +4367,8 @@ fun DialogItemContent(
|
||||
"File" -> "File"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Avatar" -> "Avatar"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Call" -> "Call"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Forwarded" -> "Forwarded message"
|
||||
dialog.lastMessage.isEmpty() ->
|
||||
|
||||
@@ -603,6 +603,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
2 -> "File" // AttachmentType.FILE = 2
|
||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||
4 -> "Call" // AttachmentType.CALL = 4
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -404,6 +404,7 @@ private fun ForwardDialogItem(
|
||||
dialog.lastMessageAttachmentType == "Photo" -> "Photo"
|
||||
dialog.lastMessageAttachmentType == "File" -> "File"
|
||||
dialog.lastMessageAttachmentType == "Avatar" -> "Avatar"
|
||||
dialog.lastMessageAttachmentType == "Call" -> "Call"
|
||||
dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message"
|
||||
dialog.lastMessage.isNotEmpty() -> dialog.lastMessage
|
||||
else -> "No messages"
|
||||
|
||||
@@ -1,187 +1,199 @@
|
||||
package com.rosetta.messenger.ui.chats.calls
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.CallEnd
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material.icons.filled.VideocamOff
|
||||
import androidx.compose.material.icons.filled.VolumeOff
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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 com.rosetta.messenger.network.CallPhase
|
||||
import com.rosetta.messenger.network.CallUiState
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
|
||||
// ── Telegram-style dark gradient colors ──────────────────────────
|
||||
|
||||
private val GradientTop = Color(0xFF1A1A2E)
|
||||
private val GradientMid = Color(0xFF16213E)
|
||||
private val GradientBottom = Color(0xFF0F3460)
|
||||
private val AcceptGreen = Color(0xFF4CC764)
|
||||
private val DeclineRed = Color(0xFFE74C3C)
|
||||
private val ButtonBg = Color.White.copy(alpha = 0.15f)
|
||||
private val ButtonBgActive = Color.White.copy(alpha = 0.30f)
|
||||
private val RingColor1 = Color.White.copy(alpha = 0.06f)
|
||||
private val RingColor2 = Color.White.copy(alpha = 0.10f)
|
||||
private val RingColor3 = Color.White.copy(alpha = 0.04f)
|
||||
|
||||
// ── Main Call Screen ─────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun CallOverlay(
|
||||
state: CallUiState,
|
||||
isDarkTheme: Boolean,
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
onEnd: () -> Unit,
|
||||
onToggleMute: () -> Unit,
|
||||
onToggleSpeaker: () -> Unit
|
||||
) {
|
||||
val view = LocalView.current
|
||||
LaunchedEffect(state.isVisible) {
|
||||
if (state.isVisible && !view.isInEditMode) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
val ctrl = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
ctrl.isAppearanceLightStatusBars = false
|
||||
ctrl.isAppearanceLightNavigationBars = false
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.isVisible,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.96f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.96f)
|
||||
enter = fadeIn(tween(300)),
|
||||
exit = fadeOut(tween(200))
|
||||
) {
|
||||
val scrim = Color.Black.copy(alpha = if (isDarkTheme) 0.56f else 0.42f)
|
||||
val cardColor = if (isDarkTheme) Color(0xFF1F1F24) else Color(0xFFF7F8FC)
|
||||
val titleColor = if (isDarkTheme) Color.White else Color(0xFF1F1F24)
|
||||
val subtitleColor = if (isDarkTheme) Color(0xFFB0B4C2) else Color(0xFF5C637A)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(scrim),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
|
||||
color = cardColor,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp
|
||||
// ── Encryption badge top center ──
|
||||
if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) {
|
||||
Text(
|
||||
text = "\uD83D\uDD12 Encrypted",
|
||||
color = Color.White.copy(alpha = 0.4f),
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding()
|
||||
.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// ── Center content: rings + avatar + name + status ──
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding()
|
||||
.padding(top = 100.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = state.displayName,
|
||||
color = titleColor,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = statusText(state),
|
||||
color = subtitleColor,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
// Avatar with rings
|
||||
CallAvatar(
|
||||
peerPublicKey = state.peerPublicKey,
|
||||
displayName = state.displayName,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showRings = state.phase != CallPhase.IDLE
|
||||
)
|
||||
|
||||
if (state.keyCast.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Key: ${state.keyCast}",
|
||||
color = subtitleColor.copy(alpha = 0.78f),
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Name
|
||||
Text(
|
||||
text = state.displayName,
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Status with animated dots
|
||||
val showDots = state.phase == CallPhase.OUTGOING ||
|
||||
state.phase == CallPhase.CONNECTING ||
|
||||
state.phase == CallPhase.INCOMING
|
||||
|
||||
if (showDots) {
|
||||
AnimatedDotsText(
|
||||
baseText = when (state.phase) {
|
||||
CallPhase.OUTGOING -> state.statusText.ifBlank { "Requesting" }
|
||||
CallPhase.CONNECTING -> state.statusText.ifBlank { "Connecting" }
|
||||
CallPhase.INCOMING -> "Ringing"
|
||||
else -> ""
|
||||
},
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
} else if (state.phase == CallPhase.ACTIVE) {
|
||||
Text(
|
||||
text = formatCallDuration(state.durationSec),
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 15.sp
|
||||
)
|
||||
}
|
||||
|
||||
// Emoji key
|
||||
if (state.keyCast.isNotBlank() && state.phase == CallPhase.ACTIVE) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val emojis = remember(state.keyCast) { keyToEmojis(state.keyCast) }
|
||||
if (emojis.isNotBlank()) {
|
||||
Text(emojis, fontSize = 32.sp, letterSpacing = 4.sp, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
when (state.phase) {
|
||||
CallPhase.INCOMING -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
RoundActionButton(
|
||||
background = Color(0xFFE5484D),
|
||||
onClick = onDecline
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CallEnd,
|
||||
contentDescription = "Decline",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
RoundActionButton(
|
||||
background = Color(0xFF2CB96B),
|
||||
onClick = onAccept
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "Accept",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CallPhase.ACTIVE -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
RoundActionButton(
|
||||
background = if (state.isMuted) Color(0xFF394150) else Color(0xFF2A313D),
|
||||
onClick = onToggleMute
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isMuted) Icons.Default.MicOff else Icons.Default.Mic,
|
||||
contentDescription = "Mute",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
RoundActionButton(
|
||||
background = if (state.isSpeakerOn) Color(0xFF394150) else Color(0xFF2A313D),
|
||||
onClick = onToggleSpeaker
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff,
|
||||
contentDescription = "Speaker",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
RoundActionButton(
|
||||
background = Color(0xFFE5484D),
|
||||
onClick = onEnd
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CallEnd,
|
||||
contentDescription = "End call",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CallPhase.OUTGOING, CallPhase.CONNECTING -> {
|
||||
RoundActionButton(
|
||||
background = Color(0xFFE5484D),
|
||||
onClick = onEnd
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CallEnd,
|
||||
contentDescription = "End call",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CallPhase.IDLE -> Unit
|
||||
// ── Bottom buttons ──
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = state.phase,
|
||||
transitionSpec = {
|
||||
(fadeIn(tween(200)) + slideInVertically { it / 3 }) togetherWith
|
||||
(fadeOut(tween(150)) + slideOutVertically { it / 3 })
|
||||
},
|
||||
label = "btns"
|
||||
) { phase ->
|
||||
when (phase) {
|
||||
CallPhase.INCOMING -> IncomingButtons(onAccept, onDecline)
|
||||
CallPhase.ACTIVE -> ActiveButtons(state, onToggleMute, onToggleSpeaker, onEnd)
|
||||
CallPhase.OUTGOING, CallPhase.CONNECTING -> OutgoingButtons(state, onToggleSpeaker, onToggleMute, onEnd)
|
||||
CallPhase.IDLE -> Spacer(Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,38 +201,245 @@ fun CallOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Avatar with concentric rings ─────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun RoundActionButton(
|
||||
background: Color,
|
||||
onClick: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
private fun CallAvatar(
|
||||
peerPublicKey: String,
|
||||
displayName: String,
|
||||
avatarRepository: AvatarRepository?,
|
||||
isDarkTheme: Boolean,
|
||||
showRings: Boolean
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(64.dp).clip(CircleShape),
|
||||
color = background,
|
||||
shape = CircleShape,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 6.dp
|
||||
val avatarSize = 130.dp
|
||||
val ringPadding = 50.dp
|
||||
val totalSize = avatarSize + ringPadding * 2
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "rings")
|
||||
val ringScale by infiniteTransition.animateFloat(
|
||||
1f, 1.08f,
|
||||
infiniteRepeatable(tween(3000, easing = EaseInOut), RepeatMode.Reverse),
|
||||
label = "ringScale"
|
||||
)
|
||||
|
||||
val ringAlpha by animateFloatAsState(
|
||||
if (showRings) 1f else 0f, tween(400), label = "ringAlpha"
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.size(totalSize), contentAlignment = Alignment.Center) {
|
||||
// Concentric rings (like Telegram)
|
||||
if (ringAlpha > 0f) {
|
||||
// Outer ring
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(avatarSize + 44.dp)
|
||||
.scale(ringScale)
|
||||
.graphicsLayer { alpha = ringAlpha }
|
||||
.clip(CircleShape)
|
||||
.background(RingColor3)
|
||||
)
|
||||
// Middle ring
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(avatarSize + 28.dp)
|
||||
.scale(ringScale * 0.98f)
|
||||
.graphicsLayer { alpha = ringAlpha }
|
||||
.clip(CircleShape)
|
||||
.background(RingColor1)
|
||||
)
|
||||
// Inner ring
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(avatarSize + 14.dp)
|
||||
.scale(ringScale * 0.96f)
|
||||
.graphicsLayer { alpha = ringAlpha }
|
||||
.clip(CircleShape)
|
||||
.background(RingColor2)
|
||||
)
|
||||
}
|
||||
|
||||
// Avatar
|
||||
AvatarImage(
|
||||
publicKey = peerPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = avatarSize,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showOnlineIndicator = false,
|
||||
isOnline = false,
|
||||
displayName = displayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Incoming: Accept + Decline ───────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun IncomingButtons(onAccept: () -> Unit, onDecline: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 60.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(onClick = onClick, modifier = Modifier.fillMaxSize()) {
|
||||
content()
|
||||
CallButton(DeclineRed, "Decline", Icons.Default.CallEnd, onClick = onDecline)
|
||||
CallButton(AcceptGreen, "Accept", Icons.Default.Call, onClick = onAccept, showPulse = true)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Outgoing/Connecting: Speaker + Mute + End Call ───────────────
|
||||
|
||||
@Composable
|
||||
private fun OutgoingButtons(
|
||||
state: CallUiState, onSpeaker: () -> Unit, onMute: () -> Unit, onEnd: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
CallButton(
|
||||
if (state.isSpeakerOn) ButtonBgActive else ButtonBg, "Speaker",
|
||||
if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff,
|
||||
onClick = onSpeaker
|
||||
)
|
||||
CallButton(
|
||||
if (state.isMuted) ButtonBgActive else ButtonBg, "Mute",
|
||||
if (state.isMuted) Icons.Default.MicOff else Icons.Default.Mic,
|
||||
onClick = onMute
|
||||
)
|
||||
CallButton(DeclineRed, "End Call", Icons.Default.CallEnd, onClick = onEnd)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active: Speaker + Video + Mute + End Call ────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ActiveButtons(
|
||||
state: CallUiState, onMute: () -> Unit, onSpeaker: () -> Unit, onEnd: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
CallButton(
|
||||
if (state.isSpeakerOn) ButtonBgActive else ButtonBg, "Speaker",
|
||||
if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff,
|
||||
onClick = onSpeaker
|
||||
)
|
||||
CallButton(
|
||||
if (state.isMuted) ButtonBgActive else ButtonBg, "Mute",
|
||||
if (state.isMuted) Icons.Default.MicOff else Icons.Default.Mic,
|
||||
onClick = onMute
|
||||
)
|
||||
CallButton(DeclineRed, "End Call", Icons.Default.CallEnd, onClick = onEnd)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reusable round button with icon + label ──────────────────────
|
||||
|
||||
@Composable
|
||||
private fun CallButton(
|
||||
color: Color,
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
size: Dp = 60.dp,
|
||||
showPulse: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val btnScale by animateFloatAsState(
|
||||
if (isPressed) 0.88f else 1f,
|
||||
spring(dampingRatio = 0.5f, stiffness = 800f), label = "s"
|
||||
)
|
||||
|
||||
val inf = rememberInfiniteTransition(label = "p_$label")
|
||||
val pulseScale by inf.animateFloat(
|
||||
1f, 1.35f,
|
||||
infiniteRepeatable(tween(1200, easing = EaseOut), RepeatMode.Restart), label = "ps"
|
||||
)
|
||||
val pulseAlpha by inf.animateFloat(
|
||||
0.4f, 0f,
|
||||
infiniteRepeatable(tween(1200, easing = EaseOut), RepeatMode.Restart), label = "pa"
|
||||
)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(72.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (showPulse) {
|
||||
Box(
|
||||
Modifier.size(size).scale(pulseScale)
|
||||
.graphicsLayer { alpha = pulseAlpha }
|
||||
.clip(CircleShape).background(color.copy(alpha = 0.4f))
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier.size(size).scale(btnScale).clip(CircleShape).background(color)
|
||||
.clickable(interactionSource, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(icon, label, tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
label, color = Color.White.copy(alpha = 0.7f), fontSize = 11.sp,
|
||||
maxLines = 1, textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Animated dots (Canvas circles with staggered scale) ──────────
|
||||
|
||||
@Composable
|
||||
private fun AnimatedDotsText(baseText: String, color: Color) {
|
||||
val inf = rememberInfiniteTransition(label = "dots")
|
||||
val d0 by inf.animateFloat(0f, 1f, infiniteRepeatable(tween(800, easing = LinearEasing)), label = "d0")
|
||||
val d1 by inf.animateFloat(0f, 1f, infiniteRepeatable(tween(800, delayMillis = 150, easing = LinearEasing)), label = "d1")
|
||||
val d2 by inf.animateFloat(0f, 1f, infiniteRepeatable(tween(800, delayMillis = 300, easing = LinearEasing)), label = "d2")
|
||||
|
||||
val density = LocalDensity.current
|
||||
val dotR = with(density) { 2.dp.toPx() }
|
||||
val spacing = with(density) { 6.dp.toPx() }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(baseText, color = color, fontSize = 15.sp)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Canvas(Modifier.size(width = 22.dp, height = 14.dp)) {
|
||||
val cy = size.height / 2
|
||||
listOf(d0, d1, d2).forEachIndexed { i, p ->
|
||||
val s = if (p < 0.4f) {
|
||||
val t = p / 0.4f; t * t * (3f - 2f * t)
|
||||
} else {
|
||||
val t = (p - 0.4f) / 0.6f; 1f - t * t * (3f - 2f * t)
|
||||
}
|
||||
drawCircle(
|
||||
color.copy(alpha = 0.4f + 0.6f * s),
|
||||
radius = dotR * (0.5f + 0.5f * s),
|
||||
center = Offset(dotR + i * spacing, cy)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusText(state: CallUiState): String {
|
||||
return when (state.phase) {
|
||||
CallPhase.INCOMING -> "Incoming call"
|
||||
CallPhase.OUTGOING -> if (state.statusText.isNotBlank()) state.statusText else "Calling..."
|
||||
CallPhase.CONNECTING -> if (state.statusText.isNotBlank()) state.statusText else "Connecting..."
|
||||
CallPhase.ACTIVE -> formatDuration(state.durationSec)
|
||||
CallPhase.IDLE -> ""
|
||||
}
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private fun formatCallDuration(seconds: Int): String {
|
||||
val s = seconds.coerceAtLeast(0)
|
||||
val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%02d:%02d".format(m, sec)
|
||||
}
|
||||
|
||||
private fun formatDuration(seconds: Int): String {
|
||||
val safe = seconds.coerceAtLeast(0)
|
||||
val minutes = safe / 60
|
||||
val remainingSeconds = safe % 60
|
||||
return "%02d:%02d".format(minutes, remainingSeconds)
|
||||
private fun keyToEmojis(keyCast: String): String {
|
||||
val emojis = listOf(
|
||||
"\uD83D\uDE00", "\uD83D\uDE0E", "\uD83D\uDE80", "\uD83D\uDD12",
|
||||
"\uD83C\uDF1F", "\uD83C\uDF08", "\uD83D\uDC8E", "\uD83C\uDF40",
|
||||
"\uD83D\uDD25", "\uD83C\uDF3A", "\uD83E\uDD8B", "\uD83C\uDF0D",
|
||||
"\uD83C\uDF89", "\uD83E\uDD84", "\uD83C\uDF52", "\uD83D\uDCA1"
|
||||
)
|
||||
val hex = keyCast.replace(Regex("[^0-9a-fA-F]"), "").take(8)
|
||||
if (hex.length < 8) return ""
|
||||
return (0 until 4).joinToString(" ") { i ->
|
||||
emojis[hex.substring(i * 2, i * 2 + 2).toInt(16) % emojis.size]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +525,15 @@ fun MessageAttachments(
|
||||
messageStatus = messageStatus
|
||||
)
|
||||
}
|
||||
AttachmentType.CALL -> {
|
||||
CallAttachment(
|
||||
attachment = attachment,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
/* MESSAGES обрабатываются отдельно */
|
||||
}
|
||||
@@ -1546,6 +1555,151 @@ fun ImageAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCallAttachmentPreview(preview: String): Pair<String, String?> {
|
||||
if (preview.isBlank()) return "Call" to null
|
||||
|
||||
val pieces = preview.split("::")
|
||||
val title = pieces.firstOrNull()?.trim().orEmpty().ifBlank { "Call" }
|
||||
val subtitle = pieces.drop(1).joinToString(" ").trim().ifBlank { null }
|
||||
|
||||
val durationRegex = Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*(\\d+)", RegexOption.IGNORE_CASE)
|
||||
val fallbackDurationRegex = Regex("^(\\d{1,5})$")
|
||||
val durationSec =
|
||||
durationRegex.find(preview)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
?: fallbackDurationRegex.find(title)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
|
||||
val normalizedSubtitle =
|
||||
durationSec?.let { sec ->
|
||||
val mins = sec / 60
|
||||
val secs = sec % 60
|
||||
"Duration ${"%d:%02d".format(mins, secs)}"
|
||||
} ?: subtitle
|
||||
|
||||
return title to normalizedSubtitle
|
||||
}
|
||||
|
||||
/** Call attachment bubble */
|
||||
@Composable
|
||||
fun CallAttachment(
|
||||
attachment: MessageAttachment,
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
timestamp: java.util.Date,
|
||||
messageStatus: MessageStatus = MessageStatus.READ
|
||||
) {
|
||||
val (title, subtitle) = remember(attachment.preview) { parseCallAttachmentPreview(attachment.preview) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.18f)
|
||||
} else {
|
||||
if (isDarkTheme) Color(0xFF2B3A4D) else Color(0xFFE7F2FF)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (isOutgoing) Color.White
|
||||
else if (isDarkTheme) Color(0xFF8EC9FF) else PrimaryBlue,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color.Black,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
color =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.7f)
|
||||
} else {
|
||||
if (isDarkTheme) Color(0xFF8BA0B8) else Color(0xFF5E6E82)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOutgoing) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
|
||||
fontSize = 11.sp,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
when (messageStatus) {
|
||||
MessageStatus.SENDING -> {
|
||||
Icon(
|
||||
painter = TelegramIcons.Clock,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
MessageStatus.SENT, MessageStatus.DELIVERED -> {
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
MessageStatus.READ -> {
|
||||
Box(modifier = Modifier.height(14.dp)) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp).offset(x = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
MessageStatus.ERROR -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE53935),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** File attachment - Telegram style */
|
||||
@Composable
|
||||
fun FileAttachment(
|
||||
|
||||
@@ -2235,6 +2235,7 @@ fun ReplyBubble(
|
||||
} else if (!hasImage) {
|
||||
val displayText = when {
|
||||
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||
else -> "..."
|
||||
}
|
||||
AppleEmojiText(
|
||||
|
||||
BIN
app/src/main/res/raw/call_calling.mp3
Normal file
BIN
app/src/main/res/raw/call_calling.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/call_connected.mp3
Normal file
BIN
app/src/main/res/raw/call_connected.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/call_end.mp3
Normal file
BIN
app/src/main/res/raw/call_end.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/call_ringtone.mp3
Normal file
BIN
app/src/main/res/raw/call_ringtone.mp3
Normal file
Binary file not shown.
Reference in New Issue
Block a user