diff --git a/apps/android/.idea/gradle.xml b/apps/android/.idea/gradle.xml index 526b4c25c6..a2d7c21338 100644 --- a/apps/android/.idea/gradle.xml +++ b/apps/android/.idea/gradle.xml @@ -13,7 +13,6 @@ - diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index dd71ea87a2..722bdccae9 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -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) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 4be64a5c68..3a5aed1779 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -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)) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index f8820cd938..87e1406954 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -45,6 +45,7 @@ class ChatModel(val controller: ChatController) { val activeCallInvitation = mutableStateOf(null) val activeCall = mutableStateOf(null) val callCommand = mutableStateOf(null) + val showCallView = mutableStateOf(false) fun updateUserProfile(profile: Profile) { val user = currentUser.value diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index 3e1fee40f9..1b7aa8438d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -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) 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 a2ab13f6c6..036f6f5e1a 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 @@ -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): 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() 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 4740a74745..b6a793bca7 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 @@ -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(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(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, onResponse: (String) -> Unit) { fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) { - lateinit var wv: WebView + val webView = remember { mutableStateOf(null) } val permissionsState = rememberMultiplePermissionsState( permissions = listOf( Manifest.permission.CAMERA, @@ -92,7 +164,7 @@ fun WebRTCView(callCommand: MutableState, 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, 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, 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 { 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 53b3ec4086..019e1d8e2b 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 @@ -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 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 9c091ebdfa..cc98299e02 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 @@ -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 = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index acc37f9e06..9ab041d283 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -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 = {} ) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 1ff932818f..e3a480c928 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -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 diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index 41845d72aa..9b53bd1e35 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -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) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 4f0df6d909..cbfee48a95 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -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) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 6b0af5da76..d4bd2d1f43 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8e7c1bd2b9..a83840ecc4 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 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 = ""; }; + 5CFE09242833E9620002594B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CFE09252833E9620002594B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CFE09262833E9620002594B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 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 = ""; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; 64431FFD2833BF0800CA19E0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64431FFE2833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a"; sourceTree = ""; }; @@ -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 = ""; diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index 6b33d7292d..9082c4925f 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -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", diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index f21210d2d6..a256965cd4 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -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 { 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 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index fe3eadd7e8..a3d5585434 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -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 diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index b1e0f821f5..1ad9ba65bc 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -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 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ce68620f31..258eb0dadc 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -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