Проработан UI звонков и частичная реализация
This commit is contained in:
@@ -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() },
|
||||||
|
|||||||
@@ -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: Расшифровка успешна
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import android.util.Log
|
||||||
|
import com.rosetta.messenger.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages call sounds (ringtone, calling, connected, end_call).
|
||||||
|
* Matches desktop CallProvider.tsx sound behavior.
|
||||||
|
*/
|
||||||
|
object CallSoundManager {
|
||||||
|
|
||||||
|
private const val TAG = "CallSoundManager"
|
||||||
|
|
||||||
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
|
private var vibrator: Vibrator? = null
|
||||||
|
private var currentSound: CallSound? = null
|
||||||
|
|
||||||
|
enum class CallSound {
|
||||||
|
RINGTONE, // Incoming call — loops
|
||||||
|
CALLING, // Outgoing call — loops
|
||||||
|
CONNECTED, // Call connected — plays once
|
||||||
|
END_CALL // Call ended — plays once
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||||
|
vm?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a call sound. Stops any currently playing sound first.
|
||||||
|
* RINGTONE and CALLING loop. CONNECTED and END_CALL play once.
|
||||||
|
*/
|
||||||
|
fun play(context: Context, sound: CallSound) {
|
||||||
|
stop()
|
||||||
|
currentSound = sound
|
||||||
|
|
||||||
|
val resId = when (sound) {
|
||||||
|
CallSound.RINGTONE -> R.raw.call_ringtone
|
||||||
|
CallSound.CALLING -> R.raw.call_calling
|
||||||
|
CallSound.CONNECTED -> R.raw.call_connected
|
||||||
|
CallSound.END_CALL -> R.raw.call_end
|
||||||
|
}
|
||||||
|
|
||||||
|
val loop = sound == CallSound.RINGTONE || sound == CallSound.CALLING
|
||||||
|
|
||||||
|
try {
|
||||||
|
val player = MediaPlayer.create(context, resId)
|
||||||
|
if (player == null) {
|
||||||
|
Log.e(TAG, "Failed to create MediaPlayer for $sound")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(
|
||||||
|
if (sound == CallSound.RINGTONE)
|
||||||
|
AudioAttributes.USAGE_NOTIFICATION_RINGTONE
|
||||||
|
else
|
||||||
|
AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING
|
||||||
|
)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
player.isLooping = loop
|
||||||
|
player.setOnCompletionListener {
|
||||||
|
if (!loop) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.start()
|
||||||
|
mediaPlayer = player
|
||||||
|
|
||||||
|
// Vibrate for incoming calls
|
||||||
|
if (sound == CallSound.RINGTONE) {
|
||||||
|
startVibration()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Playing $sound (loop=$loop)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error playing $sound", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop any currently playing sound and vibration.
|
||||||
|
*/
|
||||||
|
fun stop() {
|
||||||
|
try {
|
||||||
|
mediaPlayer?.let { player ->
|
||||||
|
if (player.isPlaying) player.stop()
|
||||||
|
player.release()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
mediaPlayer = null
|
||||||
|
currentSound = null
|
||||||
|
stopVibration()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startVibration() {
|
||||||
|
try {
|
||||||
|
val v = vibrator ?: return
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val pattern = longArrayOf(0, 500, 300, 500, 300, 500, 1000)
|
||||||
|
v.vibrate(VibrationEffect.createWaveform(pattern, 0))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
v.vibrate(longArrayOf(0, 500, 300, 500, 300, 500, 1000), 0)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopVibration() {
|
||||||
|
try {
|
||||||
|
vibrator?.cancel()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2063,6 +2063,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
|
message.attachments.any { it.type == AttachmentType.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"
|
||||||
|
|||||||
@@ -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() ->
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,226 +1,445 @@
|
|||||||
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)
|
||||||
|
CallPhase.ACTIVE -> ActiveButtons(state, onToggleMute, onToggleSpeaker, onEnd)
|
||||||
|
CallPhase.OUTGOING, CallPhase.CONNECTING -> OutgoingButtons(state, onToggleSpeaker, onToggleMute, onEnd)
|
||||||
|
CallPhase.IDLE -> Spacer(Modifier.height(1.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 -> {
|
// ── Avatar with concentric rings ─────────────────────────────────
|
||||||
RoundActionButton(
|
|
||||||
background = Color(0xFFE5484D),
|
|
||||||
onClick = onEnd
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.CallEnd,
|
|
||||||
contentDescription = "End call",
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CallPhase.IDLE -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
BIN
app/src/main/res/raw/call_calling.mp3
Normal file
BIN
app/src/main/res/raw/call_calling.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/call_connected.mp3
Normal file
BIN
app/src/main/res/raw/call_connected.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/call_end.mp3
Normal file
BIN
app/src/main/res/raw/call_end.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/call_ringtone.mp3
Normal file
BIN
app/src/main/res/raw/call_ringtone.mp3
Normal file
Binary file not shown.
Reference in New Issue
Block a user