WIP: стабилизация звонков и E2EE + инструменты сборки WebRTC
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user