From 86671a1699b3a6170cff38f80ecc11092182f56e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:45:57 +0000 Subject: [PATCH] core, ui: confirm before connecting a call answered without e2e encryption (#7647) Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 57 +++++++++++++++---- .../chat/simplex/common/model/SimpleXAPI.kt | 50 ++++++++++++---- .../commonMain/resources/MR/base/strings.xml | 2 + src/Simplex/Chat/Library/Subscriber.hs | 2 +- 4 files changed, 86 insertions(+), 25 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index af5f3c3ee9..be2cde7cd6 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2914,23 +2914,19 @@ func processReceivedMsg(_ res: ChatEvent) async { m.callInvitations[invitation.contact.id] = invitation } activateCall(invitation) - case let .callOffer(_, contact, callType, offer, sharedKey, _): + case let .callOffer(_, contact, callType, offer, sharedKey, askConfirmation): await withCall(contact) { call in await MainActor.run { call.callState = .offerReceived call.sharedKey = sharedKey } - let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) - let iceServers = getIceServers() - logger.debug(".callOffer useRelay \(useRelay)") - logger.debug(".callOffer iceServers \(String(describing: iceServers))") - await m.callCommand.processCommand(.offer( - offer: offer.rtcSession, - iceCandidates: offer.rtcIceCandidates, - media: callType.media, aesKey: sharedKey, - iceServers: iceServers, - relay: useRelay - )) + if askConfirmation { + showUnencryptedCallAlert(call) { + Task { await processCallOffer(callType, offer, sharedKey) } + } + } else { + await processCallOffer(callType, offer, sharedKey) + } } case let .callAnswer(_, contact, answer): await withCall(contact) { call in @@ -3067,6 +3063,43 @@ func processReceivedMsg(_ res: ChatEvent) async { logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") } } + + func processCallOffer(_ callType: CallType, _ offer: WebRTCSession, _ sharedKey: String?) async { + let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) + let iceServers = getIceServers() + logger.debug(".callOffer useRelay \(useRelay)") + logger.debug(".callOffer iceServers \(String(describing: iceServers))") + await m.callCommand.processCommand(.offer( + offer: offer.rtcSession, + iceCandidates: offer.rtcIceCandidates, + media: callType.media, aesKey: sharedKey, + iceServers: iceServers, + relay: useRelay + )) + } + + func showUnencryptedCallAlert(_ call: Call, onContinue: @escaping () -> Void) { + DispatchQueue.main.async { + showAlert( + NSLocalizedString("Call is not encrypted", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("%@ accepted the call without end-to-end encryption.", comment: "alert message"), call.contact.displayName), + actions: {[ + UIAlertAction(title: NSLocalizedString("End call", comment: "alert action"), style: .destructive) { _ in + // the call may have ended, or a new one started with the same contact, while the alert was shown + guard m.activeCall === call else { return } + if let uuid = call.callUUID { + CallController.shared.endCall(callUUID: uuid) + } else { + CallController.shared.endCall(call: call) {} + } + }, + UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in + if m.activeCall === call { onContinue() } + } + ]} + ) + } + } } func switchToLocalSession() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 96515fbde2..e3bea95c03 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3371,20 +3371,13 @@ object ChatController { chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId)) } is CR.CallOffer -> { - // TODO askConfirmation? - // TODO check encryption is compatible withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, hasSharedKey = r.sharedKey != null) - val useRelay = appPrefs.webrtcPolicyRelay.get() - val iceServers = getIceServers() - chatModel.callCommand.add(WCallCommand.Offer( - offer = r.offer.rtcSession, - iceCandidates = r.offer.rtcIceCandidates, - media = r.callType.media, - aesKey = r.sharedKey, - iceServers = iceServers, - relay = useRelay - )) + if (r.askConfirmation) { + showUnencryptedCallAlert(call) { processCallOffer(r) } + } else { + processCallOffer(r) + } } } is CR.CallAnswer -> { @@ -3640,6 +3633,39 @@ object ChatController { } } + private fun processCallOffer(r: CR.CallOffer) { + val useRelay = appPrefs.webrtcPolicyRelay.get() + val iceServers = getIceServers() + chatModel.callCommand.add(WCallCommand.Offer( + offer = r.offer.rtcSession, + iceCandidates = r.offer.rtcIceCandidates, + media = r.callType.media, + aesKey = r.sharedKey, + iceServers = iceServers, + relay = useRelay + )) + } + + private fun showUnencryptedCallAlert(call: Call, onContinue: () -> Unit) { + // the call may have ended, or a new one started with the same contact, while the alert was shown + fun offerPending(): Boolean { + val c = chatModel.activeCall.value + return c != null && c.remoteHostId == call.remoteHostId && c.contact.id == call.contact.id && c.callState == CallState.OfferReceived + } + val endCall = { if (offerPending()) withBGApi { chatModel.callManager.endCall(call) } } + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.call_not_encrypted_title), + text = generalGetString(MR.strings.call_not_encrypted_desc).format(call.contact.displayName), + confirmText = generalGetString(MR.strings.call_service_notification_end_call), + onConfirm = { endCall() }, + dismissText = generalGetString(MR.strings.continue_to_next_step), + onDismiss = { if (offerPending()) onContinue() }, + onDismissRequest = { endCall() }, + destructive = true, + parseHtml = false + ) + } + suspend fun leaveGroup(rh: Long?, groupId: Long) { val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != null) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index efe0423aff..8a8aad0cfa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1415,6 +1415,8 @@ no e2e encryption contact has e2e encryption contact has no e2e encryption + Call is not encrypted + %s accepted the call without end-to-end encryption. peer-to-peer via relay Hang up diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 19b78dc2ae..71337a6b0e 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3053,7 +3053,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | callVersion `isCompatible` callVR -> do let sharedKey = callMediaKey callVersion callId <$> callDhPubKey <*> localDhPrivKey callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} - askConfirmation = encryptedCall localCallType && not (encryptedCall callType) + askConfirmation = encryptedCall localCallType && isNothing sharedKey toView CEvtCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) | otherwise -> do