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" /> <option value="$PROJECT_DIR$/app" />
</set> </set>
</option> </option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View file

@ -16,15 +16,14 @@ var TransformOperation;
TransformOperation["Encrypt"] = "encrypt"; TransformOperation["Encrypt"] = "encrypt";
TransformOperation["Decrypt"] = "decrypt"; TransformOperation["Decrypt"] = "decrypt";
})(TransformOperation || (TransformOperation = {})); })(TransformOperation || (TransformOperation = {}));
; const processCommand = (function () {
(function () {
let activeCall; let activeCall;
function defaultCallConfig(encodedInsertableStreams) { function defaultCallConfig(encodedInsertableStreams) {
return { return {
peerConnectionConfig: { peerConnectionConfig: {
iceServers: [ iceServers: [
{ urls: "stun:stun.simplex.chat:5349" }, { 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, iceCandidatePoolSize: 10,
encodedInsertableStreams, encodedInsertableStreams,
@ -119,7 +118,6 @@ var TransformOperation;
function parse(s) { function parse(s) {
return JSON.parse(LZString.decompressFromBase64(s)); return JSON.parse(LZString.decompressFromBase64(s));
} }
Object.defineProperty(window, "processCommand", { value: processCommand });
async function processCommand(body) { async function processCommand(body) {
const { corrId, command } = body; const { corrId, command } = body;
const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection; const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection;
@ -135,9 +133,6 @@ var TransformOperation;
if (activeCall) { if (activeCall) {
resp = { type: "error", message: "start: call already started" }; 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 { else {
const { media, useWorker } = command; const { media, useWorker } = command;
const encryption = supportsInsertableStreams(useWorker); const encryption = supportsInsertableStreams(useWorker);
@ -386,6 +381,7 @@ var TransformOperation;
for (const t of tracks) for (const t of tracks)
t.enabled = enable; t.enabled = enable;
} }
return processCommand;
})(); })();
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used) // Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
function callCryptoFunction() { function callCryptoFunction() {
@ -432,7 +428,6 @@ function callCryptoFunction() {
} }
function decodeAesKey(aesKey) { function decodeAesKey(aesKey) {
const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(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"]); return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
} }
function concatN(...bs) { 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.model.NtfManager
import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.SplashView 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.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.chatlist.openChat import chat.simplex.app.views.chatlist.openChat
@ -90,7 +91,8 @@ fun MainPage(chatModel: ChatModel) {
when { when {
onboarding == null || userCreated == null -> SplashView() onboarding == null || userCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated -> 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) else ChatView(chatModel)
onboarding == OnboardingStage.Step1_SimpleXInfo -> onboarding == OnboardingStage.Step1_SimpleXInfo ->
Box(Modifier.padding(horizontal = 20.dp)) { Box(Modifier.padding(horizontal = 20.dp)) {

View file

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

View file

@ -7,6 +7,8 @@ import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.views.call.CallInvitation
import chat.simplex.app.views.call.CallMediaType
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
class NtfManager(val context: Context) { class NtfManager(val context: Context) {
@ -56,7 +58,7 @@ class NtfManager(val context: Context) {
.setSmallIcon(R.drawable.ntf_icon) .setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF) .setColor(0x88FFFF)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(getMsgPendingIntent(cInfo)) .setContentIntent(getMsgPendingIntent(cInfo.id))
.setSilent(recentNotification) .setSilent(recentNotification)
.build() .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 { private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText val md = cItem.formattedText
return if (md == null) { return if (md == null) {
@ -93,12 +126,12 @@ class NtfManager(val context: Context) {
} }
} }
private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{ private fun getMsgPendingIntent(chatId: String) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent ${cInfo.id}") Log.d(TAG, "getMsgPendingIntent $chatId")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt() val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
val intent = Intent(context, MainActivity::class.java) 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) .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) .setAction(OpenChatAction)
return TaskStackBuilder.create(context).run { return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent) addNextIntentWithParentStack(intent)

View file

@ -369,15 +369,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return r is CR.CmdOk return r is CR.CmdOk
} }
suspend fun apiCallStatus(contact: Contact, status: String): Boolean { suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean {
try { val r = sendCmd(CC.ApiCallStatus(contact, status))
val callStatus = WebRTCCallStatus.valueOf(status) return r is CR.CmdOk
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 apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { 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) 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 -> else ->
Log.d(TAG , "unsupported event: ${r.responseType}") 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) { private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
val cInfo = aChatItem.chatInfo val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem 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("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(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("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("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?, val askConfirmation: Boolean): 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("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): 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.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat import androidx.webkit.WebViewClientCompat
import chat.simplex.app.TAG import chat.simplex.app.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.json import chat.simplex.app.model.json
import chat.simplex.app.views.helpers.TextEditor import chat.simplex.app.views.helpers.TextEditor
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@Composable @Composable
fun VideoCallView(close: () -> Unit) { fun ActiveCallView(chatModel: ChatModel) {
val callCommand = remember { mutableStateOf<WCallCommand?>(null)} val endCall = {
val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") } Log.e(TAG, "ActiveCallView: endCall")
val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java) chatModel.activeCall.value = null
chatModel.activeCallInvitation.value = null
BackHandler(onBack = close) chatModel.callCommand.value = null
Column( chatModel.showCallView.value = false
horizontalAlignment = Alignment.CenterHorizontally, }
verticalArrangement = Arrangement.spacedBy(12.dp), BackHandler(onBack = endCall)
modifier = Modifier Box(Modifier.fillMaxSize()) {
.background(MaterialTheme.colors.background) WebRTCView(chatModel.callCommand) { apiMsg ->
.fillMaxSize() Log.e(TAG, "received from WebRTCView: $apiMsg")
) { val call = chatModel.activeCall.value
WebRTCView(callCommand) { resp -> if (call != null) {
// for debugging Log.e(TAG, "has active call $call")
// commandText.value = resp when (val r = apiMsg.resp) {
commandText.value = json.encodeToString(resp) is WCallResponse.Capabilities -> withApi {
} val callType = CallType(call.localMedia, r.capabilities)
chatModel.controller.apiSendCallInvitation(call.contact, callType)
TextEditor(Modifier.height(180.dp), text = commandText) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
}
Row( is WCallResponse.Offer -> withApi {
Modifier chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
.fillMaxWidth() chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
.padding(bottom = 6.dp), }
horizontalArrangement = Arrangement.SpaceBetween is WCallResponse.Answer -> withApi {
) { chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
Button(onClick = { chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
val clip: ClipData = ClipData.newPlainText("js command", commandText.value) }
clipboard?.setPrimaryClip(clip) is WCallResponse.Ice -> withApi {
}) { Text("Copy") } chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
Button(onClick = { }
try { is WCallResponse.Connection ->
val apiCall: WVAPICall = json.decodeFromString(commandText.value) try {
commandText.value = "" val callStatus = WebRTCCallStatus.valueOf(r.state.connectionState)
println("sending: ${commandText.value}") if (callStatus == WebRTCCallStatus.Connected) {
callCommand.value = apiCall.command chatModel.activeCall.value = call.copy(callState = CallState.Connected)
} catch(e: Error) { }
println("error parsing command: ${commandText.value}") withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
println(e) } 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 @Composable
// for debugging // for debugging
// fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (String) -> Unit) { // fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (String) -> Unit) {
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) { fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
lateinit var wv: WebView val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState( val permissionsState = rememberMultiplePermissionsState(
permissions = listOf( permissions = listOf(
Manifest.permission.CAMERA, Manifest.permission.CAMERA,
@ -92,7 +164,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
Manifest.permission.INTERNET Manifest.permission.INTERNET
) )
) )
fun processCommand(cmd: WCallCommand) { fun processCommand(wv: WebView, cmd: WCallCommand) {
val apiCall = WVAPICall(command = cmd) val apiCall = WVAPICall(command = cmd)
wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null) wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null)
} }
@ -104,30 +176,27 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
} }
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
onDispose { onDispose {
processCommand(WCallCommand.End()) val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End())
lifecycleOwner.lifecycle.removeObserver(observer) lifecycleOwner.lifecycle.removeObserver(observer)
} }
} }
LaunchedEffect(callCommand.value) { LaunchedEffect(callCommand.value, webView.value) {
val cmd = callCommand.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 callCommand.value = null
processCommand(cmd)
} }
} }
val assetLoader = WebViewAssetLoader.Builder() val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current)) .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current))
.build() .build()
if (permissionsState.allPermissionsGranted) { if (permissionsState.allPermissionsGranted) {
Box( Box(Modifier.fillMaxSize()) {
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) {
AndroidView( AndroidView(
factory = { AndroidViewContext -> factory = { AndroidViewContext ->
WebView(AndroidViewContext).apply { WebView(AndroidViewContext).apply {
@ -154,16 +223,19 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
webViewSettings.allowContentAccess = true webViewSettings.allowContentAccess = true
webViewSettings.javaScriptEnabled = true webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.allowFileAccessFromFileURLs = true;
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
this.loadUrl("file:android_asset/www/call.html") this.loadUrl("file:android_asset/www/call.html")
} }
} }
) { ) { wv ->
wv = it Log.e(TAG, "WebRTCView: webview ready")
// for debugging // for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) // 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 { } else {

View file

@ -6,7 +6,7 @@ import chat.simplex.app.views.helpers.generalGetString
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
class Call( data class Call(
val contact: Contact, val contact: Contact,
val callState: CallState, val callState: CallState,
val localMedia: CallMediaType, val localMedia: CallMediaType,
@ -46,7 +46,7 @@ enum class CallState {
sealed class WCallCommand { sealed class WCallCommand {
@Serializable @SerialName("capabilities") class Capabilities(): WCallCommand() @Serializable @SerialName("capabilities") class Capabilities(): WCallCommand()
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null): 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("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") class Ice(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() @Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@ -56,23 +56,20 @@ sealed class WCallCommand {
@Serializable @Serializable
sealed class WCallResponse { sealed class WCallResponse {
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse() @Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String): WCallResponse() @Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): 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("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse() @Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse() @Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("ended") class Ended(): WCallResponse() @Serializable @SerialName("ended") class Ended(): WCallResponse()
@Serializable @SerialName("ok") class Ok(): WCallResponse() @Serializable @SerialName("ok") class Ok(): WCallResponse()
@Serializable @SerialName("error") class Error(val message: String): 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 WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) @Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities) @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 class CallCapabilities(val encryption: Boolean)
@Serializable @Serializable

View file

@ -12,7 +12,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons 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.*
import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable 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.TAG
import chat.simplex.app.model.* import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.* 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.chat.item.ChatItemView
import chat.simplex.app.views.chatlist.openChat import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.*
@ -117,6 +118,14 @@ fun ChatView(chatModel: ChatModel) {
chatModel.upsertChatItem(cInfo, cItem) 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, info: () -> Unit,
openDirectChat: (Long) -> Unit, openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit receiveFile: (Long) -> Unit,
startCall: (CallMediaType) -> Unit
) { ) {
fun onImageChange(bitmap: Bitmap) { fun onImageChange(bitmap: Bitmap) {
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
@ -173,7 +183,7 @@ fun ChatLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) { ) {
Scaffold( Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) }, topBar = { ChatInfoToolbar(chat, back, info, startCall) },
bottomBar = composeView, bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding() modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding -> ) { contentPadding ->
@ -187,7 +197,7 @@ fun ChatLayout(
} }
@Composable @Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) { fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
Column { Column {
Box( Box(
Modifier Modifier
@ -205,6 +215,16 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
modifier = Modifier.padding(10.dp) 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( Row(
Modifier Modifier
.padding(horizontal = 68.dp) .padding(horizontal = 68.dp)
@ -378,7 +398,8 @@ fun PreviewChatLayout() {
info = {}, info = {},
openDirectChat = {}, openDirectChat = {},
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
receiveFile = {} receiveFile = {},
startCall = {}
) )
} }
} }
@ -423,7 +444,8 @@ fun PreviewGroupChatLayout() {
info = {}, info = {},
openDirectChat = {}, openDirectChat = {},
deleteMessage = { _, _ -> }, 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.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView import chat.simplex.app.views.TerminalView
import chat.simplex.app.views.call.VideoCallView
import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.SimpleXInfo import chat.simplex.app.views.onboarding.SimpleXInfo
@ -45,8 +44,8 @@ fun SettingsView(chatModel: ChatModel) {
}, },
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }, showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> VideoCallView(close) } }, // showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
) )
} }
} }
@ -62,7 +61,7 @@ fun SettingsLayout(
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit, showTerminal: () -> Unit,
showVideoChatPrototype: () -> Unit // showVideoChatPrototype: () -> Unit
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
Surface( Surface(
@ -208,8 +207,8 @@ fun SettingsLayout(
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal)) Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
} }
Divider(Modifier.padding(horizontal = 8.dp)) Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showVideoChatPrototype) { // SettingsSectionView(showVideoChatPrototype) {
// SettingsSectionView() { SettingsSectionView() {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
} }
} }
@ -246,7 +245,7 @@ fun PreviewSettingsLayout() {
showModal = {{}}, showModal = {{}},
showCustomModal = {{}}, showCustomModal = {{}},
showTerminal = {}, showTerminal = {},
showVideoChatPrototype = {} // showVideoChatPrototype = {}
) )
} }
} }

View file

@ -640,7 +640,7 @@ func processReceivedMsg(_ res: ChatResponse) {
// TODO check encryption is compatible // TODO check encryption is compatible
withCall(contact) { call in withCall(contact) { call in
m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey) 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): case let .callAnswer(contact, answer):
withCall(contact) { call in withCall(contact) { call in

View file

@ -149,7 +149,7 @@ enum WCallCommand: Equatable, Encodable, Decodable {
try container.encode(aesKey, forKey: .aesKey) try container.encode(aesKey, forKey: .aesKey)
try container.encode(useWorker, forKey: .useWorker) try container.encode(useWorker, forKey: .useWorker)
case let .offer(offer, iceCandidates, media, aesKey, 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(offer, forKey: .offer)
try container.encode(iceCandidates, forKey: .iceCandidates) try container.encode(iceCandidates, forKey: .iceCandidates)
try container.encode(media, forKey: .media) try container.encode(media, forKey: .media)
@ -213,8 +213,6 @@ enum WCallCommand: Equatable, Encodable, Decodable {
enum WCallResponse: Equatable, Decodable { enum WCallResponse: Equatable, Decodable {
case capabilities(capabilities: CallCapabilities) case capabilities(capabilities: CallCapabilities)
case offer(offer: String, iceCandidates: String, 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 answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String) case ice(iceCandidates: String)
case connection(state: ConnectionState) case connection(state: ConnectionState)

View file

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

View file

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

View file

@ -92,6 +92,16 @@
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
5CFE0922282EEAF60002594B /* 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 */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
644320022833BF0800CA19E0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64431FFD2833BF0800CA19E0 /* libgmp.a */; }; 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 */; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
644320042833BF0800CA19E0 /* libgmpxx.a in Frameworks */,
644320032833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */,
5C8F01CD27A6F0D8007D2C8D /* CodeScanner 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 */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
644320052833BF0800CA19E0 /* libffi.a in Frameworks */, 5CFE092E2833E9620002594B /* libffi.a in Frameworks */,
644320022833BF0800CA19E0 /* libgmp.a in Frameworks */, 5CFE092C2833E9620002594B /* libgmp.a in Frameworks */,
644320062833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */, 5CFE09302833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -245,7 +260,12 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( 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 */, 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */,
5CFE09292833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */,
5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */, 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -295,11 +315,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = { 5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
644320002833BF0800CA19E0 /* libffi.a */, 5CFE09262833E9620002594B /* libffi.a */,
64431FFD2833BF0800CA19E0 /* libgmp.a */, 5CFE09252833E9620002594B /* libgmp.a */,
64431FFF2833BF0800CA19E0 /* libgmpxx.a */, 5CFE09242833E9620002594B /* libgmpxx.a */,
644320012833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */, 5CFE09272833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */,
64431FFE2833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */, 5CFE09232833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */,
); );
path = Libraries; path = Libraries;
sourceTree = "<group>"; sourceTree = "<group>";

View file

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

View file

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

View file

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

View file

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

View file

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