WIP: стабилизация звонков и E2EE + инструменты сборки WebRTC

This commit is contained in:
2026-03-25 22:20:24 +05:00
parent 530047c5d0
commit eea650face
8 changed files with 1119 additions and 219 deletions

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger.network
import android.content.Context
import android.media.AudioManager
import android.util.Log
import com.rosetta.messenger.data.MessageRepository
import java.security.SecureRandom
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -14,6 +15,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.suspendCancellableCoroutine
import org.bouncycastle.math.ec.rfc7748.X25519
import org.json.JSONObject
@@ -24,6 +27,7 @@ import org.webrtc.MediaConstraints
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpReceiver
import org.webrtc.RtpSender
import org.webrtc.RtpTransceiver
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
@@ -105,10 +109,12 @@ object CallManager {
private var durationJob: Job? = null
private var protocolStateJob: Job? = null
private var disconnectResetJob: Job? = null
private var signalWaiter: ((Packet) -> Unit)? = null
private var webRtcWaiter: ((Packet) -> Unit)? = null
private var iceWaiter: ((Packet) -> Unit)? = null
private val webRtcSignalMutex = Mutex()
private var peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null
@@ -228,6 +234,7 @@ object CallManager {
}
fun endCall() {
breadcrumb("UI: endCall requested")
resetSession(reason = null, notifyPeer = true)
}
@@ -392,58 +399,85 @@ object CallManager {
}
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
val phase = _state.value.phase
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
return
}
val pc = peerConnection
if (pc == null) {
breadcrumb("RTC: IGNORED ${packet.signalType} — peerConnection=null!")
return
}
webRtcSignalMutex.withLock {
val phase = _state.value.phase
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
return@withLock
}
val pc = peerConnection
if (pc == null) {
breadcrumb("RTC: IGNORED ${packet.signalType} — peerConnection=null!")
return@withLock
}
when (packet.signalType) {
WebRTCSignalType.ANSWER -> {
breadcrumb("RTC: ANSWER received")
val answer = parseSessionDescription(packet.sdpOrCandidate) ?: return
try {
pc.setRemoteDescriptionAwait(answer)
remoteDescriptionSet = true
flushBufferedRemoteCandidates()
breadcrumb("RTC: ANSWER applied OK, remoteDesc=true")
} catch (e: Exception) {
breadcrumb("RTC: ANSWER FAILED — ${e.message}")
saveCrashReport("setRemoteDescription(answer) failed", e)
when (packet.signalType) {
WebRTCSignalType.ANSWER -> {
val answer = parseSessionDescription(packet.sdpOrCandidate) ?: return@withLock
if (answer.type != SessionDescription.Type.ANSWER) {
breadcrumb("RTC: ANSWER packet with type=${answer.type} ignored")
return@withLock
}
val stateBefore = pc.signalingState()
breadcrumb("RTC: ANSWER received state=$stateBefore")
if (stateBefore == PeerConnection.SignalingState.STABLE && remoteDescriptionSet) {
breadcrumb("RTC: ANSWER duplicate ignored (already stable)")
return@withLock
}
try {
pc.setRemoteDescriptionAwait(answer)
remoteDescriptionSet = true
flushBufferedRemoteCandidates()
breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}")
} catch (e: Exception) {
breadcrumb("RTC: ANSWER FAILED — ${e.message}")
saveCrashReport("setRemoteDescription(answer) failed", e)
}
}
}
WebRTCSignalType.ICE_CANDIDATE -> {
val candidate = parseIceCandidate(packet.sdpOrCandidate) ?: return
if (!remoteDescriptionSet) {
breadcrumb("RTC: ICE buffered (remoteDesc not set yet)")
bufferedRemoteCandidates.add(candidate)
return
WebRTCSignalType.ICE_CANDIDATE -> {
val candidate = parseIceCandidate(packet.sdpOrCandidate) ?: return@withLock
if (!remoteDescriptionSet) {
breadcrumb("RTC: ICE buffered (remoteDesc not set yet)")
bufferedRemoteCandidates.add(candidate)
return@withLock
}
breadcrumb("RTC: ICE added: ${candidate.sdp.take(40)}")
runCatching { pc.addIceCandidate(candidate) }
}
breadcrumb("RTC: ICE added: ${candidate.sdp.take(40)}")
runCatching { pc.addIceCandidate(candidate) }
}
WebRTCSignalType.OFFER -> {
breadcrumb("RTC: OFFER received (offerSent=$offerSent)")
val remoteOffer = parseSessionDescription(packet.sdpOrCandidate) ?: return
try {
pc.setRemoteDescriptionAwait(remoteOffer)
remoteDescriptionSet = true
flushBufferedRemoteCandidates()
val answer = pc.createAnswerAwait()
pc.setLocalDescriptionAwait(answer)
ProtocolManager.sendWebRtcSignal(
signalType = WebRTCSignalType.ANSWER,
sdpOrCandidate = serializeSessionDescription(answer)
)
breadcrumb("RTC: OFFER handled → ANSWER sent")
} catch (e: Exception) {
breadcrumb("RTC: OFFER FAILED — ${e.message}")
saveCrashReport("handleOffer failed", e)
WebRTCSignalType.OFFER -> {
val remoteOffer = parseSessionDescription(packet.sdpOrCandidate) ?: return@withLock
if (remoteOffer.type != SessionDescription.Type.OFFER) {
breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored")
return@withLock
}
breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})")
try {
pc.setRemoteDescriptionAwait(remoteOffer)
remoteDescriptionSet = true
flushBufferedRemoteCandidates()
val stateAfterRemote = pc.signalingState()
if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER &&
stateAfterRemote != PeerConnection.SignalingState.HAVE_LOCAL_PRANSWER
) {
breadcrumb("RTC: OFFER skip createAnswer, bad state=$stateAfterRemote")
return@withLock
}
val answer = pc.createAnswerAwait()
pc.setLocalDescriptionAwait(answer)
ProtocolManager.sendWebRtcSignal(
signalType = WebRTCSignalType.ANSWER,
sdpOrCandidate = serializeSessionDescription(answer)
)
breadcrumb("RTC: OFFER handled → ANSWER sent")
} catch (e: Exception) {
breadcrumb("RTC: OFFER FAILED — ${e.message}")
saveCrashReport("handleOffer failed", e)
}
}
}
}
@@ -493,9 +527,14 @@ object CallManager {
if (localAudioTrack == null) {
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
localAudioTrack?.setEnabled(!_state.value.isMuted)
pc.addTrack(localAudioTrack, listOf(LOCAL_MEDIA_STREAM_ID))
breadcrumb("PC: audio track added, attaching E2EE…")
attachSenderE2EE(pc)
val txInit =
RtpTransceiver.RtpTransceiverInit(
RtpTransceiver.RtpTransceiverDirection.SEND_RECV,
listOf(LOCAL_MEDIA_STREAM_ID)
)
val transceiver = pc.addTransceiver(localAudioTrack, txInit)
breadcrumb("PC: audio transceiver added, attaching E2EE…")
attachSenderE2EE(transceiver?.sender)
}
try {
@@ -561,16 +600,37 @@ object CallManager {
breadcrumb("PC: connState=$newState")
when (newState) {
PeerConnection.PeerConnectionState.CONNECTED -> {
disconnectResetJob?.cancel()
disconnectResetJob = null
onCallConnected()
}
PeerConnection.PeerConnectionState.DISCONNECTED,
PeerConnection.PeerConnectionState.FAILED,
PeerConnection.PeerConnectionState.CLOSED -> {
disconnectResetJob?.cancel()
disconnectResetJob = null
// Dispatch to our scope — this callback fires on WebRTC thread
scope.launch {
resetSession(reason = "Connection lost", notifyPeer = false)
}
}
PeerConnection.PeerConnectionState.DISCONNECTED -> {
// Desktop tolerates short network dips; do not kill call immediately.
disconnectResetJob?.cancel()
disconnectResetJob =
scope.launch {
delay(5_000L)
val pcState = peerConnection?.connectionState()
if (pcState == PeerConnection.PeerConnectionState.DISCONNECTED ||
pcState == PeerConnection.PeerConnectionState.FAILED ||
pcState == PeerConnection.PeerConnectionState.CLOSED
) {
breadcrumb("PC: DISCONNECTED timeout → reset")
resetSession(reason = "Connection lost", notifyPeer = false)
} else {
breadcrumb("PC: DISCONNECTED recovered (state=$pcState)")
}
}
}
else -> Unit
}
}
@@ -625,6 +685,34 @@ object CallManager {
peerConnectionFactory = PeerConnectionFactory.builder().createPeerConnectionFactory()
}
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
if (role != CallRole.CALLER) return
val peerPublicKey = snapshot.peerPublicKey.trim()
val context = appContext ?: return
if (peerPublicKey.isBlank()) return
val durationSec = snapshot.durationSec.coerceAtLeast(0)
val callAttachment =
MessageAttachment(
id = java.util.UUID.randomUUID().toString().replace("-", "").take(16),
blob = "",
type = AttachmentType.CALL,
preview = durationSec.toString()
)
scope.launch {
runCatching {
MessageRepository.getInstance(context).sendMessage(
toPublicKey = peerPublicKey,
text = "",
attachments = listOf(callAttachment)
)
}.onFailure { error ->
Log.w(TAG, "Failed to send call attachment", error)
}
}
}
private fun resetSession(reason: String?, notifyPeer: Boolean) {
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
val snapshot = _state.value
@@ -646,6 +734,7 @@ object CallManager {
if (!reason.isNullOrBlank()) {
Log.d(TAG, reason)
}
emitCallAttachmentIfNeeded(snapshot)
resetRtcObjects()
e2eeAvailable = true
role = null
@@ -656,6 +745,8 @@ object CallManager {
localPublicKey = null
durationJob?.cancel()
durationJob = null
disconnectResetJob?.cancel()
disconnectResetJob = null
setSpeakerphone(false)
_state.value = CallUiState()
}
@@ -732,10 +823,10 @@ object CallManager {
} catch (_: Throwable) {}
}
private fun attachSenderE2EE(pc: PeerConnection) {
private fun attachSenderE2EE(sender: RtpSender?) {
if (!e2eeAvailable) return
val key = sharedKeyBytes ?: return
val sender = pc.senders.firstOrNull() ?: return
if (sender == null) return
try {
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")

View File

@@ -19,6 +19,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -1555,27 +1556,48 @@ fun ImageAttachment(
}
}
private fun parseCallAttachmentPreview(preview: String): Pair<String, String?> {
if (preview.isBlank()) return "Call" to null
private data class DesktopCallUi(
val title: String,
val subtitle: String,
val isError: Boolean
)
val pieces = preview.split("::")
val title = pieces.firstOrNull()?.trim().orEmpty().ifBlank { "Call" }
val subtitle = pieces.drop(1).joinToString(" ").trim().ifBlank { null }
private fun parseCallDurationSeconds(preview: String): Int {
if (preview.isBlank()) return 0
val tail = preview.substringAfterLast("::").trim()
tail.toIntOrNull()?.let { return it.coerceAtLeast(0) }
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()
durationRegex.find(preview)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let {
return it.coerceAtLeast(0)
}
val normalizedSubtitle =
durationSec?.let { sec ->
val mins = sec / 60
val secs = sec % 60
"Duration ${"%d:%02d".format(mins, secs)}"
} ?: subtitle
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
}
return title to normalizedSubtitle
private fun formatDesktopCallDuration(durationSec: Int): String {
val minutes = durationSec / 60
val seconds = durationSec % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}
private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopCallUi {
val durationSec = parseCallDurationSeconds(preview)
val isError = durationSec == 0
val title =
if (isError) {
if (isOutgoing) "Rejected call" else "Missed call"
} else {
if (isOutgoing) "Outgoing call" else "Incoming call"
}
val subtitle =
if (isError) {
"Call was not answered or was rejected"
} else {
formatDesktopCallDuration(durationSec)
}
return DesktopCallUi(title = title, subtitle = subtitle, isError = isError)
}
/** Call attachment bubble */
@@ -1587,116 +1609,141 @@ fun CallAttachment(
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)
)
val callUi = remember(attachment.preview, isOutgoing) {
resolveDesktopCallUi(attachment.preview, isOutgoing)
}
val containerShape = RoundedCornerShape(10.dp)
val containerBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.12f)
} else {
if (isDarkTheme) Color(0xFF1F2733) else Color(0xFFF3F8FF)
}
val containerBorder =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else {
if (isDarkTheme) Color(0xFF33435A) else Color(0xFFD8E5F4)
}
val iconBackground = if (callUi.isError) Color(0xFFE55A5A) else PrimaryBlue
val iconVector =
when {
callUi.isError -> Icons.Default.Close
isOutgoing -> Icons.Default.CallMade
else -> Icons.Default.CallReceived
}
Spacer(modifier = Modifier.width(10.dp))
Box(
modifier =
Modifier
.padding(vertical = 4.dp)
.widthIn(min = 200.dp)
.heightIn(min = 60.dp)
.clip(containerShape)
.background(containerBackground)
.border(width = 1.dp, color = containerBorder, shape = containerShape)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(iconBackground),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = iconVector,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.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))
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = subtitle,
fontSize = 12.sp,
color =
if (isOutgoing) {
Color.White.copy(alpha = 0.7f)
} else {
if (isDarkTheme) Color(0xFF8BA0B8) else Color(0xFF5E6E82)
},
text = callUi.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 (isOutgoing) {
Spacer(modifier = Modifier.width(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
fontSize = 11.sp,
color = Color.White.copy(alpha = 0.7f)
text = callUi.subtitle,
fontSize = 12.sp,
color =
if (callUi.isError) {
Color(0xFFE55A5A)
} else if (isOutgoing) {
Color.White.copy(alpha = 0.72f)
} else {
if (isDarkTheme) Color(0xFF8EC9FF) else PrimaryBlue
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
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)) {
}
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.Done,
painter = TelegramIcons.Clock,
contentDescription = null,
tint = Color.White,
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,
modifier = Modifier.size(14.dp).offset(x = 4.dp)
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)
)
}
}
MessageStatus.ERROR -> {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = Color(0xFFE53935),
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
}