Проработан UI звонков и частичная реализация

This commit is contained in:
2026-03-23 18:25:25 +05:00
parent 9778e3b196
commit 419101a4a9
16 changed files with 792 additions and 181 deletions

View File

@@ -1552,6 +1552,7 @@ fun MainScreen(
CallOverlay(
state = callUiState,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onAccept = { acceptCallWithPermission() },
onDecline = { CallManager.declineIncomingCall() },
onEnd = { CallManager.endCall() },

View File

@@ -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: Расшифровка успешна

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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) {}
}
}

View File

@@ -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"

View File

@@ -4367,6 +4367,8 @@ fun DialogItemContent(
"File" -> "File"
dialog.lastMessageAttachmentType ==
"Avatar" -> "Avatar"
dialog.lastMessageAttachmentType ==
"Call" -> "Call"
dialog.lastMessageAttachmentType ==
"Forwarded" -> "Forwarded message"
dialog.lastMessage.isEmpty() ->

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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]
}
}

View File

@@ -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(

View File

@@ -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(

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.