mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-25 04:15:31 +00:00
webrtc: show connection information, mark call as missed if it ends while pending (#672)
This commit is contained in:
committed by
GitHub
parent
d50ebbd061
commit
9c9f6d8443
@@ -513,7 +513,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
onConfirm = {
|
||||
if (chatModel.activeCallInvitation.value == null) {
|
||||
AlertManager.shared.hideAlert()
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended))
|
||||
withApi { AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended)) }
|
||||
} else {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.activeCall.value = Call(
|
||||
@@ -546,18 +546,20 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
}
|
||||
}
|
||||
is CR.CallExtraInfo -> {
|
||||
withCall(r, r.contact) { call ->
|
||||
withCall(r, r.contact) { _ ->
|
||||
chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)
|
||||
}
|
||||
}
|
||||
is CR.CallEnded -> {
|
||||
withCall(r, r.contact) { call ->
|
||||
withCall(r, r.contact) { _ ->
|
||||
chatModel.callCommand.value = WCallCommand.End
|
||||
chatModel.activeCall.value = null
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.callCommand.value = null
|
||||
chatModel.showCallView.value = false
|
||||
withApi {
|
||||
chatModel.activeCall.value = null
|
||||
chatModel.callCommand.value = null
|
||||
}
|
||||
}
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
else ->
|
||||
Log.d(TAG , "unsupported event: ${r.responseType}")
|
||||
@@ -836,7 +838,7 @@ sealed class CC {
|
||||
is ApiSendCallAnswer -> "/_call answer @${contact.apiId} ${json.encodeToString(answer)}"
|
||||
is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}"
|
||||
is ApiEndCall -> "/_call end @${contact.apiId}"
|
||||
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus}"
|
||||
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
|
||||
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
|
||||
is ReceiveFile -> "/freceive $fileId"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.call
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.graphics.fonts.FontStyle
|
||||
import android.os.Build
|
||||
import android.service.controls.templates.ControlButton
|
||||
import android.util.Log
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -189,7 +191,7 @@ private fun ActiveCallOverlayLayout(
|
||||
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
|
||||
if (call.hasMedia) {
|
||||
IconButton(onClick = action) {
|
||||
Icon(icon, stringResource(iconText), tint = Color.White, modifier = Modifier.size(40.dp))
|
||||
Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), modifier = Modifier.size(40.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(40.dp))
|
||||
@@ -207,10 +209,16 @@ private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
|
||||
Text(text, color = Color(0xFFFFFFD8), style = style)
|
||||
Column(horizontalAlignment = alignment) {
|
||||
Text(call.contact.chatViewName, color = Color.White, style = MaterialTheme.typography.body2)
|
||||
Text(call.callState.text, color = Color.White, style = MaterialTheme.typography.body2)
|
||||
Text(call.encryptionStatus, color = Color.White, style = MaterialTheme.typography.body2)
|
||||
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
InfoText(call.callState.text)
|
||||
|
||||
val connInfo =
|
||||
if (call.connectionInfo == null) ""
|
||||
else " (${call.connectionInfo.text})"
|
||||
InfoText(call.encryptionStatus + connInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +398,8 @@ fun PreviewActiveCallOverlayVideo() {
|
||||
contact = Contact.sampleData,
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Video,
|
||||
peerMedia = CallMediaType.Video
|
||||
peerMedia = CallMediaType.Video,
|
||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||
),
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
@@ -409,7 +418,8 @@ fun PreviewActiveCallOverlayAudio() {
|
||||
contact = Contact.sampleData,
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Audio,
|
||||
peerMedia = CallMediaType.Audio
|
||||
peerMedia = CallMediaType.Audio,
|
||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||
),
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
|
||||
@@ -96,7 +96,16 @@ sealed class WCallResponse {
|
||||
})
|
||||
}
|
||||
@Serializable class CallCapabilities(val encryption: Boolean)
|
||||
@Serializable class ConnectionInfo(val localCandidate: RTCIceCandidate?, val remoteCandidate: RTCIceCandidate)
|
||||
@Serializable class ConnectionInfo(val localCandidate: RTCIceCandidate?, val remoteCandidate: RTCIceCandidate) {
|
||||
val text: String @Composable get() = when {
|
||||
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
|
||||
stringResource(R.string.call_connection_peer_to_peer)
|
||||
localCandidate?.candidateType == RTCIceCandidateType.Relay && remoteCandidate?.candidateType == RTCIceCandidateType.Relay ->
|
||||
stringResource(R.string.call_connection_via_relay)
|
||||
else ->
|
||||
"${localCandidate?.candidateType?.value ?: "unknown"} / ${remoteCandidate?.candidateType?.value ?: "unknown"}"
|
||||
}
|
||||
}
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
|
||||
@@ -104,19 +113,19 @@ sealed class WCallResponse {
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
|
||||
@Serializable
|
||||
enum class RTCIceCandidateType {
|
||||
@SerialName("host") Host,
|
||||
@SerialName("srflx") ServerReflexive,
|
||||
@SerialName("prflx") PeerReflexive,
|
||||
@SerialName("relay") Relay
|
||||
enum class RTCIceCandidateType(val value: String) {
|
||||
@SerialName("host") Host("host"),
|
||||
@SerialName("srflx") ServerReflexive("srflx"),
|
||||
@SerialName("prflx") PeerReflexive("prflx"),
|
||||
@SerialName("relay") Relay("relay")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class WebRTCCallStatus {
|
||||
@SerialName("connected") Connected,
|
||||
@SerialName("connecting") Connecting,
|
||||
@SerialName("disconnected") Disconnected,
|
||||
@SerialName("failed") Failed
|
||||
enum class WebRTCCallStatus(val value: String) {
|
||||
@SerialName("connected") Connected("connected"),
|
||||
@SerialName("connecting") Connecting("connecting"),
|
||||
@SerialName("disconnected") Disconnected("disconnected"),
|
||||
@SerialName("failed") Failed("failed")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
@@ -18,12 +19,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
@@ -174,42 +177,42 @@ fun ChatLayout(
|
||||
|
||||
@Composable
|
||||
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
|
||||
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
|
||||
IconButton(onClick, modifier = modifier) {
|
||||
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||
.padding(horizontal = 8.dp),
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
IconButton(onClick = back) {
|
||||
Icon(
|
||||
Icons.Outlined.ArrowBackIos,
|
||||
stringResource(R.string.back),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
IconButton(onClick = { startCall(CallMediaType.Video) }) {
|
||||
Icon(
|
||||
Icons.Outlined.Videocam,
|
||||
"video call",
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
val cInfo = chat.chatInfo
|
||||
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
|
||||
toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
|
||||
startCall(CallMediaType.Audio)
|
||||
}
|
||||
}
|
||||
toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
|
||||
startCall(CallMediaType.Video)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.padding(horizontal = 68.dp)
|
||||
.padding(horizontal = 80.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = info),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val cInfo = chat.chatInfo
|
||||
ChatInfoImage(cInfo, size = 40.dp)
|
||||
Column(
|
||||
Modifier.padding(start = 8.dp),
|
||||
|
||||
@@ -295,7 +295,7 @@
|
||||
<string name="callstatus_accepted">принятый звонок</string>
|
||||
<string name="callstatus_connecting">соединяется…</string>
|
||||
<string name="callstatus_in_progress">активный звонок</string>
|
||||
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s!</xliff:g></string>
|
||||
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstatus_error">ошибка соединения</string>
|
||||
|
||||
<!-- CallState -->
|
||||
@@ -347,15 +347,19 @@
|
||||
<string name="encrypted_video_call">e2e encrypted video call</string>
|
||||
<string name="audio_call_no_encryption">audio call (not e2e encrypted)</string>
|
||||
<string name="encrypted_audio_call">e2e encrypted audio call</string>
|
||||
<string name="if_you_accept_this_call_your_ip_address_visible">If you accept this call, your IP address might be visible to your contact, unless you connect via relay.</string>
|
||||
<string name="if_you_accept_this_call_your_ip_address_visible">If you accept this call and you don\'t use relay, your IP address might be visible to your contact.</string>
|
||||
<string name="answer">Answer</string>
|
||||
<string name="call_already_ended">Call already ended!</string>
|
||||
<string name="icon_descr_video_call">video call</string>
|
||||
<string name="icon_descr_audio_call">audio call</string>
|
||||
|
||||
<!-- Call overlay -->
|
||||
<string name="status_e2e_encrypted">e2e encrypted</string>
|
||||
<string name="status_no_e2e_encryption">no e2e encryption</string>
|
||||
<string name="status_contact_has_e2e_encryption">contact has e2e encryption</string>
|
||||
<string name="status_contact_has_no_e2e_encryption">contact has no e2e encryption</string>
|
||||
<string name="call_connection_peer_to_peer">peer-to-peer</string>
|
||||
<string name="call_connection_via_relay">via relay</string>
|
||||
<string name="icon_descr_hang_up">Hang up</string>
|
||||
<string name="icon_descr_video_off">Video off</string>
|
||||
<string name="icon_descr_video_on">Video on</string>
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
<string name="callstatus_accepted">accepted</string>
|
||||
<string name="callstatus_connecting">connecting…</string>
|
||||
<string name="callstatus_in_progress">in progress</string>
|
||||
<string name="callstatus_ended">ended <xliff:g id="duration" example="01:15">%1$s!</xliff:g></string>
|
||||
<string name="callstatus_ended">ended <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstatus_error">error</string>
|
||||
|
||||
<!-- CallState -->
|
||||
@@ -348,15 +348,19 @@
|
||||
<string name="encrypted_video_call">e2e encrypted video call</string>
|
||||
<string name="audio_call_no_encryption">audio call (not e2e encrypted)</string>
|
||||
<string name="encrypted_audio_call">e2e encrypted audio call</string>
|
||||
<string name="if_you_accept_this_call_your_ip_address_visible">If you accept this call, your IP address might be visible to your contact, unless you connect via relay.</string>
|
||||
<string name="if_you_accept_this_call_your_ip_address_visible">If you accept this call and you don\'t use relay, your IP address might be visible to your contact.</string>
|
||||
<string name="answer">Answer</string>
|
||||
<string name="call_already_ended">Call already ended!</string>
|
||||
<string name="icon_descr_video_call">video call</string>
|
||||
<string name="icon_descr_audio_call">audio call</string>
|
||||
|
||||
<!-- Call overlay -->
|
||||
<string name="status_e2e_encrypted">e2e encrypted</string>
|
||||
<string name="status_no_e2e_encryption">no e2e encryption</string>
|
||||
<string name="status_contact_has_e2e_encryption">contact has e2e encryption</string>
|
||||
<string name="status_contact_has_no_e2e_encryption">contact has no e2e encryption</string>
|
||||
<string name="call_connection_peer_to_peer">peer-to-peer</string>
|
||||
<string name="call_connection_via_relay">via relay</string>
|
||||
<string name="icon_descr_hang_up">Hang up</string>
|
||||
<string name="icon_descr_video_off">Video off</string>
|
||||
<string name="icon_descr_video_on">Video on</string>
|
||||
|
||||
@@ -174,7 +174,12 @@ struct ActiveCallOverlay: View {
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
Group {
|
||||
Text(call.callState.text)
|
||||
Text(call.encryptionStatus)
|
||||
HStack {
|
||||
Text(call.encryptionStatus)
|
||||
if let connInfo = call.connectionInfo?.text {
|
||||
Text("(") + Text(connInfo) + Text(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
|
||||
@@ -379,6 +379,19 @@ struct ConnectionState: Codable, Equatable {
|
||||
struct ConnectionInfo: Codable, Equatable {
|
||||
var localCandidate: RTCIceCandidate?
|
||||
var remoteCandidate: RTCIceCandidate?
|
||||
|
||||
var text: LocalizedStringKey {
|
||||
get {
|
||||
if localCandidate?.candidateType == .host && remoteCandidate?.candidateType == .host {
|
||||
return "peer-to-peer"
|
||||
} else if localCandidate?.candidateType == .relay && remoteCandidate?.candidateType == .relay {
|
||||
return "via relay"
|
||||
} else {
|
||||
let unknown = NSLocalizedString("unknown", comment: "connection info")
|
||||
return "\(localCandidate?.candidateType?.rawValue ?? unknown)) / \(remoteCandidate?.candidateType?.rawValue ?? unknown)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||
|
||||
@@ -103,7 +103,7 @@ struct ChatListView: View {
|
||||
message: Text(contact.profile.displayName).bold() +
|
||||
Text(" wants to connect with you via ") +
|
||||
Text(invitation.callTypeText) +
|
||||
Text("\nIf you accept this call, your IP address might be visible to your contact, unless you connect via relay."),
|
||||
Text("\nIf you accept this call and you don't use relay, your IP address might be visible to your contact."),
|
||||
primaryButton: .default(Text("Answer")) {
|
||||
if chatModel.activeCallInvitation == nil {
|
||||
DispatchQueue.main.async {
|
||||
|
||||
@@ -848,6 +848,7 @@ callStatusItemContent userId Contact {contactId} chatItemId receivedStatus = do
|
||||
(Just CISCallProgress, WCSConnected) -> Nothing -- if call in-progress received connected -> no change
|
||||
(Just CISCallProgress, WCSDisconnected) -> Just (CISCallEnded, callDuration) -- calculate in-progress duration
|
||||
(Just CISCallProgress, WCSFailed) -> Just (CISCallEnded, callDuration) -- whether call disconnected or failed
|
||||
(Just CISCallPending, WCSDisconnected) -> Just (CISCallMissed, 0)
|
||||
(Just CISCallEnded, _) -> Nothing -- if call already ended or failed -> no change
|
||||
(Just CISCallError, _) -> Nothing
|
||||
(Just _, WCSConnected) -> Just (CISCallProgress, 0) -- if call ended that was never connected, duration = 0
|
||||
|
||||
Reference in New Issue
Block a user