mobile: webrtc calls (#661)

* mobile: webrtc calls work on Android and iOS separately

* core: only send public key with offer if invitation/current call supports e2ee

* update npm module
This commit is contained in:
Evgeny Poberezkin 2022-05-18 07:01:32 +01:00 committed by GitHub
parent 106dceabfc
commit dc418923ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 361 additions and 155 deletions

View file

@ -13,7 +13,6 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>

View file

@ -16,15 +16,14 @@ var TransformOperation;
TransformOperation["Encrypt"] = "encrypt";
TransformOperation["Decrypt"] = "decrypt";
})(TransformOperation || (TransformOperation = {}));
;
(function () {
const processCommand = (function () {
let activeCall;
function defaultCallConfig(encodedInsertableStreams) {
return {
peerConnectionConfig: {
iceServers: [
{ urls: "stun:stun.simplex.chat:5349" },
// {urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z"},
{ urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z" },
],
iceCandidatePoolSize: 10,
encodedInsertableStreams,
@ -119,7 +118,6 @@ var TransformOperation;
function parse(s) {
return JSON.parse(LZString.decompressFromBase64(s));
}
Object.defineProperty(window, "processCommand", { value: processCommand });
async function processCommand(body) {
const { corrId, command } = body;
const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection;
@ -135,9 +133,6 @@ var TransformOperation;
if (activeCall) {
resp = { type: "error", message: "start: call already started" };
}
else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) {
resp = { type: "error", message: "start: encryption is not supported" };
}
else {
const { media, useWorker } = command;
const encryption = supportsInsertableStreams(useWorker);
@ -386,6 +381,7 @@ var TransformOperation;
for (const t of tracks)
t.enabled = enable;
}
return processCommand;
})();
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
function callCryptoFunction() {
@ -432,7 +428,6 @@ function callCryptoFunction() {
}
function decodeAesKey(aesKey) {
const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey));
console.log("keyData", keyData);
return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
}
function concatN(...bs) {

View file

@ -21,6 +21,7 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.SplashView
import chat.simplex.app.views.call.ActiveCallView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.chatlist.openChat
@ -90,7 +91,8 @@ fun MainPage(chatModel: ChatModel) {
when {
onboarding == null || userCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated ->
if (chatModel.chatId.value == null) ChatListView(chatModel)
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else if (chatModel.chatId.value == null) ChatListView(chatModel)
else ChatView(chatModel)
onboarding == OnboardingStage.Step1_SimpleXInfo ->
Box(Modifier.padding(horizontal = 20.dp)) {

View file

@ -45,6 +45,7 @@ class ChatModel(val controller: ChatController) {
val activeCallInvitation = mutableStateOf<ContactRef?>(null)
val activeCall = mutableStateOf<Call?>(null)
val callCommand = mutableStateOf<WCallCommand?>(null)
val showCallView = mutableStateOf(false)
fun updateUserProfile(profile: Profile) {
val user = currentUser.value

View file

@ -7,6 +7,8 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
import chat.simplex.app.views.call.CallInvitation
import chat.simplex.app.views.call.CallMediaType
import kotlinx.datetime.Clock
class NtfManager(val context: Context) {
@ -56,7 +58,7 @@ class NtfManager(val context: Context) {
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setContentIntent(getMsgPendingIntent(cInfo))
.setContentIntent(getMsgPendingIntent(cInfo.id))
.setSilent(recentNotification)
.build()
@ -76,6 +78,37 @@ class NtfManager(val context: Context) {
}
}
fun notifyCallInvitation(contact: Contact, invitation: CallInvitation) {
Log.d(TAG, "notifyCallInvitationReceived ${contact.id}")
val notification = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(contact.displayName)
.setContentText("Incoming ${invitation.peerMedia} call (${if (invitation.sharedKey == null) "not e2e encrypted" else "e2e encrypted"})")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setContentIntent(getMsgPendingIntent(contact.id))
.setSilent(false)
.build()
// val summary = NotificationCompat.Builder(context, MessageChannel)
// .setSmallIcon(R.drawable.ntf_icon)
// .setColor(0x88FFFF)
// .setGroup(MessageGroup)
// .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
// .setGroupSummary(true)
// .setContentIntent(getSummaryNtfIntent())
// .build()
with(NotificationManagerCompat.from(context)) {
notify(0, notification)
// notify(0, summary)
}
}
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md == null) {
@ -93,12 +126,12 @@ class NtfManager(val context: Context) {
}
}
private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent ${cInfo.id}")
private fun getMsgPendingIntent(chatId: String) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent $chatId")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
val intent = Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra("chatId", cInfo.id)
.putExtra("chatId", chatId)
.setAction(OpenChatAction)
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)

View file

@ -369,15 +369,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return r is CR.CmdOk
}
suspend fun apiCallStatus(contact: Contact, status: String): Boolean {
try {
val callStatus = WebRTCCallStatus.valueOf(status)
val r = sendCmd(CC.ApiCallStatus(contact, callStatus))
return r is CR.CmdOk
} catch (e: Error) {
Log.d(TAG,"apiCallStatus: call status $status not used")
return false
}
suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean {
val r = sendCmd(CC.ApiCallStatus(contact, status))
return r is CR.CmdOk
}
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
@ -504,11 +498,83 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
removeFile(appContext, fileName)
}
}
is CR.CallInvitation -> {
val invitation = CallInvitation(r.callType.media, r.sharedKey)
chatModel.callInvitations[r.contact.id] = invitation
if (chatModel.activeCallInvitation.value == null) {
chatModel.activeCallInvitation.value = ContactRef(r.contact.apiId, r.contact.localDisplayName)
}
ntfManager.notifyCallInvitation(r.contact, invitation)
val encryptionText = if (r.sharedKey == null) "without e2e encryption" else "with e2e encryption"
AlertManager.shared.showAlertDialog(
title = "Incoming call",
text = "${r.contact.displayName} wants to start ${r.callType.media} call with you (${encryptionText})",
confirmText = "Answer", // generalGetString(R.string.answer),
onConfirm = {
Log.e(TAG, "showAlertDialog onConfirm ${chatModel.activeCallInvitation.value}")
if (chatModel.activeCallInvitation.value == null) {
AlertManager.shared.hideAlert()
AlertManager.shared.showAlertMsg("Call already ended!")
} else {
Log.e(TAG, "showAlertDialog onConfirm has activeCallInvitation ${chatModel.activeCallInvitation.value}")
chatModel.activeCallInvitation.value = null
chatModel.activeCall.value = Call(
contact = r.contact,
callState = CallState.InvitationReceived,
localMedia = invitation.peerMedia
)
chatModel.callCommand.value = WCallCommand.Start(invitation.peerMedia, invitation.sharedKey)
Log.e(TAG, "showAlertDialog onConfirm ${chatModel.callCommand.value}")
chatModel.showCallView.value = true
}
},
onDismiss = {
chatModel.activeCallInvitation == null
}
)
}
is CR.CallOffer -> {
// TODO askConfirmation?
// TODO check encryption is compatible
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey)
}
}
is CR.CallAnswer -> {
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)
}
}
is CR.CallExtraInfo -> {
withCall(r, r.contact) { call ->
chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)
}
}
is CR.CallEnded -> {
withCall(r, r.contact) { call ->
chatModel.callCommand.value = WCallCommand.End()
chatModel.activeCall.value = null
chatModel.activeCallInvitation.value = null
chatModel.callCommand.value = null
chatModel.showCallView.value = false
}
}
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
}
}
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
val call = chatModel.activeCall?.value
if (call != null && call.contact.apiId == contact.apiId) {
perform(call)
} else {
Log.d(TAG, "processReceivedMsg: ignoring ${r.responseType}, not in call with the contact ${contact.id}")
}
}
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
@ -898,8 +964,8 @@ sealed class CR {
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?): CR()
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String?, val askConfirmation: Boolean): CR()
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null): CR()
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
@Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR()

View file

@ -23,67 +23,139 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat
import chat.simplex.app.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.json
import chat.simplex.app.views.helpers.TextEditor
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@Composable
fun VideoCallView(close: () -> Unit) {
val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
BackHandler(onBack = close)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
WebRTCView(callCommand) { resp ->
// for debugging
// commandText.value = resp
commandText.value = json.encodeToString(resp)
}
TextEditor(Modifier.height(180.dp), text = commandText)
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Button(onClick = {
val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
clipboard?.setPrimaryClip(clip)
}) { Text("Copy") }
Button(onClick = {
try {
val apiCall: WVAPICall = json.decodeFromString(commandText.value)
commandText.value = ""
println("sending: ${commandText.value}")
callCommand.value = apiCall.command
} catch(e: Error) {
println("error parsing command: ${commandText.value}")
println(e)
fun ActiveCallView(chatModel: ChatModel) {
val endCall = {
Log.e(TAG, "ActiveCallView: endCall")
chatModel.activeCall.value = null
chatModel.activeCallInvitation.value = null
chatModel.callCommand.value = null
chatModel.showCallView.value = false
}
BackHandler(onBack = endCall)
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.e(TAG, "received from WebRTCView: $apiMsg")
val call = chatModel.activeCall.value
if (call != null) {
Log.e(TAG, "has active call $call")
when (val r = apiMsg.resp) {
is WCallResponse.Capabilities -> withApi {
val callType = CallType(call.localMedia, r.capabilities)
chatModel.controller.apiSendCallInvitation(call.contact, callType)
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
}
is WCallResponse.Offer -> withApi {
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
}
is WCallResponse.Answer -> withApi {
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
}
is WCallResponse.Ice -> withApi {
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
}
is WCallResponse.Connection ->
try {
val callStatus = WebRTCCallStatus.valueOf(r.state.connectionState)
if (callStatus == WebRTCCallStatus.Connected) {
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
}
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
} catch (e: Error) {
Log.d(TAG,"call status ${r.state.connectionState} not used")
}
is WCallResponse.Ended -> endCall()
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
is WCallCommand.Media -> {
when (cmd.media) {
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
}
}
is WCallCommand.End -> endCall()
else -> {}
}
is WCallResponse.Error -> {
Log.e(TAG, "ActiveCallView: command error ${r.message}")
}
}
}) { Text("Send") }
Button(onClick = {
commandText.value = ""
}) { Text("Clear") }
}
}
ActiveCallOverlay()
}
}
@Composable
fun ActiveCallOverlay() {
}
//@Composable
//fun CallViewDebug(close: () -> Unit) {
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
// val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
// val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
//
// BackHandler(onBack = close)
// Column(
// horizontalAlignment = Alignment.CenterHorizontally,
// verticalArrangement = Arrangement.spacedBy(12.dp),
// modifier = Modifier
// .background(MaterialTheme.colors.background)
// .fillMaxSize()
// ) {
// WebRTCView(callCommand) { apiMsg ->
// // for debugging
// // commandText.value = apiMsg
// commandText.value = json.encodeToString(apiMsg)
// }
//
// TextEditor(Modifier.height(180.dp), text = commandText)
//
// Row(
// Modifier
// .fillMaxWidth()
// .padding(bottom = 6.dp),
// horizontalArrangement = Arrangement.SpaceBetween
// ) {
// Button(onClick = {
// val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
// clipboard?.setPrimaryClip(clip)
// }) { Text("Copy") }
// Button(onClick = {
// try {
// val apiCall: WVAPICall = json.decodeFromString(commandText.value)
// commandText.value = ""
// println("sending: ${commandText.value}")
// callCommand.value = apiCall.command
// } catch(e: Error) {
// println("error parsing command: ${commandText.value}")
// println(e)
// }
// }) { Text("Send") }
// Button(onClick = {
// commandText.value = ""
// }) { Text("Clear") }
// }
// }
//}
@Composable
// for debugging
// fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (String) -> Unit) {
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
lateinit var wv: WebView
val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.CAMERA,
@ -92,7 +164,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
Manifest.permission.INTERNET
)
)
fun processCommand(cmd: WCallCommand) {
fun processCommand(wv: WebView, cmd: WCallCommand) {
val apiCall = WVAPICall(command = cmd)
wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null)
}
@ -104,30 +176,27 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
processCommand(WCallCommand.End())
val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End())
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(callCommand.value) {
LaunchedEffect(callCommand.value, webView.value) {
val cmd = callCommand.value
if (cmd != null) {
val wv = webView.value
if (cmd != null && wv != null) {
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
processCommand(wv, cmd)
callCommand.value = null
processCommand(cmd)
}
}
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current))
.build()
if (permissionsState.allPermissionsGranted) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) {
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { AndroidViewContext ->
WebView(AndroidViewContext).apply {
@ -154,16 +223,19 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
webViewSettings.allowContentAccess = true
webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.allowFileAccessFromFileURLs = true;
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
this.loadUrl("file:android_asset/www/call.html")
}
}
) {
wv = it
) { wv ->
Log.e(TAG, "WebRTCView: webview ready")
// for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
withApi {
delay(2000L)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = wv
}
}
}
} else {

View file

@ -6,7 +6,7 @@ import chat.simplex.app.views.helpers.generalGetString
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class Call(
data class Call(
val contact: Contact,
val callState: CallState,
val localMedia: CallMediaType,
@ -46,7 +46,7 @@ enum class CallState {
sealed class WCallCommand {
@Serializable @SerialName("capabilities") class Capabilities(): WCallCommand()
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null): WCallCommand()
@Serializable @SerialName("offer") class Accept(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null): WCallCommand()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null): WCallCommand()
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@ -56,23 +56,20 @@ sealed class WCallCommand {
@Serializable
sealed class WCallResponse {
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String): WCallResponse()
// TODO remove accept, it is needed for debugging
@Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null): WCallResponse()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("ended") class Ended(): WCallResponse()
@Serializable @SerialName("ok") class Ok(): WCallResponse()
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
@Serializable class Invalid(val str: String): WCallResponse()
}
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
@Serializable class CallInvitation(val peerMedia: CallMediaType?, val sharedKey: String?)
@Serializable class CallInvitation(val peerMedia: CallMediaType, val sharedKey: String?)
@Serializable class CallCapabilities(val encryption: Boolean)
@Serializable

View file

@ -12,7 +12,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBackIos
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
@ -32,6 +32,7 @@ import chat.simplex.app.SimplexApp.Companion.context
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.*
@ -117,6 +118,14 @@ fun ChatView(chatModel: ChatModel) {
chatModel.upsertChatItem(cInfo, cItem)
}
}
},
startCall = { media ->
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
chatModel.showCallView.value = true
chatModel.callCommand.value = WCallCommand.Capabilities()
}
}
)
}
@ -137,7 +146,8 @@ fun ChatLayout(
info: () -> Unit,
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit
receiveFile: (Long) -> Unit,
startCall: (CallMediaType) -> Unit
) {
fun onImageChange(bitmap: Bitmap) {
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
@ -173,7 +183,7 @@ fun ChatLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
@ -187,7 +197,7 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
Column {
Box(
Modifier
@ -205,6 +215,16 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
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)
)
}
}
Row(
Modifier
.padding(horizontal = 68.dp)
@ -378,7 +398,8 @@ fun PreviewChatLayout() {
info = {},
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {}
receiveFile = {},
startCall = {}
)
}
}
@ -423,7 +444,8 @@ fun PreviewGroupChatLayout() {
info = {},
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {}
receiveFile = {},
startCall = {}
)
}
}

View file

@ -24,7 +24,6 @@ import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView
import chat.simplex.app.views.call.VideoCallView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.SimpleXInfo
@ -45,8 +44,8 @@ fun SettingsView(chatModel: ChatModel) {
},
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> VideoCallView(close) } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
)
}
}
@ -62,7 +61,7 @@ fun SettingsLayout(
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit,
showVideoChatPrototype: () -> Unit
// showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(
@ -208,8 +207,8 @@ fun SettingsLayout(
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showVideoChatPrototype) {
// SettingsSectionView() {
// SettingsSectionView(showVideoChatPrototype) {
SettingsSectionView() {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
@ -246,7 +245,7 @@ fun PreviewSettingsLayout() {
showModal = {{}},
showCustomModal = {{}},
showTerminal = {},
showVideoChatPrototype = {}
// showVideoChatPrototype = {}
)
}
}

View file

@ -640,7 +640,7 @@ func processReceivedMsg(_ res: ChatResponse) {
// TODO check encryption is compatible
withCall(contact) { call in
m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey)
m.callCommand = .offer(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey)
m.callCommand = .offer(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey, useWorker: true)
}
case let .callAnswer(contact, answer):
withCall(contact) { call in

View file

@ -149,7 +149,7 @@ enum WCallCommand: Equatable, Encodable, Decodable {
try container.encode(aesKey, forKey: .aesKey)
try container.encode(useWorker, forKey: .useWorker)
case let .offer(offer, iceCandidates, media, aesKey, useWorker):
try container.encode("accept", forKey: .type)
try container.encode("offer", forKey: .type)
try container.encode(offer, forKey: .offer)
try container.encode(iceCandidates, forKey: .iceCandidates)
try container.encode(media, forKey: .media)
@ -213,8 +213,6 @@ enum WCallCommand: Equatable, Encodable, Decodable {
enum WCallResponse: Equatable, Decodable {
case capabilities(capabilities: CallCapabilities)
case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities)
// TODO remove accept, it is needed for debugging
// case offer(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil)
case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String)
case connection(state: ConnectionState)

View file

@ -106,14 +106,14 @@ struct ChatView: View {
ChatInfoView(chat: chat, showChatInfo: $showChatInfo)
}
}
// ToolbarItem(placement: .navigationBarTrailing) {
// if case let .direct(contact) = cInfo {
// HStack {
// callButton(contact, .audio, imageName: "phone")
// callButton(contact, .video, imageName: "video")
// }
// }
// }
ToolbarItem(placement: .navigationBarTrailing) {
if case let .direct(contact) = cInfo {
HStack {
callButton(contact, .audio, imageName: "phone")
callButton(contact, .video, imageName: "video")
}
}
}
}
.navigationBarBackButtonHidden(true)
}

View file

@ -113,7 +113,7 @@ struct ChatListView: View {
localMedia: invitation.peerMedia
)
showCallView = true
chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey)
chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
}
},
secondaryButton: .cancel()

View file

@ -92,6 +92,16 @@
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
5CFE09282833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09232833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */; };
5CFE09292833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09232833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */; };
5CFE092A2833E9620002594B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09242833E9620002594B /* libgmpxx.a */; };
5CFE092B2833E9620002594B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09242833E9620002594B /* libgmpxx.a */; };
5CFE092C2833E9620002594B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09252833E9620002594B /* libgmp.a */; };
5CFE092D2833E9620002594B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09252833E9620002594B /* libgmp.a */; };
5CFE092E2833E9620002594B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09262833E9620002594B /* libffi.a */; };
5CFE092F2833E9620002594B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09262833E9620002594B /* libffi.a */; };
5CFE09302833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09272833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */; };
5CFE09312833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09272833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
644320022833BF0800CA19E0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64431FFD2833BF0800CA19E0 /* libgmp.a */; };
644320032833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64431FFE2833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */; };
@ -202,6 +212,11 @@
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
5CFE09232833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a"; sourceTree = "<group>"; };
5CFE09242833E9620002594B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CFE09252833E9620002594B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CFE09262833E9620002594B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CFE09272833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a"; sourceTree = "<group>"; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
64431FFD2833BF0800CA19E0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64431FFE2833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a"; sourceTree = "<group>"; };
@ -223,13 +238,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
644320042833BF0800CA19E0 /* libgmpxx.a in Frameworks */,
644320032833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */,
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
5CFE092A2833E9620002594B /* libgmpxx.a in Frameworks */,
5CFE09282833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
644320052833BF0800CA19E0 /* libffi.a in Frameworks */,
644320022833BF0800CA19E0 /* libgmp.a in Frameworks */,
644320062833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */,
5CFE092E2833E9620002594B /* libffi.a in Frameworks */,
5CFE092C2833E9620002594B /* libgmp.a in Frameworks */,
5CFE09302833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -245,7 +260,12 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5CFE09312833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */,
5CFE092B2833E9620002594B /* libgmpxx.a in Frameworks */,
5CFE092F2833E9620002594B /* libffi.a in Frameworks */,
5CFE092D2833E9620002594B /* libgmp.a in Frameworks */,
5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */,
5CFE09292833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */,
5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -295,11 +315,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
644320002833BF0800CA19E0 /* libffi.a */,
64431FFD2833BF0800CA19E0 /* libgmp.a */,
64431FFF2833BF0800CA19E0 /* libgmpxx.a */,
644320012833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */,
64431FFE2833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */,
5CFE09262833E9620002594B /* libffi.a */,
5CFE09252833E9620002594B /* libgmp.a */,
5CFE09242833E9620002594B /* libgmpxx.a */,
5CFE09272833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */,
5CFE09232833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */,
);
path = Libraries;
sourceTree = "<group>";

View file

@ -1,6 +1,6 @@
{
"name": "@simplex-chat/webrtc",
"version": "0.0.2",
"version": "0.0.3",
"description": "WebRTC call in browser and webview for SimpleX Chat clients",
"main": "dist/call.js",
"types": "dist/call.d.ts",

View file

@ -137,12 +137,12 @@ enum TransformOperation {
interface RTCRtpScriptTransform {}
;(function () {
interface WVAPICall {
corrId?: number
command: WCallCommand
}
interface WVAPICall {
corrId?: number
command: WCallCommand
}
const processCommand = (function () {
type RTCRtpSenderWithEncryption = RTCRtpSender & {
createEncodedStreams: () => TransformStream
transform: RTCRtpScriptTransform
@ -180,7 +180,7 @@ interface RTCRtpScriptTransform {}
peerConnectionConfig: {
iceServers: [
{urls: "stun:stun.simplex.chat:5349"},
// {urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z"},
{urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z"},
],
iceCandidatePoolSize: 10,
encodedInsertableStreams,
@ -279,8 +279,6 @@ interface RTCRtpScriptTransform {}
return JSON.parse(LZString.decompressFromBase64(s)!)
}
Object.defineProperty(window, "processCommand", {value: processCommand})
async function processCommand(body: WVAPICall): Promise<WVApiMessage> {
const {corrId, command} = body
const pc = activeCall?.connection
@ -295,8 +293,6 @@ interface RTCRtpScriptTransform {}
console.log("starting call")
if (activeCall) {
resp = {type: "error", message: "start: call already started"}
} else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) {
resp = {type: "error", message: "start: encryption is not supported"}
} else {
const {media, useWorker} = command
const encryption = supportsInsertableStreams(useWorker)
@ -560,6 +556,8 @@ interface RTCRtpScriptTransform {}
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks()
for (const t of tracks) t.enabled = enable
}
return processCommand
})()
type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void>

View file

@ -430,13 +430,13 @@ processChatCommand = \case
`E.finally` deleteContactRequest st userId connReqId
withAgent $ \a -> rejectContact a connId invId
pure $ CRContactRequestRejected cReq
APISendCallInvitation contactId callType@CallType {capabilities = CallCapabilities {encryption}} -> withUser $ \user@User {userId} -> do
APISendCallInvitation contactId callType -> withUser $ \user@User {userId} -> do
-- party initiating call
ct <- withStore $ \st -> getContact st userId contactId
calls <- asks currentCalls
withChatLock $ do
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing
dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair}
msg@SndMessage {msgId} <- sendDirectContactMessage ct (XCallInv callId invitation)
@ -460,8 +460,8 @@ processChatCommand = \case
-- party accepting call
withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of
CallInvitationReceived {peerCallType, localDhPubKey, sharedKey} -> do
-- TODO check that call type matches peerCallType
let offer = CallOffer {callType, rtcSession, callDhPubKey = localDhPubKey}
let callDhPubKey = if encryptedCall callType then localDhPubKey else Nothing
offer = CallOffer {callType, rtcSession, callDhPubKey}
callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey}
aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0
SndMessage {msgId} <- sendDirectContactMessage ct (XCallOffer callId offer)
@ -1630,8 +1630,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> m ()
xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg@RcvMessage {msgId} msgMeta = do
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
let CallType {capabilities = CallCapabilities {encryption}} = callType
dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing
dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing
ci <- saveCallItem CISCallPending
let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair))
callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey}
@ -1655,8 +1654,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
CallInvitationSent {localCallType, localDhPrivKey} -> do
let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey)
callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey}
-- TODO decide if should askConfirmation
toView CRCallOffer {contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation = False}
askConfirmation = encryptedCall localCallType && not (encryptedCall callType)
toView CRCallOffer {contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation}
pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0)
_ -> do
msgCallStateError "x.call.offer" call

View file

@ -3,6 +3,7 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Call where
@ -99,6 +100,9 @@ data CallType = CallType
defaultCallType :: CallType
defaultCallType = CallType CMVideo $ CallCapabilities {encryption = True}
encryptedCall :: CallType -> Bool
encryptedCall CallType {capabilities = CallCapabilities {encryption}} = encryption
instance ToJSON CallType where toEncoding = J.genericToEncoding J.defaultOptions
-- | * Types for chat protocol

View file

@ -646,7 +646,7 @@ fileProgress chunksNum chunkSize fileSize =
viewCallInvitation :: Contact -> CallType -> Maybe C.Key -> [StyledString]
viewCallInvitation ct@Contact {contactId} callType@CallType {media} sharedKey =
[ ttyContact' ct <> " wants to connect with you via WebRTC " <> callMediaStr callType <> " call " <> encryptedCall callType,
[ ttyContact' ct <> " wants to connect with you via WebRTC " <> callMediaStr callType <> " call " <> encryptedCallText callType,
"To accept the call, please open the link below in your browser" <> supporedBrowsers callType,
"",
"https://simplex.chat/call#" <> plain queryString
@ -662,7 +662,7 @@ viewCallInvitation ct@Contact {contactId} callType@CallType {media} sharedKey =
viewCallOffer :: Contact -> CallType -> WebRTCSession -> Maybe C.Key -> [StyledString]
viewCallOffer ct@Contact {contactId} callType@CallType {media} WebRTCSession {rtcSession = offer, rtcIceCandidates = iceCandidates} sharedKey =
[ ttyContact' ct <> " accepted your WebRTC " <> callMediaStr callType <> " call " <> encryptedCall callType,
[ ttyContact' ct <> " accepted your WebRTC " <> callMediaStr callType <> " call " <> encryptedCallText callType,
"To connect, please open the link below in your browser" <> supporedBrowsers callType,
"",
"https://simplex.chat/call#" <> plain queryString
@ -689,13 +689,14 @@ callMediaStr CallType {media} = case media of
CMVideo -> "video"
CMAudio -> "audio"
encryptedCall :: CallType -> StyledString
encryptedCall CallType {capabilities = CallCapabilities {encryption}} =
if encryption then "(e2e encrypted)" else "(not e2e encrypted)"
encryptedCallText :: CallType -> StyledString
encryptedCallText callType
| encryptedCall callType = "(e2e encrypted)"
| otherwise = "(not e2e encrypted)"
supporedBrowsers :: CallType -> StyledString
supporedBrowsers CallType {capabilities = CallCapabilities {encryption}}
| encryption = " (only Chrome and Safari support e2e encryption for WebRTC, Safari requires enabling WebRTC insertable streams)"
supporedBrowsers callType
| encryptedCall callType = " (only Chrome and Safari support e2e encryption for WebRTC, Safari may require enabling WebRTC insertable streams)"
| otherwise = ""
data WCallCommand