diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index c0edf82..bb26358 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -1552,6 +1552,7 @@ fun MainScreen( CallOverlay( state = callUiState, isDarkTheme = isDarkTheme, + avatarRepository = avatarRepository, onAccept = { acceptCallWithPermission() }, onDecline = { CallManager.declineIncomingCall() }, onEnd = { CallManager.endCall() }, diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index cff36e0..0f11295 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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: Расшифровка успешна diff --git a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt index 064edc4..a6e610d 100644 --- a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt +++ b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index 39b6d9a..ef62d7f 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -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() + // E2EE (FrameCryptor AES-GCM) + private var keyProvider: FrameCryptorKeyProvider? = null + private var senderCryptor: FrameCryptor? = null + private var receiverCryptor: FrameCryptor? = null + private var iceServers: List = 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?) = 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) diff --git a/app/src/main/java/com/rosetta/messenger/network/CallSoundManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallSoundManager.kt new file mode 100644 index 0000000..966d2df --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/CallSoundManager.kt @@ -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) {} + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index bf7a3e3..5b59134 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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" diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 8957fcf..623317c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -4367,6 +4367,8 @@ fun DialogItemContent( "File" -> "File" dialog.lastMessageAttachmentType == "Avatar" -> "Avatar" + dialog.lastMessageAttachmentType == + "Call" -> "Call" dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message" dialog.lastMessage.isEmpty() -> diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index e539ed1..744a674 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -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) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt index be6a047..c490391 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt @@ -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" diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt index 03de7dc..1ef310b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt @@ -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] + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 50e9ec5..5c38cd8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 { + 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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index e8c9368..8ba9f5e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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( diff --git a/app/src/main/res/raw/call_calling.mp3 b/app/src/main/res/raw/call_calling.mp3 new file mode 100644 index 0000000..5a00cd0 Binary files /dev/null and b/app/src/main/res/raw/call_calling.mp3 differ diff --git a/app/src/main/res/raw/call_connected.mp3 b/app/src/main/res/raw/call_connected.mp3 new file mode 100644 index 0000000..3e030fa Binary files /dev/null and b/app/src/main/res/raw/call_connected.mp3 differ diff --git a/app/src/main/res/raw/call_end.mp3 b/app/src/main/res/raw/call_end.mp3 new file mode 100644 index 0000000..c58e9ed Binary files /dev/null and b/app/src/main/res/raw/call_end.mp3 differ diff --git a/app/src/main/res/raw/call_ringtone.mp3 b/app/src/main/res/raw/call_ringtone.mp3 new file mode 100644 index 0000000..6c576c2 Binary files /dev/null and b/app/src/main/res/raw/call_ringtone.mp3 differ