mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 12:19:54 +00:00
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:
parent
106dceabfc
commit
dc418923ac
20 changed files with 361 additions and 155 deletions
1
apps/android/.idea/gradle.xml
generated
1
apps/android/.idea/gradle.xml
generated
|
@ -13,7 +13,6 @@
|
|||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveModulePerSourceSet" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -45,6 +45,7 @@ class ChatModel(val controller: ChatController) {
|
|||
val activeCallInvitation = mutableStateOf<ContactRef?>(null)
|
||||
val activeCall = mutableStateOf<Call?>(null)
|
||||
val callCommand = mutableStateOf<WCallCommand?>(null)
|
||||
val showCallView = mutableStateOf(false)
|
||||
|
||||
fun updateUserProfile(profile: Profile) {
|
||||
val user = currentUser.value
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -369,15 +369,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||
return r is CR.CmdOk
|
||||
}
|
||||
|
||||
suspend fun apiCallStatus(contact: Contact, status: String): Boolean {
|
||||
try {
|
||||
val callStatus = WebRTCCallStatus.valueOf(status)
|
||||
val r = sendCmd(CC.ApiCallStatus(contact, callStatus))
|
||||
return r is CR.CmdOk
|
||||
} catch (e: Error) {
|
||||
Log.d(TAG,"apiCallStatus: call status $status not used")
|
||||
return false
|
||||
}
|
||||
suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean {
|
||||
val r = sendCmd(CC.ApiCallStatus(contact, status))
|
||||
return r is CR.CmdOk
|
||||
}
|
||||
|
||||
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
|
||||
|
@ -504,11 +498,83 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||
removeFile(appContext, fileName)
|
||||
}
|
||||
}
|
||||
is CR.CallInvitation -> {
|
||||
val invitation = CallInvitation(r.callType.media, r.sharedKey)
|
||||
chatModel.callInvitations[r.contact.id] = invitation
|
||||
if (chatModel.activeCallInvitation.value == null) {
|
||||
chatModel.activeCallInvitation.value = ContactRef(r.contact.apiId, r.contact.localDisplayName)
|
||||
}
|
||||
ntfManager.notifyCallInvitation(r.contact, invitation)
|
||||
val encryptionText = if (r.sharedKey == null) "without e2e encryption" else "with e2e encryption"
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = "Incoming call",
|
||||
text = "${r.contact.displayName} wants to start ${r.callType.media} call with you (${encryptionText})",
|
||||
confirmText = "Answer", // generalGetString(R.string.answer),
|
||||
onConfirm = {
|
||||
Log.e(TAG, "showAlertDialog onConfirm ${chatModel.activeCallInvitation.value}")
|
||||
if (chatModel.activeCallInvitation.value == null) {
|
||||
AlertManager.shared.hideAlert()
|
||||
AlertManager.shared.showAlertMsg("Call already ended!")
|
||||
} else {
|
||||
Log.e(TAG, "showAlertDialog onConfirm has activeCallInvitation ${chatModel.activeCallInvitation.value}")
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.activeCall.value = Call(
|
||||
contact = r.contact,
|
||||
callState = CallState.InvitationReceived,
|
||||
localMedia = invitation.peerMedia
|
||||
)
|
||||
chatModel.callCommand.value = WCallCommand.Start(invitation.peerMedia, invitation.sharedKey)
|
||||
Log.e(TAG, "showAlertDialog onConfirm ${chatModel.callCommand.value}")
|
||||
chatModel.showCallView.value = true
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
chatModel.activeCallInvitation == null
|
||||
}
|
||||
)
|
||||
}
|
||||
is CR.CallOffer -> {
|
||||
// TODO askConfirmation?
|
||||
// TODO check encryption is compatible
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
|
||||
chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey)
|
||||
}
|
||||
}
|
||||
is CR.CallAnswer -> {
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)
|
||||
}
|
||||
}
|
||||
is CR.CallExtraInfo -> {
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)
|
||||
}
|
||||
}
|
||||
is CR.CallEnded -> {
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.callCommand.value = WCallCommand.End()
|
||||
chatModel.activeCall.value = null
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.callCommand.value = null
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
}
|
||||
else ->
|
||||
Log.d(TAG , "unsupported event: ${r.responseType}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
|
||||
val call = chatModel.activeCall?.value
|
||||
if (call != null && call.contact.apiId == contact.apiId) {
|
||||
perform(call)
|
||||
} else {
|
||||
Log.d(TAG, "processReceivedMsg: ignoring ${r.responseType}, not in call with the contact ${contact.id}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
|
||||
val cInfo = aChatItem.chatInfo
|
||||
val cItem = aChatItem.chatItem
|
||||
|
@ -898,8 +964,8 @@ sealed class CR {
|
|||
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
|
||||
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?): CR()
|
||||
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String?, val askConfirmation: Boolean): CR()
|
||||
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null): CR()
|
||||
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
|
||||
@Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
|
||||
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
|
||||
@Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR()
|
||||
|
|
|
@ -23,67 +23,139 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||
import androidx.webkit.WebViewAssetLoader
|
||||
import androidx.webkit.WebViewClientCompat
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.json
|
||||
import chat.simplex.app.views.helpers.TextEditor
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@Composable
|
||||
fun VideoCallView(close: () -> Unit) {
|
||||
val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
|
||||
val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
|
||||
val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
|
||||
|
||||
BackHandler(onBack = close)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
WebRTCView(callCommand) { resp ->
|
||||
// for debugging
|
||||
// commandText.value = resp
|
||||
commandText.value = json.encodeToString(resp)
|
||||
}
|
||||
|
||||
TextEditor(Modifier.height(180.dp), text = commandText)
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Button(onClick = {
|
||||
val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
|
||||
clipboard?.setPrimaryClip(clip)
|
||||
}) { Text("Copy") }
|
||||
Button(onClick = {
|
||||
try {
|
||||
val apiCall: WVAPICall = json.decodeFromString(commandText.value)
|
||||
commandText.value = ""
|
||||
println("sending: ${commandText.value}")
|
||||
callCommand.value = apiCall.command
|
||||
} catch(e: Error) {
|
||||
println("error parsing command: ${commandText.value}")
|
||||
println(e)
|
||||
fun ActiveCallView(chatModel: ChatModel) {
|
||||
val endCall = {
|
||||
Log.e(TAG, "ActiveCallView: endCall")
|
||||
chatModel.activeCall.value = null
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.callCommand.value = null
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
BackHandler(onBack = endCall)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||
Log.e(TAG, "received from WebRTCView: $apiMsg")
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) {
|
||||
Log.e(TAG, "has active call $call")
|
||||
when (val r = apiMsg.resp) {
|
||||
is WCallResponse.Capabilities -> withApi {
|
||||
val callType = CallType(call.localMedia, r.capabilities)
|
||||
chatModel.controller.apiSendCallInvitation(call.contact, callType)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
||||
}
|
||||
is WCallResponse.Offer -> withApi {
|
||||
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
||||
}
|
||||
is WCallResponse.Answer -> withApi {
|
||||
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
}
|
||||
is WCallResponse.Ice -> withApi {
|
||||
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
|
||||
}
|
||||
is WCallResponse.Connection ->
|
||||
try {
|
||||
val callStatus = WebRTCCallStatus.valueOf(r.state.connectionState)
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||
}
|
||||
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||
} catch (e: Error) {
|
||||
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
||||
}
|
||||
is WCallResponse.Ended -> endCall()
|
||||
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||
is WCallCommand.Media -> {
|
||||
when (cmd.media) {
|
||||
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
|
||||
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
|
||||
}
|
||||
}
|
||||
is WCallCommand.End -> endCall()
|
||||
else -> {}
|
||||
}
|
||||
is WCallResponse.Error -> {
|
||||
Log.e(TAG, "ActiveCallView: command error ${r.message}")
|
||||
}
|
||||
}
|
||||
}) { Text("Send") }
|
||||
Button(onClick = {
|
||||
commandText.value = ""
|
||||
}) { Text("Clear") }
|
||||
}
|
||||
}
|
||||
ActiveCallOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActiveCallOverlay() {
|
||||
|
||||
}
|
||||
|
||||
//@Composable
|
||||
//fun CallViewDebug(close: () -> Unit) {
|
||||
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
|
||||
// val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
|
||||
// val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
|
||||
//
|
||||
// BackHandler(onBack = close)
|
||||
// Column(
|
||||
// horizontalAlignment = Alignment.CenterHorizontally,
|
||||
// verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
// modifier = Modifier
|
||||
// .background(MaterialTheme.colors.background)
|
||||
// .fillMaxSize()
|
||||
// ) {
|
||||
// WebRTCView(callCommand) { apiMsg ->
|
||||
// // for debugging
|
||||
// // commandText.value = apiMsg
|
||||
// commandText.value = json.encodeToString(apiMsg)
|
||||
// }
|
||||
//
|
||||
// TextEditor(Modifier.height(180.dp), text = commandText)
|
||||
//
|
||||
// Row(
|
||||
// Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(bottom = 6.dp),
|
||||
// horizontalArrangement = Arrangement.SpaceBetween
|
||||
// ) {
|
||||
// Button(onClick = {
|
||||
// val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
|
||||
// clipboard?.setPrimaryClip(clip)
|
||||
// }) { Text("Copy") }
|
||||
// Button(onClick = {
|
||||
// try {
|
||||
// val apiCall: WVAPICall = json.decodeFromString(commandText.value)
|
||||
// commandText.value = ""
|
||||
// println("sending: ${commandText.value}")
|
||||
// callCommand.value = apiCall.command
|
||||
// } catch(e: Error) {
|
||||
// println("error parsing command: ${commandText.value}")
|
||||
// println(e)
|
||||
// }
|
||||
// }) { Text("Send") }
|
||||
// Button(onClick = {
|
||||
// commandText.value = ""
|
||||
// }) { Text("Clear") }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@Composable
|
||||
// for debugging
|
||||
// fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (String) -> Unit) {
|
||||
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
lateinit var wv: WebView
|
||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
Manifest.permission.CAMERA,
|
||||
|
@ -92,7 +164,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
|||
Manifest.permission.INTERNET
|
||||
)
|
||||
)
|
||||
fun processCommand(cmd: WCallCommand) {
|
||||
fun processCommand(wv: WebView, cmd: WCallCommand) {
|
||||
val apiCall = WVAPICall(command = cmd)
|
||||
wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null)
|
||||
}
|
||||
|
@ -104,30 +176,27 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
|||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
processCommand(WCallCommand.End())
|
||||
val wv = webView.value
|
||||
if (wv != null) processCommand(wv, WCallCommand.End())
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(callCommand.value) {
|
||||
LaunchedEffect(callCommand.value, webView.value) {
|
||||
val cmd = callCommand.value
|
||||
if (cmd != null) {
|
||||
val wv = webView.value
|
||||
if (cmd != null && wv != null) {
|
||||
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
|
||||
processCommand(wv, cmd)
|
||||
callCommand.value = null
|
||||
processCommand(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
val assetLoader = WebViewAssetLoader.Builder()
|
||||
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current))
|
||||
.build()
|
||||
|
||||
if (permissionsState.allPermissionsGranted) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = { AndroidViewContext ->
|
||||
WebView(AndroidViewContext).apply {
|
||||
|
@ -154,16 +223,19 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
|||
webViewSettings.allowContentAccess = true
|
||||
webViewSettings.javaScriptEnabled = true
|
||||
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
||||
webViewSettings.allowFileAccessFromFileURLs = true;
|
||||
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
this.loadUrl("file:android_asset/www/call.html")
|
||||
}
|
||||
}
|
||||
) {
|
||||
wv = it
|
||||
) { wv ->
|
||||
Log.e(TAG, "WebRTCView: webview ready")
|
||||
// for debugging
|
||||
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||
withApi {
|
||||
delay(2000L)
|
||||
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||
webView.value = wv
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -92,6 +92,16 @@
|
|||
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
|
||||
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||
5CFE09282833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09232833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */; };
|
||||
5CFE09292833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09232833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */; };
|
||||
5CFE092A2833E9620002594B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09242833E9620002594B /* libgmpxx.a */; };
|
||||
5CFE092B2833E9620002594B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09242833E9620002594B /* libgmpxx.a */; };
|
||||
5CFE092C2833E9620002594B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09252833E9620002594B /* libgmp.a */; };
|
||||
5CFE092D2833E9620002594B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09252833E9620002594B /* libgmp.a */; };
|
||||
5CFE092E2833E9620002594B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09262833E9620002594B /* libffi.a */; };
|
||||
5CFE092F2833E9620002594B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09262833E9620002594B /* libffi.a */; };
|
||||
5CFE09302833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09272833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */; };
|
||||
5CFE09312833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE09272833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */; };
|
||||
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
|
||||
644320022833BF0800CA19E0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64431FFD2833BF0800CA19E0 /* libgmp.a */; };
|
||||
644320032833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64431FFE2833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */; };
|
||||
|
@ -202,6 +212,11 @@
|
|||
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
|
||||
5CFE09232833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a"; sourceTree = "<group>"; };
|
||||
5CFE09242833E9620002594B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CFE09252833E9620002594B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CFE09262833E9620002594B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CFE09272833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
|
||||
64431FFD2833BF0800CA19E0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
64431FFE2833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a"; sourceTree = "<group>"; };
|
||||
|
@ -223,13 +238,13 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
644320042833BF0800CA19E0 /* libgmpxx.a in Frameworks */,
|
||||
644320032833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */,
|
||||
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
|
||||
5CFE092A2833E9620002594B /* libgmpxx.a in Frameworks */,
|
||||
5CFE09282833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */,
|
||||
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
|
||||
644320052833BF0800CA19E0 /* libffi.a in Frameworks */,
|
||||
644320022833BF0800CA19E0 /* libgmp.a in Frameworks */,
|
||||
644320062833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */,
|
||||
5CFE092E2833E9620002594B /* libffi.a in Frameworks */,
|
||||
5CFE092C2833E9620002594B /* libgmp.a in Frameworks */,
|
||||
5CFE09302833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */,
|
||||
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -245,7 +260,12 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CFE09312833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a in Frameworks */,
|
||||
5CFE092B2833E9620002594B /* libgmpxx.a in Frameworks */,
|
||||
5CFE092F2833E9620002594B /* libffi.a in Frameworks */,
|
||||
5CFE092D2833E9620002594B /* libgmp.a in Frameworks */,
|
||||
5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */,
|
||||
5CFE09292833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a in Frameworks */,
|
||||
5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -295,11 +315,11 @@
|
|||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
644320002833BF0800CA19E0 /* libffi.a */,
|
||||
64431FFD2833BF0800CA19E0 /* libgmp.a */,
|
||||
64431FFF2833BF0800CA19E0 /* libgmpxx.a */,
|
||||
644320012833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */,
|
||||
64431FFE2833BF0800CA19E0 /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */,
|
||||
5CFE09262833E9620002594B /* libffi.a */,
|
||||
5CFE09252833E9620002594B /* libgmp.a */,
|
||||
5CFE09242833E9620002594B /* libgmpxx.a */,
|
||||
5CFE09272833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L-ghc8.10.7.a */,
|
||||
5CFE09232833E9620002594B /* libHSsimplex-chat-2.0.1-IL4OVvclaJm1EwZEKXfP4L.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -137,12 +137,12 @@ enum TransformOperation {
|
|||
|
||||
interface RTCRtpScriptTransform {}
|
||||
|
||||
;(function () {
|
||||
interface WVAPICall {
|
||||
corrId?: number
|
||||
command: WCallCommand
|
||||
}
|
||||
interface WVAPICall {
|
||||
corrId?: number
|
||||
command: WCallCommand
|
||||
}
|
||||
|
||||
const processCommand = (function () {
|
||||
type RTCRtpSenderWithEncryption = RTCRtpSender & {
|
||||
createEncodedStreams: () => TransformStream
|
||||
transform: RTCRtpScriptTransform
|
||||
|
@ -180,7 +180,7 @@ interface RTCRtpScriptTransform {}
|
|||
peerConnectionConfig: {
|
||||
iceServers: [
|
||||
{urls: "stun:stun.simplex.chat:5349"},
|
||||
// {urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z"},
|
||||
{urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z"},
|
||||
],
|
||||
iceCandidatePoolSize: 10,
|
||||
encodedInsertableStreams,
|
||||
|
@ -279,8 +279,6 @@ interface RTCRtpScriptTransform {}
|
|||
return JSON.parse(LZString.decompressFromBase64(s)!)
|
||||
}
|
||||
|
||||
Object.defineProperty(window, "processCommand", {value: processCommand})
|
||||
|
||||
async function processCommand(body: WVAPICall): Promise<WVApiMessage> {
|
||||
const {corrId, command} = body
|
||||
const pc = activeCall?.connection
|
||||
|
@ -295,8 +293,6 @@ interface RTCRtpScriptTransform {}
|
|||
console.log("starting call")
|
||||
if (activeCall) {
|
||||
resp = {type: "error", message: "start: call already started"}
|
||||
} else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) {
|
||||
resp = {type: "error", message: "start: encryption is not supported"}
|
||||
} else {
|
||||
const {media, useWorker} = command
|
||||
const encryption = supportsInsertableStreams(useWorker)
|
||||
|
@ -560,6 +556,8 @@ interface RTCRtpScriptTransform {}
|
|||
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks()
|
||||
for (const t of tracks) t.enabled = enable
|
||||
}
|
||||
|
||||
return processCommand
|
||||
})()
|
||||
|
||||
type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue