diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 12719b7e58..088341544c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -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" } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt index 473d94e2ab..57a816eb3c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -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 = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt index 81c7d65857..a415710319 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -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 diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index dbaec141e9..583029e350 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -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), diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 9c500b363b..5c17693412 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -295,7 +295,7 @@ принятый звонок соединяется… активный звонок - звонок завершён %1$s! + звонок завершён %1$s ошибка соединения @@ -347,15 +347,19 @@ e2e encrypted video call audio call (not e2e encrypted) e2e encrypted audio call - If you accept this call, your IP address might be visible to your contact, unless you connect via relay. + If you accept this call and you don\'t use relay, your IP address might be visible to your contact. Answer Call already ended! + video call + audio call e2e encrypted no e2e encryption contact has e2e encryption contact has no e2e encryption + peer-to-peer + via relay Hang up Video off Video on diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index e85ccaf6b7..6ed5cad3f5 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -296,7 +296,7 @@ accepted connecting… in progress - ended %1$s! + ended %1$s error @@ -348,15 +348,19 @@ e2e encrypted video call audio call (not e2e encrypted) e2e encrypted audio call - If you accept this call, your IP address might be visible to your contact, unless you connect via relay. + If you accept this call and you don\'t use relay, your IP address might be visible to your contact. Answer Call already ended! + video call + audio call e2e encrypted no e2e encryption contact has e2e encryption contact has no e2e encryption + peer-to-peer + via relay Hang up Video off Video on diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index cda1a52939..47b1bb45ed 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -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) diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index 0382c73284..4d87f263f8 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -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 diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 1334a62373..248f331769 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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 { diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8472caca41..a2e75dc4b0 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -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