Проработан 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( CallOverlay(
state = callUiState, state = callUiState,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onAccept = { acceptCallWithPermission() }, onAccept = { acceptCallWithPermission() },
onDecline = { CallManager.declineIncomingCall() }, onDecline = { CallManager.declineIncomingCall() },
onEnd = { CallManager.endCall() }, onEnd = { CallManager.endCall() },

View File

@@ -477,15 +477,18 @@ class MessageRepository private constructor(private val context: Context) {
scope.launch { scope.launch {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
try { try {
// Шифрование // Шифрование (пропускаем для пустого текста — напр. CALL-сообщения)
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey) val hasContent = text.trim().isNotEmpty()
val encryptedContent = encryptResult.ciphertext val encryptResult = if (hasContent) MessageCrypto.encryptForSending(text.trim(), toPublicKey) else null
val encryptedKey = encryptResult.encryptedKey val encryptedContent = encryptResult?.ciphertext ?: ""
val encryptedKey = encryptResult?.encryptedKey ?: ""
val aesChachaKey = val aesChachaKey =
if (encryptResult != null) {
CryptoManager.encryptWithPassword( CryptoManager.encryptWithPassword(
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1), String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
privateKey privateKey
) )
} else ""
// 📝 LOG: Шифрование успешно // 📝 LOG: Шифрование успешно
MessageLogger.logEncryptionSuccess( 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 = val plainText =
if (isGroupMessage) { if (isAttachmentOnly) {
""
} else if (isGroupMessage) {
CryptoManager.decryptWithPassword(packet.content, groupKey!!) CryptoManager.decryptWithPassword(packet.content, groupKey!!)
?: throw IllegalStateException("Failed to decrypt group payload") ?: throw IllegalStateException("Failed to decrypt group payload")
} else if (plainKeyAndNonce != null) { } else if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce) MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else { } else {
try {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey) 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: Расшифровка успешна // 📝 LOG: Расшифровка успешна

View File

@@ -7,9 +7,11 @@ enum class AttachmentType(val value: Int) {
IMAGE(0), // Изображение IMAGE(0), // Изображение
MESSAGES(1), // Reply (цитата сообщения) MESSAGES(1), // Reply (цитата сообщения)
FILE(2), // Файл FILE(2), // Файл
AVATAR(3); // Аватар пользователя AVATAR(3), // Аватар пользователя
CALL(4), // Событие звонка (пропущен/принят/завершен)
UNKNOWN(-1); // Неизвестный тип
companion object { 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.json.JSONObject
import org.webrtc.AudioSource import org.webrtc.AudioSource
import org.webrtc.AudioTrack 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.IceCandidate
import org.webrtc.MediaConstraints import org.webrtc.MediaConstraints
import org.webrtc.PeerConnection import org.webrtc.PeerConnection
@@ -116,12 +120,18 @@ object CallManager {
private var localAudioTrack: AudioTrack? = null private var localAudioTrack: AudioTrack? = null
private val bufferedRemoteCandidates = mutableListOf<IceCandidate>() 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() private var iceServers: List<PeerConnection.IceServer> = emptyList()
fun initialize(context: Context) { fun initialize(context: Context) {
if (initialized) return if (initialized) return
initialized = true initialized = true
appContext = context.applicationContext appContext = context.applicationContext
CallSoundManager.initialize(context)
signalWaiter = ProtocolManager.waitCallSignal { packet -> signalWaiter = ProtocolManager.waitCallSignal { packet ->
scope.launch { handleSignalPacket(packet) } scope.launch { handleSignalPacket(packet) }
@@ -177,6 +187,7 @@ object CallManager {
src = ownPublicKey, src = ownPublicKey,
dst = targetKey dst = targetKey
) )
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
return CallActionResult.STARTED return CallActionResult.STARTED
} }
@@ -290,6 +301,7 @@ object CallManager {
statusText = "Incoming call..." statusText = "Incoming call..."
) )
} }
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
resolvePeerIdentity(incomingPeer) resolvePeerIdentity(incomingPeer)
} }
SignalType.KEY_EXCHANGE -> { SignalType.KEY_EXCHANGE -> {
@@ -323,6 +335,7 @@ object CallManager {
if (role == CallRole.CALLER) { if (role == CallRole.CALLER) {
generateSessionKeys() generateSessionKeys()
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
setupE2EE(sharedKey)
updateState { it.copy(keyCast = sharedKey.take(32), statusText = "Creating room...") } updateState { it.copy(keyCast = sharedKey.take(32), statusText = "Creating room...") }
val localPublic = localPublicKey ?: return val localPublic = localPublicKey ?: return
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
@@ -345,6 +358,7 @@ object CallManager {
generateSessionKeys() generateSessionKeys()
} }
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
setupE2EE(sharedKey)
updateState { it.copy(keyCast = sharedKey.take(32), phase = CallPhase.CONNECTING) } 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 = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
localAudioTrack?.setEnabled(!_state.value.isMuted) localAudioTrack?.setEnabled(!_state.value.isMuted)
pc.addTrack(localAudioTrack, listOf(LOCAL_MEDIA_STREAM_ID)) pc.addTrack(localAudioTrack, listOf(LOCAL_MEDIA_STREAM_ID))
attachSenderE2EE(pc)
} }
try { try {
@@ -468,7 +483,9 @@ object CallManager {
override fun onDataChannel(dataChannel: org.webrtc.DataChannel?) = Unit override fun onDataChannel(dataChannel: org.webrtc.DataChannel?) = Unit
override fun onRenegotiationNeeded() = Unit override fun onRenegotiationNeeded() = Unit
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = 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?) { override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
when (newState) { when (newState) {
PeerConnection.PeerConnectionState.CONNECTED -> { PeerConnection.PeerConnectionState.CONNECTED -> {
@@ -489,6 +506,7 @@ object CallManager {
} }
private fun onCallConnected() { private fun onCallConnected() {
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) }
updateState { it.copy(phase = CallPhase.ACTIVE, statusText = "Call active") } updateState { it.copy(phase = CallPhase.ACTIVE, statusText = "Call active") }
durationJob?.cancel() durationJob?.cancel()
durationJob = durationJob =
@@ -534,6 +552,7 @@ object CallManager {
private fun resetSession(reason: String?, notifyPeer: Boolean) { private fun resetSession(reason: String?, notifyPeer: Boolean) {
val snapshot = _state.value val snapshot = _state.value
val wasActive = snapshot.phase != CallPhase.IDLE
val peerToNotify = snapshot.peerPublicKey val peerToNotify = snapshot.peerPublicKey
if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) { if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) {
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
@@ -542,6 +561,12 @@ object CallManager {
dst = peerToNotify 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()) { if (!reason.isNullOrBlank()) {
Log.d(TAG, reason) Log.d(TAG, reason)
} }
@@ -567,6 +592,7 @@ object CallManager {
localAudioTrack = null localAudioTrack = null
audioSource = null audioSource = null
peerConnection = null peerConnection = null
teardownE2EE()
} }
private fun flushBufferedRemoteCandidates() { private fun flushBufferedRemoteCandidates() {
@@ -578,6 +604,63 @@ object CallManager {
bufferedRemoteCandidates.clear() 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() { private fun generateSessionKeys() {
val privateKey = ByteArray(32) val privateKey = ByteArray(32)
secureRandom.nextBytes(privateKey) 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.IMAGE } -> "Photo"
message.attachments.any { it.type == AttachmentType.FILE } -> "File" message.attachments.any { it.type == AttachmentType.FILE } -> "File"
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar" message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
message.forwardedMessages.isNotEmpty() -> "Forwarded message" message.forwardedMessages.isNotEmpty() -> "Forwarded message"
message.replyData != null -> "Reply" message.replyData != null -> "Reply"
else -> "Pinned message" else -> "Pinned message"

View File

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

View File

@@ -603,6 +603,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
2 -> "File" // AttachmentType.FILE = 2 2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3 3 -> "Avatar" // AttachmentType.AVATAR = 3
4 -> "Call" // AttachmentType.CALL = 4
else -> null else -> null
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -404,6 +404,7 @@ private fun ForwardDialogItem(
dialog.lastMessageAttachmentType == "Photo" -> "Photo" dialog.lastMessageAttachmentType == "Photo" -> "Photo"
dialog.lastMessageAttachmentType == "File" -> "File" dialog.lastMessageAttachmentType == "File" -> "File"
dialog.lastMessageAttachmentType == "Avatar" -> "Avatar" dialog.lastMessageAttachmentType == "Avatar" -> "Avatar"
dialog.lastMessageAttachmentType == "Call" -> "Call"
dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message" dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message"
dialog.lastMessage.isNotEmpty() -> dialog.lastMessage dialog.lastMessage.isNotEmpty() -> dialog.lastMessage
else -> "No messages" else -> "No messages"

View File

@@ -1,187 +1,199 @@
package com.rosetta.messenger.ui.chats.calls package com.rosetta.messenger.ui.chats.calls
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.*
import androidx.compose.animation.fadeIn import androidx.compose.animation.core.*
import androidx.compose.animation.fadeOut import androidx.compose.foundation.Canvas
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.*
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.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff 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.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.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.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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState 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 @Composable
fun CallOverlay( fun CallOverlay(
state: CallUiState, state: CallUiState,
isDarkTheme: Boolean, isDarkTheme: Boolean,
avatarRepository: AvatarRepository? = null,
onAccept: () -> Unit, onAccept: () -> Unit,
onDecline: () -> Unit, onDecline: () -> Unit,
onEnd: () -> Unit, onEnd: () -> Unit,
onToggleMute: () -> Unit, onToggleMute: () -> Unit,
onToggleSpeaker: () -> 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( AnimatedVisibility(
visible = state.isVisible, visible = state.isVisible,
enter = fadeIn() + scaleIn(initialScale = 0.96f), enter = fadeIn(tween(300)),
exit = fadeOut() + scaleOut(targetScale = 0.96f) 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( Box(
modifier = Modifier.fillMaxSize().background(scrim), modifier = Modifier
contentAlignment = Alignment.Center .fillMaxSize()
) { .background(
Surface( Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
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( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 24.dp), modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 100.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( // Avatar with rings
text = state.displayName, CallAvatar(
color = titleColor, peerPublicKey = state.peerPublicKey,
fontSize = 22.sp, displayName = state.displayName,
fontWeight = FontWeight.SemiBold, avatarRepository = avatarRepository,
maxLines = 1 isDarkTheme = isDarkTheme,
showRings = state.phase != CallPhase.IDLE
) )
Spacer(modifier = Modifier.height(8.dp))
Text(
text = statusText(state),
color = subtitleColor,
fontSize = 14.sp,
maxLines = 1
)
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)) Spacer(modifier = Modifier.height(24.dp))
when (state.phase) {
CallPhase.INCOMING -> { // Name
Row( Text(
modifier = Modifier.fillMaxWidth(), text = state.displayName,
horizontalArrangement = Arrangement.SpaceEvenly color = Color.White,
) { fontSize = 26.sp,
RoundActionButton( fontWeight = FontWeight.SemiBold,
background = Color(0xFFE5484D), maxLines = 1,
onClick = onDecline overflow = TextOverflow.Ellipsis,
) { modifier = Modifier.padding(horizontal = 48.dp)
Icon( )
imageVector = Icons.Default.CallEnd,
contentDescription = "Decline", Spacer(modifier = Modifier.height(6.dp))
tint = Color.White
// 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
) )
} }
RoundActionButton(
background = Color(0xFF2CB96B), // Emoji key
onClick = onAccept if (state.keyCast.isNotBlank() && state.phase == CallPhase.ACTIVE) {
) { Spacer(modifier = Modifier.height(16.dp))
Icon( val emojis = remember(state.keyCast) { keyToEmojis(state.keyCast) }
imageVector = Icons.Default.Call, if (emojis.isNotBlank()) {
contentDescription = "Accept", Text(emojis, fontSize = 32.sp, letterSpacing = 4.sp, textAlign = TextAlign.Center)
tint = Color.White
)
} }
} }
} }
CallPhase.ACTIVE -> { // ── Bottom buttons ──
Row( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
horizontalArrangement = Arrangement.SpaceEvenly .fillMaxWidth()
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
RoundActionButton( AnimatedContent(
background = if (state.isMuted) Color(0xFF394150) else Color(0xFF2A313D), targetState = state.phase,
onClick = onToggleMute transitionSpec = {
) { (fadeIn(tween(200)) + slideInVertically { it / 3 }) togetherWith
Icon( (fadeOut(tween(150)) + slideOutVertically { it / 3 })
imageVector = if (state.isMuted) Icons.Default.MicOff else Icons.Default.Mic, },
contentDescription = "Mute", label = "btns"
tint = Color.White ) { phase ->
) when (phase) {
} CallPhase.INCOMING -> IncomingButtons(onAccept, onDecline)
RoundActionButton( CallPhase.ACTIVE -> ActiveButtons(state, onToggleMute, onToggleSpeaker, onEnd)
background = if (state.isSpeakerOn) Color(0xFF394150) else Color(0xFF2A313D), CallPhase.OUTGOING, CallPhase.CONNECTING -> OutgoingButtons(state, onToggleSpeaker, onToggleMute, onEnd)
onClick = onToggleSpeaker CallPhase.IDLE -> Spacer(Modifier.height(1.dp))
) {
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
} }
} }
} }
@@ -189,38 +201,245 @@ fun CallOverlay(
} }
} }
// ── Avatar with concentric rings ─────────────────────────────────
@Composable @Composable
private fun RoundActionButton( private fun CallAvatar(
background: Color, peerPublicKey: String,
onClick: () -> Unit, displayName: String,
content: @Composable () -> Unit avatarRepository: AvatarRepository?,
isDarkTheme: Boolean,
showRings: Boolean
) { ) {
Surface( val avatarSize = 130.dp
modifier = Modifier.size(64.dp).clip(CircleShape), val ringPadding = 50.dp
color = background, val totalSize = avatarSize + ringPadding * 2
shape = CircleShape,
tonalElevation = 0.dp, val infiniteTransition = rememberInfiniteTransition(label = "rings")
shadowElevation = 6.dp 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()) { CallButton(DeclineRed, "Decline", Icons.Default.CallEnd, onClick = onDecline)
content() 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 { // ── Helpers ──────────────────────────────────────────────────────
return when (state.phase) {
CallPhase.INCOMING -> "Incoming call" private fun formatCallDuration(seconds: Int): String {
CallPhase.OUTGOING -> if (state.statusText.isNotBlank()) state.statusText else "Calling..." val s = seconds.coerceAtLeast(0)
CallPhase.CONNECTING -> if (state.statusText.isNotBlank()) state.statusText else "Connecting..." val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60
CallPhase.ACTIVE -> formatDuration(state.durationSec) return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%02d:%02d".format(m, sec)
CallPhase.IDLE -> ""
}
} }
private fun formatDuration(seconds: Int): String { private fun keyToEmojis(keyCast: String): String {
val safe = seconds.coerceAtLeast(0) val emojis = listOf(
val minutes = safe / 60 "\uD83D\uDE00", "\uD83D\uDE0E", "\uD83D\uDE80", "\uD83D\uDD12",
val remainingSeconds = safe % 60 "\uD83C\uDF1F", "\uD83C\uDF08", "\uD83D\uDC8E", "\uD83C\uDF40",
return "%02d:%02d".format(minutes, remainingSeconds) "\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 messageStatus = messageStatus
) )
} }
AttachmentType.CALL -> {
CallAttachment(
attachment = attachment,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus
)
}
else -> { else -> {
/* MESSAGES обрабатываются отдельно */ /* 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 */ /** File attachment - Telegram style */
@Composable @Composable
fun FileAttachment( fun FileAttachment(

View File

@@ -2235,6 +2235,7 @@ fun ReplyBubble(
} else if (!hasImage) { } else if (!hasImage) {
val displayText = when { val displayText = when {
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
else -> "..." else -> "..."
} }
AppleEmojiText( AppleEmojiText(

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.